fix(acp): add authMethods in set_model response errors

This commit is contained in:
mingholy.lmh 2026-02-01 19:47:15 +08:00
parent 6fa11a7bae
commit 06b37bd6bf
7 changed files with 166 additions and 24 deletions

View file

@ -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');

View file

@ -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,
);
}

View file

@ -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<acp.InitializeResponse> {
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<void> {
@ -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;

View file

@ -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;
}

View file

@ -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([

View file

@ -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();

View file

@ -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