Compare commits

..

4 Commits

Author SHA1 Message Date
mingholy.lmh
14826dd2a9 fix: provide available models of all configured authTypes 2026-01-20 17:30:41 +08:00
Mingholy
6eb16c0bcf Merge pull request #1548 from QwenLM/mingholy/fix/qwen-oauth-model-info
Fix: Update Qwen OAuth model information
2026-01-20 16:16:30 +08:00
tanzhenxin
7fa1dcb0e6 Merge pull request #1550 from QwenLM/refactor/acp-error-codes
fix(acp): propagate ENOENT errors correctly and centralize error codes
2026-01-20 16:03:16 +08:00
mingholy.lmh
03f12bfa3f fix: update qwen-oauth models info 2026-01-20 15:11:11 +08:00
11 changed files with 349 additions and 81 deletions

View File

@@ -34,6 +34,10 @@ import { ExtensionEnablementManager } from '../config/extensions/extensionEnable
// Import the modular Session class
import { Session } from './session/Session.js';
import {
formatAcpModelId,
parseAcpBaseModelId,
} from '../utils/acpModelUtils.js';
export async function runAcpAgent(
config: Config,
@@ -381,15 +385,24 @@ class GeminiAgent {
private buildAvailableModels(
config: Config,
): acp.NewSessionResponse['models'] {
const currentModelId = (
const rawCurrentModelId = (
config.getModel() ||
this.config.getModel() ||
''
).trim();
const availableModels = config.getAvailableModels();
const currentAuthType = config.getAuthType();
const allConfiguredModels = config.getAllConfiguredModels();
const baseCurrentModelId = parseAcpBaseModelId(rawCurrentModelId);
const currentModelId =
currentAuthType && baseCurrentModelId
? formatAcpModelId(baseCurrentModelId, currentAuthType)
: baseCurrentModelId;
const availableModels = allConfiguredModels;
const mappedAvailableModels = availableModels.map((model) => ({
modelId: model.id,
modelId: formatAcpModelId(model.id, model.authType),
name: model.label,
description: model.description ?? null,
_meta: {
@@ -406,7 +419,7 @@ class GeminiAgent {
name: currentModelId,
description: null,
_meta: {
contextLimit: tokenLimit(currentModelId),
contextLimit: tokenLimit(baseCurrentModelId),
},
});
}

View File

@@ -7,7 +7,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Session } from './Session.js';
import type { Config, GeminiChat } from '@qwen-code/qwen-code-core';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
import { ApprovalMode, AuthType } from '@qwen-code/qwen-code-core';
import type * as acp from '../acp.js';
import type { LoadedSettings } from '../../config/settings.js';
import * as nonInteractiveCliCommands from '../../nonInteractiveCliCommands.js';
@@ -24,14 +24,19 @@ describe('Session', () => {
let mockSettings: LoadedSettings;
let session: Session;
let currentModel: string;
let setModelSpy: ReturnType<typeof vi.fn>;
let currentAuthType: AuthType;
let switchModelSpy: ReturnType<typeof vi.fn>;
let getAvailableCommandsSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
currentModel = 'qwen3-code-plus';
setModelSpy = vi.fn().mockImplementation(async (modelId: string) => {
currentModel = modelId;
});
currentAuthType = AuthType.USE_OPENAI;
switchModelSpy = vi
.fn()
.mockImplementation(async (authType: AuthType, modelId: string) => {
currentAuthType = authType;
currentModel = modelId;
});
mockChat = {
sendMessageStream: vi.fn(),
@@ -40,8 +45,9 @@ describe('Session', () => {
mockConfig = {
setApprovalMode: vi.fn(),
setModel: setModelSpy,
switchModel: switchModelSpy,
getModel: vi.fn().mockImplementation(() => currentModel),
getAuthType: vi.fn().mockImplementation(() => currentAuthType),
} as unknown as Config;
mockClient = {
@@ -88,17 +94,25 @@ describe('Session', () => {
describe('setModel', () => {
it('sets model via config and returns current model', async () => {
const requested = `qwen3-coder-plus(${AuthType.USE_OPENAI})`;
const result = await session.setModel({
sessionId: 'test-session-id',
modelId: ' qwen3-coder-plus ',
modelId: ` ${requested} `,
});
expect(mockConfig.setModel).toHaveBeenCalledWith('qwen3-coder-plus', {
reason: 'user_request_acp',
context: 'session/set_model',
});
expect(mockConfig.switchModel).toHaveBeenCalledWith(
AuthType.USE_OPENAI,
'qwen3-coder-plus',
undefined,
{
reason: 'user_request_acp',
context: 'session/set_model',
},
);
expect(mockConfig.getModel).toHaveBeenCalled();
expect(result).toEqual({ modelId: 'qwen3-coder-plus' });
expect(result).toEqual({
modelId: `qwen3-coder-plus(${AuthType.USE_OPENAI})`,
});
});
it('rejects empty/whitespace model IDs', async () => {
@@ -109,17 +123,17 @@ describe('Session', () => {
}),
).rejects.toThrow('Invalid params');
expect(mockConfig.setModel).not.toHaveBeenCalled();
expect(mockConfig.switchModel).not.toHaveBeenCalled();
});
it('propagates errors from config.setModel', async () => {
it('propagates errors from config.switchModel', async () => {
const configError = new Error('Invalid model');
setModelSpy.mockRejectedValueOnce(configError);
switchModelSpy.mockRejectedValueOnce(configError);
await expect(
session.setModel({
sessionId: 'test-session-id',
modelId: 'invalid-model',
modelId: `invalid-model(${AuthType.USE_OPENAI})`,
}),
).rejects.toThrow('Invalid model');
});

View File

@@ -19,6 +19,7 @@ import type {
SubAgentEventEmitter,
} from '@qwen-code/qwen-code-core';
import {
AuthType,
ApprovalMode,
convertToFunctionResponse,
DiscoveredMCPTool,
@@ -58,6 +59,10 @@ import type {
CurrentModeUpdate,
} from '../schema.js';
import { isSlashCommand } from '../../ui/utils/commandUtils.js';
import {
formatAcpModelId,
parseAcpModelOption,
} from '../../utils/acpModelUtils.js';
// Import modular session components
import type { SessionContext, ToolCallStartParams } from './types.js';
@@ -355,23 +360,39 @@ export class Session implements SessionContext {
* Validates the model ID and switches the model via Config.
*/
async setModel(params: SetModelRequest): Promise<SetModelResponse> {
const modelId = params.modelId.trim();
const rawModelId = params.modelId.trim();
if (!modelId) {
if (!rawModelId) {
throw acp.RequestError.invalidParams('modelId cannot be empty');
}
// Attempt to set the model using config
await this.config.setModel(modelId, {
reason: 'user_request_acp',
context: 'session/set_model',
});
const parsed = parseAcpModelOption(rawModelId);
const previousAuthType = this.config.getAuthType?.();
const selectedAuthType = parsed.authType ?? previousAuthType;
if (!selectedAuthType) {
throw acp.RequestError.invalidParams('authType cannot be determined');
}
await this.config.switchModel(
selectedAuthType,
parsed.modelId,
selectedAuthType !== previousAuthType &&
selectedAuthType === AuthType.QWEN_OAUTH
? { requireCachedCredentials: true }
: undefined,
{
reason: 'user_request_acp',
context: 'session/set_model',
},
);
// Get updated model info
const currentModel = this.config.getModel();
const currentAuthType = this.config.getAuthType?.() ?? selectedAuthType;
return {
modelId: currentModel,
modelId: formatAcpModelId(currentModel, currentAuthType),
};
}

View File

@@ -47,30 +47,36 @@ const renderComponent = (
setValue: vi.fn(),
} as unknown as LoadedSettings;
const mockConfig = contextValue
? ({
// --- Functions used by ModelDialog ---
getModel: vi.fn(() => MAINLINE_CODER),
setModel: vi.fn().mockResolvedValue(undefined),
switchModel: vi.fn().mockResolvedValue(undefined),
getAuthType: vi.fn(() => 'qwen-oauth'),
const mockConfig = {
// --- Functions used by ModelDialog ---
getModel: vi.fn(() => MAINLINE_CODER),
setModel: vi.fn().mockResolvedValue(undefined),
switchModel: vi.fn().mockResolvedValue(undefined),
getAuthType: vi.fn(() => 'qwen-oauth'),
getAllConfiguredModels: vi.fn(() =>
AVAILABLE_MODELS_QWEN.map((m) => ({
id: m.id,
label: m.label,
description: m.description || '',
authType: AuthType.QWEN_OAUTH,
})),
),
// --- Functions used by ClearcutLogger ---
getUsageStatisticsEnabled: vi.fn(() => true),
getSessionId: vi.fn(() => 'mock-session-id'),
getDebugMode: vi.fn(() => false),
getContentGeneratorConfig: vi.fn(() => ({
authType: AuthType.QWEN_OAUTH,
model: MAINLINE_CODER,
})),
getUseSmartEdit: vi.fn(() => false),
getUseModelRouter: vi.fn(() => false),
getProxy: vi.fn(() => undefined),
// --- Functions used by ClearcutLogger ---
getUsageStatisticsEnabled: vi.fn(() => true),
getSessionId: vi.fn(() => 'mock-session-id'),
getDebugMode: vi.fn(() => false),
getContentGeneratorConfig: vi.fn(() => ({
authType: AuthType.QWEN_OAUTH,
model: MAINLINE_CODER,
})),
getUseSmartEdit: vi.fn(() => false),
getUseModelRouter: vi.fn(() => false),
getProxy: vi.fn(() => undefined),
// --- Spread test-specific overrides ---
...contextValue,
} as unknown as Config)
: undefined;
// --- Spread test-specific overrides ---
...(contextValue ?? {}),
} as unknown as Config;
const renderResult = render(
<SettingsContext.Provider value={mockSettings}>
@@ -308,6 +314,14 @@ describe('<ModelDialog />', () => {
{
getModel: mockGetModel,
getAuthType: mockGetAuthType,
getAllConfiguredModels: vi.fn(() =>
AVAILABLE_MODELS_QWEN.map((m) => ({
id: m.id,
label: m.label,
description: m.description || '',
authType: AuthType.QWEN_OAUTH,
})),
),
} as unknown as Config
}
>
@@ -322,6 +336,14 @@ describe('<ModelDialog />', () => {
const newMockConfig = {
getModel: mockGetModel,
getAuthType: mockGetAuthType,
getAllConfiguredModels: vi.fn(() =>
AVAILABLE_MODELS_QWEN.map((m) => ({
id: m.id,
label: m.label,
description: m.description || '',
authType: AuthType.QWEN_OAUTH,
})),
),
} as unknown as Config;
rerender(

View File

@@ -11,6 +11,7 @@ import {
AuthType,
ModelSlashCommandEvent,
logModelSlashCommand,
type AvailableModel as CoreAvailableModel,
type ContentGeneratorConfig,
type ContentGeneratorConfigSource,
type ContentGeneratorConfigSources,
@@ -21,10 +22,7 @@ import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSel
import { ConfigContext } from '../contexts/ConfigContext.js';
import { UIStateContext } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import {
getAvailableModelsForAuthType,
MAINLINE_CODER,
} from '../models/availableModels.js';
import { MAINLINE_CODER } from '../models/availableModels.js';
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
import { t } from '../../i18n/index.js';
@@ -154,13 +152,17 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
const sources = readSourcesFromConfig(config);
const availableModelEntries = useMemo(() => {
const allAuthTypes = Object.values(AuthType) as AuthType[];
const modelsByAuthType = allAuthTypes
.map((t) => ({
authType: t,
models: getAvailableModelsForAuthType(t, config ?? undefined),
}))
.filter((x) => x.models.length > 0);
const allModels = config ? config.getAllConfiguredModels() : [];
// Group models by authType
const modelsByAuthTypeMap = new Map<AuthType, CoreAvailableModel[]>();
for (const model of allModels) {
const authType = model.authType;
if (!modelsByAuthTypeMap.has(authType)) {
modelsByAuthTypeMap.set(authType, []);
}
modelsByAuthTypeMap.get(authType)!.push(model);
}
// Fixed order: qwen-oauth first, then others in a stable order
const authTypeOrder: AuthType[] = [
@@ -171,15 +173,14 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
AuthType.USE_VERTEX_AI,
];
// Filter to only include authTypes that have models
const availableAuthTypes = new Set(modelsByAuthType.map((x) => x.authType));
// Filter to only include authTypes that have models and maintain order
const availableAuthTypes = new Set(modelsByAuthTypeMap.keys());
const orderedAuthTypes = authTypeOrder.filter((t) =>
availableAuthTypes.has(t),
);
return orderedAuthTypes.flatMap((t) => {
const models =
modelsByAuthType.find((x) => x.authType === t)?.models ?? [];
const models = modelsByAuthTypeMap.get(t) ?? [];
return models.map((m) => ({ authType: t, model: m }));
});
}, [config]);

View File

@@ -0,0 +1,42 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { AuthType } from '@qwen-code/qwen-code-core';
import {
formatAcpModelId,
parseAcpBaseModelId,
parseAcpModelOption,
} from './acpModelUtils.js';
describe('acpModelUtils', () => {
it('formats modelId(authType)', () => {
expect(formatAcpModelId('qwen3', AuthType.QWEN_OAUTH)).toBe(
`qwen3(${AuthType.QWEN_OAUTH})`,
);
});
it('extracts base model id when string ends with parentheses', () => {
expect(parseAcpBaseModelId(`qwen3(${AuthType.USE_OPENAI})`)).toBe('qwen3');
});
it('does not strip when parentheses are not a trailing suffix', () => {
expect(parseAcpBaseModelId('qwen3(x) y')).toBe('qwen3(x) y');
});
it('parses modelId and validates authType', () => {
expect(parseAcpModelOption(` qwen3(${AuthType.USE_OPENAI}) `)).toEqual({
modelId: 'qwen3',
authType: AuthType.USE_OPENAI,
});
});
it('returns trimmed input as modelId when authType is invalid', () => {
expect(parseAcpModelOption('qwen3(not-a-real-auth)')).toEqual({
modelId: 'qwen3(not-a-real-auth)',
});
});
});

View File

@@ -0,0 +1,55 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { AuthType } from '@qwen-code/qwen-code-core';
import { z } from 'zod';
/**
* ACP model IDs are represented as `${modelId}(${authType})` in the ACP protocol.
*/
export function formatAcpModelId(modelId: string, authType: AuthType): string {
return `${modelId}(${authType})`;
}
/**
* Extracts the base model id from an ACP model id string.
*
* If the string ends with `(...)`, the suffix is removed; otherwise returns the
* trimmed input as-is.
*/
export function parseAcpBaseModelId(value: string): string {
const trimmed = value.trim();
const closeIdx = trimmed.lastIndexOf(')');
const openIdx = trimmed.lastIndexOf('(');
if (openIdx >= 0 && closeIdx === trimmed.length - 1 && openIdx < closeIdx) {
return trimmed.slice(0, openIdx);
}
return trimmed;
}
/**
* Parses an ACP model option string into `{ modelId, authType? }`.
*
* If the string ends with `(...)` and `...` is a valid `AuthType`, returns both;
* otherwise returns the trimmed input as `modelId` only.
*/
export function parseAcpModelOption(input: string): {
modelId: string;
authType?: AuthType;
} {
const trimmed = input.trim();
const closeIdx = trimmed.lastIndexOf(')');
const openIdx = trimmed.lastIndexOf('(');
if (openIdx >= 0 && closeIdx === trimmed.length - 1 && openIdx < closeIdx) {
const maybeModelId = trimmed.slice(0, openIdx);
const maybeAuthType = trimmed.slice(openIdx + 1, closeIdx);
const parsedAuthType = z.nativeEnum(AuthType).safeParse(maybeAuthType);
if (parsedAuthType.success) {
return { modelId: maybeModelId, authType: parsedAuthType.data };
}
}
return { modelId: trimmed };
}

View File

@@ -921,6 +921,14 @@ export class Config {
return this._modelsConfig.getAvailableModelsForAuthType(authType);
}
/**
* Get all configured models across authTypes.
* Delegates to ModelsConfig.
*/
getAllConfiguredModels(authTypes?: AuthType[]): AvailableModel[] {
return this._modelsConfig.getAllConfiguredModels(authTypes);
}
/**
* Switch authType+model via registry-backed selection.
* This triggers a refresh of the ContentGenerator when required (always on authType changes).

View File

@@ -102,16 +102,14 @@ export const QWEN_OAUTH_ALLOWED_MODELS = [
export const QWEN_OAUTH_MODELS: ModelConfig[] = [
{
id: 'coder-model',
name: 'Qwen Coder',
description:
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)',
name: 'coder-model',
description: 'The latest Qwen Coder model from Alibaba Cloud ModelStudio',
capabilities: { vision: false },
},
{
id: 'vision-model',
name: 'Qwen Vision',
description:
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)',
name: 'vision-model',
description: 'The latest Qwen Vision model from Alibaba Cloud ModelStudio',
capabilities: { vision: true },
},
];

View File

@@ -679,8 +679,8 @@ describe('ModelsConfig', () => {
expect(modelsConfig.getGenerationConfig().model).toBe('updated-model');
});
describe('getAllAvailableModels', () => {
it('should return all models across all authTypes', () => {
describe('getAllConfiguredModels', () => {
it('should return all models across all authTypes and put qwen-oauth first', () => {
const modelProvidersConfig: ModelProvidersConfig = {
openai: [
{
@@ -718,7 +718,23 @@ describe('ModelsConfig', () => {
modelProvidersConfig,
});
const allModels = modelsConfig.getAllAvailableModels();
const allModels = modelsConfig.getAllConfiguredModels();
// qwen-oauth models should be ordered first
const firstNonQwenIndex = allModels.findIndex(
(m) => m.authType !== AuthType.QWEN_OAUTH,
);
expect(firstNonQwenIndex).toBeGreaterThan(0);
expect(
allModels
.slice(0, firstNonQwenIndex)
.every((m) => m.authType === AuthType.QWEN_OAUTH),
).toBe(true);
expect(
allModels
.slice(firstNonQwenIndex)
.every((m) => m.authType !== AuthType.QWEN_OAUTH),
).toBe(true);
// Should include qwen-oauth models (hard-coded)
const qwenModels = allModels.filter(
@@ -752,7 +768,7 @@ describe('ModelsConfig', () => {
it('should return empty array when no models are registered', () => {
const modelsConfig = new ModelsConfig();
const allModels = modelsConfig.getAllAvailableModels();
const allModels = modelsConfig.getAllConfiguredModels();
// Should still include qwen-oauth models (hard-coded)
expect(allModels.length).toBeGreaterThan(0);
@@ -782,7 +798,7 @@ describe('ModelsConfig', () => {
modelProvidersConfig,
});
const allModels = modelsConfig.getAllAvailableModels();
const allModels = modelsConfig.getAllConfiguredModels();
const testModel = allModels.find((m) => m.id === 'test-model');
expect(testModel).toBeDefined();
@@ -793,5 +809,56 @@ describe('ModelsConfig', () => {
expect(testModel?.isVision).toBe(true);
expect(testModel?.capabilities?.vision).toBe(true);
});
it('should support filtering by authTypes and still put qwen-oauth first when included', () => {
const modelProvidersConfig: ModelProvidersConfig = {
openai: [
{
id: 'openai-model-1',
name: 'OpenAI Model 1',
baseUrl: 'https://api.openai.com/v1',
envKey: 'OPENAI_API_KEY',
},
],
anthropic: [
{
id: 'anthropic-model-1',
name: 'Anthropic Model 1',
baseUrl: 'https://api.anthropic.com/v1',
envKey: 'ANTHROPIC_API_KEY',
},
],
};
const modelsConfig = new ModelsConfig({
modelProvidersConfig,
});
// Filter: OpenAI only (should not include qwen-oauth)
const openaiOnly = modelsConfig.getAllConfiguredModels([
AuthType.USE_OPENAI,
]);
expect(openaiOnly.every((m) => m.authType === AuthType.USE_OPENAI)).toBe(
true,
);
expect(openaiOnly.map((m) => m.id)).toContain('openai-model-1');
// Filter: include qwen-oauth but request it later -> still ordered first
const withQwen = modelsConfig.getAllConfiguredModels([
AuthType.USE_OPENAI,
AuthType.QWEN_OAUTH,
AuthType.USE_ANTHROPIC,
]);
expect(withQwen.length).toBeGreaterThan(0);
const firstNonQwenIndex = withQwen.findIndex(
(m) => m.authType !== AuthType.QWEN_OAUTH,
);
expect(firstNonQwenIndex).toBeGreaterThan(0);
expect(
withQwen
.slice(0, firstNonQwenIndex)
.every((m) => m.authType === AuthType.QWEN_OAUTH),
).toBe(true);
});
});
});

View File

@@ -204,13 +204,40 @@ export class ModelsConfig {
}
/**
* Get all available models across all authTypes
* Get all configured models across authTypes.
*
* Notes:
* - By default, returns models across all authTypes.
* - qwen-oauth models are always ordered first.
*/
getAllAvailableModels(): AvailableModel[] {
getAllConfiguredModels(authTypes?: AuthType[]): AvailableModel[] {
const inputAuthTypes =
authTypes && authTypes.length > 0 ? authTypes : Object.values(AuthType);
// De-duplicate while preserving the original order.
const seen = new Set<AuthType>();
const uniqueAuthTypes: AuthType[] = [];
for (const authType of inputAuthTypes) {
if (!seen.has(authType)) {
seen.add(authType);
uniqueAuthTypes.push(authType);
}
}
// Force qwen-oauth to the front (if requested / defaulted in).
const orderedAuthTypes: AuthType[] = [];
if (uniqueAuthTypes.includes(AuthType.QWEN_OAUTH)) {
orderedAuthTypes.push(AuthType.QWEN_OAUTH);
}
for (const authType of uniqueAuthTypes) {
if (authType !== AuthType.QWEN_OAUTH) {
orderedAuthTypes.push(authType);
}
}
const allModels: AvailableModel[] = [];
for (const authType of Object.values(AuthType)) {
const models = this.modelRegistry.getModelsForAuthType(authType);
allModels.push(...models);
for (const authType of orderedAuthTypes) {
allModels.push(...this.modelRegistry.getModelsForAuthType(authType));
}
return allModels;
}