Compare commits

..

1 Commits

Author SHA1 Message Date
LaZzyMan
6f33d92b2c fix: can not remove the mcp server when there is only one element 2026-01-14 16:27:45 +08:00
22 changed files with 238 additions and 1352 deletions

View File

@@ -201,11 +201,6 @@ If you encounter issues, check the [troubleshooting guide](https://qwenlm.github
To report a bug from within the CLI, run `/bug` and include a short title and repro steps.
## Connect with Us
- Discord: https://discord.gg/ycKBjdNd
- Dingtalk: https://qr.dingtalk.com/action/joingroup?code=v1,k1,+FX6Gf/ZDlTahTIRi8AEQhIaBlqykA0j+eBKKdhLeAE=&_dt_no_comment=1&origin=1
## Acknowledgments
This project is based on [Google Gemini CLI](https://github.com/google-gemini/gemini-cli). We acknowledge and appreciate the excellent work of the Gemini CLI team. Our main contribution focuses on parser-level adaptations to better support Qwen-Coder models.

View File

@@ -480,7 +480,7 @@ Arguments passed directly when running the CLI can override other configurations
| `--telemetry-otlp-protocol` | | Sets the OTLP protocol for telemetry (`grpc` or `http`). | | Defaults to `grpc`. See [telemetry](../../developers/development/telemetry) for more information. |
| `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
| `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | |
| `--acp` | | Enables ACP mode (Agent Client Protocol). Useful for IDE/editor integrations like [Zed](../integration-zed). | | Stable. Replaces the deprecated `--experimental-acp` flag. |
| `--acp` | | Enables ACP mode (Agent Control Protocol). Useful for IDE/editor integrations like [Zed](../integration-zed). | | Stable. Replaces the deprecated `--experimental-acp` flag. |
| `--experimental-skills` | | Enables experimental [Agent Skills](../features/skills) (registers the `skill` tool and loads Skills from `.qwen/skills/` and `~/.qwen/skills/`). | | Experimental. |
| `--extensions` | `-e` | Specifies a list of extensions to use for the session. | Extension names | If not provided, all available extensions are used. Use the special term `qwen -e none` to disable all extensions. Example: `qwen -e my-extension -e my-other-extension` |
| `--list-extensions` | `-l` | Lists all available extensions and exits. | | |

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -1,11 +1,11 @@
# JetBrains IDEs
> JetBrains IDEs provide native support for AI coding assistants through the Agent Client Protocol (ACP). This integration allows you to use Qwen Code directly within your JetBrains IDE with real-time code suggestions.
> JetBrains IDEs provide native support for AI coding assistants through the Agent Control Protocol (ACP). This integration allows you to use Qwen Code directly within your JetBrains IDE with real-time code suggestions.
### Features
- **Native agent experience**: Integrated AI assistant panel within your JetBrains IDE
- **Agent Client Protocol**: Full support for ACP enabling advanced IDE interactions
- **Agent Control Protocol**: Full support for ACP enabling advanced IDE interactions
- **Symbol management**: #-mention files to add them to the conversation context
- **Conversation history**: Access to past conversations within the IDE
@@ -40,7 +40,7 @@
4. The Qwen Code agent should now be available in the AI Assistant panel
![Qwen Code in JetBrains AI Chat](https://img.alicdn.com/imgextra/i3/O1CN01ZxYel21y433Ci6eg0_!!6000000006524-2-tps-2774-1494.png)
![Qwen Code in JetBrains AI Chat](./images/jetbrains-acp.png)
## Troubleshooting

View File

@@ -22,7 +22,13 @@
### Installation
Download and install the extension from the [Visual Studio Code Extension Marketplace](https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion).
1. Install Qwen Code CLI:
```bash
npm install -g qwen-code
```
2. Download and install the extension from the [Visual Studio Code Extension Marketplace](https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion).
## Troubleshooting

View File

@@ -1,6 +1,6 @@
# Zed Editor
> Zed Editor provides native support for AI coding assistants through the Agent Client Protocol (ACP). This integration allows you to use Qwen Code directly within Zed's interface with real-time code suggestions.
> Zed Editor provides native support for AI coding assistants through the Agent Control Protocol (ACP). This integration allows you to use Qwen Code directly within Zed's interface with real-time code suggestions.
![Zed Editor Overview](https://img.alicdn.com/imgextra/i1/O1CN01aAhU311GwEoNh27FP_!!6000000000686-2-tps-3024-1898.png)
@@ -20,9 +20,9 @@
1. Install Qwen Code CLI:
```bash
npm install -g @qwen-code/qwen-code
```
```bash
npm install -g qwen-code
```
2. Download and install [Zed Editor](https://zed.dev/)

View File

@@ -831,7 +831,7 @@ describe('Permission Control (E2E)', () => {
TEST_TIMEOUT,
);
it.skip(
it(
'should execute dangerous commands without confirmation',
async () => {
const q = query({

2
package-lock.json generated
View File

@@ -18588,7 +18588,7 @@
},
"packages/sdk-typescript": {
"name": "@qwen-code/sdk",
"version": "0.1.3",
"version": "0.1.2",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.1",

View File

@@ -4,11 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type {
Config,
ContentGeneratorConfig,
ModelProvidersConfig,
} from '@qwen-code/qwen-code-core';
import type { Config, ModelProvidersConfig } from '@qwen-code/qwen-code-core';
import {
AuthEvent,
AuthType,
@@ -87,26 +83,12 @@ export const useAuthCommand = (
async (authType: AuthType, credentials?: OpenAICredentials) => {
try {
const authTypeScope = getPersistScopeForModelSelection(settings);
// Persist authType
settings.setValue(
authTypeScope,
'security.auth.selectedType',
authType,
);
// Persist model from ContentGenerator config (handles fallback cases)
// This ensures that when syncAfterAuthRefresh falls back to default model,
// it gets persisted to settings.json
const contentGeneratorConfig = config.getContentGeneratorConfig();
if (contentGeneratorConfig?.model) {
settings.setValue(
authTypeScope,
'model.name',
contentGeneratorConfig.model,
);
}
// Only update credentials if not switching to QWEN_OAUTH,
// so that OpenAI credentials are preserved when switching to QWEN_OAUTH.
if (authType !== AuthType.QWEN_OAUTH && credentials) {
@@ -124,6 +106,9 @@ export const useAuthCommand = (
credentials.baseUrl,
);
}
if (credentials?.model != null) {
settings.setValue(authTypeScope, 'model.name', credentials.model);
}
}
} catch (error) {
handleAuthFailure(error);
@@ -218,19 +203,11 @@ export const useAuthCommand = (
if (authType === AuthType.USE_OPENAI) {
if (credentials) {
// Pass settings.model.generationConfig to updateCredentials so it can be merged
// after clearing provider-sourced config. This ensures settings.json generationConfig
// fields (e.g., samplingParams, timeout) are preserved.
const settingsGenerationConfig = settings.merged.model
?.generationConfig as Partial<ContentGeneratorConfig> | undefined;
config.updateCredentials(
{
apiKey: credentials.apiKey,
baseUrl: credentials.baseUrl,
model: credentials.model,
},
settingsGenerationConfig,
);
config.updateCredentials({
apiKey: credentials.apiKey,
baseUrl: credentials.baseUrl,
model: credentials.model,
});
await performAuth(authType, credentials);
}
return;
@@ -238,13 +215,7 @@ export const useAuthCommand = (
await performAuth(authType);
},
[
config,
performAuth,
isProviderManagedModel,
onAuthError,
settings.merged.model?.generationConfig,
],
[config, performAuth, isProviderManagedModel, onAuthError],
);
const openAuthDialog = useCallback(() => {

View File

@@ -275,7 +275,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
persistModelSelection(settings, effectiveModelId);
persistAuthTypeSelection(settings, effectiveAuthType);
const baseUrl = after?.baseUrl ?? t('(default)');
const baseUrl = after?.baseUrl ?? '(default)';
const maskedKey = maskApiKey(after?.apiKey);
uiState?.historyManager.addItem(
{
@@ -322,7 +322,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
<>
<ConfigRow
label="Base URL"
value={effectiveConfig?.baseUrl ?? t('(default)')}
value={effectiveConfig?.baseUrl ?? ''}
badge={formatSourceBadge(sources['baseUrl'])}
/>
<ConfigRow

View File

@@ -8,7 +8,10 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { updateSettingsFilePreservingFormat } from './commentJson.js';
import {
updateSettingsFilePreservingFormat,
applyUpdates,
} from './commentJson.js';
describe('commentJson', () => {
let tempDir: string;
@@ -180,3 +183,18 @@ describe('commentJson', () => {
});
});
});
describe('applyUpdates', () => {
it('should apply updates correctly', () => {
const original = { a: 1, b: { c: 2 } };
const updates = { b: { c: 3 } };
const result = applyUpdates(original, updates);
expect(result).toEqual({ a: 1, b: { c: 3 } });
});
it('should apply updates correctly when empty', () => {
const original = { a: 1, b: { c: 2 } };
const updates = { b: {} };
const result = applyUpdates(original, updates);
expect(result).toEqual({ a: 1, b: {} });
});
});

View File

@@ -38,7 +38,7 @@ export function updateSettingsFilePreservingFormat(
fs.writeFileSync(filePath, updatedContent, 'utf-8');
}
function applyUpdates(
export function applyUpdates(
current: Record<string, unknown>,
updates: Record<string, unknown>,
): Record<string, unknown> {
@@ -50,6 +50,7 @@ function applyUpdates(
typeof value === 'object' &&
value !== null &&
!Array.isArray(value) &&
Object.keys(value).length > 0 &&
typeof result[key] === 'object' &&
result[key] !== null &&
!Array.isArray(result[key])

View File

@@ -1,721 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
AuthType,
resolveModelConfig,
type ProviderModelConfig,
} from '@qwen-code/qwen-code-core';
import {
getAuthTypeFromEnv,
resolveCliGenerationConfig,
} from './modelConfigUtils.js';
import type { Settings } from '../config/settings.js';
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...original,
resolveModelConfig: vi.fn(),
};
});
describe('modelConfigUtils', () => {
describe('getAuthTypeFromEnv', () => {
const originalEnv = process.env;
beforeEach(() => {
vi.resetModules();
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
it('should return USE_OPENAI when all OpenAI env vars are set', () => {
process.env['OPENAI_API_KEY'] = 'test-key';
process.env['OPENAI_MODEL'] = 'gpt-4';
process.env['OPENAI_BASE_URL'] = 'https://api.openai.com';
expect(getAuthTypeFromEnv()).toBe(AuthType.USE_OPENAI);
});
it('should return undefined when OpenAI env vars are incomplete', () => {
process.env['OPENAI_API_KEY'] = 'test-key';
process.env['OPENAI_MODEL'] = 'gpt-4';
// Missing OPENAI_BASE_URL
expect(getAuthTypeFromEnv()).toBeUndefined();
});
it('should return QWEN_OAUTH when QWEN_OAUTH is set', () => {
process.env['QWEN_OAUTH'] = 'true';
expect(getAuthTypeFromEnv()).toBe(AuthType.QWEN_OAUTH);
});
it('should return USE_GEMINI when Gemini env vars are set', () => {
process.env['GEMINI_API_KEY'] = 'test-key';
process.env['GEMINI_MODEL'] = 'gemini-pro';
expect(getAuthTypeFromEnv()).toBe(AuthType.USE_GEMINI);
});
it('should return undefined when Gemini env vars are incomplete', () => {
process.env['GEMINI_API_KEY'] = 'test-key';
// Missing GEMINI_MODEL
expect(getAuthTypeFromEnv()).toBeUndefined();
});
it('should return USE_VERTEX_AI when Google env vars are set', () => {
process.env['GOOGLE_API_KEY'] = 'test-key';
process.env['GOOGLE_MODEL'] = 'vertex-model';
expect(getAuthTypeFromEnv()).toBe(AuthType.USE_VERTEX_AI);
});
it('should return undefined when Google env vars are incomplete', () => {
process.env['GOOGLE_API_KEY'] = 'test-key';
// Missing GOOGLE_MODEL
expect(getAuthTypeFromEnv()).toBeUndefined();
});
it('should return USE_ANTHROPIC when Anthropic env vars are set', () => {
process.env['ANTHROPIC_API_KEY'] = 'test-key';
process.env['ANTHROPIC_MODEL'] = 'claude-3';
process.env['ANTHROPIC_BASE_URL'] = 'https://api.anthropic.com';
expect(getAuthTypeFromEnv()).toBe(AuthType.USE_ANTHROPIC);
});
it('should return undefined when Anthropic env vars are incomplete', () => {
process.env['ANTHROPIC_API_KEY'] = 'test-key';
process.env['ANTHROPIC_MODEL'] = 'claude-3';
// Missing ANTHROPIC_BASE_URL
expect(getAuthTypeFromEnv()).toBeUndefined();
});
it('should prioritize QWEN_OAUTH over other auth types when explicitly set', () => {
process.env['QWEN_OAUTH'] = 'true';
process.env['OPENAI_API_KEY'] = 'test-key';
process.env['OPENAI_MODEL'] = 'gpt-4';
process.env['OPENAI_BASE_URL'] = 'https://api.openai.com';
// QWEN_OAUTH is checked first, so it should be returned even when other auth vars are set
expect(getAuthTypeFromEnv()).toBe(AuthType.QWEN_OAUTH);
});
it('should return undefined when no auth env vars are set', () => {
expect(getAuthTypeFromEnv()).toBeUndefined();
});
});
describe('resolveCliGenerationConfig', () => {
const originalEnv = process.env;
const originalConsoleWarn = console.warn;
beforeEach(() => {
vi.resetModules();
process.env = { ...originalEnv };
console.warn = vi.fn();
});
afterEach(() => {
process.env = originalEnv;
console.warn = originalConsoleWarn;
vi.clearAllMocks();
});
function makeMockSettings(overrides?: Partial<Settings>): Settings {
return {
model: { name: 'default-model' },
security: {
auth: {
apiKey: 'settings-api-key',
baseUrl: 'https://settings.example.com',
},
},
...overrides,
} as Settings;
}
it('should resolve config from argv with highest precedence', () => {
const argv = {
model: 'argv-model',
openaiApiKey: 'argv-key',
openaiBaseUrl: 'https://argv.example.com',
};
const settings = makeMockSettings();
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'argv-model',
apiKey: 'argv-key',
baseUrl: 'https://argv.example.com',
},
sources: {
model: { kind: 'cli', detail: '--model' },
apiKey: { kind: 'cli', detail: '--openaiApiKey' },
baseUrl: { kind: 'cli', detail: '--openaiBaseUrl' },
},
warnings: [],
});
const result = resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(result.model).toBe('argv-model');
expect(result.apiKey).toBe('argv-key');
expect(result.baseUrl).toBe('https://argv.example.com');
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
expect.objectContaining({
cli: {
model: 'argv-model',
apiKey: 'argv-key',
baseUrl: 'https://argv.example.com',
},
}),
);
});
it('should resolve config from settings when argv is not provided', () => {
const argv = {};
const settings = makeMockSettings({
model: { name: 'settings-model' },
security: {
auth: {
apiKey: 'settings-key',
baseUrl: 'https://settings.example.com',
},
},
});
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'settings-model',
apiKey: 'settings-key',
baseUrl: 'https://settings.example.com',
},
sources: {
model: { kind: 'settings', detail: 'model.name' },
apiKey: { kind: 'settings', detail: 'security.auth.apiKey' },
baseUrl: { kind: 'settings', detail: 'security.auth.baseUrl' },
},
warnings: [],
});
const result = resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(result.model).toBe('settings-model');
expect(result.apiKey).toBe('settings-key');
expect(result.baseUrl).toBe('https://settings.example.com');
});
it('should merge generationConfig from settings', () => {
const argv = {};
const settings = makeMockSettings({
model: {
name: 'test-model',
generationConfig: {
samplingParams: {
temperature: 0.7,
max_tokens: 1000,
},
timeout: 5000,
} as Record<string, unknown>,
},
});
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'test-model',
apiKey: '',
baseUrl: '',
samplingParams: {
temperature: 0.7,
max_tokens: 1000,
},
timeout: 5000,
},
sources: {},
warnings: [],
});
const result = resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(result.generationConfig.samplingParams?.temperature).toBe(0.7);
expect(result.generationConfig.samplingParams?.max_tokens).toBe(1000);
expect(result.generationConfig.timeout).toBe(5000);
});
it('should resolve OpenAI logging from argv', () => {
const argv = {
openaiLogging: true,
openaiLoggingDir: '/custom/log/dir',
};
const settings = makeMockSettings();
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'test-model',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: [],
});
const result = resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(result.generationConfig.enableOpenAILogging).toBe(true);
expect(result.generationConfig.openAILoggingDir).toBe('/custom/log/dir');
});
it('should resolve OpenAI logging from settings when argv is undefined', () => {
const argv = {};
const settings = makeMockSettings({
model: {
name: 'test-model',
enableOpenAILogging: true,
openAILoggingDir: '/settings/log/dir',
},
});
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'test-model',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: [],
});
const result = resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(result.generationConfig.enableOpenAILogging).toBe(true);
expect(result.generationConfig.openAILoggingDir).toBe(
'/settings/log/dir',
);
});
it('should default OpenAI logging to false when not provided', () => {
const argv = {};
const settings = makeMockSettings();
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'test-model',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: [],
});
const result = resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(result.generationConfig.enableOpenAILogging).toBe(false);
});
it('should find modelProvider from settings when authType and model match', () => {
const argv = { model: 'provider-model' };
const modelProvider: ProviderModelConfig = {
id: 'provider-model',
name: 'Provider Model',
generationConfig: {
samplingParams: { temperature: 0.8 },
},
};
const settings = makeMockSettings({
modelProviders: {
[AuthType.USE_OPENAI]: [modelProvider],
},
});
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'provider-model',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: [],
});
resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
expect.objectContaining({
modelProvider,
}),
);
});
it('should find modelProvider from settings.model.name when argv.model is not provided', () => {
const argv = {};
const modelProvider: ProviderModelConfig = {
id: 'settings-model',
name: 'Settings Model',
generationConfig: {
samplingParams: { temperature: 0.9 },
},
};
const settings = makeMockSettings({
model: { name: 'settings-model' },
modelProviders: {
[AuthType.USE_OPENAI]: [modelProvider],
},
});
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'settings-model',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: [],
});
resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
expect.objectContaining({
modelProvider,
}),
);
});
it('should not find modelProvider when authType is undefined', () => {
const argv = { model: 'test-model' };
const settings = makeMockSettings({
modelProviders: {
[AuthType.USE_OPENAI]: [{ id: 'test-model', name: 'Test Model' }],
},
});
const selectedAuthType = undefined;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'test-model',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: [],
});
resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
expect.objectContaining({
modelProvider: undefined,
}),
);
});
it('should not find modelProvider when modelProviders is not an array', () => {
const argv = { model: 'test-model' };
const settings = makeMockSettings({
modelProviders: {
[AuthType.USE_OPENAI]: null as unknown as ProviderModelConfig[],
},
});
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'test-model',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: [],
});
resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
expect.objectContaining({
modelProvider: undefined,
}),
);
});
it('should log warnings from resolveModelConfig', () => {
const argv = {};
const settings = makeMockSettings();
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'test-model',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: ['Warning 1', 'Warning 2'],
});
resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(console.warn).toHaveBeenCalledWith('Warning 1');
expect(console.warn).toHaveBeenCalledWith('Warning 2');
});
it('should use custom env when provided', () => {
const argv = {};
const settings = makeMockSettings();
const selectedAuthType = AuthType.USE_OPENAI;
const customEnv = {
OPENAI_API_KEY: 'custom-key',
OPENAI_MODEL: 'custom-model',
};
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'custom-model',
apiKey: 'custom-key',
baseUrl: '',
},
sources: {},
warnings: [],
});
resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
env: customEnv,
});
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
expect.objectContaining({
env: customEnv,
}),
);
});
it('should use process.env when env is not provided', () => {
const argv = {};
const settings = makeMockSettings();
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'test-model',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: [],
});
resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
expect.objectContaining({
env: process.env,
}),
);
});
it('should return empty strings for missing model, apiKey, and baseUrl', () => {
const argv = {};
const settings = makeMockSettings();
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: '',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: [],
});
const result = resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(result.model).toBe('');
expect(result.apiKey).toBe('');
expect(result.baseUrl).toBe('');
});
it('should merge resolved config with logging settings', () => {
const argv = {
openaiLogging: true,
};
const settings = makeMockSettings({
model: {
name: 'test-model',
generationConfig: {
timeout: 5000,
} as Record<string, unknown>,
},
});
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'test-model',
apiKey: 'test-key',
baseUrl: 'https://test.com',
samplingParams: { temperature: 0.5 },
},
sources: {},
warnings: [],
});
const result = resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(result.generationConfig).toEqual({
model: 'test-model',
apiKey: 'test-key',
baseUrl: 'https://test.com',
samplingParams: { temperature: 0.5 },
enableOpenAILogging: true,
openAILoggingDir: undefined,
});
});
it('should handle settings without model property', () => {
const argv = {};
const settings = makeMockSettings({
model: undefined as unknown as Settings['model'],
});
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: '',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: [],
});
const result = resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(result.model).toBe('');
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
expect.objectContaining({
settings: expect.objectContaining({
model: undefined,
}),
}),
);
});
it('should handle settings without security.auth property', () => {
const argv = {};
const settings = makeMockSettings({
security: undefined,
});
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: '',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: [],
});
resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
expect.objectContaining({
settings: expect.objectContaining({
apiKey: undefined,
baseUrl: undefined,
}),
}),
);
});
});
});

View File

@@ -44,31 +44,20 @@ export interface ResolvedCliGenerationConfig {
}
export function getAuthTypeFromEnv(): AuthType | undefined {
if (process.env['OPENAI_API_KEY']) {
return AuthType.USE_OPENAI;
}
if (process.env['QWEN_OAUTH']) {
return AuthType.QWEN_OAUTH;
}
if (
process.env['OPENAI_API_KEY'] &&
process.env['OPENAI_MODEL'] &&
process.env['OPENAI_BASE_URL']
) {
return AuthType.USE_OPENAI;
}
if (process.env['GEMINI_API_KEY'] && process.env['GEMINI_MODEL']) {
if (process.env['GEMINI_API_KEY']) {
return AuthType.USE_GEMINI;
}
if (process.env['GOOGLE_API_KEY'] && process.env['GOOGLE_MODEL']) {
if (process.env['GOOGLE_API_KEY']) {
return AuthType.USE_VERTEX_AI;
}
if (
process.env['ANTHROPIC_API_KEY'] &&
process.env['ANTHROPIC_MODEL'] &&
process.env['ANTHROPIC_BASE_URL']
) {
if (process.env['ANTHROPIC_API_KEY']) {
return AuthType.USE_ANTHROPIC;
}
@@ -131,7 +120,7 @@ export function resolveCliGenerationConfig(
// Log warnings if any
for (const warning of resolved.warnings) {
console.warn(warning);
console.warn(`[modelProviderUtils] ${warning}`);
}
// Resolve OpenAI logging config (CLI-specific, not part of core resolver)

View File

@@ -706,15 +706,12 @@ export class Config {
* Exclusive for `OpenAIKeyPrompt` to update credentials via `/auth`
* Delegates to ModelsConfig.
*/
updateCredentials(
credentials: {
apiKey?: string;
baseUrl?: string;
model?: string;
},
settingsGenerationConfig?: Partial<ContentGeneratorConfig>,
): void {
this._modelsConfig.updateCredentials(credentials, settingsGenerationConfig);
updateCredentials(credentials: {
apiKey?: string;
baseUrl?: string;
model?: string;
}): void {
this._modelsConfig.updateCredentials(credentials);
}
/**

View File

@@ -106,6 +106,15 @@ export const QWEN_OAUTH_MODELS: ModelConfig[] = [
description:
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)',
capabilities: { vision: false },
generationConfig: {
samplingParams: {
temperature: 0.7,
top_p: 0.9,
max_tokens: 8192,
},
timeout: 60000,
maxRetries: 3,
},
},
{
id: 'vision-model',
@@ -113,5 +122,14 @@ export const QWEN_OAUTH_MODELS: ModelConfig[] = [
description:
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)',
capabilities: { vision: true },
generationConfig: {
samplingParams: {
temperature: 0.7,
top_p: 0.9,
max_tokens: 8192,
},
timeout: 60000,
maxRetries: 3,
},
},
];

View File

@@ -191,7 +191,7 @@ describe('ModelsConfig', () => {
expect(gc.apiKeyEnvKey).toBe('API_KEY_SHARED');
});
it('should use provider config when modelId exists in registry even after updateCredentials', () => {
it('should preserve settings generationConfig when model is updated via updateCredentials even if it matches modelProviders', () => {
const modelProvidersConfig: ModelProvidersConfig = {
openai: [
{
@@ -213,7 +213,7 @@ describe('ModelsConfig', () => {
initialAuthType: AuthType.USE_OPENAI,
modelProvidersConfig,
generationConfig: {
model: 'custom-model',
model: 'model-a',
samplingParams: { temperature: 0.9, max_tokens: 999 },
timeout: 9999,
maxRetries: 9,
@@ -235,30 +235,30 @@ describe('ModelsConfig', () => {
},
});
// User manually updates credentials via updateCredentials.
// Note: In practice, handleAuthSelect prevents using a modelId that matches a provider model,
// but if syncAfterAuthRefresh is called with a modelId that exists in registry,
// we should use provider config.
modelsConfig.updateCredentials({ apiKey: 'manual-key' });
// User manually updates the model via updateCredentials (e.g. key prompt flow).
// Even if the model ID matches a modelProviders entry, we must not apply provider defaults
// that would overwrite settings.model.generationConfig.
modelsConfig.updateCredentials({ model: 'model-a' });
// syncAfterAuthRefresh with a modelId that exists in registry should use provider config
modelsConfig.syncAfterAuthRefresh(AuthType.USE_OPENAI, 'model-a');
modelsConfig.syncAfterAuthRefresh(
AuthType.USE_OPENAI,
modelsConfig.getModel(),
);
const gc = currentGenerationConfig(modelsConfig);
expect(gc.model).toBe('model-a');
// Provider config should be applied
expect(gc.samplingParams?.temperature).toBe(0.1);
expect(gc.samplingParams?.max_tokens).toBe(123);
expect(gc.timeout).toBe(111);
expect(gc.maxRetries).toBe(1);
expect(gc.samplingParams?.temperature).toBe(0.9);
expect(gc.samplingParams?.max_tokens).toBe(999);
expect(gc.timeout).toBe(9999);
expect(gc.maxRetries).toBe(9);
});
it('should preserve settings generationConfig when modelId does not exist in registry', () => {
it('should preserve settings generationConfig across multiple auth refreshes after updateCredentials', () => {
const modelProvidersConfig: ModelProvidersConfig = {
openai: [
{
id: 'provider-model',
name: 'Provider Model',
id: 'model-a',
name: 'Model A',
baseUrl: 'https://api.example.com/v1',
envKey: 'API_KEY_A',
generationConfig: {
@@ -270,12 +270,11 @@ describe('ModelsConfig', () => {
],
};
// Simulate settings with a custom model (not in registry)
const modelsConfig = new ModelsConfig({
initialAuthType: AuthType.USE_OPENAI,
modelProvidersConfig,
generationConfig: {
model: 'custom-model',
model: 'model-a',
samplingParams: { temperature: 0.9, max_tokens: 999 },
timeout: 9999,
maxRetries: 9,
@@ -297,21 +296,25 @@ describe('ModelsConfig', () => {
},
});
// User manually sets credentials for a custom model (not in registry)
modelsConfig.updateCredentials({
apiKey: 'manual-key',
baseUrl: 'https://manual.example.com/v1',
model: 'custom-model',
model: 'model-a',
});
// First auth refresh - modelId doesn't exist in registry, so credentials should be preserved
modelsConfig.syncAfterAuthRefresh(AuthType.USE_OPENAI, 'custom-model');
// First auth refresh
modelsConfig.syncAfterAuthRefresh(
AuthType.USE_OPENAI,
modelsConfig.getModel(),
);
// Second auth refresh should still preserve settings generationConfig
modelsConfig.syncAfterAuthRefresh(AuthType.USE_OPENAI, 'custom-model');
modelsConfig.syncAfterAuthRefresh(
AuthType.USE_OPENAI,
modelsConfig.getModel(),
);
const gc = currentGenerationConfig(modelsConfig);
expect(gc.model).toBe('custom-model');
// Settings-sourced generation config should be preserved since modelId doesn't exist in registry
expect(gc.model).toBe('model-a');
expect(gc.samplingParams?.temperature).toBe(0.9);
expect(gc.samplingParams?.max_tokens).toBe(999);
expect(gc.timeout).toBe(9999);
@@ -477,91 +480,6 @@ describe('ModelsConfig', () => {
expect(gc.apiKeyEnvKey).toBeUndefined();
});
it('should use default model for new authType when switching from different authType with env vars', () => {
// Simulate cold start with OPENAI env vars (OPENAI_MODEL and OPENAI_API_KEY)
// This sets the model in generationConfig but no authType is selected yet
const modelsConfig = new ModelsConfig({
generationConfig: {
model: 'gpt-4o', // From OPENAI_MODEL env var
apiKey: 'openai-key-from-env',
},
});
// User switches to qwen-oauth via AuthDialog
// refreshAuth calls syncAfterAuthRefresh with the current model (gpt-4o)
// which doesn't exist in qwen-oauth registry, so it should use default
modelsConfig.syncAfterAuthRefresh(AuthType.QWEN_OAUTH, 'gpt-4o');
const gc = currentGenerationConfig(modelsConfig);
// Should use default qwen-oauth model (coder-model), not the OPENAI model
expect(gc.model).toBe('coder-model');
expect(gc.apiKey).toBe('QWEN_OAUTH_DYNAMIC_TOKEN');
expect(gc.apiKeyEnvKey).toBeUndefined();
});
it('should clear manual credentials when switching from USE_OPENAI to QWEN_OAUTH', () => {
// User manually set credentials for OpenAI
const modelsConfig = new ModelsConfig({
initialAuthType: AuthType.USE_OPENAI,
generationConfig: {
model: 'gpt-4o',
apiKey: 'manual-openai-key',
baseUrl: 'https://manual.example.com/v1',
},
});
// Manually set credentials via updateCredentials
modelsConfig.updateCredentials({
apiKey: 'manual-openai-key',
baseUrl: 'https://manual.example.com/v1',
model: 'gpt-4o',
});
// User switches to qwen-oauth
// Since authType is not USE_OPENAI, manual credentials should be cleared
// and default qwen-oauth model should be applied
modelsConfig.syncAfterAuthRefresh(AuthType.QWEN_OAUTH, 'gpt-4o');
const gc = currentGenerationConfig(modelsConfig);
// Should use default qwen-oauth model, not preserve manual OpenAI credentials
expect(gc.model).toBe('coder-model');
expect(gc.apiKey).toBe('QWEN_OAUTH_DYNAMIC_TOKEN');
// baseUrl should be set to qwen-oauth default, not preserved from manual OpenAI config
expect(gc.baseUrl).toBe('DYNAMIC_QWEN_OAUTH_BASE_URL');
expect(gc.apiKeyEnvKey).toBeUndefined();
});
it('should preserve manual credentials when switching to USE_OPENAI', () => {
// User manually set credentials
const modelsConfig = new ModelsConfig({
initialAuthType: AuthType.USE_OPENAI,
generationConfig: {
model: 'gpt-4o',
apiKey: 'manual-openai-key',
baseUrl: 'https://manual.example.com/v1',
samplingParams: { temperature: 0.9 },
},
});
// Manually set credentials via updateCredentials
modelsConfig.updateCredentials({
apiKey: 'manual-openai-key',
baseUrl: 'https://manual.example.com/v1',
model: 'gpt-4o',
});
// User switches to USE_OPENAI (same or different model)
// Since authType is USE_OPENAI, manual credentials should be preserved
modelsConfig.syncAfterAuthRefresh(AuthType.USE_OPENAI, 'gpt-4o');
const gc = currentGenerationConfig(modelsConfig);
// Should preserve manual credentials
expect(gc.model).toBe('gpt-4o');
expect(gc.apiKey).toBe('manual-openai-key');
expect(gc.baseUrl).toBe('https://manual.example.com/v1');
expect(gc.samplingParams?.temperature).toBe(0.9); // Preserved from initial config
});
it('should maintain consistency between currentModelId and _generationConfig.model after initialization', () => {
const modelProvidersConfig: ModelProvidersConfig = {
openai: [
@@ -678,120 +596,4 @@ describe('ModelsConfig', () => {
expect(modelsConfig.getModel()).toBe('updated-model');
expect(modelsConfig.getGenerationConfig().model).toBe('updated-model');
});
describe('getAllAvailableModels', () => {
it('should return all models across all authTypes', () => {
const modelProvidersConfig: ModelProvidersConfig = {
openai: [
{
id: 'openai-model-1',
name: 'OpenAI Model 1',
baseUrl: 'https://api.openai.com/v1',
envKey: 'OPENAI_API_KEY',
},
{
id: 'openai-model-2',
name: 'OpenAI Model 2',
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',
},
],
gemini: [
{
id: 'gemini-model-1',
name: 'Gemini Model 1',
baseUrl: 'https://generativelanguage.googleapis.com/v1',
envKey: 'GEMINI_API_KEY',
},
],
};
const modelsConfig = new ModelsConfig({
modelProvidersConfig,
});
const allModels = modelsConfig.getAllAvailableModels();
// Should include qwen-oauth models (hard-coded)
const qwenModels = allModels.filter(
(m) => m.authType === AuthType.QWEN_OAUTH,
);
expect(qwenModels.length).toBeGreaterThan(0);
// Should include openai models
const openaiModels = allModels.filter(
(m) => m.authType === AuthType.USE_OPENAI,
);
expect(openaiModels.length).toBe(2);
expect(openaiModels.map((m) => m.id)).toContain('openai-model-1');
expect(openaiModels.map((m) => m.id)).toContain('openai-model-2');
// Should include anthropic models
const anthropicModels = allModels.filter(
(m) => m.authType === AuthType.USE_ANTHROPIC,
);
expect(anthropicModels.length).toBe(1);
expect(anthropicModels[0].id).toBe('anthropic-model-1');
// Should include gemini models
const geminiModels = allModels.filter(
(m) => m.authType === AuthType.USE_GEMINI,
);
expect(geminiModels.length).toBe(1);
expect(geminiModels[0].id).toBe('gemini-model-1');
});
it('should return empty array when no models are registered', () => {
const modelsConfig = new ModelsConfig();
const allModels = modelsConfig.getAllAvailableModels();
// Should still include qwen-oauth models (hard-coded)
expect(allModels.length).toBeGreaterThan(0);
const qwenModels = allModels.filter(
(m) => m.authType === AuthType.QWEN_OAUTH,
);
expect(qwenModels.length).toBeGreaterThan(0);
});
it('should return models with correct structure', () => {
const modelProvidersConfig: ModelProvidersConfig = {
openai: [
{
id: 'test-model',
name: 'Test Model',
description: 'A test model',
baseUrl: 'https://api.example.com/v1',
envKey: 'TEST_API_KEY',
capabilities: {
vision: true,
},
},
],
};
const modelsConfig = new ModelsConfig({
modelProvidersConfig,
});
const allModels = modelsConfig.getAllAvailableModels();
const testModel = allModels.find((m) => m.id === 'test-model');
expect(testModel).toBeDefined();
expect(testModel?.id).toBe('test-model');
expect(testModel?.label).toBe('Test Model');
expect(testModel?.description).toBe('A test model');
expect(testModel?.authType).toBe(AuthType.USE_OPENAI);
expect(testModel?.isVision).toBe(true);
expect(testModel?.capabilities?.vision).toBe(true);
});
});
});

View File

@@ -203,18 +203,6 @@ export class ModelsConfig {
return this.modelRegistry.getModelsForAuthType(authType);
}
/**
* Get all available models across all authTypes
*/
getAllAvailableModels(): AvailableModel[] {
const allModels: AvailableModel[] = [];
for (const authType of Object.values(AuthType)) {
const models = this.modelRegistry.getModelsForAuthType(authType);
allModels.push(...models);
}
return allModels;
}
/**
* Check if a model exists for the given authType
*/
@@ -319,33 +307,6 @@ export class ModelsConfig {
return this.generationConfigSources;
}
/**
* Merge settings generation config, preserving existing values.
* Used when provider-sourced config is cleared but settings should still apply.
*/
mergeSettingsGenerationConfig(
settingsGenerationConfig?: Partial<ContentGeneratorConfig>,
): void {
if (!settingsGenerationConfig) {
return;
}
for (const field of MODEL_GENERATION_CONFIG_FIELDS) {
if (
!(field in this._generationConfig) &&
field in settingsGenerationConfig
) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this._generationConfig as any)[field] =
settingsGenerationConfig[field];
this.generationConfigSources[field] = {
kind: 'settings',
detail: `model.generationConfig.${field}`,
};
}
}
}
/**
* Update credentials in generation config.
* Sets a flag to prevent syncAfterAuthRefresh from overriding these credentials.
@@ -353,20 +314,12 @@ export class ModelsConfig {
* When credentials are manually set, we clear all provider-sourced configuration
* to maintain provider atomicity (either fully applied or not at all).
* Other layers (CLI, env, settings, defaults) will participate in resolve.
*
* @param settingsGenerationConfig Optional generation config from settings.json
* to merge after clearing provider-sourced config.
* This ensures settings.model.generationConfig fields
* (e.g., samplingParams, timeout) are preserved.
*/
updateCredentials(
credentials: {
apiKey?: string;
baseUrl?: string;
model?: string;
},
settingsGenerationConfig?: Partial<ContentGeneratorConfig>,
): void {
updateCredentials(credentials: {
apiKey?: string;
baseUrl?: string;
model?: string;
}): void {
/**
* If any fields are updated here, we treat the resulting config as manually overridden
* and avoid applying modelProvider defaults during the next auth refresh.
@@ -406,14 +359,6 @@ export class ModelsConfig {
this.strictModelProviderSelection = false;
// Clear apiKeyEnvKey to prevent validation from requiring environment variable
this._generationConfig.apiKeyEnvKey = undefined;
// After clearing provider-sourced config, merge settings.model.generationConfig
// to ensure fields like samplingParams, timeout, etc. are preserved.
// This follows the resolution strategy where settings.model.generationConfig
// has lower priority than programmatic overrides but should still be applied.
if (settingsGenerationConfig) {
this.mergeSettingsGenerationConfig(settingsGenerationConfig);
}
}
/**
@@ -642,89 +587,41 @@ export class ModelsConfig {
}
/**
* Sync state after auth refresh with fallback strategy:
* 1. If modelId can be found in modelRegistry, use the config from modelRegistry.
* 2. Otherwise, if existing credentials exist in resolved generationConfig from other sources
* (not modelProviders), preserve them and update authType/modelId only.
* 3. Otherwise, fall back to default model for the authType.
* 4. If no default is available, leave the generationConfig incomplete and let
* resolveContentGeneratorConfigWithSources throw exceptions as expected.
* Called by Config.refreshAuth to sync state after auth refresh.
*
* IMPORTANT: If credentials were manually set via updateCredentials(),
* we should NOT override them with modelProvider defaults.
* This handles the case where user inputs credentials via OpenAIKeyPrompt
* after removing environment variables for a previously selected model.
*/
syncAfterAuthRefresh(authType: AuthType, modelId?: string): void {
this.strictModelProviderSelection = false;
const previousAuthType = this.currentAuthType;
this.currentAuthType = authType;
// Check if we have manually set credentials that should be preserved
const preserveManualCredentials = this.hasManualCredentials;
// If credentials were manually set, don't apply modelProvider defaults
// Just update the authType and preserve the manually set credentials
if (preserveManualCredentials) {
this.strictModelProviderSelection = false;
this.currentAuthType = authType;
if (modelId) {
this._generationConfig.model = modelId;
}
return;
}
this.strictModelProviderSelection = false;
// Step 1: If modelId exists in registry, always use config from modelRegistry
// Manual credentials won't have a modelId that matches a provider model (handleAuthSelect prevents it),
// so if modelId exists in registry, we should always use provider config.
// This handles provider switching even within the same authType.
if (modelId && this.modelRegistry.hasModel(authType, modelId)) {
const resolved = this.modelRegistry.getModel(authType, modelId);
if (resolved) {
// Ensure applyResolvedModelDefaults can correctly apply authType-specific
// behavior (e.g., Qwen OAuth placeholder token) by setting currentAuthType
// before applying defaults.
this.currentAuthType = authType;
this.applyResolvedModelDefaults(resolved);
this.strictModelProviderSelection = true;
return;
}
}
// Step 2: Check if there are existing credentials from other sources (not modelProviders)
const apiKeySource = this.generationConfigSources['apiKey'];
const baseUrlSource = this.generationConfigSources['baseUrl'];
const hasExistingCredentials =
(this._generationConfig.apiKey &&
apiKeySource?.kind !== 'modelProviders') ||
(this._generationConfig.baseUrl &&
baseUrlSource?.kind !== 'modelProviders');
// Only preserve credentials if:
// 1. AuthType hasn't changed (credentials are authType-specific), AND
// 2. The modelId doesn't exist in the registry (if it did, we would have used provider config in Step 1), AND
// 3. Either:
// a. We have manual credentials (set via updateCredentials), OR
// b. We have existing credentials
// Note: Even if authType hasn't changed, switching to a different provider model (that exists in registry)
// will use provider config (Step 1), not preserve old credentials. This ensures credentials change when
// switching providers, independent of authType changes.
const isAuthTypeChange = previousAuthType !== authType;
const shouldPreserveCredentials =
!isAuthTypeChange &&
(modelId === undefined ||
!this.modelRegistry.hasModel(authType, modelId)) &&
(this.hasManualCredentials || hasExistingCredentials);
if (shouldPreserveCredentials) {
// Preserve existing credentials, just update authType and modelId if provided
if (modelId) {
this._generationConfig.model = modelId;
if (!this.generationConfigSources['model']) {
this.generationConfigSources['model'] = {
kind: 'programmatic',
detail: 'auth refresh (preserved credentials)',
};
}
}
return;
}
// Step 3: Fall back to default model for the authType
const defaultModel =
this.modelRegistry.getDefaultModelForAuthType(authType);
if (defaultModel) {
this.applyResolvedModelDefaults(defaultModel);
return;
}
// Step 4: No default available - leave generationConfig incomplete
// resolveContentGeneratorConfigWithSources will throw exceptions as expected
if (modelId) {
this._generationConfig.model = modelId;
if (!this.generationConfigSources['model']) {
this.generationConfigSources['model'] = {
kind: 'programmatic',
detail: 'auth refresh (no default model)',
};
}
} else {
this.currentAuthType = authType;
}
}

View File

@@ -751,7 +751,6 @@ describe('getQwenOAuthClient', () => {
beforeEach(() => {
mockConfig = {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(true),
} as unknown as Config;
originalFetch = global.fetch;
@@ -840,7 +839,9 @@ describe('getQwenOAuthClient', () => {
requireCachedCredentials: true,
}),
),
).rejects.toThrow('Please use /auth to re-authenticate.');
).rejects.toThrow(
'No cached Qwen-OAuth credentials found. Please re-authenticate.',
);
expect(global.fetch).not.toHaveBeenCalled();
@@ -1006,7 +1007,6 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
beforeEach(() => {
mockConfig = {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(true),
} as unknown as Config;
originalFetch = global.fetch;
@@ -1202,7 +1202,6 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => {
beforeEach(() => {
mockConfig = {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(true),
} as unknown as Config;
originalFetch = global.fetch;
@@ -1406,7 +1405,6 @@ describe('Browser Launch and Error Handling', () => {
beforeEach(() => {
mockConfig = {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(true),
} as unknown as Config;
originalFetch = global.fetch;
@@ -2045,7 +2043,6 @@ describe('SharedTokenManager Integration in QwenOAuth2Client', () => {
it('should handle TokenManagerError types correctly in getQwenOAuthClient', async () => {
const mockConfig = {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(true),
isInteractive: vi.fn().mockReturnValue(true),
} as unknown as Config;
// Test different TokenManagerError types

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(
'No cached Qwen-OAuth credentials found. Please re-authenticate.',
);
}
// If we couldn't obtain valid credentials via SharedTokenManager, fall back to
@@ -557,109 +559,6 @@ export async function getQwenOAuthClient(
}
}
/**
* Displays a formatted box with OAuth device authorization URL.
* Uses process.stderr.write() to bypass ConsolePatcher and ensure the auth URL
* is always visible to users, especially in non-interactive mode.
* Using stderr prevents corruption of structured JSON output (which goes to stdout)
* and follows the standard Unix convention of user-facing messages to stderr.
*/
function showFallbackMessage(verificationUriComplete: string): void {
const title = 'Qwen OAuth Device Authorization';
const url = verificationUriComplete;
const minWidth = 70;
const maxWidth = 80;
const boxWidth = Math.min(Math.max(title.length + 4, minWidth), maxWidth);
// Calculate the width needed for the box (account for padding)
const contentWidth = boxWidth - 4; // Subtract 2 spaces and 2 border chars
// Helper to wrap text to fit within box width
const wrapText = (text: string, width: number): string[] => {
// For URLs, break at any character if too long
if (text.startsWith('http://') || text.startsWith('https://')) {
const lines: string[] = [];
for (let i = 0; i < text.length; i += width) {
lines.push(text.substring(i, i + width));
}
return lines;
}
// For regular text, break at word boundaries
const words = text.split(' ');
const lines: string[] = [];
let currentLine = '';
for (const word of words) {
if (currentLine.length + word.length + 1 <= width) {
currentLine += (currentLine ? ' ' : '') + word;
} else {
if (currentLine) {
lines.push(currentLine);
}
currentLine = word.length > width ? word.substring(0, width) : word;
}
}
if (currentLine) {
lines.push(currentLine);
}
return lines;
};
// Build the box borders with title centered in top border
// Format: +--- Title ---+
const titleWithSpaces = ' ' + title + ' ';
const totalDashes = boxWidth - 2 - titleWithSpaces.length; // Subtract corners and title
const leftDashes = Math.floor(totalDashes / 2);
const rightDashes = totalDashes - leftDashes;
const topBorder =
'+' +
'-'.repeat(leftDashes) +
titleWithSpaces +
'-'.repeat(rightDashes) +
'+';
const emptyLine = '|' + ' '.repeat(boxWidth - 2) + '|';
const bottomBorder = '+' + '-'.repeat(boxWidth - 2) + '+';
// Build content lines
const instructionLines = wrapText(
'Please visit the following URL in your browser to authorize:',
contentWidth,
);
const urlLines = wrapText(url, contentWidth);
const waitingLine = 'Waiting for authorization to complete...';
// Write the box
process.stderr.write('\n' + topBorder + '\n');
process.stderr.write(emptyLine + '\n');
// Write instructions
for (const line of instructionLines) {
process.stderr.write(
'| ' + line + ' '.repeat(contentWidth - line.length) + ' |\n',
);
}
process.stderr.write(emptyLine + '\n');
// Write URL
for (const line of urlLines) {
process.stderr.write(
'| ' + line + ' '.repeat(contentWidth - line.length) + ' |\n',
);
}
process.stderr.write(emptyLine + '\n');
// Write waiting message
process.stderr.write(
'| ' + waitingLine + ' '.repeat(contentWidth - waitingLine.length) + ' |\n',
);
process.stderr.write(emptyLine + '\n');
process.stderr.write(bottomBorder + '\n\n');
}
async function authWithQwenDeviceFlow(
client: QwenOAuth2Client,
config: Config,
@@ -672,50 +571,6 @@ async function authWithQwenDeviceFlow(
};
qwenOAuth2Events.once(QwenOAuth2Event.AuthCancel, cancelHandler);
// Helper to check cancellation and return appropriate result
const checkCancellation = (): AuthResult | null => {
if (!isCancelled) {
return null;
}
const message = 'Authentication cancelled by user.';
console.debug('\n' + message);
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message);
return { success: false, reason: 'cancelled', message };
};
// Helper to emit auth progress events
const emitAuthProgress = (
status: 'polling' | 'success' | 'error' | 'timeout' | 'rate_limit',
message: string,
): void => {
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, status, message);
};
// Helper to handle browser launch with error handling
const launchBrowser = async (url: string): Promise<void> => {
try {
const childProcess = await open(url);
// IMPORTANT: Attach an error handler to the returned child process.
// Without this, if `open` fails to spawn a process (e.g., `xdg-open` is not found
// in a minimal Docker container), it will emit an unhandled 'error' event,
// causing the entire Node.js process to crash.
if (childProcess) {
childProcess.on('error', (err) => {
console.debug(
'Browser launch failed:',
err.message || 'Unknown error',
);
});
}
} catch (err) {
console.debug(
'Failed to open browser:',
err instanceof Error ? err.message : 'Unknown error',
);
}
};
try {
// Generate PKCE code verifier and challenge
const { code_verifier, code_challenge } = generatePKCEPair();
@@ -738,16 +593,56 @@ async function authWithQwenDeviceFlow(
// Emit device authorization event for UI integration immediately
qwenOAuth2Events.emit(QwenOAuth2Event.AuthUri, deviceAuth);
if (config.isBrowserLaunchSuppressed() || !config.isInteractive()) {
showFallbackMessage(deviceAuth.verification_uri_complete);
const showFallbackMessage = () => {
console.log('\n=== Qwen OAuth Device Authorization ===');
console.log(
'Please visit the following URL in your browser to authorize:',
);
console.log(`\n${deviceAuth.verification_uri_complete}\n`);
console.log('Waiting for authorization to complete...\n');
};
// Always show the fallback message in non-interactive environments to ensure
// users can see the authorization URL even if browser launching is attempted.
// This is critical for headless/remote environments where browser launching
// may silently fail without throwing an error.
if (config.isBrowserLaunchSuppressed()) {
// Browser launch is suppressed, show fallback message
showFallbackMessage();
} else {
// Try to open the URL in browser, but always show the URL as fallback
// to handle cases where browser launch silently fails (e.g., headless servers)
showFallbackMessage();
try {
const childProcess = await open(deviceAuth.verification_uri_complete);
// IMPORTANT: Attach an error handler to the returned child process.
// Without this, if `open` fails to spawn a process (e.g., `xdg-open` is not found
// in a minimal Docker container), it will emit an unhandled 'error' event,
// causing the entire Node.js process to crash.
if (childProcess) {
childProcess.on('error', (err) => {
console.debug(
'Browser launch failed:',
err.message || 'Unknown error',
);
});
}
} catch (err) {
console.debug(
'Failed to open browser:',
err instanceof Error ? err.message : 'Unknown error',
);
}
}
// Try to open browser if not suppressed
if (!config.isBrowserLaunchSuppressed()) {
await launchBrowser(deviceAuth.verification_uri_complete);
}
// Emit auth progress event
qwenOAuth2Events.emit(
QwenOAuth2Event.AuthProgress,
'polling',
'Waiting for authorization...',
);
emitAuthProgress('polling', 'Waiting for authorization...');
console.debug('Waiting for authorization...\n');
// Poll for the token
@@ -758,9 +653,11 @@ async function authWithQwenDeviceFlow(
for (let attempt = 0; attempt < maxAttempts; attempt++) {
// Check if authentication was cancelled
const cancellationResult = checkCancellation();
if (cancellationResult) {
return cancellationResult;
if (isCancelled) {
const message = 'Authentication cancelled by user.';
console.debug('\n' + message);
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message);
return { success: false, reason: 'cancelled', message };
}
try {
@@ -803,7 +700,9 @@ async function authWithQwenDeviceFlow(
// minimal stub; cache invalidation is best-effort and should not break auth.
}
emitAuthProgress(
// Emit auth progress success event
qwenOAuth2Events.emit(
QwenOAuth2Event.AuthProgress,
'success',
'Authentication successful! Access token obtained.',
);
@@ -826,7 +725,9 @@ async function authWithQwenDeviceFlow(
pollInterval = 2000; // Reset to default interval
}
emitAuthProgress(
// Emit polling progress event
qwenOAuth2Events.emit(
QwenOAuth2Event.AuthProgress,
'polling',
`Polling... (attempt ${attempt + 1}/${maxAttempts})`,
);
@@ -856,9 +757,15 @@ async function authWithQwenDeviceFlow(
});
// Check for cancellation after waiting
const cancellationResult = checkCancellation();
if (cancellationResult) {
return cancellationResult;
if (isCancelled) {
const message = 'Authentication cancelled by user.';
console.debug('\n' + message);
qwenOAuth2Events.emit(
QwenOAuth2Event.AuthProgress,
'error',
message,
);
return { success: false, reason: 'cancelled', message };
}
continue;
@@ -886,17 +793,15 @@ async function authWithQwenDeviceFlow(
message: string,
eventType: 'error' | 'rate_limit' = 'error',
): AuthResult => {
emitAuthProgress(eventType, message);
qwenOAuth2Events.emit(
QwenOAuth2Event.AuthProgress,
eventType,
message,
);
console.error('\n' + message);
return { success: false, reason, message };
};
// Check for cancellation first
const cancellationResult = checkCancellation();
if (cancellationResult) {
return cancellationResult;
}
// Handle credential caching failures - stop polling immediately
if (errorMessage.includes('Failed to cache credentials')) {
return handleError('error', errorMessage);
@@ -920,14 +825,26 @@ async function authWithQwenDeviceFlow(
}
const message = `Error polling for token: ${errorMessage}`;
emitAuthProgress('error', message);
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message);
if (isCancelled) {
const message = 'Authentication cancelled by user.';
return { success: false, reason: 'cancelled', message };
}
await new Promise((resolve) => setTimeout(resolve, pollInterval));
}
}
const timeoutMessage = 'Authorization timeout, please restart the process.';
emitAuthProgress('timeout', timeoutMessage);
// Emit timeout error event
qwenOAuth2Events.emit(
QwenOAuth2Event.AuthProgress,
'timeout',
timeoutMessage,
);
console.error('\n' + timeoutMessage);
return { success: false, reason: 'timeout', message: timeoutMessage };
} catch (error: unknown) {
@@ -936,7 +853,7 @@ async function authWithQwenDeviceFlow(
});
const message = `Device authorization flow failed: ${fullErrorMessage}`;
emitAuthProgress('error', message);
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message);
console.error(message);
return { success: false, reason: 'error', message };
} finally {

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/sdk",
"version": "0.1.3",
"version": "0.1.2",
"description": "TypeScript SDK for programmatic access to qwen-code CLI",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",

View File

@@ -125,9 +125,8 @@ function normalizeForRegex(dirPath: string): string {
function tryResolveCliFromImportMeta(): string | null {
try {
if (typeof import.meta !== 'undefined' && import.meta.url) {
const currentFilePath = fileURLToPath(import.meta.url);
const currentDir = path.dirname(currentFilePath);
const cliPath = path.join(currentDir, 'cli', 'cli.js');
const cliUrl = new URL('./cli/cli.js', import.meta.url);
const cliPath = fileURLToPath(cliUrl);
if (fs.existsSync(cliPath)) {
return cliPath;
}