From 5ebbceea65557beab917c18fc7c203d0555b3a31 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Sun, 8 Feb 2026 15:48:13 +0800 Subject: [PATCH 01/21] feat: add MCP tool progress update support in TUI and SDK mode Co-authored-by: Qwen-Coder --- .../io/BaseJsonOutputAdapter.test.ts | 20 +++ .../io/BaseJsonOutputAdapter.ts | 29 ++++ .../io/StreamJsonOutputAdapter.test.ts | 109 ++++++++++++ .../io/StreamJsonOutputAdapter.ts | 32 +++- packages/cli/src/nonInteractive/types.ts | 14 +- packages/cli/src/nonInteractiveCli.test.ts | 8 +- packages/cli/src/nonInteractiveCli.ts | 27 ++- .../ui/components/messages/ToolMessage.tsx | 17 ++ .../src/utils/nonInteractiveHelpers.test.ts | 110 ++++++++++++ .../cli/src/utils/nonInteractiveHelpers.ts | 45 ++++- packages/core/src/tools/mcp-client.ts | 3 + packages/core/src/tools/mcp-tool.test.ts | 161 +++++++++++++++++- packages/core/src/tools/mcp-tool.ts | 157 ++++++++++++++++- packages/core/src/tools/tools.ts | 17 +- 14 files changed, 724 insertions(+), 25 deletions(-) diff --git a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts index be04b7f2b..dbb746a10 100644 --- a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts +++ b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts @@ -1086,6 +1086,26 @@ describe('BaseJsonOutputAdapter', () => { }); }); + describe('emitToolProgress', () => { + it('should be a no-op in base class (does not emit any message)', () => { + const request: ToolCallRequestInfo = { + callId: 'tool-call-1', + name: 'mcp__echo-test__echo', + args: {}, + isClientInitiated: false, + prompt_id: '', + }; + adapter.emitToolProgress(request, { + type: 'mcp_tool_progress', + progress: 1, + total: 10, + message: 'Echo: 1', + }); + + expect(adapter.emittedMessages).toHaveLength(0); + }); + }); + describe('buildResultMessage', () => { beforeEach(() => { adapter.startAssistantMessage(); diff --git a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts index 072497000..b0d6736a5 100644 --- a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts +++ b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts @@ -12,6 +12,7 @@ import type { SessionMetrics, ServerGeminiStreamEvent, TaskResultDisplay, + McpToolProgressData, } from '@qwen-code/qwen-code-core'; import { GeminiEventType, @@ -82,6 +83,18 @@ export interface MessageEmitter { parentToolUseId?: string | null, ): void; emitSystemMessage(subtype: string, data?: unknown): void; + /** + * Emits a tool progress stream event. + * Only emits when the adapter supports partial messages (stream mode). + * In non-streaming mode, this is a no-op. + * + * @param request - Tool call request info + * @param progress - Structured MCP progress data + */ + emitToolProgress( + request: ToolCallRequestInfo, + progress: McpToolProgressData, + ): void; } /** @@ -1051,6 +1064,22 @@ export abstract class BaseJsonOutputAdapter { this.emitMessageImpl(systemMessage); } + /** + * Emits a tool progress stream event. + * Default implementation is a no-op. StreamJsonOutputAdapter overrides this + * to emit stream events when includePartialMessages is enabled. + * + * @param _request - Tool call request info + * @param _progress - Structured MCP progress data + */ + emitToolProgress( + _request: ToolCallRequestInfo, + _progress: McpToolProgressData, + ): void { + // No-op in base class. Only StreamJsonOutputAdapter emits tool progress + // as stream events when includePartialMessages is enabled. + } + /** * Builds a result message from options. * Helper method used by both emitResult implementations. diff --git a/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.test.ts b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.test.ts index ff3aa1f5d..96977d5b0 100644 --- a/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.test.ts +++ b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.test.ts @@ -882,6 +882,115 @@ describe('StreamJsonOutputAdapter', () => { }); }); + describe('emitToolProgress', () => { + const mockRequest = { + callId: 'tool-call-1', + name: 'mcp__echo-test__echo', + args: {}, + isClientInitiated: false, + prompt_id: '', + }; + + it('should emit tool_progress stream event when includePartialMessages is true', () => { + adapter = new StreamJsonOutputAdapter(mockConfig, true); + stdoutWriteSpy.mockClear(); + + adapter.emitToolProgress(mockRequest, { + type: 'mcp_tool_progress', + progress: 1, + total: 10, + message: 'Echo: 1', + }); + + expect(stdoutWriteSpy).toHaveBeenCalledTimes(1); + const output = stdoutWriteSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output); + + expect(parsed.type).toBe('stream_event'); + expect(parsed.parent_tool_use_id).toBeNull(); + expect(parsed.session_id).toBe('test-session-id'); + expect(parsed.uuid).toBeDefined(); + expect(parsed.event).toEqual({ + type: 'tool_progress', + tool_use_id: 'tool-call-1', + content: { + type: 'mcp_tool_progress', + progress: 1, + total: 10, + message: 'Echo: 1', + }, + }); + }); + + it('should not emit tool_progress when includePartialMessages is false', () => { + adapter = new StreamJsonOutputAdapter(mockConfig, false); + stdoutWriteSpy.mockClear(); + + adapter.emitToolProgress(mockRequest, { + type: 'mcp_tool_progress', + progress: 1, + total: 10, + message: 'Echo: 1', + }); + + expect(stdoutWriteSpy).not.toHaveBeenCalled(); + }); + + it('should emit multiple tool_progress events for sequential progress updates', () => { + adapter = new StreamJsonOutputAdapter(mockConfig, true); + stdoutWriteSpy.mockClear(); + + adapter.emitToolProgress(mockRequest, { + type: 'mcp_tool_progress', + progress: 1, + total: 3, + message: 'Echo: 1', + }); + adapter.emitToolProgress(mockRequest, { + type: 'mcp_tool_progress', + progress: 2, + total: 3, + message: 'Echo: 1, 2', + }); + adapter.emitToolProgress(mockRequest, { + type: 'mcp_tool_progress', + progress: 3, + total: 3, + message: 'Echo: 1, 2, 3', + }); + + expect(stdoutWriteSpy).toHaveBeenCalledTimes(3); + + const events = stdoutWriteSpy.mock.calls.map( + (call: unknown[]) => JSON.parse(call[0] as string).event, + ); + expect(events[0].content).toEqual({ + type: 'mcp_tool_progress', + progress: 1, + total: 3, + message: 'Echo: 1', + }); + expect(events[1].content).toEqual({ + type: 'mcp_tool_progress', + progress: 2, + total: 3, + message: 'Echo: 1, 2', + }); + expect(events[2].content).toEqual({ + type: 'mcp_tool_progress', + progress: 3, + total: 3, + message: 'Echo: 1, 2, 3', + }); + + // All events should share the same tool_use_id + for (const event of events) { + expect(event.type).toBe('tool_progress'); + expect(event.tool_use_id).toBe('tool-call-1'); + } + }); + }); + describe('getSessionId and getModel', () => { beforeEach(() => { adapter = new StreamJsonOutputAdapter(mockConfig, false); diff --git a/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts index af2f0bb6c..bf76d025c 100644 --- a/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts +++ b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts @@ -5,7 +5,11 @@ */ import { randomUUID } from 'node:crypto'; -import type { Config } from '@qwen-code/qwen-code-core'; +import type { + Config, + ToolCallRequestInfo, + McpToolProgressData, +} from '@qwen-code/qwen-code-core'; import type { CLIAssistantMessage, CLIMessage, @@ -267,6 +271,32 @@ export class StreamJsonOutputAdapter } } + /** + * Emits a tool progress stream event when partial messages are enabled. + * This overrides the no-op in BaseJsonOutputAdapter. + */ + override emitToolProgress( + request: ToolCallRequestInfo, + progress: McpToolProgressData, + ): void { + if (!this.includePartialMessages) { + return; + } + + const partial: CLIPartialAssistantMessage = { + type: 'stream_event', + uuid: randomUUID(), + session_id: this.getSessionId(), + parent_tool_use_id: null, + event: { + type: 'tool_progress', + tool_use_id: request.callId, + content: progress, + }, + }; + this.emitMessageImpl(partial); + } + /** * Emits stream events when partial messages are enabled. * This is a private method specific to StreamJsonOutputAdapter. diff --git a/packages/cli/src/nonInteractive/types.ts b/packages/cli/src/nonInteractive/types.ts index 1d5e800dd..84c2d0ff7 100644 --- a/packages/cli/src/nonInteractive/types.ts +++ b/packages/cli/src/nonInteractive/types.ts @@ -1,5 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { SubagentConfig } from '@qwen-code/qwen-code-core'; +import type { + SubagentConfig, + McpToolProgressData, +} from '@qwen-code/qwen-code-core'; /** * Annotation for attaching metadata to content blocks @@ -236,12 +239,19 @@ export interface MessageStopStreamEvent { type: 'message_stop'; } +export interface ToolProgressStreamEvent { + type: 'tool_progress'; + tool_use_id: string; + content: McpToolProgressData; +} + export type StreamEvent = | MessageStartStreamEvent | ContentBlockStartEvent | ContentBlockDeltaEvent | ContentBlockStopEvent - | MessageStopStreamEvent; + | MessageStopStreamEvent + | ToolProgressStreamEvent; export interface CLIPartialAssistantMessage { type: 'stream_event'; diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 2931118fc..6a6b33b87 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -296,7 +296,9 @@ describe('runNonInteractive', () => { mockConfig, expect.objectContaining({ name: 'testTool' }), expect.any(AbortSignal), - undefined, + expect.objectContaining({ + outputUpdateHandler: expect.any(Function), + }), ); // Verify first call has isContinuation: false expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( @@ -641,7 +643,9 @@ describe('runNonInteractive', () => { mockConfig, expect.objectContaining({ name: 'testTool' }), expect.any(AbortSignal), - undefined, + expect.objectContaining({ + outputUpdateHandler: expect.any(Function), + }), ); // JSON adapter emits array of messages, last one is result with stats diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index ef96d8e63..129bec380 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -41,6 +41,7 @@ import { normalizePartList, extractPartsFromUserMessage, buildSystemMessage, + createToolProgressHandler, createTaskToolProgressHandler, computeUsageFromMetrics, } from './utils/nonInteractiveHelpers.js'; @@ -313,31 +314,29 @@ export async function runNonInteractive( ? options.controlService.permission.getToolCallUpdateCallback() : undefined; - // Create output handler for Task tool (for subagent execution) + // Build outputUpdateHandler for this tool call. + // Task tool has its own complex handler (subagent messages). + // All other tools with canUpdateOutput=true (e.g., MCP tools) + // get a generic handler that emits progress via the adapter. const isTaskTool = finalRequestInfo.name === 'task'; - const taskToolProgress = isTaskTool + const { handler: outputUpdateHandler } = isTaskTool ? createTaskToolProgressHandler( config, finalRequestInfo.callId, adapter, ) - : undefined; - const taskToolProgressHandler = taskToolProgress?.handler; + : createToolProgressHandler(finalRequestInfo, adapter); const toolResponse = await executeToolCall( config, finalRequestInfo, abortController.signal, - taskToolProgressHandler || toolCallUpdateCallback - ? { - ...(taskToolProgressHandler && { - outputUpdateHandler: taskToolProgressHandler, - }), - ...(toolCallUpdateCallback && { - onToolCallsUpdate: toolCallUpdateCallback, - }), - } - : undefined, + { + outputUpdateHandler, + ...(toolCallUpdateCallback && { + onToolCallsUpdate: toolCallUpdateCallback, + }), + }, ); // Note: In JSON mode, subagent messages are automatically added to the main diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index afc16317c..c0c981197 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -20,6 +20,7 @@ import type { PlanResultDisplay, AnsiOutput, Config, + McpToolProgressData, } from '@qwen-code/qwen-code-core'; import { AgentExecutionDisplay } from '../subagents/index.js'; import { PlanSummaryDisplay } from '../PlanSummaryDisplay.js'; @@ -113,6 +114,22 @@ const useResultDisplayRenderer = ( }; } + // Check for McpToolProgressData + if ( + typeof resultDisplay === 'object' && + resultDisplay !== null && + 'type' in resultDisplay && + resultDisplay.type === 'mcp_tool_progress' + ) { + const progress = resultDisplay as McpToolProgressData; + const msg = progress.message ?? `Progress: ${progress.progress}`; + const totalStr = progress.total != null ? `/${progress.total}` : ''; + return { + type: 'string', + data: `⏳ [${progress.progress}${totalStr}] ${msg}`, + }; + } + // Check for AnsiOutput if ( typeof resultDisplay === 'object' && diff --git a/packages/cli/src/utils/nonInteractiveHelpers.test.ts b/packages/cli/src/utils/nonInteractiveHelpers.test.ts index b5565bb52..1892e6e41 100644 --- a/packages/cli/src/utils/nonInteractiveHelpers.test.ts +++ b/packages/cli/src/utils/nonInteractiveHelpers.test.ts @@ -29,6 +29,7 @@ import { extractUsageFromGeminiClient, computeUsageFromMetrics, buildSystemMessage, + createToolProgressHandler, createTaskToolProgressHandler, functionResponsePartsToString, toolResultContent, @@ -621,6 +622,115 @@ describe('buildSystemMessage', () => { }); }); +describe('createToolProgressHandler', () => { + const mockRequest = { + callId: 'tool-call-1', + name: 'mcp__echo-test__echo', + args: {}, + isClientInitiated: false, + prompt_id: '', + }; + + it('should call emitToolProgress with request and McpToolProgressData', () => { + const mockAdapter = { + emitToolProgress: vi.fn(), + } as unknown as JsonOutputAdapterInterface; + + const { handler } = createToolProgressHandler(mockRequest, mockAdapter); + + const progressData = { + type: 'mcp_tool_progress' as const, + progress: 1, + total: 10, + message: 'Echo: 1', + }; + handler('tool-call-1', progressData); + + expect(mockAdapter.emitToolProgress).toHaveBeenCalledWith( + mockRequest, + progressData, + ); + }); + + it('should not call emitToolProgress for non-McpToolProgressData output', () => { + const mockAdapter = { + emitToolProgress: vi.fn(), + } as unknown as JsonOutputAdapterInterface; + + const { handler } = createToolProgressHandler( + { ...mockRequest, name: 'test_tool' }, + mockAdapter, + ); + + // Pass a non-McpToolProgressData ToolResultDisplay (e.g., FileDiff) + handler('tool-call-1', { + fileDiff: 'diff', + fileName: 'test.ts', + originalContent: null, + newContent: 'new', + }); + + expect(mockAdapter.emitToolProgress).not.toHaveBeenCalled(); + + // Also test with a plain string — should not emit + handler('tool-call-1', 'plain string progress'); + + expect(mockAdapter.emitToolProgress).not.toHaveBeenCalled(); + }); + + it('should forward multiple progress updates', () => { + const mockAdapter = { + emitToolProgress: vi.fn(), + } as unknown as JsonOutputAdapterInterface; + + const browserRequest = { + ...mockRequest, + name: 'mcp__browser__navigate', + }; + const { handler } = createToolProgressHandler(browserRequest, mockAdapter); + + const progress1 = { + type: 'mcp_tool_progress' as const, + progress: 1, + total: 3, + message: 'Navigating...', + }; + const progress2 = { + type: 'mcp_tool_progress' as const, + progress: 2, + total: 3, + message: 'Loading page...', + }; + const progress3 = { + type: 'mcp_tool_progress' as const, + progress: 3, + total: 3, + message: 'Complete', + }; + + handler('tool-call-1', progress1); + handler('tool-call-1', progress2); + handler('tool-call-1', progress3); + + expect(mockAdapter.emitToolProgress).toHaveBeenCalledTimes(3); + expect(mockAdapter.emitToolProgress).toHaveBeenNthCalledWith( + 1, + browserRequest, + progress1, + ); + expect(mockAdapter.emitToolProgress).toHaveBeenNthCalledWith( + 2, + browserRequest, + progress2, + ); + expect(mockAdapter.emitToolProgress).toHaveBeenNthCalledWith( + 3, + browserRequest, + progress3, + ); + }); +}); + describe('createTaskToolProgressHandler', () => { let mockAdapter: JsonOutputAdapterInterface; let mockConfig: Config; diff --git a/packages/cli/src/utils/nonInteractiveHelpers.ts b/packages/cli/src/utils/nonInteractiveHelpers.ts index 4e2317b2e..a5b23e6c9 100644 --- a/packages/cli/src/utils/nonInteractiveHelpers.ts +++ b/packages/cli/src/utils/nonInteractiveHelpers.ts @@ -12,6 +12,7 @@ import type { ToolCallRequestInfo, ToolCallResponseInfo, SessionMetrics, + McpToolProgressData, } from '@qwen-code/qwen-code-core'; import { OutputFormat, @@ -26,7 +27,10 @@ import type { PermissionMode, CLISystemMessage, } from '../nonInteractive/types.js'; -import type { JsonOutputAdapterInterface } from '../nonInteractive/io/BaseJsonOutputAdapter.js'; +import type { + JsonOutputAdapterInterface, + MessageEmitter, +} from '../nonInteractive/io/BaseJsonOutputAdapter.js'; import { computeSessionStats } from '../ui/utils/computeStats.js'; import { getAvailableCommands } from '../nonInteractiveCliCommands.js'; @@ -291,6 +295,45 @@ export async function buildSystemMessage( return systemMessage; } +function isMcpToolProgressData( + output: ToolResultDisplay, +): output is McpToolProgressData { + return ( + typeof output === 'object' && + output !== null && + 'type' in output && + (output as McpToolProgressData).type === 'mcp_tool_progress' + ); +} + +/** + * Creates a generic output update handler for tools with canUpdateOutput=true. + * This handler forwards MCP progress data (McpToolProgressData) as tool_progress + * stream events via the adapter. Progress events are only emitted when the adapter + * supports partial messages (i.e., includePartialMessages is true). + * + * @param request - Tool call request info + * @param adapter - The adapter instance for emitting messages + * @returns An object containing the output update handler + */ +export function createToolProgressHandler( + request: ToolCallRequestInfo, + adapter: MessageEmitter, +): { + handler: OutputUpdateHandler; +} { + const handler: OutputUpdateHandler = ( + _callId: string, + output: ToolResultDisplay, + ) => { + if (isMcpToolProgressData(output)) { + adapter.emitToolProgress(request, output); + } + }; + + return { handler }; +} + /** * Creates an output update handler specifically for Task tool subagent execution. * This handler monitors TaskResultDisplay updates and converts them to protocol messages diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index e61318edc..0e8fc9cce 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -638,6 +638,7 @@ export async function discoverTools( return []; } + const mcpTimeout = mcpServerConfig.timeout ?? MCP_DEFAULT_TIMEOUT_MSEC; const discoveredTools: DiscoveredMCPTool[] = []; for (const funcDecl of tool.functionDeclarations) { try { @@ -655,6 +656,8 @@ export async function discoverTools( mcpServerConfig.trust, undefined, cliConfig, + mcpClient, // raw MCP Client for direct callTool with progress + mcpTimeout, ), ); } catch (error) { diff --git a/packages/core/src/tools/mcp-tool.test.ts b/packages/core/src/tools/mcp-tool.test.ts index 680fa9299..997449eba 100644 --- a/packages/core/src/tools/mcp-tool.test.ts +++ b/packages/core/src/tools/mcp-tool.test.ts @@ -8,9 +8,13 @@ import type { Mocked } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { safeJsonStringify } from '../utils/safeJsonStringify.js'; -import { DiscoveredMCPTool, generateValidName } from './mcp-tool.js'; // Added getStringifiedResultForDisplay +import { + DiscoveredMCPTool, + generateValidName, + type McpDirectClient, +} from './mcp-tool.js'; import type { ToolResult } from './tools.js'; -import { ToolConfirmationOutcome } from './tools.js'; // Added ToolConfirmationOutcome +import { ToolConfirmationOutcome } from './tools.js'; import type { CallableTool, Part } from '@google/genai'; import { ToolErrorType } from './tool-error.js'; @@ -958,4 +962,157 @@ describe('DiscoveredMCPTool', () => { expect(description).toBe('{"param":"testValue","param2":"anotherOne"}'); }); }); + + describe('streaming progress for long-running MCP tools', () => { + it('should have canUpdateOutput set to true so the scheduler creates liveOutputCallback', () => { + // For long-running MCP tools (e.g., browseruse), the scheduler needs + // canUpdateOutput=true to create a liveOutputCallback. Without this, + // users see no progress during potentially minutes-long operations. + expect(tool.canUpdateOutput).toBe(true); + }); + + it('should forward MCP progress notifications to updateOutput callback during execution', async () => { + const params = { param: 'https://example.com' }; + + // Create a mock MCP direct client that simulates progress notifications. + // When callTool is called with an onprogress callback, it invokes + // the callback to simulate the MCP server sending progress updates. + const mockMcpClient: McpDirectClient = { + callTool: vi.fn(async (_params, _schema, options) => { + // Simulate 3 progress notifications from the MCP server + for (let i = 1; i <= 3; i++) { + await new Promise((resolve) => setTimeout(resolve, 10)); + options?.onprogress?.({ + progress: i, + total: 3, + message: `Step ${i} of 3`, + }); + } + return { + content: [ + { + type: 'text', + text: 'Browser automation completed successfully.', + }, + ], + }; + }), + }; + + // Create a tool with the direct MCP client + const streamingTool = new DiscoveredMCPTool( + mockCallableToolInstance, + serverName, + serverToolName, + baseDescription, + inputSchema, + undefined, // trust + undefined, // nameOverride + undefined, // cliConfig + mockMcpClient, + ); + + const invocation = streamingTool.build(params); + const updateOutputSpy = vi.fn(); + + const result = await invocation.execute( + new AbortController().signal, + updateOutputSpy, + ); + + // The final result should still be correct + expect(result.llmContent).toEqual([ + { text: 'Browser automation completed successfully.' }, + ]); + + // The updateOutput callback SHOULD have been called at least once + // with intermediate progress, so users can see what's happening + // during the long wait. + expect(updateOutputSpy).toHaveBeenCalled(); + expect(updateOutputSpy).toHaveBeenCalledTimes(3); + // Verify progress data contains structured MCP progress info + expect(updateOutputSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'mcp_tool_progress', + progress: 1, + total: 3, + message: 'Step 1 of 3', + }), + ); + expect(updateOutputSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'mcp_tool_progress', + progress: 3, + total: 3, + message: 'Step 3 of 3', + }), + ); + }); + + it('should show incremental progress for multi-step browser automation', async () => { + const params = { param: 'fill-form' }; + const steps = [ + 'Navigating to page...', + 'Filling username field...', + 'Filling password field...', + 'Clicking submit...', + ]; + + const mockMcpClient: McpDirectClient = { + callTool: vi.fn(async (_params, _schema, options) => { + for (let i = 0; i < steps.length; i++) { + await new Promise((resolve) => setTimeout(resolve, 10)); + options?.onprogress?.({ + progress: i + 1, + total: steps.length, + message: steps[i], + }); + } + return { + content: [{ type: 'text', text: steps.join('\n') }], + }; + }), + }; + + const streamingTool = new DiscoveredMCPTool( + mockCallableToolInstance, + serverName, + serverToolName, + baseDescription, + inputSchema, + undefined, + undefined, + undefined, + mockMcpClient, + ); + + const invocation = streamingTool.build(params); + const receivedUpdates: unknown[] = []; + const updateOutputCallback = (output: unknown) => { + receivedUpdates.push(output); + }; + + await invocation.execute( + new AbortController().signal, + updateOutputCallback, + ); + + // User should have received one update per step + expect(receivedUpdates.length).toBeGreaterThan(0); + expect(receivedUpdates).toHaveLength(steps.length); + // Each update should be structured McpToolProgressData + expect(receivedUpdates[0]).toEqual({ + type: 'mcp_tool_progress', + progress: 1, + total: steps.length, + message: 'Navigating to page...', + }); + expect(receivedUpdates[3]).toEqual({ + type: 'mcp_tool_progress', + progress: 4, + total: steps.length, + message: 'Clicking submit...', + }); + }); + }); }); diff --git a/packages/core/src/tools/mcp-tool.ts b/packages/core/src/tools/mcp-tool.ts index 15f461e90..4917fa2c3 100644 --- a/packages/core/src/tools/mcp-tool.ts +++ b/packages/core/src/tools/mcp-tool.ts @@ -10,7 +10,9 @@ import type { ToolInvocation, ToolMcpConfirmationDetails, ToolResult, + ToolResultDisplay, ToolConfirmationPayload, + McpToolProgressData, } from './tools.js'; import { BaseDeclarativeTool, @@ -24,6 +26,40 @@ import type { Config } from '../config/config.js'; type ToolParams = Record; +/** + * Minimal interface for the raw MCP Client's callTool method. + * This avoids a direct import of @modelcontextprotocol/sdk in this file, + * keeping the dependency contained in mcp-client.ts. + */ +export interface McpDirectClient { + callTool( + params: { name: string; arguments?: Record }, + resultSchema?: unknown, + options?: { + onprogress?: (progress: { + progress: number; + total?: number; + message?: string; + }) => void; + timeout?: number; + signal?: AbortSignal; + }, + ): Promise; +} + +/** The result shape returned by MCP SDK Client.callTool(). */ +interface McpCallToolResult { + content?: Array<{ + type: string; + text?: string; + data?: string; + mimeType?: string; + [key: string]: unknown; + }>; + isError?: boolean; + [key: string]: unknown; +} + // Discriminated union for MCP Content Blocks to ensure type safety. type McpTextBlock = { type: 'text'; @@ -72,6 +108,8 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation< readonly trust?: boolean, params: ToolParams = {}, private readonly cliConfig?: Config, + private readonly mcpClient?: McpDirectClient, + private readonly mcpTimeout?: number, ) { super(params); } @@ -135,7 +173,91 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation< return false; } - async execute(signal: AbortSignal): Promise { + async execute( + signal: AbortSignal, + updateOutput?: (output: ToolResultDisplay) => void, + ): Promise { + // Use direct MCP client if available (supports progress notifications), + // otherwise fall back to the @google/genai mcpToTool wrapper. + if (this.mcpClient) { + return this.executeWithDirectClient(signal, updateOutput); + } + return this.executeWithCallableTool(signal); + } + + /** + * Execute using the raw MCP SDK Client, which supports progress + * notifications via the onprogress callback. This enables real-time + * streaming of progress updates to the user during long-running + * MCP tool calls (e.g., browser automation). + */ + private async executeWithDirectClient( + signal: AbortSignal, + updateOutput?: (output: ToolResultDisplay) => void, + ): Promise { + const callToolResult = await this.mcpClient!.callTool( + { + name: this.serverToolName, + arguments: this.params as Record, + }, + undefined, + { + onprogress: (progress) => { + if (updateOutput) { + const progressData: McpToolProgressData = { + type: 'mcp_tool_progress', + progress: progress.progress, + ...(progress.total != null && { total: progress.total }), + ...(progress.message != null && { message: progress.message }), + }; + updateOutput(progressData); + } + }, + timeout: this.mcpTimeout, + signal, + }, + ); + + // Wrap the raw CallToolResult into the Part[] format that the + // existing transform/display functions expect. + const rawResponseParts = wrapMcpCallToolResultAsParts( + this.serverToolName, + callToolResult, + ); + + // Ensure the response is not an error + if (this.isMCPToolError(rawResponseParts)) { + const errorMessage = `MCP tool '${ + this.serverToolName + }' reported tool error for function call: ${safeJsonStringify({ + name: this.serverToolName, + args: this.params, + })} with response: ${safeJsonStringify(rawResponseParts)}`; + return { + llmContent: errorMessage, + returnDisplay: `Error: MCP tool '${this.serverToolName}' reported an error.`, + error: { + message: errorMessage, + type: ToolErrorType.MCP_TOOL_ERROR, + }, + }; + } + + const transformedParts = transformMcpContentToParts(rawResponseParts); + + return { + llmContent: transformedParts, + returnDisplay: getStringifiedResultForDisplay(rawResponseParts), + }; + } + + /** + * Fallback: execute using the @google/genai CallableTool wrapper. + * This path does NOT support progress notifications. + */ + private async executeWithCallableTool( + signal: AbortSignal, + ): Promise { const functionCalls: FunctionCall[] = [ { name: this.serverToolName, @@ -217,6 +339,8 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool< readonly trust?: boolean, nameOverride?: string, private readonly cliConfig?: Config, + private readonly mcpClient?: McpDirectClient, + private readonly mcpTimeout?: number, ) { super( nameOverride ?? generateValidName(serverToolName), @@ -225,7 +349,7 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool< Kind.Other, parameterSchema, true, // isOutputMarkdown - false, // canUpdateOutput + true, // canUpdateOutput — enables streaming progress for MCP tools ); } @@ -239,6 +363,8 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool< this.trust, `${this.serverName}__${this.serverToolName}`, this.cliConfig, + this.mcpClient, + this.mcpTimeout, ); } @@ -253,10 +379,37 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool< this.trust, params, this.cliConfig, + this.mcpClient, + this.mcpTimeout, ); } } +/** + * Wraps a raw MCP CallToolResult into the Part[] format that the + * existing transform/display functions expect. This bridges the gap + * between the raw MCP SDK response and the @google/genai Part format. + */ +function wrapMcpCallToolResultAsParts( + toolName: string, + result: { + content?: Array<{ [key: string]: unknown }>; + isError?: boolean; + }, +): Part[] { + const response = result.isError + ? { error: result, content: result.content } + : result; + return [ + { + functionResponse: { + name: toolName, + response, + }, + }, + ]; +} + function transformTextBlock(block: McpTextBlock): Part { return { text: block.text }; } diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 7b3c893e6..96ae53402 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -470,13 +470,28 @@ export interface AnsiOutputDisplay { ansiOutput: AnsiOutput; } +/** + * Structured progress data following the MCP notifications/progress spec. + * @see https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/progress + */ +export interface McpToolProgressData { + type: 'mcp_tool_progress'; + /** Current progress value (must increase with each notification) */ + progress: number; + /** Optional total value indicating the operation's target */ + total?: number; + /** Optional human-readable progress message */ + message?: string; +} + export type ToolResultDisplay = | string | FileDiff | TodoResultDisplay | PlanResultDisplay | TaskResultDisplay - | AnsiOutputDisplay; + | AnsiOutputDisplay + | McpToolProgressData; export interface FileDiff { fileDiff: string; From d18a5a37d16e3a1d9e27830d5fe7ebb97b385f47 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 9 Feb 2026 14:01:22 +0800 Subject: [PATCH 02/21] fix(vscode): handle auth errors on model switch and show login page When session/set_model fails with an authentication error (expired token, invalid credentials, etc.), the extension now: - Shows a VSCode error notification with the model name for context - Sends a loginRequired message to the webview so it renders the Onboarding login page (without clearing chat history) - On successful re-login the forceLogin overlay is dismissed Also guards against empty/zero-width-space-only chat messages and strips the internal ACP `\nData: {...}` payload from user-facing error messages. Includes unit tests for SessionMessageHandler (setModel success, auth errors, generic errors, missing modelId, stripAcpErrorData) and useWebViewMessages (loginRequired/loginSuccess round-trip). Co-authored-by: Cursor --- .../vscode-ide-companion/src/webview/App.tsx | 13 +- .../handlers/SessionMessageHandler.test.ts | 341 ++++++++++++++++++ .../webview/handlers/SessionMessageHandler.ts | 48 ++- .../webview/hooks/useWebViewMessages.test.tsx | 254 +++++++++++++ .../src/webview/hooks/useWebViewMessages.ts | 13 + 5 files changed, 665 insertions(+), 4 deletions(-) create mode 100644 packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts create mode 100644 packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.tsx diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 33f509929..cfb612c0a 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -72,6 +72,7 @@ export const App: React.FC = () => { } | null>(null); const [planEntries, setPlanEntries] = useState([]); const [isAuthenticated, setIsAuthenticated] = useState(null); + const [forceLogin, setForceLogin] = useState(false); const [isLoading, setIsLoading] = useState(true); // Track if we're still initializing/loading const [modelInfo, setModelInfo] = useState(null); const [usageStats, setUsageStats] = useState(null); @@ -335,6 +336,7 @@ export const App: React.FC = () => { setInputText, setEditMode, setIsAuthenticated, + setForceLogin, setUsageStats: (stats) => setUsageStats(stats ?? null), setModelInfo: (info) => { setModelInfo(info); @@ -896,7 +898,16 @@ export const App: React.FC = () => { ref={messagesContainerRef} className="chat-messages messages-container flex-1 overflow-y-auto overflow-x-hidden pt-5 pr-5 pl-5 pb-[140px] flex flex-col relative min-w-0 focus:outline-none [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-white/20 [&::-webkit-scrollbar-thumb]:rounded-sm [&::-webkit-scrollbar-thumb]:hover:bg-white/30 [&>*]:flex [&>*]:gap-0 [&>*]:items-start [&>*]:text-left [&>*]:py-2 [&>*:not(:last-child)]:pb-[8px] [&>*]:flex-col [&>*]:relative [&>*]:animate-[fadeIn_0.2s_ease-in]" > - {!hasContent && !isLoading ? ( + {forceLogin ? ( + { + vscode.postMessage({ type: 'login', data: {} }); + messageHandling.setWaitingForResponse( + 'Logging in to Qwen Code...', + ); + }} + /> + ) : !hasContent && !isLoading ? ( isAuthenticated === false ? ( { diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts new file mode 100644 index 000000000..982f11b3c --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts @@ -0,0 +1,341 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { QwenAgentManager } from '../../services/qwenAgentManager.js'; +import type { ConversationStore } from '../../services/conversationStore.js'; +import { SessionMessageHandler } from './SessionMessageHandler.js'; +import { + stripAcpErrorData, + ACP_ERROR_DATA_PREFIX, +} from './SessionMessageHandler.js'; +import * as vscode from 'vscode'; + +const vscodeMock = vi.hoisted(() => ({ + window: { + showErrorMessage: vi.fn(), + showWarningMessage: vi.fn(), + showInformationMessage: vi.fn(), + }, + commands: { + executeCommand: vi.fn(), + }, + workspace: { + workspaceFolders: [], + }, + ProgressLocation: { + Notification: 15, + }, +})); + +vi.mock('vscode', () => vscodeMock); + +// --------------------------------------------------------------------------- +// Helper: create a minimal SessionMessageHandler wired to the provided stubs +// --------------------------------------------------------------------------- + +function createHandler(overrides: { + agentManager?: Partial; + sendToWebView?: ReturnType; +}) { + const sendToWebView = overrides.sendToWebView ?? vi.fn(); + const conversationStore = { + createConversation: vi.fn(), + getConversation: vi.fn(), + addMessage: vi.fn(), + } as unknown as ConversationStore; + + const handler = new SessionMessageHandler( + (overrides.agentManager ?? {}) as unknown as QwenAgentManager, + conversationStore, + null, + sendToWebView, + ); + + return { handler, sendToWebView, conversationStore }; +} + +// =========================================================================== +// stripAcpErrorData (exported helper) +// =========================================================================== + +describe('stripAcpErrorData', () => { + it('returns the original message when there is no Data: payload', () => { + expect(stripAcpErrorData('Something went wrong')).toBe( + 'Something went wrong', + ); + }); + + it('strips the Data: JSON payload', () => { + const raw = `Authentication required (code: -32000)${ACP_ERROR_DATA_PREFIX}{"details":"expired"}`; + expect(stripAcpErrorData(raw)).toBe( + 'Authentication required (code: -32000)', + ); + }); + + it('trims trailing whitespace before the payload marker', () => { + const raw = `Error message ${ACP_ERROR_DATA_PREFIX}{"a":1}`; + expect(stripAcpErrorData(raw)).toBe('Error message'); + }); + + it('handles empty string', () => { + expect(stripAcpErrorData('')).toBe(''); + }); + + it('handles message that is only the prefix', () => { + expect(stripAcpErrorData(ACP_ERROR_DATA_PREFIX)).toBe(''); + }); +}); + +// =========================================================================== +// SessionMessageHandler – setModel +// =========================================================================== + +describe('SessionMessageHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ---- empty message guard ------------------------------------------------ + + it('ignores empty sendMessage text', async () => { + const { handler, sendToWebView, conversationStore } = createHandler({ + agentManager: { isConnected: true }, + }); + + await handler.handle({ type: 'sendMessage', data: { text: ' ' } }); + + expect(conversationStore.createConversation).not.toHaveBeenCalled(); + expect(conversationStore.addMessage).not.toHaveBeenCalled(); + expect(sendToWebView).not.toHaveBeenCalled(); + }); + + it('ignores zero-width-space-only sendMessage text', async () => { + const { handler, sendToWebView, conversationStore } = createHandler({ + agentManager: { isConnected: true }, + }); + + await handler.handle({ + type: 'sendMessage', + data: { text: '\u200B \u200B' }, + }); + + expect(conversationStore.createConversation).not.toHaveBeenCalled(); + expect(sendToWebView).not.toHaveBeenCalled(); + }); + + // ---- setModel: auth-required error -------------------------------------- + + it('notifies and emits loginRequired on auth-required setModel', async () => { + const setModelFromUi = vi + .fn() + .mockRejectedValueOnce( + new Error( + 'Authentication required (code: -32000)\nData: {"details":"Qwen OAuth credentials expired.","authMethods":[{"id":"qwen-oauth"}]}', + ), + ); + + const { handler, sendToWebView } = createHandler({ + agentManager: { setModelFromUi }, + }); + + await handler.handle({ + type: 'setModel', + data: { modelId: 'coder-model' }, + }); + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + expect.stringContaining('coder-model'), + ); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + expect.stringContaining('Authentication required'), + ); + expect(sendToWebView).toHaveBeenCalledWith( + expect.objectContaining({ type: 'loginRequired' }), + ); + }); + + it('detects auth error via "Unauthorized" keyword', async () => { + const setModelFromUi = vi + .fn() + .mockRejectedValueOnce(new Error('Unauthorized')); + + const { handler, sendToWebView } = createHandler({ + agentManager: { setModelFromUi }, + }); + + await handler.handle({ + type: 'setModel', + data: { modelId: 'some-model' }, + }); + + expect(sendToWebView).toHaveBeenCalledWith( + expect.objectContaining({ type: 'loginRequired' }), + ); + }); + + it('detects auth error via "Invalid token" keyword', async () => { + const setModelFromUi = vi + .fn() + .mockRejectedValueOnce(new Error('Invalid token')); + + const { handler, sendToWebView } = createHandler({ + agentManager: { setModelFromUi }, + }); + + await handler.handle({ + type: 'setModel', + data: { modelId: 'some-model' }, + }); + + expect(sendToWebView).toHaveBeenCalledWith( + expect.objectContaining({ type: 'loginRequired' }), + ); + }); + + it('detects auth error via "Session expired" keyword', async () => { + const setModelFromUi = vi + .fn() + .mockRejectedValueOnce(new Error('Session expired')); + + const { handler, sendToWebView } = createHandler({ + agentManager: { setModelFromUi }, + }); + + await handler.handle({ + type: 'setModel', + data: { modelId: 'some-model' }, + }); + + expect(sendToWebView).toHaveBeenCalledWith( + expect.objectContaining({ type: 'loginRequired' }), + ); + }); + + // ---- setModel: generic (non-auth) error --------------------------------- + + it('shows generic error when setModel fails for non-auth reason', async () => { + const setModelFromUi = vi + .fn() + .mockRejectedValueOnce(new Error('Network timeout')); + + const { handler, sendToWebView } = createHandler({ + agentManager: { setModelFromUi }, + }); + + await handler.handle({ + type: 'setModel', + data: { modelId: 'qwen3-coder-plus' }, + }); + + // Should show generic error, NOT loginRequired + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + expect.stringContaining('Network timeout'), + ); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + expect.stringContaining('qwen3-coder-plus'), + ); + expect(sendToWebView).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ); + expect(sendToWebView).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'loginRequired' }), + ); + }); + + it('strips ACP Data payload from generic error message', async () => { + const setModelFromUi = vi + .fn() + .mockRejectedValueOnce( + new Error( + `Model not found (code: -32001)${ACP_ERROR_DATA_PREFIX}{"detail":"unknown model"}`, + ), + ); + + const { handler, sendToWebView } = createHandler({ + agentManager: { setModelFromUi }, + }); + + await handler.handle({ + type: 'setModel', + data: { modelId: 'bad-model' }, + }); + + // The Data: ... portion should be stripped from the user-facing message + const errorCall = sendToWebView.mock.calls.find( + (args: unknown[]) => + (args[0] as { type: string } | undefined)?.type === 'error', + ); + expect(errorCall).toBeDefined(); + const errorData = (errorCall![0] as { data: { message: string } }).data; + expect(errorData.message).not.toContain('unknown model'); + expect(errorData.message).toContain('Model not found'); + }); + + // ---- setModel: missing modelId ------------------------------------------ + + it('shows error when modelId is missing', async () => { + const setModelFromUi = vi.fn(); + + const { handler, sendToWebView } = createHandler({ + agentManager: { setModelFromUi }, + }); + + await handler.handle({ type: 'setModel', data: {} }); + + expect(setModelFromUi).not.toHaveBeenCalled(); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + expect.stringContaining('Model ID is required'), + ); + expect(sendToWebView).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ); + }); + + // ---- setModel: success --------------------------------------------------- + + it('shows success notification on successful model switch', async () => { + const setModelFromUi = vi.fn().mockResolvedValueOnce({ + modelId: 'qwen3-coder-plus', + name: 'qwen3-coder-plus', + }); + + const { handler, sendToWebView } = createHandler({ + agentManager: { setModelFromUi }, + }); + + await handler.handle({ + type: 'setModel', + data: { modelId: 'qwen3-coder-plus' }, + }); + + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + 'Model switched to: qwen3-coder-plus', + ); + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled(); + expect(sendToWebView).not.toHaveBeenCalled(); + }); + + // ---- setModel: non-Error throw ------------------------------------------ + + it('handles non-Error throw (e.g. string) gracefully', async () => { + const setModelFromUi = vi.fn().mockRejectedValueOnce('plain string error'); + + const { handler, sendToWebView } = createHandler({ + agentManager: { setModelFromUi }, + }); + + await handler.handle({ + type: 'setModel', + data: { modelId: 'some-model' }, + }); + + expect(vscode.window.showErrorMessage).toHaveBeenCalled(); + expect(sendToWebView).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts index a9206b4a1..21960ea1a 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts @@ -9,9 +9,25 @@ import { BaseMessageHandler } from './BaseMessageHandler.js'; import type { ChatMessage } from '../../services/qwenAgentManager.js'; import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js'; import { ACP_ERROR_CODES } from '../../constants/acpSchema.js'; +import { isAuthenticationRequiredError } from '../../utils/authErrors.js'; const AUTH_REQUIRED_CODE_PATTERN = `(code: ${ACP_ERROR_CODES.AUTH_REQUIRED})`; +/** Prefix that separates the human-readable ACP error from its JSON data payload. */ +export const ACP_ERROR_DATA_PREFIX = '\nData: '; + +/** + * Strip the trailing `\nData: {...}` payload from an ACP error message so that + * only the human-readable portion is shown to the user. + */ +export const stripAcpErrorData = (message: string): string => { + const idx = message.indexOf(ACP_ERROR_DATA_PREFIX); + if (idx === -1) { + return message; + } + return message.slice(0, idx).trim(); +}; + /** * Session message handler * Handles all session-related messages @@ -247,6 +263,12 @@ export class SessionMessageHandler extends BaseMessageHandler { ): Promise { console.log('[SessionMessageHandler] handleSendMessage called with:', text); + const trimmedText = text.replace(/\u200B/g, '').trim(); + if (!trimmedText) { + console.warn('[SessionMessageHandler] Ignoring empty message'); + return; + } + // Format message with file context if present let formattedText = text; if (context && context.length > 0) { @@ -1049,8 +1071,8 @@ export class SessionMessageHandler extends BaseMessageHandler { * Displays VSCode native notifications on success or failure. */ private async handleSetModel(data?: { modelId?: string }): Promise { + const modelId = data?.modelId; try { - const modelId = data?.modelId; if (!modelId) { throw new Error('Model ID is required'); } @@ -1060,11 +1082,31 @@ export class SessionMessageHandler extends BaseMessageHandler { ); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); + const cleanMsg = stripAcpErrorData(errorMsg); + const requiresAuth = + isAuthenticationRequiredError(error) || + cleanMsg.includes(AUTH_REQUIRED_CODE_PATTERN); + console.error('[SessionMessageHandler] Failed to set model:', error); - vscode.window.showErrorMessage(`Failed to switch model: ${errorMsg}`); + if (requiresAuth) { + const authMsg = modelId + ? `Authentication required to switch to model "${modelId}". Please login again.` + : 'Authentication required. Please login again to switch models.'; + vscode.window.showErrorMessage(authMsg); + this.sendToWebView({ + type: 'loginRequired', + data: { message: authMsg }, + }); + return; + } + + const failMsg = modelId + ? `Failed to switch to model "${modelId}": ${cleanMsg}` + : `Failed to switch model: ${cleanMsg}`; + vscode.window.showErrorMessage(failMsg); this.sendToWebView({ type: 'error', - data: { message: `Failed to set model: ${errorMsg}` }, + data: { message: failMsg }, }); } } diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.tsx b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.tsx new file mode 100644 index 000000000..25cf279c7 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.tsx @@ -0,0 +1,254 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** @vitest-environment jsdom */ + +import type React from 'react'; +import { act } from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { createRoot } from 'react-dom/client'; + +declare global { + + var acquireVsCodeApi: + | undefined + | (() => { + postMessage: (message: unknown) => void; + getState: () => unknown; + setState: (state: unknown) => void; + }); +} + +const createProps = (overrides: Record = {}) => ({ + sessionManagement: { + currentSessionId: null, + setQwenSessions: vi.fn(), + setCurrentSessionId: vi.fn(), + setCurrentSessionTitle: vi.fn(), + setShowSessionSelector: vi.fn(), + setNextCursor: vi.fn(), + setHasMore: vi.fn(), + setIsLoading: vi.fn(), + handleSaveSessionResponse: vi.fn(), + }, + fileContext: { + setActiveFileName: vi.fn(), + setActiveFilePath: vi.fn(), + setActiveSelection: vi.fn(), + setWorkspaceFilesFromResponse: vi.fn(), + addFileReference: vi.fn(), + }, + messageHandling: { + setMessages: vi.fn(), + addMessage: vi.fn(), + clearMessages: vi.fn(), + startStreaming: vi.fn(), + appendStreamChunk: vi.fn(), + endStreaming: vi.fn(), + breakAssistantSegment: vi.fn(), + appendThinkingChunk: vi.fn(), + clearThinking: vi.fn(), + setWaitingForResponse: vi.fn(), + clearWaitingForResponse: vi.fn(), + }, + handleToolCallUpdate: vi.fn(), + clearToolCalls: vi.fn(), + setPlanEntries: vi.fn(), + handlePermissionRequest: vi.fn(), + inputFieldRef: { + current: document.createElement('div'), + } as React.RefObject, + setInputText: vi.fn(), + ...overrides, +}); + +const renderHook = async (props: Record) => { + const { useWebViewMessages } = await import('./useWebViewMessages.js'); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + + const Harness = () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + useWebViewMessages(props as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return
; + }; + + await act(async () => { + root.render(); + }); + await act(async () => {}); + + return { + unmount: () => { + act(() => { + root.unmount(); + }); + container.remove(); + }, + }; +}; + +const setup = async (overrides: Record = {}) => { + const postMessage = vi.fn(); + ( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + globalThis.acquireVsCodeApi = () => ({ + postMessage, + getState: vi.fn(), + setState: vi.fn(), + }); + + const props = createProps(overrides); + const { unmount } = await renderHook(props); + + return { props, unmount, postMessage }; +}; + +// --------------------------------------------------------------------------- +// Helpers – dispatch a webview message event +// --------------------------------------------------------------------------- + +function dispatchWebViewMessage(type: string, data?: unknown) { + act(() => { + window.dispatchEvent(new MessageEvent('message', { data: { type, data } })); + }); +} + +// =========================================================================== +// Tests +// =========================================================================== + +describe('useWebViewMessages', () => { + afterEach(() => { + document.body.innerHTML = ''; + globalThis.acquireVsCodeApi = undefined; + delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT; + }); + + // ---- loginRequired ------------------------------------------------------- + + it('enters login-required state on loginRequired message', async () => { + const setForceLogin = vi.fn(); + const setIsAuthenticated = vi.fn(); + + const { unmount } = await setup({ + setForceLogin, + setIsAuthenticated, + }); + + dispatchWebViewMessage('loginRequired', { + message: 'Session expired. Please login again.', + }); + + expect(setIsAuthenticated).toHaveBeenCalledWith(false); + expect(setForceLogin).toHaveBeenCalledWith(true); + + unmount(); + }); + + it('clears waitingForResponse when loginRequired arrives', async () => { + const setForceLogin = vi.fn(); + const setIsAuthenticated = vi.fn(); + const clearWaitingForResponse = vi.fn(); + + const { unmount } = await setup({ + setForceLogin, + setIsAuthenticated, + messageHandling: { + setMessages: vi.fn(), + addMessage: vi.fn(), + clearMessages: vi.fn(), + startStreaming: vi.fn(), + appendStreamChunk: vi.fn(), + endStreaming: vi.fn(), + breakAssistantSegment: vi.fn(), + appendThinkingChunk: vi.fn(), + clearThinking: vi.fn(), + setWaitingForResponse: vi.fn(), + clearWaitingForResponse, + }, + }); + + dispatchWebViewMessage('loginRequired', { + message: 'Auth needed.', + }); + + expect(clearWaitingForResponse).toHaveBeenCalled(); + + unmount(); + }); + + // ---- loginSuccess clears forceLogin -------------------------------------- + + it('clears forceLogin on loginSuccess message', async () => { + const setForceLogin = vi.fn(); + const setIsAuthenticated = vi.fn(); + + const { unmount } = await setup({ + setForceLogin, + setIsAuthenticated, + }); + + dispatchWebViewMessage('loginSuccess', {}); + + expect(setIsAuthenticated).toHaveBeenCalledWith(true); + expect(setForceLogin).toHaveBeenCalledWith(false); + + unmount(); + }); + + // ---- loginRequired → loginSuccess round-trip ----------------------------- + + it('handles loginRequired → loginSuccess round-trip correctly', async () => { + const setForceLogin = vi.fn(); + const setIsAuthenticated = vi.fn(); + + const { unmount } = await setup({ + setForceLogin, + setIsAuthenticated, + }); + + // First: trigger loginRequired + dispatchWebViewMessage('loginRequired', { + message: 'Session expired.', + }); + + expect(setForceLogin).toHaveBeenCalledWith(true); + expect(setIsAuthenticated).toHaveBeenCalledWith(false); + + // Then: loginSuccess + dispatchWebViewMessage('loginSuccess', {}); + + expect(setForceLogin).toHaveBeenCalledWith(false); + expect(setIsAuthenticated).toHaveBeenCalledWith(true); + + unmount(); + }); + + // ---- setForceLogin not provided (graceful no-op) ------------------------- + + it('does not crash when setForceLogin is not provided', async () => { + const setIsAuthenticated = vi.fn(); + + const { unmount } = await setup({ + // intentionally NOT providing setForceLogin + setIsAuthenticated, + }); + + // Should not throw + dispatchWebViewMessage('loginRequired', { + message: 'Auth needed.', + }); + + expect(setIsAuthenticated).toHaveBeenCalledWith(false); + + unmount(); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts index 7a66e393f..990a96a38 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -121,6 +121,8 @@ interface UseWebViewMessagesProps { setEditMode?: (mode: ApprovalModeValue) => void; // Authentication state setter setIsAuthenticated?: (authenticated: boolean | null) => void; + // Force login page display + setForceLogin?: (value: boolean) => void; // Usage stats setter setUsageStats?: (stats: UsageStatsPayload | undefined) => void; // Model info setter @@ -147,6 +149,7 @@ export const useWebViewMessages = ({ setInputText, setEditMode, setIsAuthenticated, + setForceLogin, setUsageStats, setModelInfo, setAvailableCommands, @@ -168,6 +171,7 @@ export const useWebViewMessages = ({ setPlanEntries, handlePermissionRequest, setIsAuthenticated, + setForceLogin, setUsageStats, setModelInfo, setAvailableCommands, @@ -217,6 +221,7 @@ export const useWebViewMessages = ({ setPlanEntries, handlePermissionRequest, setIsAuthenticated, + setForceLogin, setUsageStats, setModelInfo, setAvailableCommands, @@ -349,6 +354,14 @@ export const useWebViewMessages = ({ }); // Set authentication state to true handlers.setIsAuthenticated?.(true); + handlers.setForceLogin?.(false); + break; + } + + case 'loginRequired': { + handlers.messageHandling.clearWaitingForResponse(); + handlers.setIsAuthenticated?.(false); + handlers.setForceLogin?.(true); break; } From 33bddd44f16f2d0a3063b5d27999a998eb425773 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 9 Feb 2026 14:32:56 +0800 Subject: [PATCH 03/21] fix(vscode): prevent empty user message when selecting model via Enter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ModelSelector's keydown handler used bubble-phase addEventListener on document, so pressing Enter to confirm a model would also propagate to the InputForm and trigger a form submit with empty text — creating a ghost user-message bubble. Fix: register the handler with `{ capture: true }` and call both `preventDefault()` and `stopPropagation()` on arrow/Enter/Escape keys so the event never reaches the InputForm. Co-authored-by: Cursor --- .../webview/components/layout/ModelSelector.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/vscode-ide-companion/src/webview/components/layout/ModelSelector.tsx b/packages/vscode-ide-companion/src/webview/components/layout/ModelSelector.tsx index 5a927842f..155cc4bb1 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/ModelSelector.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/ModelSelector.tsx @@ -61,14 +61,19 @@ export const ModelSelector: FC = ({ switch (event.key) { case 'ArrowDown': event.preventDefault(); + event.stopPropagation(); setSelected((prev) => Math.min(prev + 1, models.length - 1)); break; case 'ArrowUp': event.preventDefault(); + event.stopPropagation(); setSelected((prev) => Math.max(prev - 1, 0)); break; case 'Enter': + // Prevent form submission AND stop propagation so the input form + // does not treat this Enter as a message send. event.preventDefault(); + event.stopPropagation(); if (models[selected]) { onSelectModel(models[selected].modelId); onClose(); @@ -76,6 +81,7 @@ export const ModelSelector: FC = ({ break; case 'Escape': event.preventDefault(); + event.stopPropagation(); onClose(); break; default: @@ -84,11 +90,15 @@ export const ModelSelector: FC = ({ }; document.addEventListener('mousedown', handleClickOutside); - document.addEventListener('keydown', handleKeyDown); + // Use capture phase so this handler fires BEFORE any bubble-phase + // handlers on child elements (e.g. the InputForm's Enter-to-submit). + // Combined with stopPropagation this prevents an empty user message + // from being created when the user presses Enter to confirm a model. + document.addEventListener('keydown', handleKeyDown, true); return () => { document.removeEventListener('mousedown', handleClickOutside); - document.removeEventListener('keydown', handleKeyDown); + document.removeEventListener('keydown', handleKeyDown, true); }; }, [visible, models, selected, onSelectModel, onClose]); From 0109619c2dbed7e80cee16c1e9e6a1701a740127 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 9 Feb 2026 15:54:49 +0800 Subject: [PATCH 04/21] test: move webui component tests and clean docs --- .../vscode-ide-companion/src/webview/App.tsx | 13 +- .../handlers/SessionMessageHandler.test.ts | 341 ------------------ .../webview/handlers/SessionMessageHandler.ts | 35 +- .../webview/hooks/useWebViewMessages.test.tsx | 254 ------------- .../src/webview/hooks/useWebViewMessages.ts | 13 - 5 files changed, 10 insertions(+), 646 deletions(-) delete mode 100644 packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts delete mode 100644 packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.tsx diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index c2bf01644..8d2c0bfed 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -72,7 +72,6 @@ export const App: React.FC = () => { } | null>(null); const [planEntries, setPlanEntries] = useState([]); const [isAuthenticated, setIsAuthenticated] = useState(null); - const [forceLogin, setForceLogin] = useState(false); const [isLoading, setIsLoading] = useState(true); // Track if we're still initializing/loading const [modelInfo, setModelInfo] = useState(null); const [usageStats, setUsageStats] = useState(null); @@ -336,7 +335,6 @@ export const App: React.FC = () => { setInputText, setEditMode, setIsAuthenticated, - setForceLogin, setUsageStats: (stats) => setUsageStats(stats ?? null), setModelInfo: (info) => { setModelInfo(info); @@ -898,16 +896,7 @@ export const App: React.FC = () => { ref={messagesContainerRef} className="chat-messages messages-container flex-1 overflow-y-auto overflow-x-hidden pt-5 pr-5 pl-5 pb-[140px] flex flex-col relative min-w-0 focus:outline-none [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-white/20 [&::-webkit-scrollbar-thumb]:rounded-sm [&::-webkit-scrollbar-thumb]:hover:bg-white/30 [&>*]:flex [&>*]:gap-0 [&>*]:items-start [&>*]:text-left [&>*]:py-2 [&>*:not(:last-child)]:pb-[8px] [&>*]:flex-col [&>*]:relative [&>*]:animate-[fadeIn_0.2s_ease-in]" > - {forceLogin ? ( - { - vscode.postMessage({ type: 'login', data: {} }); - messageHandling.setWaitingForResponse( - 'Logging in to Qwen Code...', - ); - }} - /> - ) : !hasContent && !isLoading ? ( + {!hasContent && !isLoading ? ( isAuthenticated === false ? ( { diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts deleted file mode 100644 index 982f11b3c..000000000 --- a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts +++ /dev/null @@ -1,341 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { QwenAgentManager } from '../../services/qwenAgentManager.js'; -import type { ConversationStore } from '../../services/conversationStore.js'; -import { SessionMessageHandler } from './SessionMessageHandler.js'; -import { - stripAcpErrorData, - ACP_ERROR_DATA_PREFIX, -} from './SessionMessageHandler.js'; -import * as vscode from 'vscode'; - -const vscodeMock = vi.hoisted(() => ({ - window: { - showErrorMessage: vi.fn(), - showWarningMessage: vi.fn(), - showInformationMessage: vi.fn(), - }, - commands: { - executeCommand: vi.fn(), - }, - workspace: { - workspaceFolders: [], - }, - ProgressLocation: { - Notification: 15, - }, -})); - -vi.mock('vscode', () => vscodeMock); - -// --------------------------------------------------------------------------- -// Helper: create a minimal SessionMessageHandler wired to the provided stubs -// --------------------------------------------------------------------------- - -function createHandler(overrides: { - agentManager?: Partial; - sendToWebView?: ReturnType; -}) { - const sendToWebView = overrides.sendToWebView ?? vi.fn(); - const conversationStore = { - createConversation: vi.fn(), - getConversation: vi.fn(), - addMessage: vi.fn(), - } as unknown as ConversationStore; - - const handler = new SessionMessageHandler( - (overrides.agentManager ?? {}) as unknown as QwenAgentManager, - conversationStore, - null, - sendToWebView, - ); - - return { handler, sendToWebView, conversationStore }; -} - -// =========================================================================== -// stripAcpErrorData (exported helper) -// =========================================================================== - -describe('stripAcpErrorData', () => { - it('returns the original message when there is no Data: payload', () => { - expect(stripAcpErrorData('Something went wrong')).toBe( - 'Something went wrong', - ); - }); - - it('strips the Data: JSON payload', () => { - const raw = `Authentication required (code: -32000)${ACP_ERROR_DATA_PREFIX}{"details":"expired"}`; - expect(stripAcpErrorData(raw)).toBe( - 'Authentication required (code: -32000)', - ); - }); - - it('trims trailing whitespace before the payload marker', () => { - const raw = `Error message ${ACP_ERROR_DATA_PREFIX}{"a":1}`; - expect(stripAcpErrorData(raw)).toBe('Error message'); - }); - - it('handles empty string', () => { - expect(stripAcpErrorData('')).toBe(''); - }); - - it('handles message that is only the prefix', () => { - expect(stripAcpErrorData(ACP_ERROR_DATA_PREFIX)).toBe(''); - }); -}); - -// =========================================================================== -// SessionMessageHandler – setModel -// =========================================================================== - -describe('SessionMessageHandler', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - // ---- empty message guard ------------------------------------------------ - - it('ignores empty sendMessage text', async () => { - const { handler, sendToWebView, conversationStore } = createHandler({ - agentManager: { isConnected: true }, - }); - - await handler.handle({ type: 'sendMessage', data: { text: ' ' } }); - - expect(conversationStore.createConversation).not.toHaveBeenCalled(); - expect(conversationStore.addMessage).not.toHaveBeenCalled(); - expect(sendToWebView).not.toHaveBeenCalled(); - }); - - it('ignores zero-width-space-only sendMessage text', async () => { - const { handler, sendToWebView, conversationStore } = createHandler({ - agentManager: { isConnected: true }, - }); - - await handler.handle({ - type: 'sendMessage', - data: { text: '\u200B \u200B' }, - }); - - expect(conversationStore.createConversation).not.toHaveBeenCalled(); - expect(sendToWebView).not.toHaveBeenCalled(); - }); - - // ---- setModel: auth-required error -------------------------------------- - - it('notifies and emits loginRequired on auth-required setModel', async () => { - const setModelFromUi = vi - .fn() - .mockRejectedValueOnce( - new Error( - 'Authentication required (code: -32000)\nData: {"details":"Qwen OAuth credentials expired.","authMethods":[{"id":"qwen-oauth"}]}', - ), - ); - - const { handler, sendToWebView } = createHandler({ - agentManager: { setModelFromUi }, - }); - - await handler.handle({ - type: 'setModel', - data: { modelId: 'coder-model' }, - }); - - expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( - expect.stringContaining('coder-model'), - ); - expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( - expect.stringContaining('Authentication required'), - ); - expect(sendToWebView).toHaveBeenCalledWith( - expect.objectContaining({ type: 'loginRequired' }), - ); - }); - - it('detects auth error via "Unauthorized" keyword', async () => { - const setModelFromUi = vi - .fn() - .mockRejectedValueOnce(new Error('Unauthorized')); - - const { handler, sendToWebView } = createHandler({ - agentManager: { setModelFromUi }, - }); - - await handler.handle({ - type: 'setModel', - data: { modelId: 'some-model' }, - }); - - expect(sendToWebView).toHaveBeenCalledWith( - expect.objectContaining({ type: 'loginRequired' }), - ); - }); - - it('detects auth error via "Invalid token" keyword', async () => { - const setModelFromUi = vi - .fn() - .mockRejectedValueOnce(new Error('Invalid token')); - - const { handler, sendToWebView } = createHandler({ - agentManager: { setModelFromUi }, - }); - - await handler.handle({ - type: 'setModel', - data: { modelId: 'some-model' }, - }); - - expect(sendToWebView).toHaveBeenCalledWith( - expect.objectContaining({ type: 'loginRequired' }), - ); - }); - - it('detects auth error via "Session expired" keyword', async () => { - const setModelFromUi = vi - .fn() - .mockRejectedValueOnce(new Error('Session expired')); - - const { handler, sendToWebView } = createHandler({ - agentManager: { setModelFromUi }, - }); - - await handler.handle({ - type: 'setModel', - data: { modelId: 'some-model' }, - }); - - expect(sendToWebView).toHaveBeenCalledWith( - expect.objectContaining({ type: 'loginRequired' }), - ); - }); - - // ---- setModel: generic (non-auth) error --------------------------------- - - it('shows generic error when setModel fails for non-auth reason', async () => { - const setModelFromUi = vi - .fn() - .mockRejectedValueOnce(new Error('Network timeout')); - - const { handler, sendToWebView } = createHandler({ - agentManager: { setModelFromUi }, - }); - - await handler.handle({ - type: 'setModel', - data: { modelId: 'qwen3-coder-plus' }, - }); - - // Should show generic error, NOT loginRequired - expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( - expect.stringContaining('Network timeout'), - ); - expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( - expect.stringContaining('qwen3-coder-plus'), - ); - expect(sendToWebView).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error' }), - ); - expect(sendToWebView).not.toHaveBeenCalledWith( - expect.objectContaining({ type: 'loginRequired' }), - ); - }); - - it('strips ACP Data payload from generic error message', async () => { - const setModelFromUi = vi - .fn() - .mockRejectedValueOnce( - new Error( - `Model not found (code: -32001)${ACP_ERROR_DATA_PREFIX}{"detail":"unknown model"}`, - ), - ); - - const { handler, sendToWebView } = createHandler({ - agentManager: { setModelFromUi }, - }); - - await handler.handle({ - type: 'setModel', - data: { modelId: 'bad-model' }, - }); - - // The Data: ... portion should be stripped from the user-facing message - const errorCall = sendToWebView.mock.calls.find( - (args: unknown[]) => - (args[0] as { type: string } | undefined)?.type === 'error', - ); - expect(errorCall).toBeDefined(); - const errorData = (errorCall![0] as { data: { message: string } }).data; - expect(errorData.message).not.toContain('unknown model'); - expect(errorData.message).toContain('Model not found'); - }); - - // ---- setModel: missing modelId ------------------------------------------ - - it('shows error when modelId is missing', async () => { - const setModelFromUi = vi.fn(); - - const { handler, sendToWebView } = createHandler({ - agentManager: { setModelFromUi }, - }); - - await handler.handle({ type: 'setModel', data: {} }); - - expect(setModelFromUi).not.toHaveBeenCalled(); - expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( - expect.stringContaining('Model ID is required'), - ); - expect(sendToWebView).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error' }), - ); - }); - - // ---- setModel: success --------------------------------------------------- - - it('shows success notification on successful model switch', async () => { - const setModelFromUi = vi.fn().mockResolvedValueOnce({ - modelId: 'qwen3-coder-plus', - name: 'qwen3-coder-plus', - }); - - const { handler, sendToWebView } = createHandler({ - agentManager: { setModelFromUi }, - }); - - await handler.handle({ - type: 'setModel', - data: { modelId: 'qwen3-coder-plus' }, - }); - - expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( - 'Model switched to: qwen3-coder-plus', - ); - expect(vscode.window.showErrorMessage).not.toHaveBeenCalled(); - expect(sendToWebView).not.toHaveBeenCalled(); - }); - - // ---- setModel: non-Error throw ------------------------------------------ - - it('handles non-Error throw (e.g. string) gracefully', async () => { - const setModelFromUi = vi.fn().mockRejectedValueOnce('plain string error'); - - const { handler, sendToWebView } = createHandler({ - agentManager: { setModelFromUi }, - }); - - await handler.handle({ - type: 'setModel', - data: { modelId: 'some-model' }, - }); - - expect(vscode.window.showErrorMessage).toHaveBeenCalled(); - expect(sendToWebView).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error' }), - ); - }); -}); diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts index 21960ea1a..f0d10ea43 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts @@ -9,24 +9,23 @@ import { BaseMessageHandler } from './BaseMessageHandler.js'; import type { ChatMessage } from '../../services/qwenAgentManager.js'; import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js'; import { ACP_ERROR_CODES } from '../../constants/acpSchema.js'; -import { isAuthenticationRequiredError } from '../../utils/authErrors.js'; const AUTH_REQUIRED_CODE_PATTERN = `(code: ${ACP_ERROR_CODES.AUTH_REQUIRED})`; /** Prefix that separates the human-readable ACP error from its JSON data payload. */ -export const ACP_ERROR_DATA_PREFIX = '\nData: '; +const ACP_ERROR_DATA_PREFIX = '\nData: '; /** * Strip the trailing `\nData: {...}` payload from an ACP error message so that * only the human-readable portion is shown to the user. */ -export const stripAcpErrorData = (message: string): string => { +function stripAcpErrorData(message: string): string { const idx = message.indexOf(ACP_ERROR_DATA_PREFIX); if (idx === -1) { return message; } return message.slice(0, idx).trim(); -}; +} /** * Session message handler @@ -263,6 +262,9 @@ export class SessionMessageHandler extends BaseMessageHandler { ): Promise { console.log('[SessionMessageHandler] handleSendMessage called with:', text); + // Guard: do not process empty or whitespace-only messages. + // This prevents ghost user-message bubbles when slash-command completions + // or model-selector interactions clear the input but still trigger a submit. const trimmedText = text.replace(/\u200B/g, '').trim(); if (!trimmedText) { console.warn('[SessionMessageHandler] Ignoring empty message'); @@ -1071,8 +1073,8 @@ export class SessionMessageHandler extends BaseMessageHandler { * Displays VSCode native notifications on success or failure. */ private async handleSetModel(data?: { modelId?: string }): Promise { - const modelId = data?.modelId; try { + const modelId = data?.modelId; if (!modelId) { throw new Error('Model ID is required'); } @@ -1083,30 +1085,11 @@ export class SessionMessageHandler extends BaseMessageHandler { } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); const cleanMsg = stripAcpErrorData(errorMsg); - const requiresAuth = - isAuthenticationRequiredError(error) || - cleanMsg.includes(AUTH_REQUIRED_CODE_PATTERN); - console.error('[SessionMessageHandler] Failed to set model:', error); - if (requiresAuth) { - const authMsg = modelId - ? `Authentication required to switch to model "${modelId}". Please login again.` - : 'Authentication required. Please login again to switch models.'; - vscode.window.showErrorMessage(authMsg); - this.sendToWebView({ - type: 'loginRequired', - data: { message: authMsg }, - }); - return; - } - - const failMsg = modelId - ? `Failed to switch to model "${modelId}": ${cleanMsg}` - : `Failed to switch model: ${cleanMsg}`; - vscode.window.showErrorMessage(failMsg); + vscode.window.showErrorMessage(`Failed to switch model: ${cleanMsg}`); this.sendToWebView({ type: 'error', - data: { message: failMsg }, + data: { message: `Failed to set model: ${cleanMsg}` }, }); } } diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.tsx b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.tsx deleted file mode 100644 index 25cf279c7..000000000 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.tsx +++ /dev/null @@ -1,254 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -/** @vitest-environment jsdom */ - -import type React from 'react'; -import { act } from 'react'; -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { createRoot } from 'react-dom/client'; - -declare global { - - var acquireVsCodeApi: - | undefined - | (() => { - postMessage: (message: unknown) => void; - getState: () => unknown; - setState: (state: unknown) => void; - }); -} - -const createProps = (overrides: Record = {}) => ({ - sessionManagement: { - currentSessionId: null, - setQwenSessions: vi.fn(), - setCurrentSessionId: vi.fn(), - setCurrentSessionTitle: vi.fn(), - setShowSessionSelector: vi.fn(), - setNextCursor: vi.fn(), - setHasMore: vi.fn(), - setIsLoading: vi.fn(), - handleSaveSessionResponse: vi.fn(), - }, - fileContext: { - setActiveFileName: vi.fn(), - setActiveFilePath: vi.fn(), - setActiveSelection: vi.fn(), - setWorkspaceFilesFromResponse: vi.fn(), - addFileReference: vi.fn(), - }, - messageHandling: { - setMessages: vi.fn(), - addMessage: vi.fn(), - clearMessages: vi.fn(), - startStreaming: vi.fn(), - appendStreamChunk: vi.fn(), - endStreaming: vi.fn(), - breakAssistantSegment: vi.fn(), - appendThinkingChunk: vi.fn(), - clearThinking: vi.fn(), - setWaitingForResponse: vi.fn(), - clearWaitingForResponse: vi.fn(), - }, - handleToolCallUpdate: vi.fn(), - clearToolCalls: vi.fn(), - setPlanEntries: vi.fn(), - handlePermissionRequest: vi.fn(), - inputFieldRef: { - current: document.createElement('div'), - } as React.RefObject, - setInputText: vi.fn(), - ...overrides, -}); - -const renderHook = async (props: Record) => { - const { useWebViewMessages } = await import('./useWebViewMessages.js'); - const container = document.createElement('div'); - document.body.appendChild(container); - const root = createRoot(container); - - const Harness = () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - useWebViewMessages(props as any); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return
; - }; - - await act(async () => { - root.render(); - }); - await act(async () => {}); - - return { - unmount: () => { - act(() => { - root.unmount(); - }); - container.remove(); - }, - }; -}; - -const setup = async (overrides: Record = {}) => { - const postMessage = vi.fn(); - ( - globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } - ).IS_REACT_ACT_ENVIRONMENT = true; - globalThis.acquireVsCodeApi = () => ({ - postMessage, - getState: vi.fn(), - setState: vi.fn(), - }); - - const props = createProps(overrides); - const { unmount } = await renderHook(props); - - return { props, unmount, postMessage }; -}; - -// --------------------------------------------------------------------------- -// Helpers – dispatch a webview message event -// --------------------------------------------------------------------------- - -function dispatchWebViewMessage(type: string, data?: unknown) { - act(() => { - window.dispatchEvent(new MessageEvent('message', { data: { type, data } })); - }); -} - -// =========================================================================== -// Tests -// =========================================================================== - -describe('useWebViewMessages', () => { - afterEach(() => { - document.body.innerHTML = ''; - globalThis.acquireVsCodeApi = undefined; - delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) - .IS_REACT_ACT_ENVIRONMENT; - }); - - // ---- loginRequired ------------------------------------------------------- - - it('enters login-required state on loginRequired message', async () => { - const setForceLogin = vi.fn(); - const setIsAuthenticated = vi.fn(); - - const { unmount } = await setup({ - setForceLogin, - setIsAuthenticated, - }); - - dispatchWebViewMessage('loginRequired', { - message: 'Session expired. Please login again.', - }); - - expect(setIsAuthenticated).toHaveBeenCalledWith(false); - expect(setForceLogin).toHaveBeenCalledWith(true); - - unmount(); - }); - - it('clears waitingForResponse when loginRequired arrives', async () => { - const setForceLogin = vi.fn(); - const setIsAuthenticated = vi.fn(); - const clearWaitingForResponse = vi.fn(); - - const { unmount } = await setup({ - setForceLogin, - setIsAuthenticated, - messageHandling: { - setMessages: vi.fn(), - addMessage: vi.fn(), - clearMessages: vi.fn(), - startStreaming: vi.fn(), - appendStreamChunk: vi.fn(), - endStreaming: vi.fn(), - breakAssistantSegment: vi.fn(), - appendThinkingChunk: vi.fn(), - clearThinking: vi.fn(), - setWaitingForResponse: vi.fn(), - clearWaitingForResponse, - }, - }); - - dispatchWebViewMessage('loginRequired', { - message: 'Auth needed.', - }); - - expect(clearWaitingForResponse).toHaveBeenCalled(); - - unmount(); - }); - - // ---- loginSuccess clears forceLogin -------------------------------------- - - it('clears forceLogin on loginSuccess message', async () => { - const setForceLogin = vi.fn(); - const setIsAuthenticated = vi.fn(); - - const { unmount } = await setup({ - setForceLogin, - setIsAuthenticated, - }); - - dispatchWebViewMessage('loginSuccess', {}); - - expect(setIsAuthenticated).toHaveBeenCalledWith(true); - expect(setForceLogin).toHaveBeenCalledWith(false); - - unmount(); - }); - - // ---- loginRequired → loginSuccess round-trip ----------------------------- - - it('handles loginRequired → loginSuccess round-trip correctly', async () => { - const setForceLogin = vi.fn(); - const setIsAuthenticated = vi.fn(); - - const { unmount } = await setup({ - setForceLogin, - setIsAuthenticated, - }); - - // First: trigger loginRequired - dispatchWebViewMessage('loginRequired', { - message: 'Session expired.', - }); - - expect(setForceLogin).toHaveBeenCalledWith(true); - expect(setIsAuthenticated).toHaveBeenCalledWith(false); - - // Then: loginSuccess - dispatchWebViewMessage('loginSuccess', {}); - - expect(setForceLogin).toHaveBeenCalledWith(false); - expect(setIsAuthenticated).toHaveBeenCalledWith(true); - - unmount(); - }); - - // ---- setForceLogin not provided (graceful no-op) ------------------------- - - it('does not crash when setForceLogin is not provided', async () => { - const setIsAuthenticated = vi.fn(); - - const { unmount } = await setup({ - // intentionally NOT providing setForceLogin - setIsAuthenticated, - }); - - // Should not throw - dispatchWebViewMessage('loginRequired', { - message: 'Auth needed.', - }); - - expect(setIsAuthenticated).toHaveBeenCalledWith(false); - - unmount(); - }); -}); diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts index 3d9e0b1d9..30a1166b0 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -121,8 +121,6 @@ interface UseWebViewMessagesProps { setEditMode?: (mode: ApprovalModeValue) => void; // Authentication state setter setIsAuthenticated?: (authenticated: boolean | null) => void; - // Force login page display - setForceLogin?: (value: boolean) => void; // Usage stats setter setUsageStats?: (stats: UsageStatsPayload | undefined) => void; // Model info setter @@ -149,7 +147,6 @@ export const useWebViewMessages = ({ setInputText, setEditMode, setIsAuthenticated, - setForceLogin, setUsageStats, setModelInfo, setAvailableCommands, @@ -171,7 +168,6 @@ export const useWebViewMessages = ({ setPlanEntries, handlePermissionRequest, setIsAuthenticated, - setForceLogin, setUsageStats, setModelInfo, setAvailableCommands, @@ -221,7 +217,6 @@ export const useWebViewMessages = ({ setPlanEntries, handlePermissionRequest, setIsAuthenticated, - setForceLogin, setUsageStats, setModelInfo, setAvailableCommands, @@ -354,14 +349,6 @@ export const useWebViewMessages = ({ }); // Set authentication state to true handlers.setIsAuthenticated?.(true); - handlers.setForceLogin?.(false); - break; - } - - case 'loginRequired': { - handlers.messageHandling.clearWaitingForResponse(); - handlers.setIsAuthenticated?.(false); - handlers.setForceLogin?.(true); break; } From 981692b23923f467d35c8bfabe21c813ef60e0f4 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 9 Feb 2026 19:17:46 +0800 Subject: [PATCH 05/21] fix(vscode-ide-companion): refine model selection keyboard handling and error display Co-authored-by: Qwen-Coder --- .../components/layout/ModelSelector.tsx | 10 +++------- .../webview/handlers/SessionMessageHandler.ts | 20 ++----------------- 2 files changed, 5 insertions(+), 25 deletions(-) diff --git a/packages/vscode-ide-companion/src/webview/components/layout/ModelSelector.tsx b/packages/vscode-ide-companion/src/webview/components/layout/ModelSelector.tsx index 155cc4bb1..3d594f435 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/ModelSelector.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/ModelSelector.tsx @@ -61,12 +61,10 @@ export const ModelSelector: FC = ({ switch (event.key) { case 'ArrowDown': event.preventDefault(); - event.stopPropagation(); setSelected((prev) => Math.min(prev + 1, models.length - 1)); break; case 'ArrowUp': event.preventDefault(); - event.stopPropagation(); setSelected((prev) => Math.max(prev - 1, 0)); break; case 'Enter': @@ -81,7 +79,6 @@ export const ModelSelector: FC = ({ break; case 'Escape': event.preventDefault(); - event.stopPropagation(); onClose(); break; default: @@ -90,10 +87,9 @@ export const ModelSelector: FC = ({ }; document.addEventListener('mousedown', handleClickOutside); - // Use capture phase so this handler fires BEFORE any bubble-phase - // handlers on child elements (e.g. the InputForm's Enter-to-submit). - // Combined with stopPropagation this prevents an empty user message - // from being created when the user presses Enter to confirm a model. + // Use capture phase so Enter is handled before bubble-phase handlers + // (e.g. the InputForm's Enter-to-submit) and stopPropagation can + // prevent an empty user message. document.addEventListener('keydown', handleKeyDown, true); return () => { diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts index f0d10ea43..72278d62e 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts @@ -12,21 +12,6 @@ import { ACP_ERROR_CODES } from '../../constants/acpSchema.js'; const AUTH_REQUIRED_CODE_PATTERN = `(code: ${ACP_ERROR_CODES.AUTH_REQUIRED})`; -/** Prefix that separates the human-readable ACP error from its JSON data payload. */ -const ACP_ERROR_DATA_PREFIX = '\nData: '; - -/** - * Strip the trailing `\nData: {...}` payload from an ACP error message so that - * only the human-readable portion is shown to the user. - */ -function stripAcpErrorData(message: string): string { - const idx = message.indexOf(ACP_ERROR_DATA_PREFIX); - if (idx === -1) { - return message; - } - return message.slice(0, idx).trim(); -} - /** * Session message handler * Handles all session-related messages @@ -1084,12 +1069,11 @@ export class SessionMessageHandler extends BaseMessageHandler { ); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); - const cleanMsg = stripAcpErrorData(errorMsg); console.error('[SessionMessageHandler] Failed to set model:', error); - vscode.window.showErrorMessage(`Failed to switch model: ${cleanMsg}`); + vscode.window.showErrorMessage(`Failed to switch model: ${errorMsg}`); this.sendToWebView({ type: 'error', - data: { message: `Failed to set model: ${cleanMsg}` }, + data: { message: `Failed to set model: ${errorMsg}` }, }); } } From b9dd080bd17459f5d34125bd2aadc5bd1a5f1f62 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Tue, 10 Feb 2026 17:59:47 +0800 Subject: [PATCH 06/21] feat: add auth entry: coding plan --- .../cli/src/constants/codingPlanTemplates.ts | 43 +++ packages/cli/src/i18n/locales/de.js | 18 ++ packages/cli/src/i18n/locales/en.js | 37 +++ packages/cli/src/i18n/locales/ja.js | 18 ++ packages/cli/src/i18n/locales/pt.js | 18 ++ packages/cli/src/i18n/locales/ru.js | 18 ++ packages/cli/src/i18n/locales/zh.js | 36 +++ packages/cli/src/ui/AppContainer.tsx | 3 + packages/cli/src/ui/auth/AuthDialog.test.tsx | 16 +- packages/cli/src/ui/auth/AuthDialog.tsx | 292 +++++++++++++++--- packages/cli/src/ui/auth/useAuth.ts | 130 +++++++- .../cli/src/ui/components/ApiKeyInput.tsx | 61 ++++ .../cli/src/ui/components/DialogManager.tsx | 35 +-- .../ui/components/OpenAIKeyPrompt.test.tsx | 74 ----- .../cli/src/ui/components/OpenAIKeyPrompt.tsx | 280 ----------------- .../cli/src/ui/contexts/UIActionsContext.tsx | 8 +- packages/cli/src/ui/hooks/useDialogClose.ts | 7 +- packages/core/src/config/config.ts | 13 + packages/core/src/models/modelRegistry.ts | 45 ++- packages/core/src/models/modelsConfig.ts | 12 + packages/core/src/telemetry/types.ts | 4 +- 21 files changed, 721 insertions(+), 447 deletions(-) create mode 100644 packages/cli/src/constants/codingPlanTemplates.ts create mode 100644 packages/cli/src/ui/components/ApiKeyInput.tsx delete mode 100644 packages/cli/src/ui/components/OpenAIKeyPrompt.test.tsx delete mode 100644 packages/cli/src/ui/components/OpenAIKeyPrompt.tsx diff --git a/packages/cli/src/constants/codingPlanTemplates.ts b/packages/cli/src/constants/codingPlanTemplates.ts new file mode 100644 index 000000000..99c034ecc --- /dev/null +++ b/packages/cli/src/constants/codingPlanTemplates.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ProviderModelConfig as ModelConfig } from '@qwen-code/qwen-code-core'; + +/** + * Coding plan template - array of model configurations + * When user provides an api-key, these configs will be cloned with envKey pointing to the stored api-key + */ +export type CodingPlanTemplate = ModelConfig[]; + +/** + * Environment variable key for storing the coding plan API key + */ +export const CODING_PLAN_ENV_KEY = 'BAILIAN_CODING_PLAN_API_KEY'; + +/** + * CODING_PLAN_TEMPLATE defines the model configurations for coding-plan mode. + */ +export const CODING_PLAN_TEMPLATE: CodingPlanTemplate = [ + { + id: 'qwen3-coder-plus', + name: 'qwen3-coder-plur', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + description: 'Qwen3 Coder Plus model from Bailian Coding Plan', + envKey: CODING_PLAN_ENV_KEY, + }, + { + id: 'qwen3-max-2026-01-23', + name: 'qwen3-max-2026-01-23', + description: 'Qwen3 Max Thinking model from Bailian Coding Plan', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + }, + }, +]; diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index f0054b397..5466844d8 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -1371,4 +1371,22 @@ export default { 'Sie können den Berechtigungsmodus schnell mit Shift+Tab oder /approval-mode wechseln.', 'You can switch permission mode quickly with Tab or /approval-mode.': 'Sie können den Berechtigungsmodus schnell mit Tab oder /approval-mode wechseln.', + + // ============================================================================ + // Custom API-KEY Configuration + // ============================================================================ + 'For advanced users who want to configure models manually.': + 'Für fortgeschrittene Benutzer, die Modelle manuell konfigurieren möchten.', + 'Please configure your models in settings.json:': + 'Bitte konfigurieren Sie Ihre Modelle in settings.json:', + 'Set API key via environment variable (e.g., OPENAI_API_KEY)': + 'API-Schlüssel über Umgebungsvariable setzen (z.B. OPENAI_API_KEY)', + "Add model configuration to modelProviders['openai'] (or other auth types)": + "Modellkonfiguration zu modelProviders['openai'] (oder anderen Authentifizierungstypen) hinzufügen", + 'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig': + 'Jeder Anbieter benötigt: id, envKey (erforderlich), plus optionale baseUrl, generationConfig', + 'Use /model command to select your preferred model from the configured list': + 'Verwenden Sie den /model-Befehl, um Ihr bevorzugtes Modell aus der konfigurierten Liste auszuwählen', + 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.': + 'Unterstützte Authentifizierungstypen: openai, anthropic, gemini, vertex-ai, usw.', }; diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 79af44452..78f57abce 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1362,4 +1362,41 @@ export default { 'Opening extensions page in your browser: {{url}}', 'Failed to open browser. Check out the extensions gallery at {{url}}': 'Failed to open browser. Check out the extensions gallery at {{url}}', + + // ============================================================================ + // Coding Plan Authentication + // ============================================================================ + 'Please enter your API key:': 'Please enter your API key:', + 'API key cannot be empty.': 'API key cannot be empty.', + 'API key is stored in settings.env. You can migrate it to a .env file for better security.': + 'API key is stored in settings.env. You can migrate it to a .env file for better security.', + + // ============================================================================ + // Custom API-KEY Configuration + // ============================================================================ + 'For advanced users who want to configure models manually.': + 'For advanced users who want to configure models manually.', + 'Please configure your models in settings.json:': + 'Please configure your models in settings.json:', + 'Set API key via environment variable (e.g., OPENAI_API_KEY)': + 'Set API key via environment variable (e.g., OPENAI_API_KEY)', + "Add model configuration to modelProviders['openai'] (or other auth types)": + "Add model configuration to modelProviders['openai'] (or other auth types)", + 'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig': + 'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig', + 'Use /model command to select your preferred model from the configured list': + 'Use /model command to select your preferred model from the configured list', + 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.': + 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.', + + // ============================================================================ + // Auth Dialog - View Titles and Labels + // ============================================================================ + 'API-KEY': 'API-KEY', + 'Coding Plan': 'Coding Plan', + Custom: 'Custom', + 'Select API-KEY configuration mode:': 'Select API-KEY configuration mode:', + '(Press Escape to go back)': '(Press Escape to go back)', + '(Press Enter to submit, Escape to cancel)': + '(Press Enter to submit, Escape to cancel)', }; diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index a9a27c107..e2c7306b7 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -882,4 +882,22 @@ export default { 'コードが壊れた?叩けば治るさ', 'USBの差し込みに挑戦中...', ], + + // ============================================================================ + // Custom API-KEY Configuration + // ============================================================================ + 'For advanced users who want to configure models manually.': + 'モデルを手動で設定したい上級ユーザー向け。', + 'Please configure your models in settings.json:': + 'settings.json でモデルを設定してください:', + 'Set API key via environment variable (e.g., OPENAI_API_KEY)': + '環境変数を使用して API キーを設定してください(例:OPENAI_API_KEY)', + "Add model configuration to modelProviders['openai'] (or other auth types)": + "modelProviders['openai'](または他の認証タイプ)にモデル設定を追加してください", + 'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig': + '各プロバイダーには:id、envKey(必須)、およびオプションの baseUrl、generationConfig が必要です', + 'Use /model command to select your preferred model from the configured list': + '/model コマンドを使用して、設定済みリストからお好みのモデルを選択してください', + 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.': + 'サポートされている認証タイプ:openai、anthropic、gemini、vertex-ai など', }; diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 1f085dfcf..3e0089fa1 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -1385,4 +1385,22 @@ export default { 'Abrindo página de extensões no seu navegador: {{url}}', 'Failed to open browser. Check out the extensions gallery at {{url}}': 'Falha ao abrir o navegador. Confira a galeria de extensões em {{url}}', + + // ============================================================================ + // Custom API-KEY Configuration + // ============================================================================ + 'For advanced users who want to configure models manually.': + 'Para usuários avançados que desejam configurar modelos manualmente.', + 'Please configure your models in settings.json:': + 'Por favor, configure seus modelos em settings.json:', + 'Set API key via environment variable (e.g., OPENAI_API_KEY)': + 'Defina a chave de API via variável de ambiente (ex: OPENAI_API_KEY)', + "Add model configuration to modelProviders['openai'] (or other auth types)": + "Adicione a configuração do modelo a modelProviders['openai'] (ou outros tipos de autenticação)", + 'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig': + 'Cada provedor precisa de: id, envKey (obrigatório), além de baseUrl e generationConfig opcionais', + 'Use /model command to select your preferred model from the configured list': + 'Use o comando /model para selecionar seu modelo preferido da lista configurada', + 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.': + 'Tipos de autenticação suportados: openai, anthropic, gemini, vertex-ai, etc.', }; diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 2a3ad1385..4bf570351 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -1375,4 +1375,22 @@ export default { 'Вы можете быстро переключать режим разрешений с помощью Shift+Tab или /approval-mode.', 'You can switch permission mode quickly with Tab or /approval-mode.': 'Вы можете быстро переключать режим разрешений с помощью Tab или /approval-mode.', + + // ============================================================================ + // Custom API-KEY Configuration + // ============================================================================ + 'For advanced users who want to configure models manually.': + 'Для продвинутых пользователей, которые хотят настраивать модели вручную.', + 'Please configure your models in settings.json:': + 'Пожалуйста, настройте ваши модели в settings.json:', + 'Set API key via environment variable (e.g., OPENAI_API_KEY)': + 'Установите ключ API через переменную окружения (например, OPENAI_API_KEY)', + "Add model configuration to modelProviders['openai'] (or other auth types)": + "Добавьте конфигурацию модели в modelProviders['openai'] (или другие типы аутентификации)", + 'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig': + 'Каждому провайдеру нужны: id, envKey (обязательно), а также опциональные baseUrl, generationConfig', + 'Use /model command to select your preferred model from the configured list': + 'Используйте команду /model, чтобы выбрать предпочитаемую модель из настроенного списка', + 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.': + 'Поддерживаемые типы аутентификации: openai, anthropic, gemini, vertex-ai и др.', }; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 10530a4ac..e314d5129 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1198,4 +1198,40 @@ export default { '正在浏览器中打开扩展页面:{{url}}', 'Failed to open browser. Check out the extensions gallery at {{url}}': '打开浏览器失败。请访问扩展市场:{{url}}', + + // ============================================================================ + // Coding Plan Authentication + // ============================================================================ + 'Please enter your API key:': '请输入您的 API Key:', + 'API key cannot be empty.': 'API Key 不能为空。', + 'API key is stored in settings.env. You can migrate it to a .env file for better security.': + 'API Key 已存储在 settings.env 中。您可以将其迁移到 .env 文件以获得更好的安全性。', + + // ============================================================================ + // Custom API-KEY Configuration + // ============================================================================ + 'For advanced users who want to configure models manually.': + '适合需要手动配置模型的高级用户。', + 'Please configure your models in settings.json:': + '请在 settings.json 中配置您的模型:', + 'Set API key via environment variable (e.g., OPENAI_API_KEY)': + '通过环境变量设置 API Key(例如:OPENAI_API_KEY)', + "Add model configuration to modelProviders['openai'] (or other auth types)": + "将模型配置添加到 modelProviders['openai'](或其他认证类型)", + 'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig': + '每个提供商需要:id、envKey(必需),以及可选的 baseUrl、generationConfig', + 'Use /model command to select your preferred model from the configured list': + '使用 /model 命令从配置列表中选择您偏好的模型', + 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.': + '支持的认证类型:openai、anthropic、gemini、vertex-ai 等', + + // ============================================================================ + // Auth Dialog - View Titles and Labels + // ============================================================================ + 'API-KEY': 'API-KEY', + 'Coding Plan': 'Coding Plan', + Custom: '自定义', + 'Select API-KEY configuration mode:': '选择 API-KEY 配置模式:', + '(Press Escape to go back)': '(按 Escape 键返回)', + '(Press Enter to submit, Escape to cancel)': '(按 Enter 提交,Escape 取消)', }; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 8181583f9..7ac34def2 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -402,6 +402,7 @@ export const AppContainer = (props: AppContainerProps) => { pendingAuthType, qwenAuthState, handleAuthSelect, + handleCodingPlanSubmit, openAuthDialog, cancelAuthentication, } = useAuthCommand(settings, config, historyManager.addItem, refreshStatic); @@ -1508,6 +1509,7 @@ export const AppContainer = (props: AppContainerProps) => { setAuthState, onAuthError, cancelAuthentication, + handleCodingPlanSubmit, handleEditorSelect, exitEditorDialog, closeSettingsDialog, @@ -1552,6 +1554,7 @@ export const AppContainer = (props: AppContainerProps) => { setAuthState, onAuthError, cancelAuthentication, + handleCodingPlanSubmit, handleEditorSelect, exitEditorDialog, closeSettingsDialog, diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx index 83208614f..a975a599e 100644 --- a/packages/cli/src/ui/auth/AuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx @@ -169,9 +169,9 @@ describe('AuthDialog', () => { const { lastFrame } = renderAuthDialog(settings); - // Since the auth dialog only shows OpenAI option now, + // Since the auth dialog shows API-KEY option now, // it won't show GEMINI_API_KEY messages - expect(lastFrame()).toContain('OpenAI'); + expect(lastFrame()).toContain('API-KEY'); }); it('should not show the GEMINI_API_KEY message if QWEN_DEFAULT_AUTH_TYPE is set to something else', () => { @@ -257,15 +257,17 @@ describe('AuthDialog', () => { const { lastFrame } = renderAuthDialog(settings); - // Since the auth dialog only shows OpenAI option now, + // Since the auth dialog shows API-KEY option now, // it won't show GEMINI_API_KEY messages - expect(lastFrame()).toContain('OpenAI'); + expect(lastFrame()).toContain('API-KEY'); }); }); describe('QWEN_DEFAULT_AUTH_TYPE environment variable', () => { it('should select the auth type specified by QWEN_DEFAULT_AUTH_TYPE', () => { - process.env['QWEN_DEFAULT_AUTH_TYPE'] = AuthType.USE_OPENAI; + // QWEN_OAUTH is the only valid AuthType that can be selected via env var + // API-KEY is not an AuthType enum value, so it cannot be selected this way + process.env['QWEN_DEFAULT_AUTH_TYPE'] = AuthType.QWEN_OAUTH; const settings: LoadedSettings = new LoadedSettings( { @@ -302,8 +304,8 @@ describe('AuthDialog', () => { const { lastFrame } = renderAuthDialog(settings); - // This is a bit brittle, but it's the best way to check which item is selected. - expect(lastFrame()).toContain('● 2. OpenAI'); + // QWEN_OAUTH is the first option, so it should be selected + expect(lastFrame()).toContain('● 1. Qwen OAuth'); }); it('should fall back to default if QWEN_DEFAULT_AUTH_TYPE is not set', () => { diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index 9ae1ea2a7..f06ba360c 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -11,6 +11,7 @@ import { Box, Text } from 'ink'; import { Colors } from '../colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; +import { ApiKeyInput } from '../components/ApiKeyInput.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; @@ -28,30 +29,54 @@ function parseDefaultAuthType( return null; } +// Sub-mode types for API-KEY authentication +type ApiKeySubMode = 'coding-plan' | 'custom'; + +// View level for navigation +type ViewLevel = 'main' | 'api-key-sub' | 'api-key-input' | 'custom-info'; + export function AuthDialog(): React.JSX.Element { const { pendingAuthType, authError } = useUIState(); - const { handleAuthSelect: onAuthSelect } = useUIActions(); + const { handleAuthSelect: onAuthSelect, handleCodingPlanSubmit } = + useUIActions(); const config = useConfig(); const [errorMessage, setErrorMessage] = useState(null); const [selectedIndex, setSelectedIndex] = useState(null); + const [viewLevel, setViewLevel] = useState('main'); + const [apiKeySubModeIndex, setApiKeySubModeIndex] = useState(0); - const items = [ + // Main authentication entries + const mainItems = [ { key: AuthType.QWEN_OAUTH, label: t('Qwen OAuth'), value: AuthType.QWEN_OAUTH, }, { - key: AuthType.USE_OPENAI, - label: t('OpenAI'), - value: AuthType.USE_OPENAI, + key: 'API-KEY', + label: t('API-KEY'), + value: 'API-KEY' as const, + }, + ]; + + // API-KEY sub-mode entries + const apiKeySubItems = [ + { + key: 'coding-plan', + label: t('Coding Plan'), + value: 'coding-plan' as ApiKeySubMode, + }, + { + key: 'custom', + label: t('Custom'), + value: 'custom' as ApiKeySubMode, }, ]; const initialAuthIndex = Math.max( 0, - items.findIndex((item) => { + mainItems.findIndex((item) => { // Priority 1: pendingAuthType if (pendingAuthType) { return item.value === pendingAuthType; @@ -79,29 +104,75 @@ export function AuthDialog(): React.JSX.Element { const hasApiKey = Boolean(config.getContentGeneratorConfig()?.apiKey); const currentSelectedAuthType = selectedIndex !== null - ? items[selectedIndex]?.value - : items[initialAuthIndex]?.value; + ? mainItems[selectedIndex]?.value + : mainItems[initialAuthIndex]?.value; - const handleAuthSelect = async (authMethod: AuthType) => { + const handleMainSelect = async ( + value: (typeof mainItems)[number]['value'], + ) => { setErrorMessage(null); - await onAuthSelect(authMethod); + + if (value === 'API-KEY') { + // Navigate to API-KEY sub-mode selection + setViewLevel('api-key-sub'); + return; + } + + // For Qwen OAuth, proceed directly + await onAuthSelect(value); }; - const handleHighlight = (authMethod: AuthType) => { - const index = items.findIndex((item) => item.value === authMethod); - setSelectedIndex(index); + const handleApiKeySubSelect = async (subMode: ApiKeySubMode) => { + setErrorMessage(null); + + if (subMode === 'coding-plan') { + setViewLevel('api-key-input'); + } else { + setViewLevel('custom-info'); + } + }; + + const handleApiKeyInputSubmit = async (apiKey: string) => { + setErrorMessage(null); + + if (!apiKey.trim()) { + setErrorMessage(t('API key cannot be empty.')); + return; + } + + // Submit to parent for processing + await handleCodingPlanSubmit(apiKey); + }; + + const handleGoBack = () => { + setErrorMessage(null); + + if (viewLevel === 'api-key-sub') { + setViewLevel('main'); + } else if (viewLevel === 'api-key-input' || viewLevel === 'custom-info') { + setViewLevel('api-key-sub'); + } }; useKeypress( (key) => { if (key.name === 'escape') { - // Prevent exit if there is an error message. - // This means they user is not authenticated yet. + // Handle Escape based on current view level + if (viewLevel === 'api-key-sub') { + handleGoBack(); + return; + } + + if (viewLevel === 'api-key-input' || viewLevel === 'custom-info') { + handleGoBack(); + return; + } + + // For main view, use existing logic if (errorMessage) { return; } if (config.getAuthType() === undefined) { - // Prevent exiting if no auth method is set setErrorMessage( t( 'You must select an auth method to proceed. Press Ctrl+C again to exit.', @@ -115,6 +186,129 @@ export function AuthDialog(): React.JSX.Element { { isActive: true }, ); + // Render main auth selection + const renderMainView = () => ( + <> + + {t('How would you like to authenticate for this project?')} + + + { + const index = mainItems.findIndex((item) => item.value === value); + setSelectedIndex(index); + }} + /> + + + ); + + // Render API-KEY sub-mode selection + const renderApiKeySubView = () => ( + <> + + {t('Select API-KEY configuration mode:')} + + + { + const index = apiKeySubItems.findIndex( + (item) => item.value === value, + ); + setApiKeySubModeIndex(index); + }} + /> + + + {t('(Press Escape to go back)')} + + + ); + + // Render API key input for coding-plan mode + const renderApiKeyInputView = () => ( + + + + ); + + // Render custom mode info + const renderCustomInfoView = () => ( + <> + + {t('Custom API-KEY Configuration')} + + + + {t('For advanced users who want to configure models manually.')} + + + + {t('Please configure your models in settings.json:')} + + + + 1. {t('Set API key via environment variable (e.g., OPENAI_API_KEY)')} + + + + + 2.{' '} + {t( + "Add model configuration to modelProviders['openai'] (or other auth types)", + )} + + + + + 3.{' '} + {t( + 'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig', + )} + + + + + 4.{' '} + {t( + 'Use /model command to select your preferred model from the configured list', + )} + + + + + {t( + 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.', + )} + + + + {t('(Press Escape to go back)')} + + + ); + + const getViewTitle = () => { + switch (viewLevel) { + case 'main': + return t('Get started'); + case 'api-key-sub': + return t('API-KEY Configuration'); + case 'api-key-input': + return t('Coding Plan Setup'); + case 'custom-info': + return t('Custom Configuration'); + default: + return t('Get started'); + } + }; + return ( - {t('Get started')} - - {t('How would you like to authenticate for this project?')} - - - - + {getViewTitle()} + + {viewLevel === 'main' && renderMainView()} + {viewLevel === 'api-key-sub' && renderApiKeySubView()} + {viewLevel === 'api-key-input' && renderApiKeyInputView()} + {viewLevel === 'custom-info' && renderCustomInfoView()} + {(authError || errorMessage) && ( {authError || errorMessage} )} - - {t('(Use Enter to Set Auth)')} - - {hasApiKey && currentSelectedAuthType === AuthType.QWEN_OAUTH && ( - - - {t( - 'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.', - )} - - + + {viewLevel === 'main' && ( + <> + + + {t('(Use Enter to Set Auth)')} + + + {hasApiKey && currentSelectedAuthType === AuthType.QWEN_OAUTH && ( + + + {t( + 'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.', + )} + + + )} + + + {t('Terms of Services and Privacy Notice for Qwen Code')} + + + + + {'https://github.com/QwenLM/Qwen3-Coder/blob/main/README.md'} + + + )} - - {t('Terms of Services and Privacy Notice for Qwen Code')} - - - - {'https://github.com/QwenLM/Qwen3-Coder/blob/main/README.md'} - - ); } diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index 82211362c..a327152c2 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -8,6 +8,7 @@ import type { Config, ContentGeneratorConfig, ModelProvidersConfig, + ProviderModelConfig, } from '@qwen-code/qwen-code-core'; import { AuthEvent, @@ -18,11 +19,20 @@ import { import { useCallback, useEffect, useState } from 'react'; import type { LoadedSettings } from '../../config/settings.js'; import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; -import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js'; +// OpenAICredentials type (previously imported from OpenAIKeyPrompt) +export interface OpenAICredentials { + apiKey: string; + baseUrl?: string; + model?: string; +} import { useQwenAuth } from '../hooks/useQwenAuth.js'; import { AuthState, MessageType } from '../types.js'; import type { HistoryItem } from '../types.js'; import { t } from '../../i18n/index.js'; +import { + CODING_PLAN_TEMPLATE, + CODING_PLAN_ENV_KEY, +} from '../../constants/codingPlanTemplates.js'; export type { QwenAuthState } from '../hooks/useQwenAuth.js'; @@ -272,6 +282,123 @@ export const useAuthCommand = ( setAuthError(null); }, [isAuthenticating, pendingAuthType, cancelQwenAuth, config]); + /** + * Handle coding plan submission - generates configs from template and stores api-key + */ + const handleCodingPlanSubmit = useCallback( + async (apiKey: string) => { + try { + setIsAuthenticating(true); + setAuthError(null); + + const envKeyName = CODING_PLAN_ENV_KEY; + + // Get persist scope + const persistScope = getPersistScopeForModelSelection(settings); + + // Store api-key in settings.env + settings.setValue(persistScope, `env.${envKeyName}`, apiKey); + + // Sync to process.env immediately so refreshAuth can read the apiKey + process.env[envKeyName] = apiKey; + + // Generate model configs from template + const newConfigs: ProviderModelConfig[] = CODING_PLAN_TEMPLATE.map( + (templateConfig) => ({ + ...templateConfig, + envKey: envKeyName, + }), + ); + + // Get existing configs + const existingConfigs = + ( + settings.merged.modelProviders as ModelProvidersConfig | undefined + )?.[AuthType.USE_OPENAI] || []; + + // Deduplicate: check if config with same id, baseUrl, and envKey exists + const isDuplicate = (config: ProviderModelConfig) => + existingConfigs.some( + (existing) => + existing.id === config.id && + existing.baseUrl === config.baseUrl && + existing.envKey === config.envKey, + ); + + // Filter out duplicates and replace existing ones + const uniqueNewConfigs = newConfigs.filter( + (config) => !isDuplicate(config), + ); + + // Unshift new configs to the beginning + const updatedConfigs = [...uniqueNewConfigs, ...existingConfigs]; + + // Persist to modelProviders + settings.setValue( + persistScope, + `modelProviders.${AuthType.USE_OPENAI}`, + updatedConfigs, + ); + + // Also persist authType + settings.setValue( + persistScope, + 'security.auth.selectedType', + AuthType.USE_OPENAI, + ); + + // If there are configs, use the first one as the model + if (updatedConfigs.length > 0 && updatedConfigs[0]?.id) { + settings.setValue(persistScope, 'model.name', updatedConfigs[0].id); + } + + // Hot-reload model providers configuration before refreshAuth + // This ensures ModelsConfig has the latest configuration from settings.json + const updatedModelProviders: ModelProvidersConfig = { + ...(settings.merged.modelProviders as + | ModelProvidersConfig + | undefined), + [AuthType.USE_OPENAI]: updatedConfigs, + }; + config.reloadModelProvidersConfig(updatedModelProviders); + + // Refresh auth with the new configuration + await config.refreshAuth(AuthType.USE_OPENAI); + + // Success handling + setAuthError(null); + setAuthState(AuthState.Authenticated); + setIsAuthDialogOpen(false); + setIsAuthenticating(false); + + // Trigger UI refresh + onAuthChange?.(); + + // Add success message + addItem( + { + type: MessageType.INFO, + text: t( + 'Authenticated successfully with Coding Plan. API key is stored in settings.env.', + ), + }, + Date.now(), + ); + + // Log success + const authEvent = new AuthEvent( + AuthType.USE_OPENAI, + 'coding-plan', + 'success', + ); + logAuth(config, authEvent); + } catch (error) { + handleAuthFailure(error); + } + }, + [settings, config, handleAuthFailure, addItem, onAuthChange], + ); + /** /** * We previously used a useEffect to trigger authentication automatically when @@ -322,6 +449,7 @@ export const useAuthCommand = ( pendingAuthType, qwenAuthState, handleAuthSelect, + handleCodingPlanSubmit, openAuthDialog, cancelAuthentication, }; diff --git a/packages/cli/src/ui/components/ApiKeyInput.tsx b/packages/cli/src/ui/components/ApiKeyInput.tsx new file mode 100644 index 000000000..ee8a361d2 --- /dev/null +++ b/packages/cli/src/ui/components/ApiKeyInput.tsx @@ -0,0 +1,61 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useState } from 'react'; +import { Box, Text } from 'ink'; +import { TextInput } from './shared/TextInput.js'; +import { Colors } from '../colors.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { t } from '../../i18n/index.js'; + +interface ApiKeyInputProps { + onSubmit: (apiKey: string) => void; + onCancel: () => void; +} + +export function ApiKeyInput({ + onSubmit, + onCancel, +}: ApiKeyInputProps): React.JSX.Element { + const [apiKey, setApiKey] = useState(''); + const [error, setError] = useState(null); + + useKeypress( + (key) => { + if (key.name === 'escape') { + onCancel(); + } else if (key.name === 'return') { + const trimmedKey = apiKey.trim(); + if (!trimmedKey) { + setError(t('API key cannot be empty.')); + return; + } + onSubmit(trimmedKey); + } + }, + { isActive: true }, + ); + + return ( + + + {t('Please enter your API key:')} + + + {error && ( + + {error} + + )} + + + {t('(Press Enter to submit, Escape to cancel)')} + + + + ); +} diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index c68afd420..b73ab1287 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -17,7 +17,6 @@ import { ThemeDialog } from './ThemeDialog.js'; import { SettingsDialog } from './SettingsDialog.js'; import { QwenOAuthProgress } from './QwenOAuthProgress.js'; import { AuthDialog } from '../auth/AuthDialog.js'; -import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js'; import { EditorSettingsDialog } from './EditorSettingsDialog.js'; import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js'; import { ModelDialog } from './ModelDialog.js'; @@ -56,16 +55,6 @@ export const DialogManager = ({ const { constrainHeight, terminalHeight, staticExtraHeight, mainAreaWidth } = uiState; - const getDefaultOpenAIConfig = () => { - const fromSettings = settings.merged.security?.auth; - const modelSettings = settings.merged.model; - return { - apiKey: fromSettings?.apiKey || process.env['OPENAI_API_KEY'] || '', - baseUrl: fromSettings?.baseUrl || process.env['OPENAI_BASE_URL'] || '', - model: modelSettings?.name || process.env['OPENAI_MODEL'] || '', - }; - }; - if (uiState.showWelcomeBackDialog && uiState.welcomeBackInfo?.hasHistory) { return ( { - uiActions.handleAuthSelect(AuthType.USE_OPENAI, { - apiKey, - baseUrl, - model, - }); - }} - onCancel={() => { - uiActions.cancelAuthentication(); - uiActions.setAuthState(AuthState.Updating); - }} - defaultApiKey={defaults.apiKey} - defaultBaseUrl={defaults.baseUrl} - defaultModel={defaults.model} - /> - ); - } - + // OpenAI authentication now handled through AuthDialog with coding-plan/custom sub-modes + // Qwen OAuth remains as a separate flow if (uiState.pendingAuthType === AuthType.QWEN_OAUTH) { return ( ({ - useKeypress: vi.fn(), -})); - -describe('OpenAIKeyPrompt', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - it('should render the prompt correctly', () => { - const onSubmit = vi.fn(); - const onCancel = vi.fn(); - - const { lastFrame } = render( - , - ); - - expect(lastFrame()).toContain('OpenAI Configuration Required'); - expect(lastFrame()).toContain( - 'https://bailian.console.aliyun.com/?tab=model#/api-key', - ); - expect(lastFrame()).toContain( - 'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel', - ); - }); - - it('should show the component with proper styling', () => { - const onSubmit = vi.fn(); - const onCancel = vi.fn(); - - const { lastFrame } = render( - , - ); - - const output = lastFrame(); - expect(output).toContain('OpenAI Configuration Required'); - expect(output).toContain('API Key:'); - expect(output).toContain('Base URL:'); - expect(output).toContain('Model:'); - expect(output).toContain( - 'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel', - ); - }); - - it('should handle paste with control characters', async () => { - const onSubmit = vi.fn(); - const onCancel = vi.fn(); - - const { stdin } = render( - , - ); - - // Simulate paste with control characters - const pasteWithControlChars = '\x1b[200~sk-test123\x1b[201~'; - stdin.write(pasteWithControlChars); - - // Wait a bit for processing - await new Promise((resolve) => setTimeout(resolve, 50)); - - // The component should have filtered out the control characters - // and only kept 'sk-test123' - expect(onSubmit).not.toHaveBeenCalled(); // Should not submit yet - }); -}); diff --git a/packages/cli/src/ui/components/OpenAIKeyPrompt.tsx b/packages/cli/src/ui/components/OpenAIKeyPrompt.tsx deleted file mode 100644 index ae65d3585..000000000 --- a/packages/cli/src/ui/components/OpenAIKeyPrompt.tsx +++ /dev/null @@ -1,280 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { useState } from 'react'; -import { z } from 'zod'; -import { Box, Text } from 'ink'; -import { Colors } from '../colors.js'; -import { useKeypress } from '../hooks/useKeypress.js'; -import { t } from '../../i18n/index.js'; - -interface OpenAIKeyPromptProps { - onSubmit: (apiKey: string, baseUrl: string, model: string) => void; - onCancel: () => void; - defaultApiKey?: string; - defaultBaseUrl?: string; - defaultModel?: string; -} - -export const credentialSchema = z.object({ - apiKey: z.string().min(1, 'API key is required'), - baseUrl: z - .union([z.string().url('Base URL must be a valid URL'), z.literal('')]) - .optional(), - model: z.string().min(1, 'Model must be a non-empty string').optional(), -}); - -export type OpenAICredentials = z.infer; - -export function OpenAIKeyPrompt({ - onSubmit, - onCancel, - defaultApiKey, - defaultBaseUrl, - defaultModel, -}: OpenAIKeyPromptProps): React.JSX.Element { - const [apiKey, setApiKey] = useState(defaultApiKey || ''); - const [baseUrl, setBaseUrl] = useState(defaultBaseUrl || ''); - const [model, setModel] = useState(defaultModel || ''); - const [currentField, setCurrentField] = useState< - 'apiKey' | 'baseUrl' | 'model' - >('apiKey'); - const [validationError, setValidationError] = useState(null); - - const validateAndSubmit = () => { - setValidationError(null); - - try { - const validated = credentialSchema.parse({ - apiKey: apiKey.trim(), - baseUrl: baseUrl.trim() || undefined, - model: model.trim() || undefined, - }); - - onSubmit( - validated.apiKey, - validated.baseUrl === '' ? '' : validated.baseUrl || '', - validated.model || '', - ); - } catch (error) { - if (error instanceof z.ZodError) { - const errorMessage = error.errors - .map((e) => `${e.path.join('.')}: ${e.message}`) - .join(', '); - setValidationError( - t('Invalid credentials: {{errorMessage}}', { errorMessage }), - ); - } else { - setValidationError(t('Failed to validate credentials')); - } - } - }; - - useKeypress( - (key) => { - // Handle escape - if (key.name === 'escape') { - onCancel(); - return; - } - - // Handle Enter key - if (key.name === 'return') { - if (currentField === 'apiKey') { - // 允许空 API key 跳转到下一个字段,让用户稍后可以返回修改 - setCurrentField('baseUrl'); - return; - } else if (currentField === 'baseUrl') { - setCurrentField('model'); - return; - } else if (currentField === 'model') { - // 只有在提交时才检查 API key 是否为空 - if (apiKey.trim()) { - validateAndSubmit(); - } else { - // 如果 API key 为空,回到 API key 字段 - setCurrentField('apiKey'); - } - } - return; - } - - // Handle Tab key for field navigation - if (key.name === 'tab') { - if (currentField === 'apiKey') { - setCurrentField('baseUrl'); - } else if (currentField === 'baseUrl') { - setCurrentField('model'); - } else if (currentField === 'model') { - setCurrentField('apiKey'); - } - return; - } - - // Handle arrow keys for field navigation - if (key.name === 'up') { - if (currentField === 'baseUrl') { - setCurrentField('apiKey'); - } else if (currentField === 'model') { - setCurrentField('baseUrl'); - } - return; - } - - if (key.name === 'down') { - if (currentField === 'apiKey') { - setCurrentField('baseUrl'); - } else if (currentField === 'baseUrl') { - setCurrentField('model'); - } - return; - } - - // Handle backspace/delete - if (key.name === 'backspace' || key.name === 'delete') { - if (currentField === 'apiKey') { - setApiKey((prev) => prev.slice(0, -1)); - } else if (currentField === 'baseUrl') { - setBaseUrl((prev) => prev.slice(0, -1)); - } else if (currentField === 'model') { - setModel((prev) => prev.slice(0, -1)); - } - return; - } - - // Handle paste mode - if it's a paste event with content - if (key.paste && key.sequence) { - // 过滤粘贴相关的控制序列 - let cleanInput = key.sequence - // 过滤 ESC 开头的控制序列(如 \u001b[200~、\u001b[201~ 等) - .replace(/\u001b\[[0-9;]*[a-zA-Z]/g, '') // eslint-disable-line no-control-regex - // 过滤粘贴开始标记 [200~ - .replace(/\[200~/g, '') - // 过滤粘贴结束标记 [201~ - .replace(/\[201~/g, '') - // 过滤单独的 [ 和 ~ 字符(可能是粘贴标记的残留) - .replace(/^\[|~$/g, ''); - - // 再过滤所有不可见字符(ASCII < 32,除了回车换行) - cleanInput = cleanInput - .split('') - .filter((ch) => ch.charCodeAt(0) >= 32) - .join(''); - - if (cleanInput.length > 0) { - if (currentField === 'apiKey') { - setApiKey((prev) => prev + cleanInput); - } else if (currentField === 'baseUrl') { - setBaseUrl((prev) => prev + cleanInput); - } else if (currentField === 'model') { - setModel((prev) => prev + cleanInput); - } - } - return; - } - - // Handle regular character input - if (key.sequence && !key.ctrl && !key.meta) { - // Filter control characters - const cleanInput = key.sequence - .split('') - .filter((ch) => ch.charCodeAt(0) >= 32) - .join(''); - - if (cleanInput.length > 0) { - if (currentField === 'apiKey') { - setApiKey((prev) => prev + cleanInput); - } else if (currentField === 'baseUrl') { - setBaseUrl((prev) => prev + cleanInput); - } else if (currentField === 'model') { - setModel((prev) => prev + cleanInput); - } - } - } - }, - { isActive: true }, - ); - - return ( - - - {t('OpenAI Configuration Required')} - - {validationError && ( - - {validationError} - - )} - - - {t( - 'Please enter your OpenAI configuration. You can get an API key from', - )}{' '} - - https://bailian.console.aliyun.com/?tab=model#/api-key - - - - - - - {t('API Key:')} - - - - - {currentField === 'apiKey' ? '> ' : ' '} - {apiKey || ' '} - - - - - - - {t('Base URL:')} - - - - - {currentField === 'baseUrl' ? '> ' : ' '} - {baseUrl} - - - - - - - {t('Model:')} - - - - - {currentField === 'model' ? '> ' : ' '} - {model} - - - - - - {t('Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel')} - - - - ); -} diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 000740bed..f93ee84eb 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -17,7 +17,12 @@ import { import { type SettingScope } from '../../config/settings.js'; import type { AuthState } from '../types.js'; import { type VisionSwitchOutcome } from '../components/ModelSwitchDialog.js'; -import { type OpenAICredentials } from '../components/OpenAIKeyPrompt.js'; +// OpenAICredentials type (previously imported from OpenAIKeyPrompt) +export interface OpenAICredentials { + apiKey: string; + baseUrl?: string; + model?: string; +} export interface UIActions { openThemeDialog: () => void; @@ -35,6 +40,7 @@ export interface UIActions { authType: AuthType | undefined, credentials?: OpenAICredentials, ) => Promise; + handleCodingPlanSubmit: (apiKey: string) => Promise; setAuthState: (state: AuthState) => void; onAuthError: (error: string) => void; cancelAuthentication: () => void; diff --git a/packages/cli/src/ui/hooks/useDialogClose.ts b/packages/cli/src/ui/hooks/useDialogClose.ts index 8191e16b8..d71a21190 100644 --- a/packages/cli/src/ui/hooks/useDialogClose.ts +++ b/packages/cli/src/ui/hooks/useDialogClose.ts @@ -7,7 +7,12 @@ import { useCallback } from 'react'; import { SettingScope } from '../../config/settings.js'; import type { AuthType, ApprovalMode } from '@qwen-code/qwen-code-core'; -import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js'; +// OpenAICredentials type (previously imported from OpenAIKeyPrompt) +interface OpenAICredentials { + apiKey: string; + baseUrl?: string; + model?: string; +} export interface DialogCloseOptions { // Theme dialog diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index ed07a16c5..e1598a641 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -770,6 +770,19 @@ export class Config { this.modelsConfig.updateCredentials(credentials, settingsGenerationConfig); } + /** + * Reload model providers configuration at runtime. + * This enables hot-reloading of modelProviders settings without restarting the CLI. + * Should be called before refreshAuth when settings.json has been updated. + * + * @param modelProvidersConfig - The updated model providers configuration + */ + reloadModelProvidersConfig( + modelProvidersConfig?: ModelProvidersConfig, + ): void { + this.modelsConfig.reloadModelProvidersConfig(modelProvidersConfig); + } + /** * Refresh authentication and rebuild ContentGenerator. */ diff --git a/packages/core/src/models/modelRegistry.ts b/packages/core/src/models/modelRegistry.ts index bb9b5b8b1..7b9bdad77 100644 --- a/packages/core/src/models/modelRegistry.ts +++ b/packages/core/src/models/modelRegistry.ts @@ -82,7 +82,8 @@ export class ModelRegistry { } /** - * Register models for an authType + * Register models for an authType. + * If multiple models have the same id, the first one takes precedence. */ private registerAuthTypeModels( authType: AuthType, @@ -91,6 +92,13 @@ export class ModelRegistry { const modelMap = new Map(); for (const config of models) { + // Skip if a model with the same id is already registered (first one wins) + if (modelMap.has(config.id)) { + debugLogger.warn( + `Duplicate model id "${config.id}" for authType "${authType}". Using the first registered config.`, + ); + continue; + } const resolved = this.resolveModelConfig(config, authType); modelMap.set(config.id, resolved); } @@ -181,4 +189,39 @@ export class ModelRegistry { ); } } + + /** + * Reload models from updated configuration. + * Clears existing user-configured models and re-registers from new config. + * Preserves hard-coded qwen-oauth models. + */ + reloadModels(modelProvidersConfig?: ModelProvidersConfig): void { + // Clear existing user-configured models (preserve qwen-oauth) + for (const authType of this.modelsByAuthType.keys()) { + if (authType !== AuthType.QWEN_OAUTH) { + this.modelsByAuthType.delete(authType); + } + } + + // Re-register user-configured models for other authTypes + if (modelProvidersConfig) { + for (const [rawKey, models] of Object.entries(modelProvidersConfig)) { + const authType = validateAuthTypeKey(rawKey); + + if (!authType) { + debugLogger.warn( + `Invalid authType key "${rawKey}" in modelProviders config. Expected one of: ${Object.values(AuthType).join(', ')}. Skipping.`, + ); + continue; + } + + // Skip qwen-oauth as it uses hard-coded models + if (authType === AuthType.QWEN_OAUTH) { + continue; + } + + this.registerAuthTypeModels(authType, models); + } + } + } } diff --git a/packages/core/src/models/modelsConfig.ts b/packages/core/src/models/modelsConfig.ts index f7925699e..9311c9279 100644 --- a/packages/core/src/models/modelsConfig.ts +++ b/packages/core/src/models/modelsConfig.ts @@ -1175,4 +1175,16 @@ export class ModelsConfig { this.activeRuntimeModelSnapshotId = undefined; } } + + /** + * Reload model providers configuration at runtime. + * This enables hot-reloading of modelProviders settings without restarting the CLI. + * + * @param modelProvidersConfig - The updated model providers configuration + */ + reloadModelProvidersConfig( + modelProvidersConfig?: ModelProvidersConfig, + ): void { + this.modelRegistry.reloadModels(modelProvidersConfig); + } } diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 5b410b096..98c8d5cac 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -763,13 +763,13 @@ export class AuthEvent implements BaseTelemetryEvent { 'event.name': 'auth'; 'event.timestamp': string; auth_type: AuthType; - action_type: 'auto' | 'manual'; + action_type: 'auto' | 'manual' | 'coding-plan'; status: 'success' | 'error' | 'cancelled'; error_message?: string; constructor( auth_type: AuthType, - action_type: 'auto' | 'manual', + action_type: 'auto' | 'manual' | 'coding-plan', status: 'success' | 'error' | 'cancelled', error_message?: string, ) { From 66267451978711660aee76267583cdc09e8b1003 Mon Sep 17 00:00:00 2001 From: Mingholy Date: Tue, 10 Feb 2026 20:58:15 +0800 Subject: [PATCH 07/21] fix: type & i18n & ut --- .../cli/src/constants/codingPlanTemplates.ts | 7 +- packages/cli/src/i18n/locales/de.js | 20 ++ packages/cli/src/i18n/locales/en.js | 10 +- packages/cli/src/i18n/locales/ja.js | 19 ++ packages/cli/src/i18n/locales/pt.js | 20 ++ packages/cli/src/i18n/locales/ru.js | 19 ++ packages/cli/src/i18n/locales/zh.js | 8 + packages/cli/src/ui/auth/AuthDialog.tsx | 16 ++ .../core/src/models/modelRegistry.test.ts | 211 ++++++++++++++++++ packages/core/src/models/modelsConfig.test.ts | 209 +++++++++++++++++ 10 files changed, 535 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/constants/codingPlanTemplates.ts b/packages/cli/src/constants/codingPlanTemplates.ts index 99c034ecc..8cedab91f 100644 --- a/packages/cli/src/constants/codingPlanTemplates.ts +++ b/packages/cli/src/constants/codingPlanTemplates.ts @@ -23,15 +23,16 @@ export const CODING_PLAN_ENV_KEY = 'BAILIAN_CODING_PLAN_API_KEY'; export const CODING_PLAN_TEMPLATE: CodingPlanTemplate = [ { id: 'qwen3-coder-plus', - name: 'qwen3-coder-plur', + name: 'qwen3-coder-plus', baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', - description: 'Qwen3 Coder Plus model from Bailian Coding Plan', + description: 'qwen3-coder-plus model from Bailian Coding Plan', envKey: CODING_PLAN_ENV_KEY, }, { id: 'qwen3-max-2026-01-23', name: 'qwen3-max-2026-01-23', - description: 'Qwen3 Max Thinking model from Bailian Coding Plan', + description: + 'qwen3 max model from Bailian Coding Plan with thinking enabled', baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', envKey: CODING_PLAN_ENV_KEY, generationConfig: { diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 5466844d8..377d88511 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -946,6 +946,11 @@ export default { 'Terms of Services and Privacy Notice for Qwen Code': 'Nutzungsbedingungen und Datenschutzhinweis für Qwen Code', 'Qwen OAuth': 'Qwen OAuth', + 'Login with QwenChat account to use daily free quota.': + 'Melden Sie sich mit Ihrem QwenChat-Konto an, um das tägliche kostenlose Kontingent zu nutzen.', + 'API-KEY': 'API-KEY', + 'Use coding plan credentials or your own api-keys/providers.': + 'Verwenden Sie Coding Plan-Anmeldedaten oder Ihre eigenen API-Schlüssel/Anbieter.', OpenAI: 'OpenAI', 'Failed to login. Message: {{message}}': 'Anmeldung fehlgeschlagen. Meldung: {{message}}', @@ -1389,4 +1394,19 @@ export default { 'Verwenden Sie den /model-Befehl, um Ihr bevorzugtes Modell aus der konfigurierten Liste auszuwählen', 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.': 'Unterstützte Authentifizierungstypen: openai, anthropic, gemini, vertex-ai, usw.', + + // ============================================================================ + // Auth Dialog - View Titles and Labels + // ============================================================================ + 'Coding Plan': 'Coding Plan', + "Paste your api key of Bailian Coding Plan and you're all set!": + 'Fügen Sie Ihren Bailian Coding Plan API-Schlüssel ein und Sie sind bereit!', + Custom: 'Benutzerdefiniert', + 'More instructions about configuring `modelProviders` manually.': + 'Weitere Anweisungen zur manuellen Konfiguration von `modelProviders`.', + 'Select API-KEY configuration mode:': + 'API-KEY-Konfigurationsmodus auswählen:', + '(Press Escape to go back)': '(Escape drücken zum Zurückgehen)', + '(Press Enter to submit, Escape to cancel)': + '(Enter zum Absenden, Escape zum Abbrechen)', }; diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 78f57abce..9d27a84e9 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -937,6 +937,11 @@ export default { 'Terms of Services and Privacy Notice for Qwen Code': 'Terms of Services and Privacy Notice for Qwen Code', 'Qwen OAuth': 'Qwen OAuth', + 'Login with QwenChat account to use daily free quota.': + 'Login with QwenChat account to use daily free quota.', + 'API-KEY': 'API-KEY', + 'Use coding plan credentials or your own api-keys/providers.': + 'Use coding plan credentials or your own api-keys/providers.', OpenAI: 'OpenAI', 'Failed to login. Message: {{message}}': 'Failed to login. Message: {{message}}', @@ -1392,9 +1397,12 @@ export default { // ============================================================================ // Auth Dialog - View Titles and Labels // ============================================================================ - 'API-KEY': 'API-KEY', 'Coding Plan': 'Coding Plan', + "Paste your api key of Bailian Coding Plan and you're all set!": + "Paste your api key of Bailian Coding Plan and you're all set!", Custom: 'Custom', + 'More instructions about configuring `modelProviders` manually.': + 'More instructions about configuring `modelProviders` manually.', 'Select API-KEY configuration mode:': 'Select API-KEY configuration mode:', '(Press Escape to go back)': '(Press Escape to go back)', '(Press Enter to submit, Escape to cancel)': diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index e2c7306b7..6a4f728e9 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -679,6 +679,11 @@ export default { 'Terms of Services and Privacy Notice for Qwen Code': 'Qwen Code の利用規約とプライバシー通知', 'Qwen OAuth': 'Qwen OAuth', + 'Login with QwenChat account to use daily free quota.': + 'QwenChatアカウントでログインして、毎日の無料クォータをご利用ください。', + 'API-KEY': 'API-KEY', + 'Use coding plan credentials or your own api-keys/providers.': + 'Coding Planの認証情報またはご自身のAPIキー/プロバイダーをご利用ください。', OpenAI: 'OpenAI', 'Failed to login. Message: {{message}}': 'ログインに失敗しました。メッセージ: {{message}}', @@ -900,4 +905,18 @@ export default { '/model コマンドを使用して、設定済みリストからお好みのモデルを選択してください', 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.': 'サポートされている認証タイプ:openai、anthropic、gemini、vertex-ai など', + + // ============================================================================ + // Auth Dialog - View Titles and Labels + // ============================================================================ + 'Coding Plan': 'Coding Plan', + "Paste your api key of Bailian Coding Plan and you're all set!": + 'Bailian Coding PlanのAPIキーを貼り付けるだけで準備完了です!', + Custom: 'カスタム', + 'More instructions about configuring `modelProviders` manually.': + '`modelProviders`を手動で設定する方法の詳細はこちら。', + 'Select API-KEY configuration mode:': 'API-KEY設定モードを選択してください:', + '(Press Escape to go back)': '(Escapeキーで戻る)', + '(Press Enter to submit, Escape to cancel)': + '(Enterで送信、Escapeでキャンセル)', }; diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 3e0089fa1..96facbe6d 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -958,6 +958,11 @@ export default { 'Terms of Services and Privacy Notice for Qwen Code': 'Termos de Serviço e Aviso de Privacidade do Qwen Code', 'Qwen OAuth': 'Qwen OAuth', + 'Login with QwenChat account to use daily free quota.': + 'Faça login com sua conta QwenChat para usar a cota gratuita diária.', + 'API-KEY': 'API-KEY', + 'Use coding plan credentials or your own api-keys/providers.': + 'Use credenciais do Coding Plan ou suas próprias chaves API/provedores.', OpenAI: 'OpenAI', 'Failed to login. Message: {{message}}': 'Falha ao fazer login. Mensagem: {{message}}', @@ -1403,4 +1408,19 @@ export default { 'Use o comando /model para selecionar seu modelo preferido da lista configurada', 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.': 'Tipos de autenticação suportados: openai, anthropic, gemini, vertex-ai, etc.', + + // ============================================================================ + // Auth Dialog - View Titles and Labels + // ============================================================================ + 'Coding Plan': 'Coding Plan', + "Paste your api key of Bailian Coding Plan and you're all set!": + 'Cole sua chave de API do Bailian Coding Plan e pronto!', + Custom: 'Personalizado', + 'More instructions about configuring `modelProviders` manually.': + 'Mais instruções sobre como configurar `modelProviders` manualmente.', + 'Select API-KEY configuration mode:': + 'Selecione o modo de configuração da API-KEY:', + '(Press Escape to go back)': '(Pressione Escape para voltar)', + '(Press Enter to submit, Escape to cancel)': + '(Pressione Enter para enviar, Escape para cancelar)', }; diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 4bf570351..f783b98b5 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -952,6 +952,11 @@ export default { 'Terms of Services and Privacy Notice for Qwen Code': 'Условия обслуживания и уведомление о конфиденциальности для Qwen Code', 'Qwen OAuth': 'Qwen OAuth', + 'Login with QwenChat account to use daily free quota.': + 'Войдите с помощью аккаунта QwenChat, чтобы использовать ежедневную бесплатную квоту.', + 'API-KEY': 'API-KEY', + 'Use coding plan credentials or your own api-keys/providers.': + 'Используйте учетные данные Coding Plan или свои собственные API-ключи/провайдеры.', OpenAI: 'OpenAI', 'Failed to login. Message: {{message}}': 'Не удалось войти. Сообщение: {{message}}', @@ -1393,4 +1398,18 @@ export default { 'Используйте команду /model, чтобы выбрать предпочитаемую модель из настроенного списка', 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.': 'Поддерживаемые типы аутентификации: openai, anthropic, gemini, vertex-ai и др.', + + // ============================================================================ + // Auth Dialog - View Titles and Labels + // ============================================================================ + 'Coding Plan': 'Coding Plan', + "Paste your api key of Bailian Coding Plan and you're all set!": + 'Вставьте ваш API-ключ Bailian Coding Plan и всё готово!', + Custom: 'Пользовательский', + 'More instructions about configuring `modelProviders` manually.': + 'Дополнительные инструкции по ручной настройке `modelProviders`.', + 'Select API-KEY configuration mode:': 'Выберите режим конфигурации API-KEY:', + '(Press Escape to go back)': '(Нажмите Escape для возврата)', + '(Press Enter to submit, Escape to cancel)': + '(Нажмите Enter для отправки, Escape для отмены)', }; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index e314d5129..657919326 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -886,6 +886,10 @@ export default { 'Terms of Services and Privacy Notice for Qwen Code': 'Qwen Code 的服务条款和隐私声明', 'Qwen OAuth': 'Qwen OAuth (免费)', + 'Login with QwenChat account to use daily free quota.': + '使用 QwenChat 账号登录,享受每日免费额度。', + 'Use coding plan credentials or your own api-keys/providers.': + '使用 Coding Plan 凭证或您自己的 API 密钥/提供商。', OpenAI: 'OpenAI', 'Failed to login. Message: {{message}}': '登录失败。消息:{{message}}', 'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.': @@ -1230,7 +1234,11 @@ export default { // ============================================================================ 'API-KEY': 'API-KEY', 'Coding Plan': 'Coding Plan', + "Paste your api key of Bailian Coding Plan and you're all set!": + '粘贴您的百炼 Coding Plan API Key,即可完成设置!', Custom: '自定义', + 'More instructions about configuring `modelProviders` manually.': + '关于手动配置 `modelProviders` 的更多说明。', 'Select API-KEY configuration mode:': '选择 API-KEY 配置模式:', '(Press Escape to go back)': '(按 Escape 键返回)', '(Press Enter to submit, Escape to cancel)': '(按 Enter 提交,Escape 取消)', diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index f06ba360c..32d3af88f 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -203,6 +203,13 @@ export function AuthDialog(): React.JSX.Element { }} /> + + + {currentSelectedAuthType === AuthType.QWEN_OAUTH + ? t('Login with QwenChat account to use daily free quota.') + : t('Use coding plan credentials or your own api-keys/providers.')} + + ); @@ -225,6 +232,15 @@ export function AuthDialog(): React.JSX.Element { }} /> + + + {apiKeySubItems[apiKeySubModeIndex]?.value === 'coding-plan' + ? t("Paste your api key of Bailian Coding Plan and you're all set!") + : t( + 'More instructions about configuring `modelProviders` manually.', + )} + + {t('(Press Escape to go back)')} diff --git a/packages/core/src/models/modelRegistry.test.ts b/packages/core/src/models/modelRegistry.test.ts index 3a5993603..01ccc8207 100644 --- a/packages/core/src/models/modelRegistry.test.ts +++ b/packages/core/src/models/modelRegistry.test.ts @@ -320,4 +320,215 @@ describe('ModelRegistry', () => { expect(models.find((m) => m.id === 'invalid-model')).toBeUndefined(); }); }); + + describe('duplicate model id handling', () => { + it('should skip duplicate model ids and use first registered config', () => { + const registry = new ModelRegistry({ + openai: [ + { id: 'gpt-4', name: 'GPT-4 First', description: 'First config' }, + { id: 'gpt-4', name: 'GPT-4 Second', description: 'Second config' }, + { id: 'gpt-3.5', name: 'GPT-3.5' }, + ], + }); + + const models = registry.getModelsForAuthType(AuthType.USE_OPENAI); + expect(models.length).toBe(2); + + const gpt4 = registry.getModel(AuthType.USE_OPENAI, 'gpt-4'); + expect(gpt4).toBeDefined(); + expect(gpt4?.name).toBe('GPT-4 First'); + expect(gpt4?.description).toBe('First config'); + }); + + it('should handle multiple duplicate ids in same authType', () => { + const registry = new ModelRegistry({ + openai: [ + { id: 'model-a', name: 'Model A First' }, + { id: 'model-a', name: 'Model A Second' }, + { id: 'model-b', name: 'Model B First' }, + { id: 'model-b', name: 'Model B Second' }, + { id: 'model-c', name: 'Model C' }, + ], + }); + + const models = registry.getModelsForAuthType(AuthType.USE_OPENAI); + expect(models.length).toBe(3); + + expect(registry.getModel(AuthType.USE_OPENAI, 'model-a')?.name).toBe( + 'Model A First', + ); + expect(registry.getModel(AuthType.USE_OPENAI, 'model-b')?.name).toBe( + 'Model B First', + ); + expect(registry.getModel(AuthType.USE_OPENAI, 'model-c')?.name).toBe( + 'Model C', + ); + }); + + it('should treat same id in different authTypes as different models', () => { + const registry = new ModelRegistry({ + openai: [{ id: 'shared-model', name: 'OpenAI Shared' }], + gemini: [{ id: 'shared-model', name: 'Gemini Shared' }], + }); + + const openaiModel = registry.getModel( + AuthType.USE_OPENAI, + 'shared-model', + ); + const geminiModel = registry.getModel( + AuthType.USE_GEMINI, + 'shared-model', + ); + + expect(openaiModel?.name).toBe('OpenAI Shared'); + expect(geminiModel?.name).toBe('Gemini Shared'); + }); + }); + + describe('reloadModels', () => { + it('should reload models from new config', () => { + const registry = new ModelRegistry({ + openai: [{ id: 'gpt-4', name: 'GPT-4' }], + }); + + expect(registry.getModelsForAuthType(AuthType.USE_OPENAI).length).toBe(1); + expect(registry.getModel(AuthType.USE_OPENAI, 'gpt-4')).toBeDefined(); + expect(registry.getModel(AuthType.USE_OPENAI, 'gpt-3.5')).toBeUndefined(); + + registry.reloadModels({ + openai: [{ id: 'gpt-3.5', name: 'GPT-3.5' }], + }); + + // After reload, only new models should exist + expect(registry.getModelsForAuthType(AuthType.USE_OPENAI).length).toBe(1); + expect(registry.getModel(AuthType.USE_OPENAI, 'gpt-4')).toBeUndefined(); + expect(registry.getModel(AuthType.USE_OPENAI, 'gpt-3.5')).toBeDefined(); + }); + + it('should preserve hard-coded qwen-oauth models after reload', () => { + const registry = new ModelRegistry({ + openai: [{ id: 'gpt-4', name: 'GPT-4' }], + }); + + expect(registry.getModelsForAuthType(AuthType.QWEN_OAUTH).length).toBe( + QWEN_OAUTH_MODELS.length, + ); + + registry.reloadModels({ + openai: [{ id: 'gpt-3.5', name: 'GPT-3.5' }], + }); + + // qwen-oauth models should still exist + expect(registry.getModelsForAuthType(AuthType.QWEN_OAUTH).length).toBe( + QWEN_OAUTH_MODELS.length, + ); + expect( + registry.getModel(AuthType.QWEN_OAUTH, 'coder-model'), + ).toBeDefined(); + }); + + it('should clear user-configured models when reload with empty config', () => { + const registry = new ModelRegistry({ + openai: [{ id: 'gpt-4', name: 'GPT-4' }], + gemini: [{ id: 'gemini-pro', name: 'Gemini Pro' }], + }); + + expect(registry.getModelsForAuthType(AuthType.USE_OPENAI).length).toBe(1); + expect(registry.getModelsForAuthType(AuthType.USE_GEMINI).length).toBe(1); + + registry.reloadModels({}); + + // All user-configured models should be cleared + expect(registry.getModelsForAuthType(AuthType.USE_OPENAI).length).toBe(0); + expect(registry.getModelsForAuthType(AuthType.USE_GEMINI).length).toBe(0); + + // qwen-oauth models should still exist + expect(registry.getModelsForAuthType(AuthType.QWEN_OAUTH).length).toBe( + QWEN_OAUTH_MODELS.length, + ); + }); + + it('should ignore qwen-oauth models in reload config', () => { + const registry = new ModelRegistry(); + + registry.reloadModels({ + 'qwen-oauth': [{ id: 'custom-qwen', name: 'Custom Qwen' }], + }); + + // qwen-oauth should still use hard-coded models + const qwenModels = registry.getModelsForAuthType(AuthType.QWEN_OAUTH); + expect(qwenModels.length).toBe(QWEN_OAUTH_MODELS.length); + expect(qwenModels.find((m) => m.id === 'custom-qwen')).toBeUndefined(); + }); + + it('should handle reload with multiple authTypes', () => { + const registry = new ModelRegistry({ + openai: [{ id: 'gpt-4', name: 'GPT-4' }], + }); + + registry.reloadModels({ + openai: [ + { id: 'gpt-4', name: 'GPT-4 Updated' }, + { id: 'gpt-3.5', name: 'GPT-3.5' }, + ], + gemini: [{ id: 'gemini-pro', name: 'Gemini Pro' }], + }); + + const openaiModels = registry.getModelsForAuthType(AuthType.USE_OPENAI); + expect(openaiModels.length).toBe(2); + expect(registry.getModel(AuthType.USE_OPENAI, 'gpt-4')?.name).toBe( + 'GPT-4 Updated', + ); + + const geminiModels = registry.getModelsForAuthType(AuthType.USE_GEMINI); + expect(geminiModels.length).toBe(1); + }); + + it('should skip invalid authType keys during reload', () => { + const registry = new ModelRegistry({ + openai: [{ id: 'gpt-4', name: 'GPT-4' }], + }); + + registry.reloadModels({ + openai: [{ id: 'gpt-3.5', name: 'GPT-3.5' }], + 'invalid-key': [{ id: 'invalid-model', name: 'Invalid Model' }], + } as unknown as ModelProvidersConfig); + + const openaiModels = registry.getModelsForAuthType(AuthType.USE_OPENAI); + expect(openaiModels.length).toBe(1); + expect(registry.getModel(AuthType.USE_OPENAI, 'gpt-3.5')).toBeDefined(); + }); + + it('should handle reload with undefined config', () => { + const registry = new ModelRegistry({ + openai: [{ id: 'gpt-4', name: 'GPT-4' }], + }); + + registry.reloadModels(undefined); + + // All user-configured models should be cleared + expect(registry.getModelsForAuthType(AuthType.USE_OPENAI).length).toBe(0); + // qwen-oauth models should still exist + expect(registry.getModelsForAuthType(AuthType.QWEN_OAUTH).length).toBe( + QWEN_OAUTH_MODELS.length, + ); + }); + + it('should apply duplicate model id handling during reload', () => { + const registry = new ModelRegistry(); + + registry.reloadModels({ + openai: [ + { id: 'model-a', name: 'Model A First' }, + { id: 'model-a', name: 'Model A Second' }, + ], + }); + + const models = registry.getModelsForAuthType(AuthType.USE_OPENAI); + expect(models.length).toBe(1); + expect(registry.getModel(AuthType.USE_OPENAI, 'model-a')?.name).toBe( + 'Model A First', + ); + }); + }); }); diff --git a/packages/core/src/models/modelsConfig.test.ts b/packages/core/src/models/modelsConfig.test.ts index d0bb62f03..03a724829 100644 --- a/packages/core/src/models/modelsConfig.test.ts +++ b/packages/core/src/models/modelsConfig.test.ts @@ -1297,4 +1297,213 @@ describe('ModelsConfig', () => { }); }); }); + + describe('reloadModelProvidersConfig', () => { + it('should reload model providers configuration', async () => { + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + modelProvidersConfig: { + openai: [{ id: 'gpt-4', name: 'GPT-4' }], + }, + }); + + // Verify initial model + await modelsConfig.switchModel(AuthType.USE_OPENAI, 'gpt-4'); + expect(modelsConfig.getModel()).toBe('gpt-4'); + + // Reload with new config + modelsConfig.reloadModelProvidersConfig({ + openai: [{ id: 'gpt-3.5', name: 'GPT-3.5' }], + }); + + // After reload, old model should not exist + expect( + modelsConfig.getAllConfiguredModels().find((m) => m.id === 'gpt-4'), + ).toBeUndefined(); + expect( + modelsConfig.getAllConfiguredModels().find((m) => m.id === 'gpt-3.5'), + ).toBeDefined(); + }); + + it('should preserve current model selection if still available after reload', async () => { + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + modelProvidersConfig: { + openai: [ + { id: 'gpt-4', name: 'GPT-4' }, + { id: 'gpt-3.5', name: 'GPT-3.5' }, + ], + }, + }); + + await modelsConfig.switchModel(AuthType.USE_OPENAI, 'gpt-4'); + expect(modelsConfig.getModel()).toBe('gpt-4'); + + // Reload with config that still includes gpt-4 + modelsConfig.reloadModelProvidersConfig({ + openai: [ + { id: 'gpt-4', name: 'GPT-4 Updated' }, + { id: 'new-model', name: 'New Model' }, + ], + }); + + // Current model should still be available + const availableModels = modelsConfig.getAllConfiguredModels(); + expect(availableModels.find((m) => m.id === 'gpt-4')).toBeDefined(); + expect(availableModels.find((m) => m.id === 'new-model')).toBeDefined(); + }); + + it('should update available models after reload', async () => { + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + modelProvidersConfig: { + openai: [{ id: 'gpt-4', name: 'GPT-4' }], + }, + }); + + const initialModels = modelsConfig.getAllConfiguredModels(); + expect(initialModels.some((m) => m.id === 'gpt-4')).toBe(true); + expect(initialModels.some((m) => m.id === 'gemini-pro')).toBe(false); + + // Reload with different config + modelsConfig.reloadModelProvidersConfig({ + openai: [{ id: 'gpt-3.5', name: 'GPT-3.5' }], + gemini: [{ id: 'gemini-pro', name: 'Gemini Pro' }], + }); + + const updatedModels = modelsConfig.getAllConfiguredModels(); + expect(updatedModels.some((m) => m.id === 'gpt-4')).toBe(false); + expect(updatedModels.some((m) => m.id === 'gpt-3.5')).toBe(true); + expect(updatedModels.some((m) => m.id === 'gemini-pro')).toBe(true); + }); + + it('should handle reload with empty config', async () => { + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + modelProvidersConfig: { + openai: [{ id: 'gpt-4', name: 'GPT-4' }], + gemini: [{ id: 'gemini-pro', name: 'Gemini Pro' }], + }, + }); + + expect( + modelsConfig + .getAllConfiguredModels() + .filter((m) => m.authType !== 'qwen-oauth').length, + ).toBeGreaterThan(0); + + // Reload with empty config + modelsConfig.reloadModelProvidersConfig({}); + + // Only qwen-oauth models should remain + const models = modelsConfig.getAllConfiguredModels(); + expect(models.every((m) => m.authType === 'qwen-oauth')).toBe(true); + }); + + it('should preserve qwen-oauth models after reload', () => { + const modelsConfig = new ModelsConfig({ + modelProvidersConfig: { + openai: [{ id: 'gpt-4', name: 'GPT-4' }], + }, + }); + + const initialQwenModels = modelsConfig + .getAllConfiguredModels() + .filter((m) => m.authType === 'qwen-oauth'); + + modelsConfig.reloadModelProvidersConfig({ + gemini: [{ id: 'gemini-pro', name: 'Gemini Pro' }], + }); + + // qwen-oauth models should still exist + const qwenModelsAfterReload = modelsConfig + .getAllConfiguredModels() + .filter((m) => m.authType === 'qwen-oauth'); + expect(qwenModelsAfterReload.length).toBe(initialQwenModels.length); + }); + + it('should handle reload with undefined config', () => { + const modelsConfig = new ModelsConfig({ + modelProvidersConfig: { + openai: [{ id: 'gpt-4', name: 'GPT-4' }], + }, + }); + + expect( + modelsConfig + .getAllConfiguredModels() + .filter((m) => m.authType === 'openai').length, + ).toBeGreaterThan(0); + + modelsConfig.reloadModelProvidersConfig(undefined); + + // User-configured models should be cleared + expect( + modelsConfig + .getAllConfiguredModels() + .filter((m) => m.authType === 'openai').length, + ).toBe(0); + }); + + it('should support multiple reloads', () => { + const modelsConfig = new ModelsConfig(); + + // First reload + modelsConfig.reloadModelProvidersConfig({ + openai: [{ id: 'model-v1', name: 'Model V1' }], + }); + expect( + modelsConfig.getAllConfiguredModels().some((m) => m.id === 'model-v1'), + ).toBe(true); + + // Second reload + modelsConfig.reloadModelProvidersConfig({ + openai: [{ id: 'model-v2', name: 'Model V2' }], + }); + expect( + modelsConfig.getAllConfiguredModels().some((m) => m.id === 'model-v1'), + ).toBe(false); + expect( + modelsConfig.getAllConfiguredModels().some((m) => m.id === 'model-v2'), + ).toBe(true); + + // Third reload with empty config + modelsConfig.reloadModelProvidersConfig({}); + expect( + modelsConfig.getAllConfiguredModels().some((m) => m.id === 'model-v2'), + ).toBe(false); + }); + + it('should handle complex multi-authType reload', async () => { + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + modelProvidersConfig: { + openai: [ + { id: 'gpt-4', name: 'GPT-4' }, + { id: 'gpt-3.5', name: 'GPT-3.5' }, + ], + gemini: [{ id: 'gemini-pro', name: 'Gemini Pro' }], + }, + }); + + // Reload with completely different config + modelsConfig.reloadModelProvidersConfig({ + openai: [{ id: 'new-openai', name: 'New OpenAI' }], + anthropic: [{ id: 'claude', name: 'Claude' }], + gemini: [{ id: 'gemini-ultra', name: 'Gemini Ultra' }], + }); + + const allModels = modelsConfig.getAllConfiguredModels(); + + // Old models should be gone + expect(allModels.some((m) => m.id === 'gpt-4')).toBe(false); + expect(allModels.some((m) => m.id === 'gpt-3.5')).toBe(false); + expect(allModels.some((m) => m.id === 'gemini-pro')).toBe(false); + + // New models should exist + expect(allModels.some((m) => m.id === 'new-openai')).toBe(true); + expect(allModels.some((m) => m.id === 'claude')).toBe(true); + expect(allModels.some((m) => m.id === 'gemini-ultra')).toBe(true); + }); + }); }); From 50e5cf75083a76a528fbfdf88504bfc04c070ffe Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Wed, 11 Feb 2026 11:27:07 +0800 Subject: [PATCH 08/21] feat(ui): enhance AuthDialog with theming and documentation link --- packages/cli/src/ui/auth/AuthDialog.tsx | 29 ++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index 32d3af88f..9199723a1 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -8,6 +8,8 @@ import type React from 'react'; import { useState } from 'react'; import { AuthType } from '@qwen-code/qwen-code-core'; import { Box, Text } from 'ink'; +import Link from 'ink-link'; +import { theme } from '../semantic-colors.js'; import { Colors } from '../colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; @@ -17,6 +19,9 @@ import { useUIActions } from '../contexts/UIActionsContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { t } from '../../i18n/index.js'; +const MODEL_PROVIDERS_DOCUMENTATION_URL = + 'https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/#modelproviders'; + function parseDefaultAuthType( defaultAuthType: string | undefined, ): AuthType | null { @@ -242,7 +247,9 @@ export function AuthDialog(): React.JSX.Element { - {t('(Press Escape to go back)')} + + {t('(Press Escape to go back)')} + ); @@ -298,14 +305,26 @@ export function AuthDialog(): React.JSX.Element { - + {t( 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.', )} - {t('(Press Escape to go back)')} + + {t('More instructions please check:')} + + + + {MODEL_PROVIDERS_DOCUMENTATION_URL} + + + + + + {t('(Press Escape to go back)')} + ); @@ -328,7 +347,7 @@ export function AuthDialog(): React.JSX.Element { return ( {hasApiKey && currentSelectedAuthType === AuthType.QWEN_OAUTH && ( - + {t( 'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.', )} From 5f710b5f6fd755e3b66a70a9c33784c688775464 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Wed, 11 Feb 2026 11:46:45 +0800 Subject: [PATCH 09/21] fix: ui --- packages/cli/src/i18n/locales/de.js | 1 + packages/cli/src/i18n/locales/en.js | 1 + packages/cli/src/i18n/locales/ja.js | 1 + packages/cli/src/i18n/locales/pt.js | 1 + packages/cli/src/i18n/locales/ru.js | 1 + packages/cli/src/i18n/locales/zh.js | 1 + packages/cli/src/ui/auth/AuthDialog.tsx | 2 ++ packages/cli/src/ui/components/ApiKeyInput.tsx | 2 +- 8 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 377d88511..ce2594d41 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -1409,4 +1409,5 @@ export default { '(Press Escape to go back)': '(Escape drücken zum Zurückgehen)', '(Press Enter to submit, Escape to cancel)': '(Enter zum Absenden, Escape zum Abbrechen)', + 'More instructions please check:': 'Weitere Anweisungen finden Sie unter:', }; diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 9d27a84e9..bfb70e6cb 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1393,6 +1393,7 @@ export default { 'Use /model command to select your preferred model from the configured list', 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.': 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.', + 'More instructions please check:': 'More instructions please check:', // ============================================================================ // Auth Dialog - View Titles and Labels diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 6a4f728e9..764bf7313 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -919,4 +919,5 @@ export default { '(Press Escape to go back)': '(Escapeキーで戻る)', '(Press Enter to submit, Escape to cancel)': '(Enterで送信、Escapeでキャンセル)', + 'More instructions please check:': '詳細な手順はこちらをご確認ください:', }; diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 96facbe6d..b6ad6d5fd 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -1423,4 +1423,5 @@ export default { '(Press Escape to go back)': '(Pressione Escape para voltar)', '(Press Enter to submit, Escape to cancel)': '(Pressione Enter para enviar, Escape para cancelar)', + 'More instructions please check:': 'Mais instruções, consulte:', }; diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index f783b98b5..ab5a9fa89 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -1412,4 +1412,5 @@ export default { '(Press Escape to go back)': '(Нажмите Escape для возврата)', '(Press Enter to submit, Escape to cancel)': '(Нажмите Enter для отправки, Escape для отмены)', + 'More instructions please check:': 'Дополнительные инструкции см.:', }; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 657919326..7399a08f2 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1228,6 +1228,7 @@ export default { '使用 /model 命令从配置列表中选择您偏好的模型', 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.': '支持的认证类型:openai、anthropic、gemini、vertex-ai 等', + 'More instructions please check:': '更多说明请查看:', // ============================================================================ // Auth Dialog - View Titles and Labels diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index 9199723a1..ff73cf204 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -315,6 +315,8 @@ export function AuthDialog(): React.JSX.Element { {t('More instructions please check:')} + + {MODEL_PROVIDERS_DOCUMENTATION_URL} diff --git a/packages/cli/src/ui/components/ApiKeyInput.tsx b/packages/cli/src/ui/components/ApiKeyInput.tsx index ee8a361d2..0177f8791 100644 --- a/packages/cli/src/ui/components/ApiKeyInput.tsx +++ b/packages/cli/src/ui/components/ApiKeyInput.tsx @@ -45,7 +45,7 @@ export function ApiKeyInput({ {t('Please enter your API key:')} - + {error && ( {error} From 63c067bf34a0410bdd21a952c25eb68989a7b7dd Mon Sep 17 00:00:00 2001 From: joeytoday Date: Wed, 11 Feb 2026 13:54:31 +0800 Subject: [PATCH 10/21] docs: update authentication documentation with Coding Plan setup guide - Update images to use CDN links - Add Coding Plan quick setup instructions - Add settings.json modelProviders configuration guide - Add direct configuration via Qwen Code conversation guide - Improve formatting and structure --- docs/users/configuration/auth.md | 111 ++++++++++++++++++++++--------- 1 file changed, 79 insertions(+), 32 deletions(-) diff --git a/docs/users/configuration/auth.md b/docs/users/configuration/auth.md index 405154c69..992bd004e 100644 --- a/docs/users/configuration/auth.md +++ b/docs/users/configuration/auth.md @@ -1,3 +1,4 @@ + # Authentication Qwen Code supports two authentication methods. Pick the one that matches how you want to run the CLI: @@ -5,7 +6,7 @@ Qwen Code supports two authentication methods. Pick the one that matches how you - **Qwen OAuth (recommended)**: sign in with your `qwen.ai` account in a browser. - **OpenAI-compatible API**: use an API key (OpenAI or any OpenAI-compatible provider / endpoint). -![](https://img.alicdn.com/imgextra/i2/O1CN01IxI1bt1sNO543AVTT_!!6000000005754-0-tps-1958-822.jpg) +![](https://gw.alicdn.com/imgextra/i4/O1CN01yXSXc91uYxJxhJXBF_!!6000000006050-2-tps-2372-916.png) ## Option 1: Qwen OAuth (recommended & free) 👍 @@ -24,53 +25,98 @@ qwen ## Option 2: OpenAI-compatible API (API key) -Use this if you want to use OpenAI models or any provider that exposes an OpenAI-compatible API (e.g. OpenAI, Azure OpenAI, OpenRouter, ModelScope, Alibaba Cloud Bailian, or a self-hosted compatible endpoint). +Use this if you want to use OpenAI models or any provider that exposes an OpenAI-compatible API (e.g. OpenAI, Alibaba Cloud Bailian, Azure OpenAI, OpenRouter, ModelScope, or a self-hosted compatible endpoint). ### Recommended: Coding Plan (subscription-based) 🚀 Use this if you want predictable costs with higher usage quotas for the qwen3-coder-plus model. -> [!IMPORTANT] +>[!important] > > Coding Plan is only available for users in China mainland (Beijing region). -- **How it works**: subscribe to the Coding Plan with a fixed monthly fee, then configure Qwen Code to use the dedicated endpoint and your subscription API key. -- **Requirements**: an active Coding Plan subscription from [Alibaba Cloud Bailian](https://bailian.console.aliyun.com/cn-beijing/?tab=globalset#/efm/coding_plan). -- **Benefits**: higher usage quotas, predictable monthly costs, access to latest qwen3-coder-plus model. -- **Cost & quota**: varies by plan (see table below). +- **How it works**: Subscribe to the Coding Plan with a fixed monthly fee, then configure Qwen Code to use the dedicated endpoint and your subscription API key. +- **Requirements**: Obtain an active Coding Plan subscription from [Alibaba Cloud Bailian](https://bailian.console.aliyun.com/cn-beijing/?tab=globalset#/efm/coding_plan). +- **Benefits**: Higher usage quotas, predictable monthly costs, access to the latest qwen3-coder-plus model. +- **Cost & quota**: View [Alibaba Cloud Bailian Coding Plan documentation](https://bailian.console.aliyun.com/cn-beijing/?tab=doc#/doc/?type=model&url=3005961). -#### Coding Plan Pricing & Quotas +#### Coding Plan Quick Setup -| Feature | Lite Basic Plan | Pro Advanced Plan | -| :------------------ | :-------------------- | :-------------------- | -| **Price** | ¥40/month | ¥200/month | -| **5-Hour Limit** | Up to 1,200 requests | Up to 6,000 requests | -| **Weekly Limit** | Up to 9,000 requests | Up to 45,000 requests | -| **Monthly Limit** | Up to 18,000 requests | Up to 90,000 requests | -| **Supported Model** | qwen3-coder-plus | qwen3-coder-plus | +Enter `qwen` in the terminal to launch Qwen Code, then enter the `/auth` command and select `API-KEY` -#### Quick Setup for Coding Plan +![](https://gw.alicdn.com/imgextra/i4/O1CN01yXSXc91uYxJxhJXBF_!!6000000006050-2-tps-2372-916.png) -When you select the OpenAI-compatible option in the CLI, enter these values: +After entering, select `Coding Plan`: -- **API key**: `sk-sp-xxxxx` -- **Base URL**: `https://coding.dashscope.aliyuncs.com/v1` -- **Model**: `qwen3-coder-plus` +![](https://gw.alicdn.com/imgextra/i4/O1CN01Irk0AD1ebfop69o0r_!!6000000003890-2-tps-2308-830.png) -> **Note**: Coding Plan API keys have the format `sk-sp-xxxxx`, which is different from standard Alibaba Cloud API keys. +Enter your `sk-sp-xxxxxxxxx` key, then use the `/model` command to switch between all Bailian `Coding Plan` supported models: + +![](https://gw.alicdn.com/imgextra/i4/O1CN01fWArmf1kaCEgSmPln_!!6000000004699-2-tps-2304-1374.png) + +> [!note] +> +> Coding Plan API key format is `sk-sp-xxxxx`, which differs from standard Alibaba Cloud API keys. +> - **API key**: `sk-sp-xxxxx` +> - **Base URL**: `https://coding.dashscope.aliyuncs.com/v1` + +For more details about the Coding Plan, including subscription options and troubleshooting, see the [full Coding Plan documentation](https://bailian.console.aliyun.com/cn-beijing/?tab=doc#/doc/?type=model&url=3005961). + +#### Configure via settings.json modelProviders + +You can add support for multiple models in the settings.json file and then use the `/model` command in Qwen Code to switch between them. The supported models are listed below: + +```json +"modelProviders": { + "openai": [ + { + "id": "qwen3-coder-plus", + "name": "qwen3-coder-plus", + "envKey": "OPENAI_API_KEY", + "baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1" + }, + { + "id": "qwen3-max", + "name": "qwen3-max", + "envKey": "OPENAI_API_KEY", + "baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1" + } + ] +} +``` + +Then enter the command below in your terminal to add your API key: + +```bash +export OPENAI_API_KEY="your-coding-plan-api-key" +# Format: sk-sp-xxxxx +``` + +#### Direct Configuration via Qwen Code + +After launching Qwen Code, directly say in conversation: + +``` +Help me configure a third-party API model, Bailian API is: sk-xxxxxxxxxx, model is: qwen3-max +``` + +![](https://gw.alicdn.com/imgextra/i2/O1CN0123Tvau1bz5DPY6htQ_!!6000000003535-2-tps-2506-1252.png) + +After restarting Qwen Code, the configuration is successful: + +![](https://gw.alicdn.com/imgextra/i4/O1CN01AKq3Y61ybTy8KOdwD_!!6000000006597-2-tps-2496-796.png) #### Configure via Environment Variables Set these environment variables to use Coding Plan: ```bash -export OPENAI_API_KEY="your-coding-plan-api-key" # Format: sk-sp-xxxxx +export OPENAI_API_KEY="your-coding-plan-api-key" +# Format: sk-sp-xxxxx export OPENAI_BASE_URL="https://coding.dashscope.aliyuncs.com/v1" export OPENAI_MODEL="qwen3-coder-plus" ``` -For more details about Coding Plan, including subscription options and troubleshooting, see the [full Coding Plan documentation](https://bailian.console.aliyun.com/cn-beijing/?tab=doc#/doc/?type=model&url=3005961). - ### Other OpenAI-compatible Providers If you are using other providers (OpenAI, Azure, local LLMs, etc.), use the following configuration methods. @@ -94,8 +140,8 @@ You can set these in your shell profile, CI, or an `.env` file: ```bash export OPENAI_API_KEY="your-api-key-here" -export OPENAI_BASE_URL="https://api.openai.com/v1" # optional -export OPENAI_MODEL="gpt-4o" # optional +export OPENAI_BASE_URL="https://api.openai.com/v1" # optional +export OPENAI_MODEL="gpt-4o" # optional ``` #### Persisting env vars with `.env` / `.qwen/.env` @@ -105,11 +151,11 @@ Qwen Code will auto-load environment variables from the **first** `.env` file it Search order: 1. From the **current directory**, walking upward toward `/`: - 1. `.qwen/.env` - 2. `.env` -2. If nothing is found, it falls back to your **home directory**: - - `~/.qwen/.env` - - `~/.env` +2. `.qwen/.env` +3. `.env` +4. If nothing is found, it falls back to your **home directory**: + - `~/.qwen/.env` + - `~/.env` `.qwen/.env` is recommended to keep Qwen Code variables isolated from other tools. Some variables (like `DEBUG` and `DEBUG_MODE`) are excluded from project `.env` files to avoid interfering with qwen-code behavior. @@ -146,6 +192,7 @@ In the Qwen Code UI, run: ## Non-interactive / headless environments (CI, SSH, containers) In a non-interactive terminal you typically **cannot** complete the OAuth browser login flow. + Use the OpenAI-compatible API method via environment variables: - Set at least `OPENAI_API_KEY`. @@ -157,4 +204,4 @@ If none of these are set in a non-interactive session, Qwen Code will exit with - Don’t commit API keys to version control. - Prefer `.qwen/.env` for project-local secrets (and keep it out of git). -- Treat your terminal output as sensitive if it prints credentials for verification. +- Treat your terminal output as sensitive if it prints credentials for verification. \ No newline at end of file From a8a05188cb17d3241eb31ade2a18d380a776285e Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Wed, 11 Feb 2026 16:18:23 +0800 Subject: [PATCH 11/21] feat(coding-plan): implement Coding Plan configuration management and update prompts --- packages/cli/src/config/settingsSchema.ts | 23 ++ .../{codingPlanTemplates.ts => codingPlan.ts} | 23 +- packages/cli/src/i18n/locales/de.js | 12 + packages/cli/src/i18n/locales/en.js | 8 + packages/cli/src/i18n/locales/ja.js | 12 + packages/cli/src/i18n/locales/pt.js | 12 + packages/cli/src/i18n/locales/ru.js | 12 + packages/cli/src/i18n/locales/zh.js | 8 + packages/cli/src/ui/AppContainer.tsx | 9 + packages/cli/src/ui/auth/useAuth.ts | 37 ++- .../cli/src/ui/components/DialogManager.tsx | 9 + .../cli/src/ui/contexts/UIActionsContext.tsx | 1 + .../cli/src/ui/contexts/UIStateContext.tsx | 2 + .../src/ui/hooks/useCodingPlanUpdates.test.ts | 288 ++++++++++++++++++ .../cli/src/ui/hooks/useCodingPlanUpdates.ts | 201 ++++++++++++ 15 files changed, 639 insertions(+), 18 deletions(-) rename packages/cli/src/constants/{codingPlanTemplates.ts => codingPlan.ts} (56%) create mode 100644 packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts create mode 100644 packages/cli/src/ui/hooks/useCodingPlanUpdates.ts diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index a45884e04..711bf3e8e 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -116,6 +116,29 @@ const SETTINGS_SCHEMA = { mergeStrategy: MergeStrategy.REPLACE, }, + // Coding Plan configuration + codingPlan: { + type: 'object', + label: 'Coding Plan', + category: 'Model', + requiresRestart: false, + default: {}, + description: 'Coding Plan template version tracking and configuration.', + showInDialog: false, + properties: { + version: { + type: 'string', + label: 'Coding Plan Template Version', + category: 'Model', + requiresRestart: false, + default: undefined as string | undefined, + description: + 'SHA256 hash of the Coding Plan template. Used to detect template updates.', + showInDialog: false, + }, + }, + }, + // Environment variables fallback env: { type: 'object', diff --git a/packages/cli/src/constants/codingPlanTemplates.ts b/packages/cli/src/constants/codingPlan.ts similarity index 56% rename from packages/cli/src/constants/codingPlanTemplates.ts rename to packages/cli/src/constants/codingPlan.ts index 8cedab91f..e55aeb93d 100644 --- a/packages/cli/src/constants/codingPlanTemplates.ts +++ b/packages/cli/src/constants/codingPlan.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { createHash } from 'node:crypto'; import type { ProviderModelConfig as ModelConfig } from '@qwen-code/qwen-code-core'; /** @@ -18,9 +19,9 @@ export type CodingPlanTemplate = ModelConfig[]; export const CODING_PLAN_ENV_KEY = 'BAILIAN_CODING_PLAN_API_KEY'; /** - * CODING_PLAN_TEMPLATE defines the model configurations for coding-plan mode. + * CODING_PLAN_MODELS defines the model configurations for coding-plan mode. */ -export const CODING_PLAN_TEMPLATE: CodingPlanTemplate = [ +export const CODING_PLAN_MODELS: CodingPlanTemplate = [ { id: 'qwen3-coder-plus', name: 'qwen3-coder-plus', @@ -32,7 +33,7 @@ export const CODING_PLAN_TEMPLATE: CodingPlanTemplate = [ id: 'qwen3-max-2026-01-23', name: 'qwen3-max-2026-01-23', description: - 'qwen3 max model from Bailian Coding Plan with thinking enabled', + 'qwen3-max model with thinking enabled from Bailian Coding Plan', baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', envKey: CODING_PLAN_ENV_KEY, generationConfig: { @@ -42,3 +43,19 @@ export const CODING_PLAN_TEMPLATE: CodingPlanTemplate = [ }, }, ]; + +/** + * Computes the version hash for the coding plan template. + * Uses SHA256 of the JSON-serialized template for deterministic versioning. + * @returns Hexadecimal string representing the template version + */ +export function computeCodingPlanVersion(): string { + const templateString = JSON.stringify(CODING_PLAN_MODELS); + return createHash('sha256').update(templateString).digest('hex'); +} + +/** + * Current version of the coding plan template. + * Computed at runtime from the template content. + */ +export const CODING_PLAN_VERSION = computeCodingPlanVersion(); diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index ce2594d41..063af8715 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -1395,6 +1395,18 @@ export default { 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.': 'Unterstützte Authentifizierungstypen: openai, anthropic, gemini, vertex-ai, usw.', + // ============================================================================ + // Coding Plan Authentication + // ============================================================================ + 'New model configurations are available for Bailian Coding Plan. Update now?': + 'Neue Modellkonfigurationen sind für Bailian Coding Plan verfügbar. Jetzt aktualisieren?', + 'Coding Plan configuration updated successfully. New models are now available.': + 'Coding Plan-Konfiguration erfolgreich aktualisiert. Neue Modelle sind jetzt verfügbar.', + 'Coding Plan API key not found. Please re-authenticate with Coding Plan.': + 'Coding Plan API-Schlüssel nicht gefunden. Bitte authentifizieren Sie sich erneut mit Coding Plan.', + 'Failed to update Coding Plan configuration: {{message}}': + 'Fehler beim Aktualisieren der Coding Plan-Konfiguration: {{message}}', + // ============================================================================ // Auth Dialog - View Titles and Labels // ============================================================================ diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index bfb70e6cb..ab03a9512 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1375,6 +1375,14 @@ export default { 'API key cannot be empty.': 'API key cannot be empty.', 'API key is stored in settings.env. You can migrate it to a .env file for better security.': 'API key is stored in settings.env. You can migrate it to a .env file for better security.', + 'New model configurations are available for Bailian Coding Plan. Update now?': + 'New model configurations are available for Bailian Coding Plan. Update now?', + 'Coding Plan configuration updated successfully. New models are now available.': + 'Coding Plan configuration updated successfully. New models are now available.', + 'Coding Plan API key not found. Please re-authenticate with Coding Plan.': + 'Coding Plan API key not found. Please re-authenticate with Coding Plan.', + 'Failed to update Coding Plan configuration: {{message}}': + 'Failed to update Coding Plan configuration: {{message}}', // ============================================================================ // Custom API-KEY Configuration diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 764bf7313..348aabc88 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -906,6 +906,18 @@ export default { 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.': 'サポートされている認証タイプ:openai、anthropic、gemini、vertex-ai など', + // ============================================================================ + // Coding Plan Authentication + // ============================================================================ + 'New model configurations are available for Bailian Coding Plan. Update now?': + 'Bailian Coding Plan の新しいモデル設定が利用可能です。今すぐ更新しますか?', + 'Coding Plan configuration updated successfully. New models are now available.': + 'Coding Plan の設定が正常に更新されました。新しいモデルが利用可能になりました。', + 'Coding Plan API key not found. Please re-authenticate with Coding Plan.': + 'Coding Plan の API キーが見つかりません。Coding Plan で再認証してください。', + 'Failed to update Coding Plan configuration: {{message}}': + 'Coding Plan の設定更新に失敗しました: {{message}}', + // ============================================================================ // Auth Dialog - View Titles and Labels // ============================================================================ diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index b6ad6d5fd..fb8aff23d 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -1409,6 +1409,18 @@ export default { 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.': 'Tipos de autenticação suportados: openai, anthropic, gemini, vertex-ai, etc.', + // ============================================================================ + // Coding Plan Authentication + // ============================================================================ + 'New model configurations are available for Bailian Coding Plan. Update now?': + 'Novas configurações de modelo estão disponíveis para o Bailian Coding Plan. Atualizar agora?', + 'Coding Plan configuration updated successfully. New models are now available.': + 'Configuração do Coding Plan atualizada com sucesso. Novos modelos agora estão disponíveis.', + 'Coding Plan API key not found. Please re-authenticate with Coding Plan.': + 'Chave de API do Coding Plan não encontrada. Por favor, re-autentique com o Coding Plan.', + 'Failed to update Coding Plan configuration: {{message}}': + 'Falha ao atualizar a configuração do Coding Plan: {{message}}', + // ============================================================================ // Auth Dialog - View Titles and Labels // ============================================================================ diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index ab5a9fa89..70c428197 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -1399,6 +1399,18 @@ export default { 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.': 'Поддерживаемые типы аутентификации: openai, anthropic, gemini, vertex-ai и др.', + // ============================================================================ + // Coding Plan Authentication + // ============================================================================ + 'New model configurations are available for Bailian Coding Plan. Update now?': + 'Доступны новые конфигурации моделей для Bailian Coding Plan. Обновить сейчас?', + 'Coding Plan configuration updated successfully. New models are now available.': + 'Конфигурация Coding Plan успешно обновлена. Новые модели теперь доступны.', + 'Coding Plan API key not found. Please re-authenticate with Coding Plan.': + 'API-ключ Coding Plan не найден. Пожалуйста, повторно авторизуйтесь с Coding Plan.', + 'Failed to update Coding Plan configuration: {{message}}': + 'Не удалось обновить конфигурацию Coding Plan: {{message}}', + // ============================================================================ // Auth Dialog - View Titles and Labels // ============================================================================ diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 7399a08f2..cb7b7a7f6 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1210,6 +1210,14 @@ export default { 'API key cannot be empty.': 'API Key 不能为空。', 'API key is stored in settings.env. You can migrate it to a .env file for better security.': 'API Key 已存储在 settings.env 中。您可以将其迁移到 .env 文件以获得更好的安全性。', + 'New model configurations are available for Bailian Coding Plan. Update now?': + '百炼 Coding Plan 有新模型配置可用。是否立即更新?', + 'Coding Plan configuration updated successfully. New models are now available.': + 'Coding Plan 配置更新成功。新模型现已可用。', + 'Coding Plan API key not found. Please re-authenticate with Coding Plan.': + '未找到 Coding Plan API Key。请重新通过 Coding Plan 认证。', + 'Failed to update Coding Plan configuration: {{message}}': + '更新 Coding Plan 配置失败:{{message}}', // ============================================================================ // Custom API-KEY Configuration diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 7ac34def2..9c546004c 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -94,6 +94,7 @@ import { useSettingInputRequests, usePluginChoiceRequests, } from './hooks/useExtensionUpdates.js'; +import { useCodingPlanUpdates } from './hooks/useCodingPlanUpdates.js'; import { ShellFocusContext } from './contexts/ShellFocusContext.js'; import { t } from '../i18n/index.js'; import { useWelcomeBack } from './hooks/useWelcomeBack.js'; @@ -232,6 +233,9 @@ export const AppContainer = (props: AppContainerProps) => { config.getWorkingDir(), ); + const { codingPlanUpdateRequest, dismissCodingPlanUpdate } = + useCodingPlanUpdates(settings, config, historyManager.addItem); + const [isPermissionsDialogOpen, setPermissionsDialogOpen] = useState(false); const openPermissionsDialog = useCallback( () => setPermissionsDialogOpen(true), @@ -1276,6 +1280,7 @@ export const AppContainer = (props: AppContainerProps) => { !!shellConfirmationRequest || !!confirmationRequest || confirmUpdateExtensionRequests.length > 0 || + !!codingPlanUpdateRequest || settingInputRequests.length > 0 || pluginChoiceRequests.length > 0 || !!loopDetectionConfirmationRequest || @@ -1340,6 +1345,7 @@ export const AppContainer = (props: AppContainerProps) => { shellConfirmationRequest, confirmationRequest, confirmUpdateExtensionRequests, + codingPlanUpdateRequest, settingInputRequests, pluginChoiceRequests, loopDetectionConfirmationRequest, @@ -1430,6 +1436,7 @@ export const AppContainer = (props: AppContainerProps) => { shellConfirmationRequest, confirmationRequest, confirmUpdateExtensionRequests, + codingPlanUpdateRequest, settingInputRequests, pluginChoiceRequests, loopDetectionConfirmationRequest, @@ -1514,6 +1521,7 @@ export const AppContainer = (props: AppContainerProps) => { exitEditorDialog, closeSettingsDialog, closeModelDialog, + dismissCodingPlanUpdate, closePermissionsDialog, setShellModeActive, vimHandleInput, @@ -1559,6 +1567,7 @@ export const AppContainer = (props: AppContainerProps) => { exitEditorDialog, closeSettingsDialog, closeModelDialog, + dismissCodingPlanUpdate, closePermissionsDialog, setShellModeActive, vimHandleInput, diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index a327152c2..0ea157af5 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -30,9 +30,10 @@ import { AuthState, MessageType } from '../types.js'; import type { HistoryItem } from '../types.js'; import { t } from '../../i18n/index.js'; import { - CODING_PLAN_TEMPLATE, + CODING_PLAN_MODELS, CODING_PLAN_ENV_KEY, -} from '../../constants/codingPlanTemplates.js'; + CODING_PLAN_VERSION, +} from '../../constants/codingPlan.js'; export type { QwenAuthState } from '../hooks/useQwenAuth.js'; @@ -303,7 +304,7 @@ export const useAuthCommand = ( process.env[envKeyName] = apiKey; // Generate model configs from template - const newConfigs: ProviderModelConfig[] = CODING_PLAN_TEMPLATE.map( + const newConfigs: ProviderModelConfig[] = CODING_PLAN_MODELS.map( (templateConfig) => ({ ...templateConfig, envKey: envKeyName, @@ -316,22 +317,21 @@ export const useAuthCommand = ( settings.merged.modelProviders as ModelProvidersConfig | undefined )?.[AuthType.USE_OPENAI] || []; - // Deduplicate: check if config with same id, baseUrl, and envKey exists - const isDuplicate = (config: ProviderModelConfig) => - existingConfigs.some( - (existing) => - existing.id === config.id && - existing.baseUrl === config.baseUrl && - existing.envKey === config.envKey, + // Identify Coding Plan configs by baseUrl + envKey + // Remove existing Coding Plan configs to ensure template changes are applied + const isCodingPlanConfig = (config: ProviderModelConfig) => + config.envKey === envKeyName && + CODING_PLAN_MODELS.some( + (template) => template.baseUrl === config.baseUrl, ); - // Filter out duplicates and replace existing ones - const uniqueNewConfigs = newConfigs.filter( - (config) => !isDuplicate(config), + // Filter out existing Coding Plan configs, keep user custom configs + const nonCodingPlanConfigs = existingConfigs.filter( + (existing) => !isCodingPlanConfig(existing), ); - // Unshift new configs to the beginning - const updatedConfigs = [...uniqueNewConfigs, ...existingConfigs]; + // Add new Coding Plan configs at the beginning + const updatedConfigs = [...newConfigs, ...nonCodingPlanConfigs]; // Persist to modelProviders settings.setValue( @@ -347,6 +347,13 @@ export const useAuthCommand = ( AuthType.USE_OPENAI, ); + // Persist coding plan version for future update detection + settings.setValue( + persistScope, + 'codingPlan.version', + CODING_PLAN_VERSION, + ); + // If there are configs, use the first one as the model if (updatedConfigs.length > 0 && updatedConfigs[0]?.id) { settings.setValue(persistScope, 'model.name', updatedConfigs[0].id); diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index b73ab1287..dbb6f2207 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -122,6 +122,15 @@ export const DialogManager = ({ /> ); } + if (uiState.codingPlanUpdateRequest) { + return ( + + ); + } if (uiState.settingInputRequests.length > 0) { const request = uiState.settingInputRequests[0]; // Use settingName as key to force re-mount when switching between different settings diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index f93ee84eb..ed339e6fa 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -51,6 +51,7 @@ export interface UIActions { exitEditorDialog: () => void; closeSettingsDialog: () => void; closeModelDialog: () => void; + dismissCodingPlanUpdate: () => void; closePermissionsDialog: () => void; setShellModeActive: (value: boolean) => void; vimHandleInput: (key: Key) => boolean; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 6a48d3eca..f8d52faa1 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -32,6 +32,7 @@ import type { UpdateObject } from '../utils/updateCheck.js'; import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; import { type RestartReason } from '../hooks/useIdeTrustListener.js'; +import { type CodingPlanUpdateRequest } from '../hooks/useCodingPlanUpdates.js'; export interface UIState { history: HistoryItem[]; @@ -60,6 +61,7 @@ export interface UIState { shellConfirmationRequest: ShellConfirmationRequest | null; confirmationRequest: ConfirmationRequest | null; confirmUpdateExtensionRequests: ConfirmationRequest[]; + codingPlanUpdateRequest: CodingPlanUpdateRequest | undefined; settingInputRequests: SettingInputRequest[]; pluginChoiceRequests: PluginChoiceRequest[]; loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null; diff --git a/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts b/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts new file mode 100644 index 000000000..a004fbdcb --- /dev/null +++ b/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts @@ -0,0 +1,288 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { useCodingPlanUpdates } from './useCodingPlanUpdates.js'; +import { CODING_PLAN_ENV_KEY } from '../../constants/codingPlan.js'; +import { AuthType } from '@qwen-code/qwen-code-core'; + +// Mock the constants module +vi.mock('../../constants/codingPlan.js', async () => { + const actual = await vi.importActual('../../constants/codingPlan.js'); + return { + ...actual, + CODING_PLAN_VERSION: 'test-version-hash', + CODING_PLAN_MODELS: [ + { + id: 'test-model-1', + name: 'Test Model 1', + baseUrl: 'https://test.example.com/v1', + description: 'Test model 1', + envKey: 'BAILIAN_CODING_PLAN_API_KEY', + }, + { + id: 'test-model-2', + name: 'Test Model 2', + baseUrl: 'https://test.example.com/v1', + description: 'Test model 2', + envKey: 'BAILIAN_CODING_PLAN_API_KEY', + }, + ], + }; +}); + +describe('useCodingPlanUpdates', () => { + const mockSettings = { + merged: { + modelProviders: {}, + codingPlan: {}, + }, + setValue: vi.fn(), + isTrusted: true, + workspace: { settings: {} }, + user: { settings: {} }, + }; + + const mockConfig = { + reloadModelProvidersConfig: vi.fn(), + refreshAuth: vi.fn(), + }; + + const mockAddItem = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + delete process.env[CODING_PLAN_ENV_KEY]; + }); + + describe('version comparison', () => { + it('should not show update prompt when no version is stored', () => { + mockSettings.merged.codingPlan = {}; + + const { result } = renderHook(() => + useCodingPlanUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + expect(result.current.codingPlanUpdateRequest).toBeUndefined(); + }); + + it('should not show update prompt when versions match', () => { + mockSettings.merged.codingPlan = { version: 'test-version-hash' }; + + const { result } = renderHook(() => + useCodingPlanUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + expect(result.current.codingPlanUpdateRequest).toBeUndefined(); + }); + + it('should show update prompt when versions differ', async () => { + mockSettings.merged.codingPlan = { version: 'old-version-hash' }; + + const { result } = renderHook(() => + useCodingPlanUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.codingPlanUpdateRequest).toBeDefined(); + }); + + expect(result.current.codingPlanUpdateRequest?.prompt).toContain( + 'New model configurations', + ); + }); + }); + + describe('update execution', () => { + it('should execute update when user confirms', async () => { + process.env[CODING_PLAN_ENV_KEY] = 'test-api-key'; + mockSettings.merged.codingPlan = { version: 'old-version-hash' }; + mockSettings.merged.modelProviders = { + [AuthType.USE_OPENAI]: [ + { + id: 'test-model-1', + baseUrl: 'https://test.example.com/v1', + envKey: CODING_PLAN_ENV_KEY, + }, + { + id: 'custom-model', + baseUrl: 'https://custom.example.com', + envKey: 'CUSTOM_API_KEY', + }, + ], + }; + mockConfig.refreshAuth.mockResolvedValue(undefined); + + const { result } = renderHook(() => + useCodingPlanUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.codingPlanUpdateRequest).toBeDefined(); + }); + + // Confirm the update + await result.current.codingPlanUpdateRequest!.onConfirm(true); + + // Wait for async update to complete + await waitFor(() => { + // Should update model providers (at least 2 calls: modelProviders + version) + expect(mockSettings.setValue).toHaveBeenCalled(); + }); + + // Should update version + expect(mockSettings.setValue).toHaveBeenCalledWith( + expect.anything(), + 'codingPlan.version', + 'test-version-hash', + ); + + // Should reload and refresh auth + expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled(); + expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI); + + // Should show success message + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'info', + text: expect.stringContaining('updated successfully'), + }), + expect.any(Number), + ); + }); + + it('should not execute update when user declines', async () => { + mockSettings.merged.codingPlan = { version: 'old-version-hash' }; + + const { result } = renderHook(() => + useCodingPlanUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.codingPlanUpdateRequest).toBeDefined(); + }); + + // Decline the update + await result.current.codingPlanUpdateRequest!.onConfirm(false); + + // Should not update anything + expect(mockSettings.setValue).not.toHaveBeenCalled(); + expect(mockConfig.reloadModelProvidersConfig).not.toHaveBeenCalled(); + }); + + it('should preserve non-Coding Plan configs during update', async () => { + process.env[CODING_PLAN_ENV_KEY] = 'test-api-key'; + mockSettings.merged.codingPlan = { version: 'old-version-hash' }; + const customConfig = { + id: 'custom-model', + baseUrl: 'https://custom.example.com', + envKey: 'CUSTOM_API_KEY', + }; + mockSettings.merged.modelProviders = { + [AuthType.USE_OPENAI]: [ + { + id: 'test-model-1', + baseUrl: 'https://test.example.com/v1', + envKey: CODING_PLAN_ENV_KEY, + }, + customConfig, + ], + }; + mockConfig.refreshAuth.mockResolvedValue(undefined); + + const { result } = renderHook(() => + useCodingPlanUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.codingPlanUpdateRequest).toBeDefined(); + }); + + await result.current.codingPlanUpdateRequest!.onConfirm(true); + + // Wait for async update to complete + await waitFor(() => { + // Should preserve custom config - verify setValue was called + expect(mockSettings.setValue).toHaveBeenCalled(); + }); + }); + + it('should handle missing API key error', async () => { + mockSettings.merged.codingPlan = { version: 'old-version-hash' }; + + const { result } = renderHook(() => + useCodingPlanUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.codingPlanUpdateRequest).toBeDefined(); + }); + + await result.current.codingPlanUpdateRequest!.onConfirm(true); + + // Should show error message + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + }), + expect.any(Number), + ); + }); + }); + + describe('dismissUpdate', () => { + it('should clear update request when dismissed', async () => { + mockSettings.merged.codingPlan = { version: 'old-version-hash' }; + + const { result } = renderHook(() => + useCodingPlanUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.codingPlanUpdateRequest).toBeDefined(); + }); + + result.current.dismissCodingPlanUpdate(); + + await waitFor(() => { + expect(result.current.codingPlanUpdateRequest).toBeUndefined(); + }); + }); + }); +}); diff --git a/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts b/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts new file mode 100644 index 000000000..85584def8 --- /dev/null +++ b/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts @@ -0,0 +1,201 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback, useEffect, useState } from 'react'; +import type { Config, ModelProvidersConfig } from '@qwen-code/qwen-code-core'; +import { AuthType } from '@qwen-code/qwen-code-core'; +import type { LoadedSettings } from '../../config/settings.js'; +import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; +import { + CODING_PLAN_MODELS, + CODING_PLAN_ENV_KEY, + CODING_PLAN_VERSION, +} from '../../constants/codingPlan.js'; +import { t } from '../../i18n/index.js'; + +export interface CodingPlanUpdateRequest { + prompt: string; + onConfirm: (confirmed: boolean) => void; +} + +/** + * Checks if a config is a Coding Plan configuration by matching baseUrl and envKey. + * This ensures only configs from the Coding Plan provider are identified. + */ +function isCodingPlanConfig(config: { + baseUrl?: string; + envKey?: string; +}): boolean { + return ( + config.envKey === CODING_PLAN_ENV_KEY && + CODING_PLAN_MODELS.some((template) => template.baseUrl === config.baseUrl) + ); +} + +/** + * Hook for detecting and handling Coding Plan template updates. + * Compares the persisted version with the current template version + * and prompts the user to update if they differ. + */ +export function useCodingPlanUpdates( + settings: LoadedSettings, + config: Config, + addItem: ( + item: { type: 'info' | 'error' | 'warning'; text: string }, + timestamp: number, + ) => void, +) { + const [updateRequest, setUpdateRequest] = useState< + CodingPlanUpdateRequest | undefined + >(); + + /** + * Execute the Coding Plan configuration update. + * Removes old Coding Plan configs and replaces them with new ones from the template. + */ + const executeUpdate = useCallback(async () => { + try { + const persistScope = getPersistScopeForModelSelection(settings); + + // Get current configs + const currentConfigs = + ( + settings.merged.modelProviders as + | Record>> + | undefined + )?.[AuthType.USE_OPENAI] || []; + + // Filter out Coding Plan configs (keep user custom configs) + const nonCodingPlanConfigs = currentConfigs.filter( + (cfg) => + !isCodingPlanConfig({ + baseUrl: cfg['baseUrl'] as string | undefined, + envKey: cfg['envKey'] as string | undefined, + }), + ); + + // Generate new configs from template with the stored API key + const apiKey = process.env[CODING_PLAN_ENV_KEY]; + if (!apiKey) { + throw new Error( + t( + 'Coding Plan API key not found. Please re-authenticate with Coding Plan.', + ), + ); + } + + const newConfigs = CODING_PLAN_MODELS.map((templateConfig) => ({ + ...templateConfig, + envKey: CODING_PLAN_ENV_KEY, + })); + + // Combine: new Coding Plan configs at the front, user configs preserved + const updatedConfigs = [ + ...newConfigs, + ...(nonCodingPlanConfigs as Array>), + ] as Array>; + + // Persist updated model providers + settings.setValue( + persistScope, + `modelProviders.${AuthType.USE_OPENAI}`, + updatedConfigs, + ); + + // Update the version + settings.setValue( + persistScope, + 'codingPlan.version', + CODING_PLAN_VERSION, + ); + + // Hot-reload model providers configuration + const updatedModelProviders = { + ...(settings.merged.modelProviders as + | Record + | undefined), + [AuthType.USE_OPENAI]: updatedConfigs, + }; + config.reloadModelProvidersConfig( + updatedModelProviders as unknown as ModelProvidersConfig, + ); + + // Refresh auth with the new configuration + await config.refreshAuth(AuthType.USE_OPENAI); + + addItem( + { + type: 'info', + text: t( + 'Coding Plan configuration updated successfully. New models are now available.', + ), + }, + Date.now(), + ); + + return true; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + addItem( + { + type: 'error', + text: t('Failed to update Coding Plan configuration: {{message}}', { + message: errorMessage, + }), + }, + Date.now(), + ); + return false; + } + }, [settings, config, addItem]); + + /** + * Check for version mismatch and prompt user for update if needed. + */ + const checkForUpdates = useCallback(() => { + const savedVersion = ( + settings.merged as { codingPlan?: { version?: string } } + ).codingPlan?.version; + + // If no version is stored, user hasn't used Coding Plan yet - skip check + if (!savedVersion) { + return; + } + + // If versions match, no update needed + if (savedVersion === CODING_PLAN_VERSION) { + return; + } + + // Version mismatch - prompt user for update + setUpdateRequest({ + prompt: t( + 'New model configurations are available for Bailian Coding Plan. Update now?', + ), + onConfirm: async (confirmed: boolean) => { + setUpdateRequest(undefined); + if (confirmed) { + await executeUpdate(); + } + }, + }); + }, [settings, executeUpdate]); + + // Check for updates on mount + useEffect(() => { + checkForUpdates(); + }, [checkForUpdates]); + + const dismissCodingPlanUpdate = useCallback(() => { + setUpdateRequest(undefined); + }, []); + + return { + codingPlanUpdateRequest: updateRequest, + dismissCodingPlanUpdate, + }; +} From 54d9073cc0ceafd6abf24fe5fbe456567dec15de Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Wed, 11 Feb 2026 16:48:54 +0800 Subject: [PATCH 12/21] feat(auth): enhance AuthDialog with error handling and update API key link --- packages/cli/src/i18n/locales/de.js | 4 ++++ packages/cli/src/i18n/locales/en.js | 2 ++ packages/cli/src/i18n/locales/ja.js | 4 ++++ packages/cli/src/i18n/locales/pt.js | 4 ++++ packages/cli/src/i18n/locales/ru.js | 4 ++++ packages/cli/src/i18n/locales/zh.js | 2 ++ packages/cli/src/ui/auth/AuthDialog.tsx | 16 ++++++++++++---- packages/cli/src/ui/components/ApiKeyInput.tsx | 14 ++++++++++++++ .../cli/src/ui/contexts/UIActionsContext.tsx | 2 +- 9 files changed, 47 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 063af8715..a0510a200 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -1398,6 +1398,10 @@ export default { // ============================================================================ // Coding Plan Authentication // ============================================================================ + 'Please enter your API key:': 'Bitte geben Sie Ihren API-Schlüssel ein:', + 'API key cannot be empty.': 'API-Schlüssel darf nicht leer sein.', + 'You can get your exclusive Coding Plan API-KEY here:': + 'Hier können Sie Ihren exklusiven Coding Plan API-KEY erhalten:', 'New model configurations are available for Bailian Coding Plan. Update now?': 'Neue Modellkonfigurationen sind für Bailian Coding Plan verfügbar. Jetzt aktualisieren?', 'Coding Plan configuration updated successfully. New models are now available.': diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index ab03a9512..dfd37df5f 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1373,6 +1373,8 @@ export default { // ============================================================================ 'Please enter your API key:': 'Please enter your API key:', 'API key cannot be empty.': 'API key cannot be empty.', + 'You can get your exclusive Coding Plan API-KEY here:': + 'You can get your exclusive Coding Plan API-KEY here:', 'API key is stored in settings.env. You can migrate it to a .env file for better security.': 'API key is stored in settings.env. You can migrate it to a .env file for better security.', 'New model configurations are available for Bailian Coding Plan. Update now?': diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 348aabc88..99db86f73 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -909,6 +909,10 @@ export default { // ============================================================================ // Coding Plan Authentication // ============================================================================ + 'Please enter your API key:': 'APIキーを入力してください:', + 'API key cannot be empty.': 'APIキーは空にできません。', + 'You can get your exclusive Coding Plan API-KEY here:': + 'Coding Plan の API-KEY はこちらで取得できます:', 'New model configurations are available for Bailian Coding Plan. Update now?': 'Bailian Coding Plan の新しいモデル設定が利用可能です。今すぐ更新しますか?', 'Coding Plan configuration updated successfully. New models are now available.': diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index fb8aff23d..9f047aff2 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -1412,6 +1412,10 @@ export default { // ============================================================================ // Coding Plan Authentication // ============================================================================ + 'Please enter your API key:': 'Por favor, digite sua chave de API:', + 'API key cannot be empty.': 'A chave de API não pode estar vazia.', + 'You can get your exclusive Coding Plan API-KEY here:': + 'Você pode obter sua chave de API exclusiva do Coding Plan aqui:', 'New model configurations are available for Bailian Coding Plan. Update now?': 'Novas configurações de modelo estão disponíveis para o Bailian Coding Plan. Atualizar agora?', 'Coding Plan configuration updated successfully. New models are now available.': diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 70c428197..3552eca4d 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -1402,6 +1402,10 @@ export default { // ============================================================================ // Coding Plan Authentication // ============================================================================ + 'Please enter your API key:': 'Пожалуйста, введите ваш API-ключ:', + 'API key cannot be empty.': 'API-ключ не может быть пустым.', + 'You can get your exclusive Coding Plan API-KEY here:': + 'Получите свой эксклюзивный API-KEY Coding Plan здесь:', 'New model configurations are available for Bailian Coding Plan. Update now?': 'Доступны новые конфигурации моделей для Bailian Coding Plan. Обновить сейчас?', 'Coding Plan configuration updated successfully. New models are now available.': diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index cb7b7a7f6..291235482 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1208,6 +1208,8 @@ export default { // ============================================================================ 'Please enter your API key:': '请输入您的 API Key:', 'API key cannot be empty.': 'API Key 不能为空。', + 'You can get your exclusive Coding Plan API-KEY here:': + '您可以在这里获取专属的 Coding Plan API-KEY:', 'API key is stored in settings.env. You can migrate it to a .env file for better security.': 'API Key 已存储在 settings.env 中。您可以将其迁移到 .env 文件以获得更好的安全性。', 'New model configurations are available for Bailian Coding Plan. Update now?': diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index ff73cf204..2d4794d72 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -42,8 +42,11 @@ type ViewLevel = 'main' | 'api-key-sub' | 'api-key-input' | 'custom-info'; export function AuthDialog(): React.JSX.Element { const { pendingAuthType, authError } = useUIState(); - const { handleAuthSelect: onAuthSelect, handleCodingPlanSubmit } = - useUIActions(); + const { + handleAuthSelect: onAuthSelect, + handleCodingPlanSubmit, + onAuthError, + } = useUIActions(); const config = useConfig(); const [errorMessage, setErrorMessage] = useState(null); @@ -69,7 +72,7 @@ export function AuthDialog(): React.JSX.Element { const apiKeySubItems = [ { key: 'coding-plan', - label: t('Coding Plan'), + label: t('Coding Plan (Bailian)'), value: 'coding-plan' as ApiKeySubMode, }, { @@ -116,6 +119,7 @@ export function AuthDialog(): React.JSX.Element { value: (typeof mainItems)[number]['value'], ) => { setErrorMessage(null); + onAuthError(null); if (value === 'API-KEY') { // Navigate to API-KEY sub-mode selection @@ -129,6 +133,7 @@ export function AuthDialog(): React.JSX.Element { const handleApiKeySubSelect = async (subMode: ApiKeySubMode) => { setErrorMessage(null); + onAuthError(null); if (subMode === 'coding-plan') { setViewLevel('api-key-input'); @@ -151,6 +156,7 @@ export function AuthDialog(): React.JSX.Element { const handleGoBack = () => { setErrorMessage(null); + onAuthError(null); if (viewLevel === 'api-key-sub') { setViewLevel('main'); @@ -390,7 +396,9 @@ export function AuthDialog(): React.JSX.Element { - {'https://github.com/QwenLM/Qwen3-Coder/blob/main/README.md'} + { + 'https://qwenlm.github.io/qwen-code-docs/en/users/support/tos-privacy/' + } diff --git a/packages/cli/src/ui/components/ApiKeyInput.tsx b/packages/cli/src/ui/components/ApiKeyInput.tsx index 0177f8791..f741e3fab 100644 --- a/packages/cli/src/ui/components/ApiKeyInput.tsx +++ b/packages/cli/src/ui/components/ApiKeyInput.tsx @@ -11,12 +11,16 @@ import { TextInput } from './shared/TextInput.js'; import { Colors } from '../colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { t } from '../../i18n/index.js'; +import Link from 'ink-link'; interface ApiKeyInputProps { onSubmit: (apiKey: string) => void; onCancel: () => void; } +const CODING_PLAN_API_KEY_URL = + 'https://bailian.console.aliyun.com/?tab=model#/efm/coding_plan'; + export function ApiKeyInput({ onSubmit, onCancel, @@ -51,6 +55,16 @@ export function ApiKeyInput({ {error} )} + + {t('You can get your exclusive Coding Plan API-KEY here:')} + + + + + {CODING_PLAN_API_KEY_URL} + + + {t('(Press Enter to submit, Escape to cancel)')} diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index ed339e6fa..e4cb85003 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -42,7 +42,7 @@ export interface UIActions { ) => Promise; handleCodingPlanSubmit: (apiKey: string) => Promise; setAuthState: (state: AuthState) => void; - onAuthError: (error: string) => void; + onAuthError: (error: string | null) => void; cancelAuthentication: () => void; handleEditorSelect: ( editorType: EditorType | undefined, From a5c7f1db3b5844b279c77daffef31373e786391b Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Wed, 11 Feb 2026 16:58:54 +0800 Subject: [PATCH 13/21] feat(i18n): add translations for "Configure authentication information for login" in multiple languages --- packages/cli/src/i18n/locales/de.js | 2 ++ packages/cli/src/i18n/locales/en.js | 2 ++ packages/cli/src/i18n/locales/ja.js | 2 ++ packages/cli/src/i18n/locales/pt.js | 2 ++ packages/cli/src/i18n/locales/ru.js | 2 ++ packages/cli/src/i18n/locales/zh.js | 1 + packages/cli/src/ui/commands/authCommand.test.ts | 4 +++- packages/cli/src/ui/commands/authCommand.ts | 3 ++- 8 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index a0510a200..5757b135f 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -119,6 +119,8 @@ export default { 'Vollständige Qwen Code Dokumentation im Browser öffnen', 'Configuration not available.': 'Konfiguration nicht verfügbar.', 'change the auth method': 'Authentifizierungsmethode ändern', + 'Configure authentication information for login': + 'Authentifizierungsinformationen für die Anmeldung konfigurieren', 'Copy the last result or code snippet to clipboard': 'Letztes Ergebnis oder Codeausschnitt in die Zwischenablage kopieren', diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index dfd37df5f..f4e299fc1 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -140,6 +140,8 @@ export default { 'open full Qwen Code documentation in your browser', 'Configuration not available.': 'Configuration not available.', 'change the auth method': 'change the auth method', + 'Configure authentication information for login': + 'Configure authentication information for login', 'Copy the last result or code snippet to clipboard': 'Copy the last result or code snippet to clipboard', diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 99db86f73..f9d95b34a 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -110,6 +110,8 @@ export default { 'ブラウザで Qwen Code のドキュメントを開く', 'Configuration not available.': '設定が利用できません', 'change the auth method': '認証方式を変更', + 'Configure authentication information for login': + 'ログイン用の認証情報を設定', 'Copy the last result or code snippet to clipboard': '最後の結果またはコードスニペットをクリップボードにコピー', diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 9f047aff2..8613c3076 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -141,6 +141,8 @@ export default { 'abrir documentação completa do Qwen Code no seu navegador', 'Configuration not available.': 'Configuração não disponível.', 'change the auth method': 'alterar o método de autenticação', + 'Configure authentication information for login': + 'Configurar informações de autenticação para login', 'Copy the last result or code snippet to clipboard': 'Copiar o último resultado ou trecho de código para a área de transferência', diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 3552eca4d..92c9f8c50 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -143,6 +143,8 @@ export default { 'Открытие полной документации Qwen Code в браузере', 'Configuration not available.': 'Конфигурация недоступна.', 'change the auth method': 'Изменение метода авторизации', + 'Configure authentication information for login': + 'Настройка аутентификационной информации для входа', 'Copy the last result or code snippet to clipboard': 'Копирование последнего результата или фрагмента кода в буфер обмена', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 291235482..f20bf06c9 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -138,6 +138,7 @@ export default { '在浏览器中打开完整的 Qwen Code 文档', 'Configuration not available.': '配置不可用', 'change the auth method': '更改认证方法', + 'Configure authentication information for login': '配置登录认证信息', 'Copy the last result or code snippet to clipboard': '将最后的结果或代码片段复制到剪贴板', diff --git a/packages/cli/src/ui/commands/authCommand.test.ts b/packages/cli/src/ui/commands/authCommand.test.ts index d6d925dbd..8930af62d 100644 --- a/packages/cli/src/ui/commands/authCommand.test.ts +++ b/packages/cli/src/ui/commands/authCommand.test.ts @@ -31,6 +31,8 @@ describe('authCommand', () => { it('should have the correct name and description', () => { expect(authCommand.name).toBe('auth'); - expect(authCommand.description).toBe('change the auth method'); + expect(authCommand.description).toBe( + 'Configure authentication information for login', + ); }); }); diff --git a/packages/cli/src/ui/commands/authCommand.ts b/packages/cli/src/ui/commands/authCommand.ts index 9caee464d..83ab454b0 100644 --- a/packages/cli/src/ui/commands/authCommand.ts +++ b/packages/cli/src/ui/commands/authCommand.ts @@ -10,8 +10,9 @@ import { t } from '../../i18n/index.js'; export const authCommand: SlashCommand = { name: 'auth', + altNames: ['login'], get description() { - return t('change the auth method'); + return t('Configure authentication information for login'); }, kind: CommandKind.BUILT_IN, action: (_context, _args): OpenDialogActionReturn => ({ From 489890cf931676114fdd85d5f4e86dee2f48fd60 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Wed, 11 Feb 2026 17:37:58 +0800 Subject: [PATCH 14/21] docs: update auth docs for multi-protocol support Add Anthropic/Gemini protocols, modelProviders config, and env var priority docs Co-authored-by: Qwen-Coder --- README.md | 27 ++- docs/users/configuration/auth.md | 288 ++++++++++++++++--------------- 2 files changed, 164 insertions(+), 151 deletions(-) diff --git a/README.md b/README.md index 378c7bcc1..4c9d28179 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,9 @@ Qwen Code is an open-source AI agent for the terminal, optimized for [Qwen3-Code ## Why Qwen Code? -- **OpenAI-compatible, OAuth free tier**: use an OpenAI-compatible API, or sign in with Qwen OAuth to get 1,000 free requests/day. +- **Multi-protocol, OAuth free tier**: use OpenAI / Anthropic / Gemini-compatible APIs, or sign in with Qwen OAuth for 1,000 free requests/day. - **Open-source, co-evolving**: both the framework and the Qwen3-Coder model are open-source—and they ship and evolve together. -- **Agentic workflow, feature-rich**: rich built-in tools (Skills, SubAgents, Plan Mode) for a full agentic workflow and a Claude Code-like experience. +- **Agentic workflow, feature-rich**: rich built-in tools (Skills, SubAgents) for a full agentic workflow and a Claude Code-like experience. - **Terminal-first, IDE-friendly**: built for developers who live in the command line, with optional integration for VS Code, Zed, and JetBrains IDEs. ## Installation @@ -51,10 +51,7 @@ curl -fsSL -o %TEMP%\install-qwen.bat https://qwen-code-assets.oss-cn-hangzhou.a #### Prerequisites -```bash -# Node.js 20+ -curl -qL https://www.npmjs.com/install.sh | sh -``` +Make sure you have Node.js 20 or later installed. Download it from [nodejs.org](https://nodejs.org/en/download). #### NPM @@ -104,7 +101,7 @@ Your browser does not support the video tag. Qwen Code supports two authentication methods: - **Qwen OAuth (recommended & free)**: sign in with your `qwen.ai` account in a browser. -- **OpenAI-compatible API**: use `OPENAI_API_KEY` (and optionally a custom base URL / model). +- **API-KEY**: use an API key to connect to any supported provider (OpenAI, Anthropic, Google GenAI, Alibaba Cloud Bailian, and other compatible endpoints). #### Qwen OAuth (recommended) @@ -116,17 +113,17 @@ Start `qwen`, then run: Choose **Qwen OAuth** and complete the browser flow. Your credentials are cached locally so you usually won't need to log in again. -#### OpenAI-compatible API (API key) +> **Note:** In non-interactive or headless environments (e.g., CI, SSH, containers), you typically **cannot** complete the OAuth browser login flow. In these cases, please use the API-KEY authentication method. -Environment variables (recommended for CI / headless environments): +#### API-KEY (flexible) -```bash -export OPENAI_API_KEY="your-api-key-here" -export OPENAI_BASE_URL="https://api.openai.com/v1" # optional -export OPENAI_MODEL="gpt-4o" # optional -``` +Use this if you want more flexibility over which provider and model to use. Supports multiple protocols: -For details (including `.qwen/.env` loading and security notes), see the [authentication guide](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/auth/). +- **OpenAI-compatible**: Alibaba Cloud Bailian, ModelScope, OpenAI, OpenRouter, and other OpenAI-compatible providers +- **Anthropic**: Claude models +- **Google GenAI**: Gemini models + +For full details (including `modelProviders` configuration, `.env` file loading, environment variable priorities, and security notes), see the [authentication guide](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/auth/). ## Usage diff --git a/docs/users/configuration/auth.md b/docs/users/configuration/auth.md index 992bd004e..0a5b700ea 100644 --- a/docs/users/configuration/auth.md +++ b/docs/users/configuration/auth.md @@ -1,14 +1,13 @@ - # Authentication Qwen Code supports two authentication methods. Pick the one that matches how you want to run the CLI: - **Qwen OAuth (recommended)**: sign in with your `qwen.ai` account in a browser. -- **OpenAI-compatible API**: use an API key (OpenAI or any OpenAI-compatible provider / endpoint). +- **API-KEY**: use an API key to connect to any supported provider. More flexible — supports OpenAI, Anthropic, Google GenAI, Alibaba Cloud Bailian, and other compatible endpoints. ![](https://gw.alicdn.com/imgextra/i4/O1CN01yXSXc91uYxJxhJXBF_!!6000000006050-2-tps-2372-916.png) -## Option 1: Qwen OAuth (recommended & free) 👍 +## 👍 Option 1: Qwen OAuth (recommended & free) Use this if you want the simplest setup and you're using Qwen models. @@ -23,25 +22,24 @@ Start the CLI and follow the browser flow: qwen ``` -## Option 2: OpenAI-compatible API (API key) +> [!note] +> +> In non-interactive or headless environments (e.g., CI, SSH, containers), you typically **cannot** complete the OAuth browser login flow. +> In these cases, please use the API-KEY authentication method. -Use this if you want to use OpenAI models or any provider that exposes an OpenAI-compatible API (e.g. OpenAI, Alibaba Cloud Bailian, Azure OpenAI, OpenRouter, ModelScope, or a self-hosted compatible endpoint). +## 🚀 Option 2: API-KEY (flexible) -### Recommended: Coding Plan (subscription-based) 🚀 +Use this if you want more flexibility over which provider and model to use. Supports multiple protocols and providers, including OpenAI, Anthropic, Google GenAI, Alibaba Cloud Bailian, Azure OpenAI, OpenRouter, ModelScope, or a self-hosted compatible endpoint. + +### Option1: Coding Plan(Aliyun Bailian) Use this if you want predictable costs with higher usage quotas for the qwen3-coder-plus model. ->[!important] -> -> Coding Plan is only available for users in China mainland (Beijing region). - - **How it works**: Subscribe to the Coding Plan with a fixed monthly fee, then configure Qwen Code to use the dedicated endpoint and your subscription API key. - **Requirements**: Obtain an active Coding Plan subscription from [Alibaba Cloud Bailian](https://bailian.console.aliyun.com/cn-beijing/?tab=globalset#/efm/coding_plan). - **Benefits**: Higher usage quotas, predictable monthly costs, access to the latest qwen3-coder-plus model. - **Cost & quota**: View [Alibaba Cloud Bailian Coding Plan documentation](https://bailian.console.aliyun.com/cn-beijing/?tab=doc#/doc/?type=model&url=3005961). -#### Coding Plan Quick Setup - Enter `qwen` in the terminal to launch Qwen Code, then enter the `/auth` command and select `API-KEY` ![](https://gw.alicdn.com/imgextra/i4/O1CN01yXSXc91uYxJxhJXBF_!!6000000006050-2-tps-2372-916.png) @@ -54,154 +52,172 @@ Enter your `sk-sp-xxxxxxxxx` key, then use the `/model` command to switch be ![](https://gw.alicdn.com/imgextra/i4/O1CN01fWArmf1kaCEgSmPln_!!6000000004699-2-tps-2304-1374.png) -> [!note] +### Option2: Third-party API-KEY + +Use this if you want to connect to third-party providers such as OpenAI, Anthropic, Google, Azure OpenAI, OpenRouter, ModelScope, or a self-hosted endpoint. + +The key concept is **Model Providers** (`modelProviders`): Qwen Code supports multiple API protocols, not just OpenAI. You configure which providers and models are available by editing `~/.qwen/settings.json`, then switch between them at runtime with the `/model` command. + +#### Supported protocols + +| Protocol | `modelProviders` key | Environment variables | Providers | +| ----------------- | -------------------- | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- | +| OpenAI-compatible | `openai` | `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL` | OpenAI, Azure OpenAI, OpenRouter, ModelScope, Alibaba Cloud Bailian, any OpenAI-compatible endpoint | +| Anthropic | `anthropic` | `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`, `ANTHROPIC_MODEL` | Anthropic Claude | +| Google GenAI | `gemini` | `GEMINI_API_KEY`, `GEMINI_MODEL` | Google Gemini | +| Google Vertex AI | `vertex-ai` | `GOOGLE_API_KEY`, `GOOGLE_MODEL` | Google Vertex AI | + +#### Step 1: Configure `modelProviders` in `~/.qwen/settings.json` + +Define which models are available for each protocol. Each model entry requires at minimum an `id` and an `envKey` (the environment variable name that holds your API key). + +> [!important] > -> Coding Plan API key format is `sk-sp-xxxxx`, which differs from standard Alibaba Cloud API keys. -> - **API key**: `sk-sp-xxxxx` -> - **Base URL**: `https://coding.dashscope.aliyuncs.com/v1` +> It is recommended to define `modelProviders` in the user-scope `~/.qwen/settings.json` to avoid merge conflicts between project and user settings. -For more details about the Coding Plan, including subscription options and troubleshooting, see the [full Coding Plan documentation](https://bailian.console.aliyun.com/cn-beijing/?tab=doc#/doc/?type=model&url=3005961). - -#### Configure via settings.json modelProviders - -You can add support for multiple models in the settings.json file and then use the `/model` command in Qwen Code to switch between them. The supported models are listed below: +Edit `~/.qwen/settings.json` (create it if it doesn't exist): ```json -"modelProviders": { - "openai": [ - { - "id": "qwen3-coder-plus", - "name": "qwen3-coder-plus", - "envKey": "OPENAI_API_KEY", - "baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1" - }, - { - "id": "qwen3-max", - "name": "qwen3-max", - "envKey": "OPENAI_API_KEY", - "baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1" - } - ] +{ + "modelProviders": { + "openai": [ + { + "id": "gpt-4o", + "name": "GPT-4o", + "envKey": "OPENAI_API_KEY", + "baseUrl": "https://api.openai.com/v1" + } + ], + "anthropic": [ + { + "id": "claude-sonnet-4-20250514", + "name": "Claude Sonnet 4", + "envKey": "ANTHROPIC_API_KEY" + } + ], + "gemini": [ + { + "id": "gemini-2.5-pro", + "name": "Gemini 2.5 Pro", + "envKey": "GEMINI_API_KEY" + } + ] + } } ``` -Then enter the command below in your terminal to add your API key: +You can mix multiple protocols and models in a single configuration. The `ModelConfig` fields are: + +| Field | Required | Description | +| ------------------ | -------- | -------------------------------------------------------------------- | +| `id` | Yes | Model ID sent to the API (e.g. `gpt-4o`, `claude-sonnet-4-20250514`) | +| `name` | No | Display name in the `/model` picker (defaults to `id`) | +| `envKey` | Yes | Environment variable name for the API key (e.g. `OPENAI_API_KEY`) | +| `baseUrl` | No | API endpoint override (useful for proxies or custom endpoints) | +| `generationConfig` | No | Fine-tune `timeout`, `maxRetries`, `samplingParams`, etc. | + +> [!note] +> +> Credentials are **never** stored in `settings.json`. The runtime reads them from the environment variable specified in `envKey`. + +For the full `modelProviders` schema and advanced options like `generationConfig`, `customHeaders`, and `extra_body`, see [Settings Reference → modelProviders](settings.md#modelproviders). + +#### Step 2: Set environment variables + +Qwen Code reads API keys from environment variables (specified by `envKey` in your model config). There are multiple ways to provide them, listed below from **highest to lowest priority**: + +**1. Shell environment / `export` (highest priority)** + +Set directly in your shell profile (`~/.zshrc`, `~/.bashrc`, etc.) or inline before launching: ```bash -export OPENAI_API_KEY="your-coding-plan-api-key" -# Format: sk-sp-xxxxx + +# Alibaba Dashscope +export DASHSCOPE_API_KEY="sk-..." + +# OpenAI / OpenAI-compatible +export OPENAI_API_KEY="sk-..." + +# Anthropic +export ANTHROPIC_API_KEY="sk-ant-..." + +# Google GenAI +export GEMINI_API_KEY="AIza..." ``` -#### Direct Configuration via Qwen Code +**2. `.env` files** -After launching Qwen Code, directly say in conversation: +Qwen Code auto-loads the **first** `.env` file it finds (variables are **not merged** across multiple files). Only variables not already present in `process.env` are loaded. -``` -Help me configure a third-party API model, Bailian API is: sk-xxxxxxxxxx, model is: qwen3-max +Search order (from the current directory, walking upward toward `/`): + +1. `.qwen/.env` (preferred — keeps Qwen Code variables isolated from other tools) +2. `.env` + +If nothing is found, it falls back to your **home directory**: + +3. `~/.qwen/.env` +4. `~/.env` + +> [!tip] +> +> `.qwen/.env` is recommended over `.env` to avoid conflicts with other tools. Some variables (like `DEBUG` and `DEBUG_MODE`) are excluded from project-level `.env` files to avoid interfering with Qwen Code behavior. + +**3. `settings.json` → `env` field (lowest priority)** + +You can also define environment variables directly in `~/.qwen/settings.json` under the `env` key. These are loaded as the **lowest-priority fallback** — only applied when a variable is not already set by the system environment or `.env` files. + +```json +{ + "env": { + "DASHSCOPE_API_KEY":"sk-...", + "OPENAI_API_KEY": "sk-...", + "ANTHROPIC_API_KEY": "sk-ant-...", + "GEMINI_API_KEY": "AIza..." + }, + "modelProviders": { + ... + } +} ``` -![](https://gw.alicdn.com/imgextra/i2/O1CN0123Tvau1bz5DPY6htQ_!!6000000003535-2-tps-2506-1252.png) +> [!note] +> +> This is useful when you want to keep all configuration (providers + credentials) in a single file. However, be mindful that `settings.json` may be shared or synced — prefer `.env` files for sensitive secrets. -After restarting Qwen Code, the configuration is successful: +**Priority summary:** -![](https://gw.alicdn.com/imgextra/i4/O1CN01AKq3Y61ybTy8KOdwD_!!6000000006597-2-tps-2496-796.png) +| Priority | Source | Override behavior | +| ----------- | ------------------------------ | ---------------------------------------- | +| 1 (highest) | CLI flags (`--openai-api-key`) | Always wins | +| 2 | System env (`export`, inline) | Overrides `.env` and `settings.env` | +| 3 | `.env` file | Only sets if not in system env | +| 4 (lowest) | `settings.json` → `env` | Only sets if not in system env or `.env` | -#### Configure via Environment Variables +#### Step 3: Switch models with `/model` -Set these environment variables to use Coding Plan: +After launching Qwen Code, use the `/model` command to switch between all configured models. Models are grouped by protocol: + +``` +/model +``` + +The picker will show all models from your `modelProviders` configuration, grouped by their protocol (e.g. `openai`, `anthropic`, `gemini`). Your selection is persisted across sessions. + +You can also switch models directly with a command-line argument, which is convenient when working across multiple terminals. ```bash -export OPENAI_API_KEY="your-coding-plan-api-key" -# Format: sk-sp-xxxxx -export OPENAI_BASE_URL="https://coding.dashscope.aliyuncs.com/v1" -export OPENAI_MODEL="qwen3-coder-plus" +# In one terminal + +qwen --model "qwen3-coder-plus" + +# In another terminal + +qwen --model "qwen3-coder-next" ``` -### Other OpenAI-compatible Providers - -If you are using other providers (OpenAI, Azure, local LLMs, etc.), use the following configuration methods. - -### Configure via command-line arguments - -```bash -# API key only -qwen-code --openai-api-key "your-api-key-here" - -# Custom base URL (OpenAI-compatible endpoint) -qwen-code --openai-api-key "your-api-key-here" --openai-base-url "https://your-endpoint.com/v1" - -# Custom model -qwen-code --openai-api-key "your-api-key-here" --model "gpt-4o-mini" -``` - -### Configure via environment variables - -You can set these in your shell profile, CI, or an `.env` file: - -```bash -export OPENAI_API_KEY="your-api-key-here" -export OPENAI_BASE_URL="https://api.openai.com/v1" # optional -export OPENAI_MODEL="gpt-4o" # optional -``` - -#### Persisting env vars with `.env` / `.qwen/.env` - -Qwen Code will auto-load environment variables from the **first** `.env` file it finds (variables are **not merged** across multiple files). - -Search order: - -1. From the **current directory**, walking upward toward `/`: -2. `.qwen/.env` -3. `.env` -4. If nothing is found, it falls back to your **home directory**: - - `~/.qwen/.env` - - `~/.env` - -`.qwen/.env` is recommended to keep Qwen Code variables isolated from other tools. Some variables (like `DEBUG` and `DEBUG_MODE`) are excluded from project `.env` files to avoid interfering with qwen-code behavior. - -Examples: - -```bash -# Project-specific settings (recommended) -mkdir -p .qwen -cat >> .qwen/.env <<'EOF' -OPENAI_API_KEY="your-api-key" -OPENAI_BASE_URL="https://api-inference.modelscope.cn/v1" -OPENAI_MODEL="Qwen/Qwen3-Coder-480B-A35B-Instruct" -EOF -``` - -```bash -# User-wide settings (available everywhere) -mkdir -p ~/.qwen -cat >> ~/.qwen/.env <<'EOF' -OPENAI_API_KEY="your-api-key" -OPENAI_BASE_URL="https://dashscope.aliyuncs.com/compatible-mode/v1" -OPENAI_MODEL="qwen3-coder-plus" -EOF -``` - -## Switch authentication method (without restarting) - -In the Qwen Code UI, run: - -```bash -/auth -``` - -## Non-interactive / headless environments (CI, SSH, containers) - -In a non-interactive terminal you typically **cannot** complete the OAuth browser login flow. - -Use the OpenAI-compatible API method via environment variables: - -- Set at least `OPENAI_API_KEY`. -- Optionally set `OPENAI_BASE_URL` and `OPENAI_MODEL`. - -If none of these are set in a non-interactive session, Qwen Code will exit with an error. - ## Security notes - Don’t commit API keys to version control. - Prefer `.qwen/.env` for project-local secrets (and keep it out of git). -- Treat your terminal output as sensitive if it prints credentials for verification. \ No newline at end of file +- Treat your terminal output as sensitive if it prints credentials for verification. From cc4ad12e0618bfbdec6a84789d877151c824fe63 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Wed, 11 Feb 2026 19:22:31 +0800 Subject: [PATCH 15/21] chore: bump version to 0.10.1 Co-authored-by: Qwen-Coder --- package-lock.json | 14 +++++++------- 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 +- packages/webui/package.json | 2 +- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index ff5a902d7..9a49d849e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qwen-code/qwen-code", - "version": "0.10.0", + "version": "0.10.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qwen-code/qwen-code", - "version": "0.10.0", + "version": "0.10.1", "workspaces": [ "packages/*" ], @@ -18655,7 +18655,7 @@ }, "packages/cli": { "name": "@qwen-code/qwen-code", - "version": "0.10.0", + "version": "0.10.1", "dependencies": { "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", @@ -19274,7 +19274,7 @@ }, "packages/core": { "name": "@qwen-code/qwen-code-core", - "version": "0.10.0", + "version": "0.10.1", "hasInstallScript": true, "dependencies": { "@anthropic-ai/sdk": "^0.36.1", @@ -22754,7 +22754,7 @@ }, "packages/test-utils": { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.10.0", + "version": "0.10.1", "dev": true, "license": "Apache-2.0", "devDependencies": { @@ -22766,7 +22766,7 @@ }, "packages/vscode-ide-companion": { "name": "qwen-code-vscode-ide-companion", - "version": "0.10.0", + "version": "0.10.1", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.1", @@ -23013,7 +23013,7 @@ }, "packages/webui": { "name": "@qwen-code/webui", - "version": "0.10.0", + "version": "0.10.1", "license": "MIT", "dependencies": { "markdown-it": "^14.1.0" diff --git a/package.json b/package.json index 374dd32c6..1605e1aeb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.10.0", + "version": "0.10.1", "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.10.0" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.10.1" }, "scripts": { "start": "cross-env node scripts/start.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index 729f04a33..7ec5da972 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.10.0", + "version": "0.10.1", "description": "Qwen Code", "repository": { "type": "git", @@ -34,7 +34,7 @@ "dist" ], "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.10.0" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.10.1" }, "dependencies": { "@google/genai": "1.30.0", diff --git a/packages/core/package.json b/packages/core/package.json index 3f55393f3..8320a946d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-core", - "version": "0.10.0", + "version": "0.10.1", "description": "Qwen Code Core", "repository": { "type": "git", diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index b3425fe61..299ddc611 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.10.0", + "version": "0.10.1", "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 f93a60027..f9e9d040e 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.10.0", + "version": "0.10.1", "publisher": "qwenlm", "icon": "assets/icon.png", "repository": { diff --git a/packages/webui/package.json b/packages/webui/package.json index 246d9a3e7..f4305e230 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/webui", - "version": "0.10.0", + "version": "0.10.1", "description": "Shared UI components for Qwen Code packages", "type": "module", "main": "./dist/index.cjs", From 8355c96cf149ed9053db1f3e6e71c0ff30798a28 Mon Sep 17 00:00:00 2001 From: DennisYu07 <617072224@qq.com> Date: Wed, 11 Feb 2026 05:23:55 -0800 Subject: [PATCH 16/21] fix installation script --- .../installation/install-qwen-with-source.sh | 77 +++++++++++++------ 1 file changed, 52 insertions(+), 25 deletions(-) diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index 4da27d2f2..61ded8e50 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -353,40 +353,67 @@ install_qwen_code() { echo " Upgrading to the latest version..." fi - # Check if running as root + # Determine npm install command + # Priority: 1) npm without sudo (if it works), 2) npm with sudo, 3) sudo with preserved PATH USER_ID=$(id -u) || true - if [[ "${USER_ID}" -eq 0 ]]; then - # Running as root, no need for sudo - NPM_INSTALL_CMD="npm install -g @qwen-code/qwen-code@latest" + NPM_INSTALL_CMD="" + USE_SUDO=false + + # First, try npm directly without sudo + if npm --version >/dev/null 2>&1; then + # Check if npm can write to global directory + if npm install -g --dry-run @qwen-code/qwen-code@latest >/dev/null 2>&1; then + NPM_INSTALL_CMD="npm install -g @qwen-code/qwen-code@latest" + USE_SUDO=false + else + # npm exists but needs elevated permissions + if [[ "${USER_ID}" -eq 0 ]]; then + NPM_INSTALL_CMD="npm install -g @qwen-code/qwen-code@latest" + USE_SUDO=false + else + NPM_INSTALL_CMD="npm install -g @qwen-code/qwen-code@latest" + USE_SUDO=true + fi + fi else - # Not root, use sudo - NPM_INSTALL_CMD="sudo npm install -g @qwen-code/qwen-code@latest" + echo "✗ npm is not available in PATH" + exit 1 fi # Install/Upgrade Qwen Code globally # Note: Don't suppress output to allow sudo password prompt to be visible - if ${NPM_INSTALL_CMD}; then - echo "✓ Qwen Code installed/upgraded successfully!" - - # Create/Update source.json only if source parameter was provided - if [[ "${SOURCE}" != "unknown" ]]; then - create_source_json + if [[ "${USE_SUDO}" == true ]]; then + # Use sudo with preserved PATH to find npm in user environment + if sudo -E env "PATH=$PATH" npm install -g @qwen-code/qwen-code@latest; then + echo "✓ Qwen Code installed/upgraded successfully!" else - echo " (Skipping source.json creation - no source specified)" - fi - - # Verify installation - if command_exists qwen; then - QWEN_VERSION=$(qwen --version 2>/dev/null || echo "unknown") - echo "✓ Qwen Code is available as 'qwen' command" - echo " Installed version: ${QWEN_VERSION}" - else - echo "⚠ Qwen Code installed but not in PATH" - echo " You may need to restart your terminal" + echo "✗ Failed to install Qwen Code" + exit 1 fi else - echo "✗ Failed to install Qwen Code" - exit 1 + if ${NPM_INSTALL_CMD}; then + echo "✓ Qwen Code installed/upgraded successfully!" + else + echo "✗ Failed to install Qwen Code" + exit 1 + fi + fi + + # Create/Update source.json only if source parameter was provided + if [[ "${SOURCE}" != "unknown" ]]; then + create_source_json + else + echo " (Skipping source.json creation - no source specified)" + fi + + # Verify installation + if command_exists qwen; then + QWEN_VERSION=$(qwen --version 2>/dev/null || echo "unknown") + echo "✓ Qwen Code is available as 'qwen' command" + echo " Installed version: ${QWEN_VERSION}" + else + echo "⚠ Qwen Code installed but not in PATH" + echo " You may need to restart your terminal" fi } From 1c3884049092cc9e93b357b70da6443709ad32dd Mon Sep 17 00:00:00 2001 From: DennisYu07 <617072224@qq.com> Date: Wed, 11 Feb 2026 23:03:09 -0800 Subject: [PATCH 17/21] fix installation user permission issue --- .../installation/install-qwen-with-source.sh | 74 +++++++++---------- 1 file changed, 34 insertions(+), 40 deletions(-) diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index 61ded8e50..8341ee76f 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -54,6 +54,19 @@ command_exists() { command -v "$1" >/dev/null 2>&1 } +# Function to fix npm global directory permissions +fix_npm_permissions() { + echo "Fixing npm global directory permissions..." + # 1. Change ownership of the entire .npm-global directory to current user + sudo chown -R $(whoami):staff ~/.npm-global 2>/dev/null || true + + # 2. Fix directory permissions (ensure user has full read/write/execute permissions) + chmod -R u+rwX ~/.npm-global 2>/dev/null || true + + # 3. Specifically fix parent directory permissions (to prevent mkdir failures) + chmod u+rwx ~/.npm-global ~/.npm-global/lib ~/.npm-global/lib/node_modules 2>/dev/null || true +} + # Function to check and install Node.js install_nodejs() { if command_exists node; then @@ -68,7 +81,7 @@ install_nodejs() { install_nodejs_via_nvm elif [[ "${NODE_MAJOR_VERSION}" -ge 20 ]]; then echo "✓ Node.js is already installed: ${NODE_VERSION}" - + # Check npm after confirming Node.js exists if ! command_exists npm; then echo "⚠ npm not found, installing npm..." @@ -93,6 +106,11 @@ install_nodejs() { fi fi + # Check if npm global directory has permission issues + if ! npm config get prefix >/dev/null 2>&1; then + fix_npm_permissions + fi + return 0 else echo "⚠ Node.js ${NODE_VERSION} is installed, but Qwen Code requires Node.js 20+" @@ -353,48 +371,24 @@ install_qwen_code() { echo " Upgrading to the latest version..." fi - # Determine npm install command - # Priority: 1) npm without sudo (if it works), 2) npm with sudo, 3) sudo with preserved PATH - USER_ID=$(id -u) || true - NPM_INSTALL_CMD="" - USE_SUDO=false - - # First, try npm directly without sudo - if npm --version >/dev/null 2>&1; then - # Check if npm can write to global directory - if npm install -g --dry-run @qwen-code/qwen-code@latest >/dev/null 2>&1; then - NPM_INSTALL_CMD="npm install -g @qwen-code/qwen-code@latest" - USE_SUDO=false - else - # npm exists but needs elevated permissions - if [[ "${USER_ID}" -eq 0 ]]; then - NPM_INSTALL_CMD="npm install -g @qwen-code/qwen-code@latest" - USE_SUDO=false - else - NPM_INSTALL_CMD="npm install -g @qwen-code/qwen-code@latest" - USE_SUDO=true - fi - fi + # First, try to install without sudo (user level) + echo " Attempting to install Qwen Code with current user permissions..." + if npm install -g @qwen-code/qwen-code@latest 2>/dev/null; then + echo "✓ Qwen Code installed/upgraded successfully!" else - echo "✗ npm is not available in PATH" - exit 1 - fi + # Installation failed, likely due to permissions + echo " Installation failed with user permissions, attempting to fix permissions..." - # Install/Upgrade Qwen Code globally - # Note: Don't suppress output to allow sudo password prompt to be visible - if [[ "${USE_SUDO}" == true ]]; then - # Use sudo with preserved PATH to find npm in user environment - if sudo -E env "PATH=$PATH" npm install -g @qwen-code/qwen-code@latest; then - echo "✓ Qwen Code installed/upgraded successfully!" + # Fix npm global directory permissions + fix_npm_permissions + + # Try again after fixing permissions + if npm install -g @qwen-code/qwen-code@latest 2>/dev/null; then + echo "✓ Qwen Code installed/upgraded successfully after permission fix!" else - echo "✗ Failed to install Qwen Code" - exit 1 - fi - else - if ${NPM_INSTALL_CMD}; then - echo "✓ Qwen Code installed/upgraded successfully!" - else - echo "✗ Failed to install Qwen Code" + # Both attempts failed + echo "✗ Failed to install Qwen Code even after permission fix" + echo " Please check your system permissions or contact support" exit 1 fi fi From 66e3cdfb71701c83c861f95d24259c1a0f01b50f Mon Sep 17 00:00:00 2001 From: DennisYu07 <617072224@qq.com> Date: Wed, 11 Feb 2026 23:08:00 -0800 Subject: [PATCH 18/21] fix warning --- scripts/installation/install-qwen-with-source.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index 8341ee76f..f7aebdcf0 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -58,7 +58,7 @@ command_exists() { fix_npm_permissions() { echo "Fixing npm global directory permissions..." # 1. Change ownership of the entire .npm-global directory to current user - sudo chown -R $(whoami):staff ~/.npm-global 2>/dev/null || true + sudo chown -R "$(whoami)":staff ~/.npm-global 2>/dev/null || true # 2. Fix directory permissions (ensure user has full read/write/execute permissions) chmod -R u+rwX ~/.npm-global 2>/dev/null || true From be37ba0fda2d75631d6647b449e651409f935538 Mon Sep 17 00:00:00 2001 From: DennisYu07 <617072224@qq.com> Date: Wed, 11 Feb 2026 23:26:41 -0800 Subject: [PATCH 19/21] change staff group for cross platform --- scripts/installation/install-qwen-with-source.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index f7aebdcf0..464ebe0b0 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -58,7 +58,8 @@ command_exists() { fix_npm_permissions() { echo "Fixing npm global directory permissions..." # 1. Change ownership of the entire .npm-global directory to current user - sudo chown -R "$(whoami)":staff ~/.npm-global 2>/dev/null || true + # Using only user ownership without specifying a group for cross-platform compatibility + sudo chown -R "$(whoami)" ~/.npm-global 2>/dev/null || true # 2. Fix directory permissions (ensure user has full read/write/execute permissions) chmod -R u+rwX ~/.npm-global 2>/dev/null || true From 23294b88a2720bf3b0a94e6877bb572f070808e4 Mon Sep 17 00:00:00 2001 From: DennisYu07 <617072224@qq.com> Date: Wed, 11 Feb 2026 23:43:59 -0800 Subject: [PATCH 20/21] fix issue in script --- .../installation/install-qwen-with-source.sh | 48 ++++++++++++++++--- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index 464ebe0b0..e39dfc0e0 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -147,7 +147,7 @@ check_nvm_complete() { echo "⚠ Incomplete NVM: nvm command unavailable" return 1 fi - + return 0 } @@ -372,6 +372,27 @@ install_qwen_code() { echo " Upgrading to the latest version..." fi + # Check if .npmrc contains incompatible settings for nvm + if [[ -f "${HOME}/.npmrc" ]]; then + if grep -q "prefix\|globalconfig" "${HOME}/.npmrc"; then + echo "⚠ Found incompatible settings in ~/.npmrc for NVM" + echo " Creating temporary backup and removing incompatible settings..." + + # Backup .npmrc file + cp "${HOME}/.npmrc" "${HOME}/.npmrc.backup.before.qwen.install" + + # Create temporary .npmrc without incompatible settings + grep -v -E '^(prefix|globalconfig)' "${HOME}/.npmrc" > "${HOME}/.npmrc.temp.for.qwen.install" + + # Use the temporary .npmrc + mv "${HOME}/.npmrc" "${HOME}/.npmrc.original" + mv "${HOME}/.npmrc.temp.for.qwen.install" "${HOME}/.npmrc" + + # Remember to restore later + RESTORE_NPMRC=true + fi + fi + # First, try to install without sudo (user level) echo " Attempting to install Qwen Code with current user permissions..." if npm install -g @qwen-code/qwen-code@latest 2>/dev/null; then @@ -390,10 +411,23 @@ install_qwen_code() { # Both attempts failed echo "✗ Failed to install Qwen Code even after permission fix" echo " Please check your system permissions or contact support" + # Restore .npmrc if we backed it up + if [[ "${RESTORE_NPMRC}" = true ]]; then + mv "${HOME}/.npmrc" "${HOME}/.npmrc.temp.after.failed.install" + mv "${HOME}/.npmrc.original" "${HOME}/.npmrc" + echo " Restored original ~/.npmrc file" + fi exit 1 fi fi + # Restore original .npmrc file if we modified it + if [[ "${RESTORE_NPMRC}" = true ]]; then + mv "${HOME}/.npmrc" "${HOME}/.npmrc.temp.after.successful.install" + mv "${HOME}/.npmrc.original" "${HOME}/.npmrc" + echo " Restored original ~/.npmrc file" + fi + # Create/Update source.json only if source parameter was provided if [[ "${SOURCE}" != "unknown" ]]; then create_source_json @@ -437,6 +471,9 @@ EOF # Main execution main() { + # Initialize variables + RESTORE_NPMRC=false + # Step 1: Check and install Node.js install_nodejs echo "" @@ -449,7 +486,7 @@ main() { echo "✓ Installation completed!" echo "===========================================" echo "" - + # Check if qwen is immediately available if command_exists qwen; then echo "✓ Qwen Code is ready to use!" @@ -458,7 +495,7 @@ main() { else echo "⚠ To start using Qwen Code, please run one of the following commands:" echo "" - + # Detect user's shell USER_SHELL=$(basename "${SHELL}") @@ -476,12 +513,11 @@ main() { [[ -f "${HOME}/.bashrc" ]] && echo " source ~/.bashrc" [[ -f "${HOME}/.bash_profile" ]] && echo " source ~/.bash_profile" fi - + echo "" echo "Or simply restart your terminal, then run: qwen" fi } # Run main function -main "$@" -main \ No newline at end of file +main "$@" \ No newline at end of file From 6cd07a5f8cb634bcfb7299728bb55836a0273f15 Mon Sep 17 00:00:00 2001 From: DennisYu07 <617072224@qq.com> Date: Thu, 12 Feb 2026 00:01:24 -0800 Subject: [PATCH 21/21] fix warning in shell script --- .../installation/install-qwen-with-source.sh | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index e39dfc0e0..880ee89a7 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -57,15 +57,24 @@ command_exists() { # Function to fix npm global directory permissions fix_npm_permissions() { echo "Fixing npm global directory permissions..." - # 1. Change ownership of the entire .npm-global directory to current user + + # Get the actual npm global directory + NPM_GLOBAL_DIR=$(npm config get prefix 2>/dev/null) + if [[ -z "${NPM_GLOBAL_DIR}" ]] || [[ "${NPM_GLOBAL_DIR}" == *"error"* ]]; then + # Fallback to default if npm config fails + NPM_GLOBAL_DIR="${HOME}/.npm-global" + echo "Warning: Could not determine npm prefix, using fallback: ${NPM_GLOBAL_DIR}" + fi + + # 1. Change ownership of the entire npm global directory to current user # Using only user ownership without specifying a group for cross-platform compatibility - sudo chown -R "$(whoami)" ~/.npm-global 2>/dev/null || true + sudo chown -R "$(whoami)" "${NPM_GLOBAL_DIR}" 2>/dev/null || true # 2. Fix directory permissions (ensure user has full read/write/execute permissions) - chmod -R u+rwX ~/.npm-global 2>/dev/null || true + chmod -R u+rwX "${NPM_GLOBAL_DIR}" 2>/dev/null || true # 3. Specifically fix parent directory permissions (to prevent mkdir failures) - chmod u+rwx ~/.npm-global ~/.npm-global/lib ~/.npm-global/lib/node_modules 2>/dev/null || true + chmod u+rwx "${NPM_GLOBAL_DIR}" "${NPM_GLOBAL_DIR}/lib" "${NPM_GLOBAL_DIR}/lib/node_modules" 2>/dev/null || true } # Function to check and install Node.js @@ -393,7 +402,6 @@ install_qwen_code() { fi fi - # First, try to install without sudo (user level) echo " Attempting to install Qwen Code with current user permissions..." if npm install -g @qwen-code/qwen-code@latest 2>/dev/null; then echo "✓ Qwen Code installed/upgraded successfully!"