mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-23 17:26:23 +00:00
Compare commits
1 Commits
mingholy/f
...
fix/mcp-se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f33d92b2c |
@@ -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.
|
||||
|
||||
@@ -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. | | |
|
||||
|
||||
BIN
docs/users/images/jetbrains-acp.png
Normal file
BIN
docs/users/images/jetbrains-acp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
@@ -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
|
||||
|
||||

|
||||

|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
@@ -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/)
|
||||
|
||||
|
||||
@@ -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
2
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {} });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user