diff --git a/integration-tests/acp-integration.test.ts b/integration-tests/acp-integration.test.ts index 7b989e4c7..61a5a487d 100644 --- a/integration-tests/acp-integration.test.ts +++ b/integration-tests/acp-integration.test.ts @@ -146,7 +146,9 @@ function setupAcpTest( clearTimeout(waiter.timeout); pending.delete(msg.id); if (msg.error) { - waiter.reject(new Error(msg.error.message ?? 'Unknown error')); + const error = new Error(msg.error.message ?? 'Unknown error'); + (error as Error & { response?: unknown }).response = msg.error; + waiter.reject(error); } else { waiter.resolve(msg.result); } @@ -417,6 +419,42 @@ function setupAcpTest( } }); + it('includes authMethods in error data when auth is required', async () => { + const rig = new TestRig(); + rig.setup('acp auth methods in error data'); + + const { sendRequest, cleanup, stderr } = setupAcpTest(rig); + + try { + await sendRequest('initialize', { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + }, + }); + + await expect( + sendRequest('session/new', { + cwd: rig.testDir!, + mcpServers: [], + }), + ).rejects.toMatchObject({ + response: { + data: { + authMethods: expect.any(Array), + }, + }, + }); + } catch (e) { + if (stderr.length) { + console.error('Agent stderr:', stderr.join('')); + } + throw e; + } finally { + await cleanup(); + } + }); + it('receives available_commands_update with slash commands after session creation', async () => { const rig = new TestRig(); rig.setup('acp slash commands'); diff --git a/packages/cli/src/acp-integration/acp.ts b/packages/cli/src/acp-integration/acp.ts index c3705a98c..68a936c0e 100644 --- a/packages/cli/src/acp-integration/acp.ts +++ b/packages/cli/src/acp-integration/acp.ts @@ -9,6 +9,7 @@ import { z } from 'zod'; import * as schema from './schema.js'; import { ACP_ERROR_CODES } from './errorCodes.js'; +import { pickAuthMethodsForDetails } from './authMethods.js'; export * from './schema.js'; import type { WritableStream, ReadableStream } from 'node:stream/web'; @@ -180,6 +181,7 @@ type ErrorResponse = { code: number; message: string; data?: unknown; + authMethods?: schema.AuthMethod[]; }; type PendingResponse = { @@ -282,8 +284,11 @@ class Connection { details = error.message; } - if (errorName === 'TokenManagerError') { - return RequestError.authRequired(details).toResult(); + if (errorName === 'TokenManagerError' || details?.includes('/auth')) { + return RequestError.authRequired( + details, + pickAuthMethodsForDetails(details), + ).toResult(); } if (details?.includes('/auth')) { @@ -339,17 +344,24 @@ class Connection { } export class RequestError extends Error { - data?: { details?: string }; + data?: { details?: string; authMethods?: schema.AuthMethod[] }; constructor( public code: number, message: string, details?: string, + authMethods?: schema.AuthMethod[], ) { super(message); this.name = 'RequestError'; - if (details) { - this.data = { details }; + if (details || authMethods) { + this.data = {}; + if (details) { + this.data.details = details; + } + if (authMethods) { + this.data.authMethods = authMethods; + } } } @@ -393,11 +405,15 @@ export class RequestError extends Error { ); } - static authRequired(details?: string): RequestError { + static authRequired( + details?: string, + authMethods?: schema.AuthMethod[], + ): RequestError { return new RequestError( ACP_ERROR_CODES.AUTH_REQUIRED, 'Authentication required', details, + authMethods, ); } diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index ac16921ea..9a2d2555e 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -22,6 +22,7 @@ import { } from '@qwen-code/qwen-code-core'; import type { ApprovalModeValue } from './schema.js'; import * as acp from './acp.js'; +import { buildAuthMethods } from './authMethods.js'; import { AcpFileSystemService } from './service/filesystem.js'; import { Readable, Writable } from 'node:stream'; import type { LoadedSettings } from '../config/settings.js'; @@ -73,20 +74,7 @@ class GeminiAgent { args: acp.InitializeRequest, ): Promise { this.clientCapabilities = args.clientCapabilities; - const authMethods = [ - { - id: AuthType.USE_OPENAI, - name: 'Use OpenAI API key', - description: - 'Requires setting the `OPENAI_API_KEY` environment variable', - }, - { - id: AuthType.QWEN_OAUTH, - name: 'Qwen OAuth', - description: - 'OAuth authentication for Qwen models with 2000 daily requests', - }, - ]; + const authMethods = buildAuthMethods(); // Get current approval mode from config const currentApprovalMode = this.config.getApprovalMode(); @@ -290,7 +278,7 @@ class GeminiAgent { `Session not found for id: ${params.sessionId}`, ); } - return session.setModel(params); + return await session.setModel(params); } private async ensureAuthenticated(config: Config): Promise { @@ -298,6 +286,7 @@ class GeminiAgent { if (!selectedType) { throw acp.RequestError.authRequired( 'Use Qwen Code CLI to authenticate first.', + this.pickAuthMethodsForAuthRequired(), ); } @@ -308,10 +297,55 @@ class GeminiAgent { console.error(`Authentication failed: ${e}`); throw acp.RequestError.authRequired( 'Authentication failed: ' + (e as Error).message, + this.pickAuthMethodsForAuthRequired(selectedType, e), ); } } + private pickAuthMethodsForAuthRequired( + selectedType?: AuthType | string, + error?: unknown, + ): acp.AuthMethod[] { + const authMethods = buildAuthMethods(); + const errorMessage = this.extractErrorMessage(error); + if ( + errorMessage?.includes('qwen-oauth') || + errorMessage?.includes('Qwen OAuth') + ) { + const qwenOAuthMethods = authMethods.filter( + (method) => method.id === AuthType.QWEN_OAUTH, + ); + return qwenOAuthMethods.length ? qwenOAuthMethods : authMethods; + } + + if (selectedType) { + const matchedMethods = authMethods.filter( + (method) => method.id === selectedType, + ); + return matchedMethods.length ? matchedMethods : authMethods; + } + + return authMethods; + } + + private extractErrorMessage(error?: unknown): string | undefined { + if (error instanceof Error) { + return error.message; + } + if ( + typeof error === 'object' && + error != null && + 'message' in error && + typeof error.message === 'string' + ) { + return error.message; + } + if (typeof error === 'string') { + return error; + } + return undefined; + } + private setupFileSystem(config: Config): void { if (!this.clientCapabilities?.fs) { return; diff --git a/packages/cli/src/acp-integration/authMethods.ts b/packages/cli/src/acp-integration/authMethods.ts new file mode 100644 index 000000000..35cafdc71 --- /dev/null +++ b/packages/cli/src/acp-integration/authMethods.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AuthType } from '@qwen-code/qwen-code-core'; +import type { AuthMethod } from './schema.js'; + +export function buildAuthMethods(): AuthMethod[] { + return [ + { + id: AuthType.USE_OPENAI, + name: 'Use OpenAI API key', + description: 'Requires setting the `OPENAI_API_KEY` environment variable', + type: 'terminal', + args: ['--auth-type=openai'], + }, + { + id: AuthType.QWEN_OAUTH, + name: 'Qwen OAuth', + description: + 'OAuth authentication for Qwen models with free daily requests', + type: 'terminal', + args: ['--auth-type=qwen-oauth'], + }, + ]; +} + +export function filterAuthMethodsById( + authMethods: AuthMethod[], + authMethodId: string, +): AuthMethod[] { + return authMethods.filter((method) => method.id === authMethodId); +} + +export function pickAuthMethodsForDetails(details?: string): AuthMethod[] { + const authMethods = buildAuthMethods(); + if (!details) { + return authMethods; + } + if (details.includes('qwen-oauth') || details.includes('Qwen OAuth')) { + const narrowed = filterAuthMethodsById(authMethods, AuthType.QWEN_OAUTH); + return narrowed.length ? narrowed : authMethods; + } + return authMethods; +} diff --git a/packages/cli/src/acp-integration/schema.ts b/packages/cli/src/acp-integration/schema.ts index 8e81b140d..cf616e56f 100644 --- a/packages/cli/src/acp-integration/schema.ts +++ b/packages/cli/src/acp-integration/schema.ts @@ -406,9 +406,12 @@ export const agentCapabilitiesSchema = z.object({ }); export const authMethodSchema = z.object({ + args: z.array(z.string()).optional(), description: z.string().nullable(), + env: z.record(z.string()).optional(), id: z.string(), name: z.string(), + type: z.string().optional(), }); export const clientResponseSchema = z.union([ diff --git a/packages/core/src/qwen/qwenOAuth2.test.ts b/packages/core/src/qwen/qwenOAuth2.test.ts index 920ca85e3..0d51f047e 100644 --- a/packages/core/src/qwen/qwenOAuth2.test.ts +++ b/packages/core/src/qwen/qwenOAuth2.test.ts @@ -840,7 +840,9 @@ describe('getQwenOAuthClient', () => { requireCachedCredentials: true, }), ), - ).rejects.toThrow('Please use /auth to re-authenticate.'); + ).rejects.toThrow( + 'Qwen OAuth credentials expired. Please use /auth to re-authenticate with qwen-oauth.', + ); expect(global.fetch).not.toHaveBeenCalled(); diff --git a/packages/core/src/qwen/qwenOAuth2.ts b/packages/core/src/qwen/qwenOAuth2.ts index b18c0319d..940bdcb18 100644 --- a/packages/core/src/qwen/qwenOAuth2.ts +++ b/packages/core/src/qwen/qwenOAuth2.ts @@ -516,7 +516,9 @@ export async function getQwenOAuthClient( } if (options?.requireCachedCredentials) { - throw new Error('Please use /auth to re-authenticate.'); + throw new Error( + 'Qwen OAuth credentials expired. Please use /auth to re-authenticate with qwen-oauth.', + ); } // If we couldn't obtain valid credentials via SharedTokenManager, fall back to