Compare commits

..

98 Commits

Author SHA1 Message Date
mingholy.lmh
81de79c899 fix: best effort to use resolved authType/model across the repo 2026-01-08 12:11:23 +08:00
mingholy.lmh
5ea841dd02 fix: refine auth message to give explicit tip 2026-01-07 22:58:11 +08:00
mingholy.lmh
ded1ebcdff fix: fallback and auth issues when configuring a duplicate model id 2026-01-07 22:58:11 +08:00
mingholy.lmh
afe6ba255e fix: align authType & model persisting behavior across dialogs 2026-01-07 22:58:11 +08:00
mingholy.lmh
fe2ed889b9 docs: add modelProviders documents 2026-01-07 22:58:10 +08:00
mingholy.lmh
8da376637a fix: remove detailed generationConfig 2026-01-07 22:58:10 +08:00
mingholy.lmh
15f4c1ebd6 chore: add i18n 2026-01-07 22:58:10 +08:00
mingholy.lmh
492da0c8c0 chore: update copyright notice in modelConfigUtils.ts 2026-01-07 22:58:09 +08:00
mingholy.lmh
90855c93d1 fix: lint & ci issues 2026-01-07 22:58:09 +08:00
mingholy.lmh
db12796df5 refactor: update authentication handling and model configuration
- Enhanced authentication method validation in `auth.ts` and `auth.test.ts`.
- Introduced new model provider configuration logic
- Updated environment variable handling for various auth types.
- Removed deprecated utility functions and tests related to fallback mechanisms.
2026-01-07 22:58:09 +08:00
mingholy.lmh
aa9cdf2a3c review: stage1 2026-01-07 22:58:08 +08:00
tanzhenxin
570ec432af Merge pull request #1282 from BlockHand/fix-sandbox-ideInstall
feat: Optimize the issue where an error message indicating unfriendli…
2026-01-07 17:40:45 +08:00
tanzhenxin
bfc3bbfa9c update user messages 2026-01-07 17:25:27 +08:00
tanzhenxin
91af9bf6c8 Merge branch 'main' into fix-sandbox-ideInstall 2026-01-07 17:12:22 +08:00
tanzhenxin
f6771c0858 Merge pull request #1393 from Weaxs/main
[OpenaiContentGenerate] convertOpenAIResponseToGemini record thoughtsTokenCount
2026-01-07 17:05:28 +08:00
tanzhenxin
2c8be05029 Merge pull request #1415 from QwenLM/fix/openai-reasoning-config
fix(core): don’t force reasoning/topP defaults for OpenAI-compatible APIs
2026-01-07 16:59:26 +08:00
tanzhenxin
4744af1ea8 Merge pull request #1406 from QwenLM/fix/non-interactive-tool-permission
fix(cli,core): honor `tools.core` / `tools.allowed` in non-interactive runs
2026-01-07 16:59:19 +08:00
Mingholy
2c285394c7 Merge pull request #1423 from QwenLM/chore/release-v0.6.1
chore: bump version to 0.6.1
2026-01-07 16:55:45 +08:00
mingholy.lmh
f2d941e469 chore: bump version to 0.6.1 2026-01-07 16:25:14 +08:00
tanzhenxin
9b2dfe1e06 Merge pull request #1374 from QwenLM/fix/resume-command-broken-after-new-chat
Fix resume command broken after new chat
2026-01-07 16:21:47 +08:00
tanzhenxin
3e695cd82b Merge pull request #1146 from QwenLM/fix/windows-background-terminal-execute-x
fix: improve windows background process handling and cleanup
2026-01-07 16:21:14 +08:00
xwj02155382
177a91f1d5 Merge branch 'main' of github.com:QwenLM/qwen-code into fix/windows-background-terminal-execute-x 2026-01-07 15:50:03 +08:00
Weaxs
870d207f18 revert enable_thinking & thinking_budget 2026-01-07 10:25:34 +08:00
tanzhenxin
3f512528cb Merge pull request #1391 from tt-a1i/feat/approval-mode-direct-arg
feat(cli): add direct argument support for /approval-mode command
2026-01-07 09:39:33 +08:00
Tu Shaokun
0878ee4cbd fix: handle setApprovalMode error in untrusted folders
Add try/catch to gracefully handle errors when setting privileged
modes (yolo/auto-edit) in untrusted folders, returning an error
message instead of throwing.
2026-01-06 22:04:43 +08:00
Tu Shaokun
bfe7298858 refactor: apply session-only approval mode per review feedback
- Remove persistence to user settings (no setValue call)
- Only use config.setApprovalMode() for session scope
- Remove autocomplete feature for simplicity
- Align with Shift+Tab behavior
2026-01-06 21:57:21 +08:00
Tu Shaokun
2f2937aafe test: add explicit assertions for setApprovalMode argument
Verify the exact mode value passed to config.setApprovalMode to catch
potential regressions in settings merge/update mechanism.
2026-01-06 21:54:33 +08:00
Tu Shaokun
8fcdd86b91 feat(cli): add direct argument support for /approval-mode command
Allow users to set approval mode directly via argument instead of
opening the dialog. For example:
- /approval-mode plan
- /approval-mode yolo
- /approval-mode auto-edit
- /approval-mode default

If no argument is provided, the dialog opens as before.
If an invalid argument is provided, an error message shows valid options.

Also adds tab completion for mode arguments.

Fixes #1353
2026-01-06 21:54:33 +08:00
tanzhenxin
d7d7bf0c39 fix default values of reasoning config for openai compatible api 2026-01-06 19:39:28 +08:00
gwinthis
b95d9a8d2d Merge pull request #1414 from QwenLM/doc/qwencode-java
Doc/qwencode java
2026-01-06 17:54:47 +08:00
顾盼
6f39ae120c Merge pull request #1355 from QwenLM/feat/stable-acp-flag
feat: graduate `--experimental-acp` to stable `--acp` flag
2026-01-06 17:51:43 +08:00
顾盼
627857621a Merge pull request #1365 from QwenLM/fix/missing-whitespaces
fix: preserve whitespace in thinking content for stream-json output format
2026-01-06 17:51:26 +08:00
顾盼
65c7cf5d8f Merge pull request #1376 from QwenLM/fix/missing-error-throw-nonInteractive
fix: exit with non-zero code on API errors in text mode
2026-01-06 17:51:13 +08:00
顾盼
7a823060ac Merge pull request #1383 from QwenLM/fix/tool-result-text-mode
fix: improve tool execution feedback in non-interactive mode
2026-01-06 17:50:58 +08:00
xwj02155382
2c88ea6dc1 Merge branch 'main' of github.com:QwenLM/qwen-code into fix/windows-background-terminal-execute-x 2026-01-06 17:26:01 +08:00
skyfire
ad3086f7dd add qwencode-sdk java doc 2026-01-06 17:18:41 +08:00
skyfire
8f3bbef575 add qwencode-sdk java doc 2026-01-06 17:11:47 +08:00
xwj02155382
e2d6ab9b7e refactor: simplify background shell command handling
- Remove ineffective error detection for background processes
  (stdio is detached/ignored, so cumulativeOutput is always empty)
- Add kill command hints for both Windows and macOS/Linux
- Simplify code from 40 lines to 12 lines with clearer logic
- Add explanatory comment about why startup errors cannot be reliably detected
2026-01-06 16:46:56 +08:00
Weaxs
35bf5ef4d0 remove duplicate reasoning_content handle 2026-01-06 14:38:01 +08:00
Weaxs
1d16513e27 remove duplicate reasoning_content handle && remove extra_body.enable_thinking 2026-01-06 14:38:01 +08:00
Weaxs
731fd99800 remove duplicate reasoning_content handle && remove extra_body.enable_thinking 2026-01-06 14:21:42 +08:00
skyfire
c6ae0a8be7 for alpha stage 2026-01-06 11:16:47 +08:00
gwinthis
49892a8e17 Merge pull request #1412 from QwenLM/feat/javasdk
Feat/javasdk
2026-01-06 09:49:36 +08:00
skyfire
d1a3e828b7 add license 2026-01-06 09:21:58 +08:00
pomelo
b19bb6cb20 Merge pull request #1378 from afarber/add-german-language-support
feat(i18n): add German language support
2026-01-05 22:14:00 +08:00
skyfire
e8625658ba publish 0.0.1-alpha 2026-01-05 20:27:37 +08:00
skyfire
a4eb3adea8 for pom 2026-01-05 19:22:50 +08:00
skyfire
7dc7c6380d for pom 2026-01-05 18:14:40 +08:00
skyfire
d2d2b845c5 for README.md 2026-01-05 18:12:48 +08:00
skyfire
96080f84a6 for README.md 2026-01-05 18:00:38 +08:00
skyfire
2b6218e564 for README.md 2026-01-05 17:49:43 +08:00
skyfire
24edf32da8 for README.md 2026-01-05 17:46:18 +08:00
skyfire
51b08f700c for examples 2026-01-05 17:44:07 +08:00
tanzhenxin
58eac7f595 Merge pull request #1397 from liqiongyu/fix/1304-disable-update-nag
fix(cli): skip update check when disableUpdateNag is true
2026-01-05 14:19:13 +08:00
skyfire
32e8b01cf0 for javadoc 2026-01-04 19:39:00 +08:00
skyfire
db9d5cb45d add javadoc 2026-01-04 18:07:56 +08:00
liqoingyu
473cb7b951 fix(cli): skip update check when disableUpdateNag is true 2026-01-04 14:32:38 +08:00
Weaxs
e5cced8813 buildRequest add thinking config && convert Handle reasoning content 2026-01-02 18:59:23 +08:00
skyfire
73848d3867 fix arg 2026-01-01 01:30:58 +08:00
skyfire
6a62167f79 for README.md 2025-12-31 23:36:17 +08:00
skyfire
6ff437671e for README.md 2025-12-31 23:26:20 +08:00
skyfire
30f9e9c782 for README.md 2025-12-31 22:57:20 +08:00
skyfire
e4caa7a856 for partial message processing and event timeout processing 2025-12-31 20:15:51 +08:00
LaZzyMan
aaa66b3172 fix: add tool result and deny warning in text mode 2025-12-31 17:38:33 +08:00
Alexander Farber
0ae59b900c Add German umlauts 2025-12-30 16:50:23 +01:00
Alexander Farber
5a5dae1987 Add German language support and remove a misleading witty phrase 2025-12-30 16:35:34 +01:00
skyfire
ac7ba95d65 add permission 2025-12-30 20:08:05 +08:00
LaZzyMan
15912892f2 fix: missing error throw in non-Interactive mode 2025-12-30 19:40:24 +08:00
cris
e3c20b03bd reslove blank 2025-12-30 16:03:11 +08:00
cris
4db50d4158 fix resume unwork on windows 2025-12-30 16:00:55 +08:00
skyfire
4154493640 message and session use 2025-12-29 21:44:02 +08:00
LaZzyMan
61aad5a162 fix: missing whitespaces for stream-json/json output format via GLM 4.7 model 2025-12-29 16:59:09 +08:00
xuewenjie
98c043bf50 test: update tests for detached process changes 2025-12-29 11:37:54 +08:00
cris
f610133660 improve ad hoc method for windows background terminal task 2025-12-28 22:14:16 +08:00
LaZzyMan
fe7ff5b148 feat: stable-acp-flag 2025-12-26 17:09:16 +08:00
xuewenjie
5417de4219 Merge branch 'main' of github.com:QwenLM/qwen-code into fix/windows-background-terminal-execute-x 2025-12-25 16:44:30 +08:00
skyfire
422998d7f0 add ProcessTransport unitTest and fix bug 2025-12-24 21:20:47 +08:00
skyfire
68628bf952 add ProcessTransport 2025-12-24 20:45:17 +08:00
skyfire
e5efad89e0 Merge branch 'feat/javasdk' of github.com:QwenLM/qwen-code into feat/javasdk 2025-12-24 10:01:28 +08:00
skyfire
e09bb5f5c0 modify junit version to 5 and add org developers 2025-12-23 20:14:11 +08:00
乾离
24d11179d8 modify junit version to 5 and add org developers 2025-12-23 20:04:58 +08:00
乾离
2ef8b6f350 ProcessTransport stru init 2025-12-23 17:44:28 +08:00
乾离
5779f7ab1d project initialize 2025-12-23 17:20:12 +08:00
刘伟光
43e0815def feat: 修改链接ide之前的判断逻辑,检测是否安装过ide扩展 2025-12-22 11:22:51 +08:00
刘伟光
0c14f4ce08 Merge branch 'main' into fix-sandbox-ideInstall 2025-12-22 11:00:01 +08:00
刘伟光
34d8dbf9b2 feat: 兼容宿主机在不同ide上的instal提示 2025-12-19 11:07:33 +08:00
刘伟光
b3b2bc6ad5 feat: 兼容宿主机在不同ide上的instal提示 2025-12-19 10:39:05 +08:00
刘伟光
6ca54beba2 feat: Optimize the issue where an error message indicating unfriendliness occurs after executing the ideinstall command in the sandbox environment 2025-12-17 13:38:38 +08:00
xuewenjie
8673426d5c fix(core): use current chunk for shell output update instead of cumulative 2025-12-16 10:26:20 +08:00
xuewenjie
b272ac0119 Fix: Make cleanup strategy dynamic to support testing mocks 2025-12-12 17:47:03 +08:00
xuewenjie
574d89da14 Refactor ShellExecutionService cleanup to use strategy pattern 2025-12-12 17:03:04 +08:00
xuewenjie
16939c0bc8 Refactor ShellTool: remove ping hack and timeout, optimize cleanup 2025-12-10 13:49:51 +08:00
xuewenjie
6fc09a82fb fix: use && for windows background keep-alive ping and add test 2025-12-09 13:33:42 +08:00
xuewenjie
d622f8d1bf Merge branch 'main' of github.com:QwenLM/qwen-code into fix/windows-background-terminal-execute-x 2025-12-09 11:32:17 +08:00
xuewenjie
28d178b5c1 fix: handle windows background execution errors and add tests 2025-12-09 11:24:30 +08:00
xuewenjie
4c69d536ac test: fix shell tool tests by updating pid expectation and AbortSignal matching 2025-12-05 10:47:06 +08:00
xuewenjie
403fd06117 chore: update .gitignore 2025-12-04 15:55:17 +08:00
xuewenjie
d9928eab66 fix: improve windows background process handling and cleanup 2025-12-04 15:55:11 +08:00
179 changed files with 17654 additions and 1135 deletions

1
.gitignore vendored
View File

@@ -23,6 +23,7 @@ package-lock.json
.idea
*.iml
.cursor
.qoder
# OS metadata
.DS_Store

View File

@@ -11,6 +11,7 @@ export default {
type: 'separator',
},
'sdk-typescript': 'Typescript SDK',
'sdk-java': 'Java SDK(alpha)',
'Dive Into Qwen Code': {
title: 'Dive Into Qwen Code',
type: 'separator',

312
docs/developers/sdk-java.md Normal file
View File

@@ -0,0 +1,312 @@
# Qwen Code Java SDK
The Qwen Code Java SDK is a minimum experimental SDK for programmatic access to Qwen Code functionality. It provides a Java interface to interact with the Qwen Code CLI, allowing developers to integrate Qwen Code capabilities into their Java applications.
## Requirements
- Java >= 1.8
- Maven >= 3.6.0 (for building from source)
- qwen-code >= 0.5.0
### Dependencies
- **Logging**: ch.qos.logback:logback-classic
- **Utilities**: org.apache.commons:commons-lang3
- **JSON Processing**: com.alibaba.fastjson2:fastjson2
- **Testing**: JUnit 5 (org.junit.jupiter:junit-jupiter)
## Installation
Add the following dependency to your Maven `pom.xml`:
```xml
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>qwencode-sdk</artifactId>
<version>{$version}</version>
</dependency>
```
Or if using Gradle, add to your `build.gradle`:
```gradle
implementation 'com.alibaba:qwencode-sdk:{$version}'
```
## Building and Running
### Build Commands
```bash
# Compile the project
mvn compile
# Run tests
mvn test
# Package the JAR
mvn package
# Install to local repository
mvn install
```
## Quick Start
The simplest way to use the SDK is through the `QwenCodeCli.simpleQuery()` method:
```java
public static void runSimpleExample() {
List<String> result = QwenCodeCli.simpleQuery("hello world");
result.forEach(logger::info);
}
```
For more advanced usage with custom transport options:
```java
public static void runTransportOptionsExample() {
TransportOptions options = new TransportOptions()
.setModel("qwen3-coder-flash")
.setPermissionMode(PermissionMode.AUTO_EDIT)
.setCwd("./")
.setEnv(new HashMap<String, String>() {{put("CUSTOM_VAR", "value");}})
.setIncludePartialMessages(true)
.setTurnTimeout(new Timeout(120L, TimeUnit.SECONDS))
.setMessageTimeout(new Timeout(90L, TimeUnit.SECONDS))
.setAllowedTools(Arrays.asList("read_file", "write_file", "list_directory"));
List<String> result = QwenCodeCli.simpleQuery("who are you, what are your capabilities?", options);
result.forEach(logger::info);
}
```
For streaming content handling with custom content consumers:
```java
public static void runStreamingExample() {
QwenCodeCli.simpleQuery("who are you, what are your capabilities?",
new TransportOptions().setMessageTimeout(new Timeout(10L, TimeUnit.SECONDS)), new AssistantContentSimpleConsumers() {
@Override
public void onText(Session session, TextAssistantContent textAssistantContent) {
logger.info("Text content received: {}", textAssistantContent.getText());
}
@Override
public void onThinking(Session session, ThingkingAssistantContent thingkingAssistantContent) {
logger.info("Thinking content received: {}", thingkingAssistantContent.getThinking());
}
@Override
public void onToolUse(Session session, ToolUseAssistantContent toolUseContent) {
logger.info("Tool use content received: {} with arguments: {}",
toolUseContent, toolUseContent.getInput());
}
@Override
public void onToolResult(Session session, ToolResultAssistantContent toolResultContent) {
logger.info("Tool result content received: {}", toolResultContent.getContent());
}
@Override
public void onOtherContent(Session session, AssistantContent<?> other) {
logger.info("Other content received: {}", other);
}
@Override
public void onUsage(Session session, AssistantUsage assistantUsage) {
logger.info("Usage information received: Input tokens: {}, Output tokens: {}",
assistantUsage.getUsage().getInputTokens(), assistantUsage.getUsage().getOutputTokens());
}
}.setDefaultPermissionOperation(Operation.allow));
logger.info("Streaming example completed.");
}
```
other examples see src/test/java/com/alibaba/qwen/code/cli/example
## Architecture
The SDK follows a layered architecture:
- **API Layer**: Provides the main entry points through `QwenCodeCli` class with simple static methods for basic usage
- **Session Layer**: Manages communication sessions with the Qwen Code CLI through the `Session` class
- **Transport Layer**: Handles the communication mechanism between the SDK and CLI process (currently using process transport via `ProcessTransport`)
- **Protocol Layer**: Defines data structures for communication based on the CLI protocol
- **Utils**: Common utilities for concurrent execution, timeout handling, and error management
## Key Features
### Permission Modes
The SDK supports different permission modes for controlling tool execution:
- **`default`**: Write tools are denied unless approved via `canUseTool` callback or in `allowedTools`. Read-only tools execute without confirmation.
- **`plan`**: Blocks all write tools, instructing AI to present a plan first.
- **`auto-edit`**: Auto-approve edit tools (edit, write_file) while other tools require confirmation.
- **`yolo`**: All tools execute automatically without confirmation.
### Session Event Consumers and Assistant Content Consumers
The SDK provides two key interfaces for handling events and content from the CLI:
#### SessionEventConsumers Interface
The `SessionEventConsumers` interface provides callbacks for different types of messages during a session:
- `onSystemMessage`: Handles system messages from the CLI (receives Session and SDKSystemMessage)
- `onResultMessage`: Handles result messages from the CLI (receives Session and SDKResultMessage)
- `onAssistantMessage`: Handles assistant messages (AI responses) (receives Session and SDKAssistantMessage)
- `onPartialAssistantMessage`: Handles partial assistant messages during streaming (receives Session and SDKPartialAssistantMessage)
- `onUserMessage`: Handles user messages (receives Session and SDKUserMessage)
- `onOtherMessage`: Handles other types of messages (receives Session and String message)
- `onControlResponse`: Handles control responses (receives Session and CLIControlResponse)
- `onControlRequest`: Handles control requests (receives Session and CLIControlRequest, returns CLIControlResponse)
- `onPermissionRequest`: Handles permission requests (receives Session and CLIControlRequest<CLIControlPermissionRequest>, returns Behavior)
#### AssistantContentConsumers Interface
The `AssistantContentConsumers` interface handles different types of content within assistant messages:
- `onText`: Handles text content (receives Session and TextAssistantContent)
- `onThinking`: Handles thinking content (receives Session and ThingkingAssistantContent)
- `onToolUse`: Handles tool use content (receives Session and ToolUseAssistantContent)
- `onToolResult`: Handles tool result content (receives Session and ToolResultAssistantContent)
- `onOtherContent`: Handles other content types (receives Session and AssistantContent)
- `onUsage`: Handles usage information (receives Session and AssistantUsage)
- `onPermissionRequest`: Handles permission requests (receives Session and CLIControlPermissionRequest, returns Behavior)
- `onOtherControlRequest`: Handles other control requests (receives Session and ControlRequestPayload, returns ControlResponsePayload)
#### Relationship Between the Interfaces
**Important Note on Event Hierarchy:**
- `SessionEventConsumers` is the **high-level** event processor that handles different message types (system, assistant, user, etc.)
- `AssistantContentConsumers` is the **low-level** content processor that handles different types of content within assistant messages (text, tools, thinking, etc.)
**Processor Relationship:**
- `SessionEventConsumers``AssistantContentConsumers` (SessionEventConsumers uses AssistantContentConsumers to process content within assistant messages)
**Event Derivation Relationships:**
- `onAssistantMessage``onText`, `onThinking`, `onToolUse`, `onToolResult`, `onOtherContent`, `onUsage`
- `onPartialAssistantMessage``onText`, `onThinking`, `onToolUse`, `onToolResult`, `onOtherContent`
- `onControlRequest``onPermissionRequest`, `onOtherControlRequest`
**Event Timeout Relationships:**
Each event handler method has a corresponding timeout method that allows customizing the timeout behavior for that specific event:
- `onSystemMessage``onSystemMessageTimeout`
- `onResultMessage``onResultMessageTimeout`
- `onAssistantMessage``onAssistantMessageTimeout`
- `onPartialAssistantMessage``onPartialAssistantMessageTimeout`
- `onUserMessage``onUserMessageTimeout`
- `onOtherMessage``onOtherMessageTimeout`
- `onControlResponse``onControlResponseTimeout`
- `onControlRequest``onControlRequestTimeout`
For AssistantContentConsumers timeout methods:
- `onText``onTextTimeout`
- `onThinking``onThinkingTimeout`
- `onToolUse``onToolUseTimeout`
- `onToolResult``onToolResultTimeout`
- `onOtherContent``onOtherContentTimeout`
- `onPermissionRequest``onPermissionRequestTimeout`
- `onOtherControlRequest``onOtherControlRequestTimeout`
**Default Timeout Values:**
- `SessionEventSimpleConsumers` default timeout: 180 seconds (Timeout.TIMEOUT_180_SECONDS)
- `AssistantContentSimpleConsumers` default timeout: 60 seconds (Timeout.TIMEOUT_60_SECONDS)
**Timeout Hierarchy Requirements:**
For proper operation, the following timeout relationships should be maintained:
- `onAssistantMessageTimeout` return value should be greater than `onTextTimeout`, `onThinkingTimeout`, `onToolUseTimeout`, `onToolResultTimeout`, and `onOtherContentTimeout` return values
- `onControlRequestTimeout` return value should be greater than `onPermissionRequestTimeout` and `onOtherControlRequestTimeout` return values
### Transport Options
The `TransportOptions` class allows configuration of how the SDK communicates with the Qwen Code CLI:
- `pathToQwenExecutable`: Path to the Qwen Code CLI executable
- `cwd`: Working directory for the CLI process
- `model`: AI model to use for the session
- `permissionMode`: Permission mode that controls tool execution
- `env`: Environment variables to pass to the CLI process
- `maxSessionTurns`: Limits the number of conversation turns in a session
- `coreTools`: List of core tools that should be available to the AI
- `excludeTools`: List of tools to exclude from being available to the AI
- `allowedTools`: List of tools that are pre-approved for use without additional confirmation
- `authType`: Authentication type to use for the session
- `includePartialMessages`: Enables receiving partial messages during streaming responses
- `skillsEnable`: Enables or disables skills functionality for the session
- `turnTimeout`: Timeout for a complete turn of conversation
- `messageTimeout`: Timeout for individual messages within a turn
- `resumeSessionId`: ID of a previous session to resume
- `otherOptions`: Additional command-line options to pass to the CLI
### Session Control Features
- **Session creation**: Use `QwenCodeCli.newSession()` to create a new session with custom options
- **Session management**: The `Session` class provides methods to send prompts, handle responses, and manage session state
- **Session cleanup**: Always close sessions using `session.close()` to properly terminate the CLI process
- **Session resumption**: Use `setResumeSessionId()` in `TransportOptions` to resume a previous session
- **Session interruption**: Use `session.interrupt()` to interrupt a currently running prompt
- **Dynamic model switching**: Use `session.setModel()` to change the model during a session
- **Dynamic permission mode switching**: Use `session.setPermissionMode()` to change the permission mode during a session
### Thread Pool Configuration
The SDK uses a thread pool for managing concurrent operations with the following default configuration:
- **Core Pool Size**: 30 threads
- **Maximum Pool Size**: 100 threads
- **Keep-Alive Time**: 60 seconds
- **Queue Capacity**: 300 tasks (using LinkedBlockingQueue)
- **Thread Naming**: "qwen_code_cli-pool-{number}"
- **Daemon Threads**: false
- **Rejected Execution Handler**: CallerRunsPolicy
## Error Handling
The SDK provides specific exception types for different error scenarios:
- `SessionControlException`: Thrown when there's an issue with session control (creation, initialization, etc.)
- `SessionSendPromptException`: Thrown when there's an issue sending a prompt or receiving a response
- `SessionClosedException`: Thrown when attempting to use a closed session
## FAQ / Troubleshooting
### Q: Do I need to install the Qwen CLI separately?
A: yes, requires Qwen CLI 0.5.5 or higher.
### Q: What Java versions are supported?
A: The SDK requires Java 1.8 or higher.
### Q: How do I handle long-running requests?
A: The SDK includes timeout utilities. You can configure timeouts using the `Timeout` class in `TransportOptions`.
### Q: Why are some tools not executing?
A: This is likely due to permission modes. Check your permission mode settings and consider using `allowedTools` to pre-approve certain tools.
### Q: How do I resume a previous session?
A: Use the `setResumeSessionId()` method in `TransportOptions` to resume a previous session.
### Q: Can I customize the environment for the CLI process?
A: Yes, use the `setEnv()` method in `TransportOptions` to pass environment variables to the CLI process.
## License
Apache-2.0 - see [LICENSE](./LICENSE) for details.

View File

@@ -136,6 +136,95 @@ Settings are organized into categories. All settings should be placed within the
- `"./custom-logs"` - Logs to `./custom-logs` relative to current directory
- `"/tmp/openai-logs"` - Logs to absolute path `/tmp/openai-logs`
#### modelProviders
Use `modelProviders` to declare curated model lists per auth type that the `/model` picker can switch between. Keys must be valid auth types (`openai`, `anthropic`, `gemini`, `vertex-ai`, etc.). Each entry requires an `id` and **must include `envKey`**, with optional `name`, `description`, `baseUrl`, and `generationConfig`. Credentials are never persisted in settings; the runtime reads them from `process.env[envKey]`. Qwen OAuth models remain hard-coded and cannot be overridden.
##### Example
```json
{
"modelProviders": {
"openai": [
{
"id": "gpt-4o",
"name": "GPT-4o",
"envKey": "OPENAI_API_KEY",
"baseUrl": "https://api.openai.com/v1",
"generationConfig": {
"timeout": 60000,
"maxRetries": 3,
"samplingParams": { "temperature": 0.2 }
}
}
],
"anthropic": [
{
"id": "claude-3-5-sonnet",
"envKey": "ANTHROPIC_API_KEY",
"baseUrl": "https://api.anthropic.com/v1"
}
],
"gemini": [
{
"id": "gemini-2.0-flash",
"name": "Gemini 2.0 Flash",
"envKey": "GEMINI_API_KEY",
"baseUrl": "https://generativelanguage.googleapis.com"
}
],
"vertex-ai": [
{
"id": "gemini-1.5-pro-vertex",
"envKey": "GOOGLE_API_KEY",
"baseUrl": "https://generativelanguage.googleapis.com"
}
]
}
}
```
> [!note]
> Only the `/model` command exposes non-default auth types. Anthropic, Gemini, Vertex AI, etc., must be defined via `modelProviders`. The `/auth` command intentionally lists only the built-in Qwen OAuth and OpenAI flows.
##### Resolution layers and atomicity
The effective auth/model/credential values are chosen per field using the following precedence (first present wins). You can combine `--auth-type` with `--model` to point directly at a provider entry; these CLI flags run before other layers.
| Layer (highest → lowest) | authType | model | apiKey | baseUrl | apiKeyEnvKey | proxy |
| -------------------------- | ----------------------------------- | ----------------------------------------------- | --------------------------------------------------- | ---------------------------------------------------- | ---------------------- | --------------------------------- |
| Programmatic overrides | `/auth ` | `/auth` input | `/auth` input | `/auth` input | — | — |
| Model provider selection | — | `modelProvider.id` | `env[modelProvider.envKey]` | `modelProvider.baseUrl` | `modelProvider.envKey` | — |
| CLI arguments | `--auth-type` | `--model` | `--openaiApiKey` (or provider-specific equivalents) | `--openaiBaseUrl` (or provider-specific equivalents) | — | — |
| Environment variables | — | Provider-specific mapping (e.g. `OPENAI_MODEL`) | Provider-specific mapping (e.g. `OPENAI_API_KEY`) | Provider-specific mapping (e.g. `OPENAI_BASE_URL`) | — | — |
| Settings (`settings.json`) | `security.auth.selectedType` | `model.name` | `security.auth.apiKey` | `security.auth.baseUrl` | — | — |
| Default / computed | Falls back to `AuthType.QWEN_OAUTH` | Built-in default (OpenAI ⇒ `qwen3-coder-plus`) | — | — | — | `Config.getProxy()` if configured |
\*When present, CLI auth flags override settings. Otherwise, `security.auth.selectedType` or the implicit default determine the auth type. Qwen OAuth and OpenAI are the only auth types surfaced without extra configuration.
Model-provider sourced values are applied atomically: once a provider model is active, every field it defines is protected from lower layers until you manually clear credentials via `/auth`. The final `generationConfig` is the projection across all layers—lower layers only fill gaps left by higher ones, and the provider layer remains impenetrable.
The merge strategy for `modelProviders` is REPLACE: the entire `modelProviders` from project settings will override the corresponding section in user settings, rather than merging the two.
##### Generation config layering
Per-field precedence for `generationConfig`:
1. Programmatic overrides (e.g. runtime `/model`, `/auth` changes)
2. `modelProviders[authType][].generationConfig`
3. `settings.model.generationConfig`
4. Content-generator defaults (`getDefaultGenerationConfig` for OpenAI, `getParameterValue` for Gemini, etc.)
`samplingParams` is treated atomically; provider values replace the entire object. Defaults from the content generator apply last so each provider retains its tuned baseline.
##### Selection persistence and recommendations
> [!important]
> Define `modelProviders` in the user-scope `~/.qwen/settings.json` whenever possible and avoid persisting credential overrides in any scope. Keeping the provider catalog in user settings prevents merge/override conflicts between project and user scopes and ensures `/auth` and `/model` updates always write back to a consistent scope.
- `/model` and `/auth` persist `model.name` (where applicable) and `security.auth.selectedType` to the closest writable scope that already defines `modelProviders`; otherwise they fall back to the user scope. This keeps workspace/user files in sync with the active provider catalog.
- Without `modelProviders`, the resolver mixes CLI/env/settings layers, which is fine for single-provider setups but cumbersome when frequently switching. Define provider catalogs whenever multi-model workflows are common so that switches stay atomic, source-attributed, and debuggable.
#### context
| Setting | Type | Description | Default |
@@ -381,7 +470,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). | | |
| `--experimental-acp` | | Enables ACP mode (Agent Control Protocol). Useful for IDE/editor integrations like [Zed](../integration-zed). | | Experimental. |
| `--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. | | |

View File

@@ -32,7 +32,7 @@
"Qwen Code": {
"type": "custom",
"command": "qwen",
"args": ["--experimental-acp"],
"args": ["--acp"],
"env": {}
}
```

View File

@@ -1,5 +1,6 @@
# Qwen Code overview
[![@qwen-code/qwen-code downloads](https://img.shields.io/npm/dw/@qwen-code/qwen-code.svg)](https://npm-compare.com/@qwen-code/qwen-code)
[![@qwen-code/qwen-code downloads](https://img.shields.io/npm/dw/@qwen-code/qwen-code.svg)](https://npm-compare.com/@qwen-code/qwen-code)
[![@qwen-code/qwen-code version](https://img.shields.io/npm/v/@qwen-code/qwen-code.svg)](https://www.npmjs.com/package/@qwen-code/qwen-code)
> Learn about Qwen Code, Qwen's agentic coding tool that lives in your terminal and helps you turn ideas into code faster than ever before.

View File

@@ -80,10 +80,11 @@ type PermissionHandler = (
/**
* Sets up an ACP test environment with all necessary utilities.
* @param useNewFlag - If true, uses --acp; if false, uses --experimental-acp (for backward compatibility testing)
*/
function setupAcpTest(
rig: TestRig,
options?: { permissionHandler?: PermissionHandler },
options?: { permissionHandler?: PermissionHandler; useNewFlag?: boolean },
) {
const pending = new Map<number, PendingRequest>();
let nextRequestId = 1;
@@ -95,9 +96,13 @@ function setupAcpTest(
const permissionHandler =
options?.permissionHandler ?? (() => ({ optionId: 'proceed_once' }));
// Use --acp by default, but allow testing with --experimental-acp for backward compatibility
const acpFlag =
options?.useNewFlag !== false ? '--acp' : '--experimental-acp';
const agent = spawn(
'node',
[rig.bundlePath, '--experimental-acp', '--no-chat-recording'],
[rig.bundlePath, acpFlag, '--no-chat-recording'],
{
cwd: rig.testDir!,
stdio: ['pipe', 'pipe', 'pipe'],
@@ -621,3 +626,99 @@ function setupAcpTest(
}
});
});
(IS_SANDBOX ? describe.skip : describe)(
'acp flag backward compatibility',
() => {
it('should work with deprecated --experimental-acp flag and show warning', async () => {
const rig = new TestRig();
rig.setup('acp backward compatibility');
const { sendRequest, cleanup, stderr } = setupAcpTest(rig, {
useNewFlag: false,
});
try {
const initResult = await sendRequest('initialize', {
protocolVersion: 1,
clientCapabilities: {
fs: { readTextFile: true, writeTextFile: true },
},
});
expect(initResult).toBeDefined();
// Verify deprecation warning is shown
const stderrOutput = stderr.join('');
expect(stderrOutput).toContain('--experimental-acp is deprecated');
expect(stderrOutput).toContain('Please use --acp instead');
await sendRequest('authenticate', { methodId: 'openai' });
const newSession = (await sendRequest('session/new', {
cwd: rig.testDir!,
mcpServers: [],
})) as { sessionId: string };
expect(newSession.sessionId).toBeTruthy();
// Verify functionality still works
const promptResult = await sendRequest('session/prompt', {
sessionId: newSession.sessionId,
prompt: [{ type: 'text', text: 'Say hello.' }],
});
expect(promptResult).toBeDefined();
} catch (e) {
if (stderr.length) {
console.error('Agent stderr:', stderr.join(''));
}
throw e;
} finally {
await cleanup();
}
});
it('should work with new --acp flag without warnings', async () => {
const rig = new TestRig();
rig.setup('acp new flag');
const { sendRequest, cleanup, stderr } = setupAcpTest(rig, {
useNewFlag: true,
});
try {
const initResult = await sendRequest('initialize', {
protocolVersion: 1,
clientCapabilities: {
fs: { readTextFile: true, writeTextFile: true },
},
});
expect(initResult).toBeDefined();
// Verify no deprecation warning is shown
const stderrOutput = stderr.join('');
expect(stderrOutput).not.toContain('--experimental-acp is deprecated');
await sendRequest('authenticate', { methodId: 'openai' });
const newSession = (await sendRequest('session/new', {
cwd: rig.testDir!,
mcpServers: [],
})) as { sessionId: string };
expect(newSession.sessionId).toBeTruthy();
// Verify functionality works
const promptResult = await sendRequest('session/prompt', {
sessionId: newSession.sessionId,
prompt: [{ type: 'text', text: 'Say hello.' }],
});
expect(promptResult).toBeDefined();
} catch (e) {
if (stderr.length) {
console.error('Agent stderr:', stderr.join(''));
}
throw e;
} finally {
await cleanup();
}
});
},
);

12
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.6.0",
"version": "0.6.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
"version": "0.6.0",
"version": "0.6.1",
"workspaces": [
"packages/*"
],
@@ -17316,7 +17316,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
"version": "0.6.0",
"version": "0.6.1",
"dependencies": {
"@google/genai": "1.30.0",
"@iarna/toml": "^2.2.5",
@@ -17953,7 +17953,7 @@
},
"packages/core": {
"name": "@qwen-code/qwen-code-core",
"version": "0.6.0",
"version": "0.6.1",
"hasInstallScript": true,
"dependencies": {
"@anthropic-ai/sdk": "^0.36.1",
@@ -21413,7 +21413,7 @@
},
"packages/test-utils": {
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.6.0",
"version": "0.6.1",
"dev": true,
"license": "Apache-2.0",
"devDependencies": {
@@ -21425,7 +21425,7 @@
},
"packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion",
"version": "0.6.0",
"version": "0.6.1",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.6.0",
"version": "0.6.1",
"engines": {
"node": ">=20.0.0"
},
@@ -13,7 +13,7 @@
"url": "git+https://github.com/QwenLM/qwen-code.git"
},
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.0"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.1"
},
"scripts": {
"start": "cross-env node scripts/start.js",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.6.0",
"version": "0.6.1",
"description": "Qwen Code",
"repository": {
"type": "git",
@@ -33,7 +33,7 @@
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.0"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.1"
},
"dependencies": {
"@google/genai": "1.30.0",

View File

@@ -311,7 +311,7 @@ class GeminiAgent {
}
private async ensureAuthenticated(config: Config): Promise<void> {
const selectedType = this.settings.merged.security?.auth?.selectedType;
const selectedType = config.getAuthType();
if (!selectedType) {
throw acp.RequestError.authRequired('No Selected Type');
}

View File

@@ -1,41 +1,112 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { AuthType } from '@qwen-code/qwen-code-core';
import { vi } from 'vitest';
import { validateAuthMethod } from './auth.js';
import * as settings from './settings.js';
vi.mock('./settings.js', () => ({
loadEnvironment: vi.fn(),
loadSettings: vi.fn().mockReturnValue({
merged: vi.fn().mockReturnValue({}),
merged: {},
}),
}));
describe('validateAuthMethod', () => {
beforeEach(() => {
vi.resetModules();
// Reset mock to default
vi.mocked(settings.loadSettings).mockReturnValue({
merged: {},
} as ReturnType<typeof settings.loadSettings>);
});
afterEach(() => {
vi.unstubAllEnvs();
delete process.env['OPENAI_API_KEY'];
delete process.env['CUSTOM_API_KEY'];
delete process.env['GEMINI_API_KEY'];
delete process.env['GEMINI_API_KEY_ALTERED'];
delete process.env['ANTHROPIC_API_KEY'];
delete process.env['ANTHROPIC_BASE_URL'];
delete process.env['GOOGLE_API_KEY'];
});
it('should return null for USE_OPENAI', () => {
it('should return null for USE_OPENAI with default env key', () => {
process.env['OPENAI_API_KEY'] = 'fake-key';
expect(validateAuthMethod(AuthType.USE_OPENAI)).toBeNull();
});
it('should return an error message for USE_OPENAI if OPENAI_API_KEY is not set', () => {
delete process.env['OPENAI_API_KEY'];
it('should return an error message for USE_OPENAI if no API key is available', () => {
expect(validateAuthMethod(AuthType.USE_OPENAI)).toBe(
'OPENAI_API_KEY environment variable not found. You can enter it interactively or add it to your .env file.',
"Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the 'OPENAI_API_KEY' environment variable.",
);
});
it('should return null for USE_OPENAI with custom envKey from modelProviders', () => {
vi.mocked(settings.loadSettings).mockReturnValue({
merged: {
model: { name: 'custom-model' },
modelProviders: {
openai: [{ id: 'custom-model', envKey: 'CUSTOM_API_KEY' }],
},
},
} as unknown as ReturnType<typeof settings.loadSettings>);
process.env['CUSTOM_API_KEY'] = 'custom-key';
expect(validateAuthMethod(AuthType.USE_OPENAI)).toBeNull();
});
it('should return error with custom envKey hint when modelProviders envKey is set but env var is missing', () => {
vi.mocked(settings.loadSettings).mockReturnValue({
merged: {
model: { name: 'custom-model' },
modelProviders: {
openai: [{ id: 'custom-model', envKey: 'CUSTOM_API_KEY' }],
},
},
} as unknown as ReturnType<typeof settings.loadSettings>);
const result = validateAuthMethod(AuthType.USE_OPENAI);
expect(result).toContain('CUSTOM_API_KEY');
});
it('should return null for USE_GEMINI with custom envKey', () => {
vi.mocked(settings.loadSettings).mockReturnValue({
merged: {
model: { name: 'gemini-1.5-flash' },
modelProviders: {
gemini: [
{ id: 'gemini-1.5-flash', envKey: 'GEMINI_API_KEY_ALTERED' },
],
},
},
} as unknown as ReturnType<typeof settings.loadSettings>);
process.env['GEMINI_API_KEY_ALTERED'] = 'altered-key';
expect(validateAuthMethod(AuthType.USE_GEMINI)).toBeNull();
});
it('should return error with custom envKey for USE_GEMINI when env var is missing', () => {
vi.mocked(settings.loadSettings).mockReturnValue({
merged: {
model: { name: 'gemini-1.5-flash' },
modelProviders: {
gemini: [
{ id: 'gemini-1.5-flash', envKey: 'GEMINI_API_KEY_ALTERED' },
],
},
},
} as unknown as ReturnType<typeof settings.loadSettings>);
const result = validateAuthMethod(AuthType.USE_GEMINI);
expect(result).toContain('GEMINI_API_KEY_ALTERED');
});
it('should return null for QWEN_OAUTH', () => {
expect(validateAuthMethod(AuthType.QWEN_OAUTH)).toBeNull();
});
@@ -45,4 +116,115 @@ describe('validateAuthMethod', () => {
'Invalid auth method selected.',
);
});
it('should return null for USE_ANTHROPIC with custom envKey and baseUrl', () => {
vi.mocked(settings.loadSettings).mockReturnValue({
merged: {
model: { name: 'claude-3' },
modelProviders: {
anthropic: [
{
id: 'claude-3',
envKey: 'CUSTOM_ANTHROPIC_KEY',
baseUrl: 'https://api.anthropic.com',
},
],
},
},
} as unknown as ReturnType<typeof settings.loadSettings>);
process.env['CUSTOM_ANTHROPIC_KEY'] = 'custom-anthropic-key';
expect(validateAuthMethod(AuthType.USE_ANTHROPIC)).toBeNull();
});
it('should return error for USE_ANTHROPIC when baseUrl is missing', () => {
vi.mocked(settings.loadSettings).mockReturnValue({
merged: {
model: { name: 'claude-3' },
modelProviders: {
anthropic: [{ id: 'claude-3', envKey: 'CUSTOM_ANTHROPIC_KEY' }],
},
},
} as unknown as ReturnType<typeof settings.loadSettings>);
process.env['CUSTOM_ANTHROPIC_KEY'] = 'custom-key';
const result = validateAuthMethod(AuthType.USE_ANTHROPIC);
expect(result).toContain('modelProviders[].baseUrl');
});
it('should return null for USE_VERTEX_AI with custom envKey', () => {
vi.mocked(settings.loadSettings).mockReturnValue({
merged: {
model: { name: 'vertex-model' },
modelProviders: {
'vertex-ai': [
{ id: 'vertex-model', envKey: 'GOOGLE_API_KEY_VERTEX' },
],
},
},
} as unknown as ReturnType<typeof settings.loadSettings>);
process.env['GOOGLE_API_KEY_VERTEX'] = 'vertex-key';
expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBeNull();
});
it('should use config.modelsConfig.getModel() when Config is provided', () => {
// Settings has a different model
vi.mocked(settings.loadSettings).mockReturnValue({
merged: {
model: { name: 'settings-model' },
modelProviders: {
openai: [
{ id: 'settings-model', envKey: 'SETTINGS_API_KEY' },
{ id: 'cli-model', envKey: 'CLI_API_KEY' },
],
},
},
} as unknown as ReturnType<typeof settings.loadSettings>);
// Mock Config object that returns a different model (e.g., from CLI args)
const mockConfig = {
modelsConfig: {
getModel: vi.fn().mockReturnValue('cli-model'),
},
} as unknown as import('@qwen-code/qwen-code-core').Config;
// Set the env key for the CLI model, not the settings model
process.env['CLI_API_KEY'] = 'cli-key';
// Should use 'cli-model' from config.modelsConfig.getModel(), not 'settings-model'
const result = validateAuthMethod(AuthType.USE_OPENAI, mockConfig);
expect(result).toBeNull();
expect(mockConfig.modelsConfig.getModel).toHaveBeenCalled();
});
it('should fail validation when Config provides different model without matching env key', () => {
// Clean up any existing env keys first
delete process.env['CLI_API_KEY'];
delete process.env['SETTINGS_API_KEY'];
delete process.env['OPENAI_API_KEY'];
vi.mocked(settings.loadSettings).mockReturnValue({
merged: {
model: { name: 'settings-model' },
modelProviders: {
openai: [
{ id: 'settings-model', envKey: 'SETTINGS_API_KEY' },
{ id: 'cli-model', envKey: 'CLI_API_KEY' },
],
},
},
} as unknown as ReturnType<typeof settings.loadSettings>);
const mockConfig = {
modelsConfig: {
getModel: vi.fn().mockReturnValue('cli-model'),
},
} as unknown as import('@qwen-code/qwen-code-core').Config;
// Don't set CLI_API_KEY - validation should fail
const result = validateAuthMethod(AuthType.USE_OPENAI, mockConfig);
expect(result).not.toBeNull();
expect(result).toContain('CLI_API_KEY');
});
});

View File

@@ -1,21 +1,169 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { AuthType } from '@qwen-code/qwen-code-core';
import { loadEnvironment, loadSettings } from './settings.js';
import {
AuthType,
type Config,
type ModelProvidersConfig,
type ProviderModelConfig,
} from '@qwen-code/qwen-code-core';
import { loadEnvironment, loadSettings, type Settings } from './settings.js';
import { t } from '../i18n/index.js';
export function validateAuthMethod(authMethod: string): string | null {
/**
* Default environment variable names for each auth type
*/
const DEFAULT_ENV_KEYS: Record<string, string> = {
[AuthType.USE_OPENAI]: 'OPENAI_API_KEY',
[AuthType.USE_ANTHROPIC]: 'ANTHROPIC_API_KEY',
[AuthType.USE_GEMINI]: 'GEMINI_API_KEY',
[AuthType.USE_VERTEX_AI]: 'GOOGLE_API_KEY',
};
/**
* Find model configuration from modelProviders by authType and modelId
*/
function findModelConfig(
modelProviders: ModelProvidersConfig | undefined,
authType: string,
modelId: string | undefined,
): ProviderModelConfig | undefined {
if (!modelProviders || !modelId) {
return undefined;
}
const models = modelProviders[authType];
if (!Array.isArray(models)) {
return undefined;
}
return models.find((m) => m.id === modelId);
}
/**
* Check if API key is available for the given auth type and model configuration.
* Prioritizes custom envKey from modelProviders over default environment variables.
*/
function hasApiKeyForAuth(
authType: string,
settings: Settings,
config?: Config,
): {
hasKey: boolean;
checkedEnvKey: string | undefined;
isExplicitEnvKey: boolean;
} {
const modelProviders = settings.modelProviders as
| ModelProvidersConfig
| undefined;
// Use config.modelsConfig.getModel() if available for accurate model ID resolution
// that accounts for CLI args, env vars, and settings. Fall back to settings.model.name.
const modelId = config?.modelsConfig.getModel() ?? settings.model?.name;
// Try to find model-specific envKey from modelProviders
const modelConfig = findModelConfig(modelProviders, authType, modelId);
if (modelConfig?.envKey) {
// Explicit envKey configured - only check this env var, no apiKey fallback
const hasKey = !!process.env[modelConfig.envKey];
return {
hasKey,
checkedEnvKey: modelConfig.envKey,
isExplicitEnvKey: true,
};
}
// Using default environment variable - apiKey fallback is allowed
const defaultEnvKey = DEFAULT_ENV_KEYS[authType];
if (defaultEnvKey) {
const hasKey = !!process.env[defaultEnvKey];
if (hasKey) {
return { hasKey, checkedEnvKey: defaultEnvKey, isExplicitEnvKey: false };
}
}
// Also check settings.security.auth.apiKey as fallback (only for default env key)
if (settings.security?.auth?.apiKey) {
return {
hasKey: true,
checkedEnvKey: defaultEnvKey || undefined,
isExplicitEnvKey: false,
};
}
return {
hasKey: false,
checkedEnvKey: defaultEnvKey,
isExplicitEnvKey: false,
};
}
/**
* Generate API key error message based on auth check result.
* Returns null if API key is present, otherwise returns the appropriate error message.
*/
function getApiKeyError(
authMethod: string,
settings: Settings,
config?: Config,
): string | null {
const { hasKey, checkedEnvKey, isExplicitEnvKey } = hasApiKeyForAuth(
authMethod,
settings,
config,
);
if (hasKey) {
return null;
}
const envKeyHint = checkedEnvKey || DEFAULT_ENV_KEYS[authMethod];
if (isExplicitEnvKey) {
return t(
'{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.',
{ envKeyHint },
);
}
return t(
'{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.',
{ envKeyHint },
);
}
/**
* Validate that the required credentials and configuration exist for the given auth method.
*/
export function validateAuthMethod(
authMethod: string,
config?: Config,
): string | null {
const settings = loadSettings();
loadEnvironment(settings.merged);
if (authMethod === AuthType.USE_OPENAI) {
const hasApiKey =
process.env['OPENAI_API_KEY'] || settings.merged.security?.auth?.apiKey;
if (!hasApiKey) {
return 'OPENAI_API_KEY environment variable not found. You can enter it interactively or add it to your .env file.';
const { hasKey, checkedEnvKey, isExplicitEnvKey } = hasApiKeyForAuth(
authMethod,
settings.merged,
config,
);
if (!hasKey) {
const envKeyHint = checkedEnvKey
? `'${checkedEnvKey}'`
: "'OPENAI_API_KEY'";
if (isExplicitEnvKey) {
// Explicit envKey configured - only suggest setting the env var
return t(
'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.',
{ envKeyHint },
);
}
// Default env key - can use either apiKey or env var
return t(
'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.',
{ envKeyHint },
);
}
return null;
}
@@ -27,36 +175,49 @@ export function validateAuthMethod(authMethod: string): string | null {
}
if (authMethod === AuthType.USE_ANTHROPIC) {
const hasApiKey = process.env['ANTHROPIC_API_KEY'];
if (!hasApiKey) {
return 'ANTHROPIC_API_KEY environment variable not found.';
const apiKeyError = getApiKeyError(authMethod, settings.merged, config);
if (apiKeyError) {
return apiKeyError;
}
const hasBaseUrl = process.env['ANTHROPIC_BASE_URL'];
if (!hasBaseUrl) {
return 'ANTHROPIC_BASE_URL environment variable not found.';
// Check baseUrl - can come from modelProviders or environment
const modelProviders = settings.merged.modelProviders as
| ModelProvidersConfig
| undefined;
// Use config.modelsConfig.getModel() if available for accurate model ID
const modelId =
config?.modelsConfig.getModel() ?? settings.merged.model?.name;
const modelConfig = findModelConfig(modelProviders, authMethod, modelId);
if (modelConfig && !modelConfig.baseUrl) {
return t(
'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.',
);
}
if (!modelConfig && !process.env['ANTHROPIC_BASE_URL']) {
return t('ANTHROPIC_BASE_URL environment variable not found.');
}
return null;
}
if (authMethod === AuthType.USE_GEMINI) {
const hasApiKey = process.env['GEMINI_API_KEY'];
if (!hasApiKey) {
return 'GEMINI_API_KEY environment variable not found. Please set it in your .env file or environment variables.';
const apiKeyError = getApiKeyError(authMethod, settings.merged, config);
if (apiKeyError) {
return apiKeyError;
}
return null;
}
if (authMethod === AuthType.USE_VERTEX_AI) {
const hasApiKey = process.env['GOOGLE_API_KEY'];
if (!hasApiKey) {
return 'GOOGLE_API_KEY environment variable not found. Please set it in your .env file or environment variables.';
const apiKeyError = getApiKeyError(authMethod, settings.merged, config);
if (apiKeyError) {
return apiKeyError;
}
process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true';
return null;
}
return 'Invalid auth method selected.';
return t('Invalid auth method selected.');
}

View File

@@ -77,10 +77,8 @@ vi.mock('read-package-up', () => ({
),
}));
vi.mock('@qwen-code/qwen-code-core', async () => {
const actualServer = await vi.importActual<typeof ServerConfig>(
'@qwen-code/qwen-code-core',
);
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actualServer = await importOriginal<typeof ServerConfig>();
return {
...actualServer,
IdeClient: {

View File

@@ -31,6 +31,10 @@ import {
} from '@qwen-code/qwen-code-core';
import { extensionsCommand } from '../commands/extensions.js';
import type { Settings } from './settings.js';
import {
resolveCliGenerationConfig,
getAuthTypeFromEnv,
} from '../utils/modelConfigUtils.js';
import yargs, { type Argv } from 'yargs';
import { hideBin } from 'yargs/helpers';
import * as fs from 'node:fs';
@@ -113,6 +117,7 @@ export interface CliArgs {
telemetryOutfile: string | undefined;
allowedMcpServerNames: string[] | undefined;
allowedTools: string[] | undefined;
acp: boolean | undefined;
experimentalAcp: boolean | undefined;
experimentalSkills: boolean | undefined;
extensions: string[] | undefined;
@@ -306,10 +311,16 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
description: 'Enables checkpointing of file edits',
default: false,
})
.option('experimental-acp', {
.option('acp', {
type: 'boolean',
description: 'Starts the agent in ACP mode',
})
.option('experimental-acp', {
type: 'boolean',
description:
'Starts the agent in ACP mode (deprecated, use --acp instead)',
hidden: true,
})
.option('experimental-skills', {
type: 'boolean',
description: 'Enable experimental Skills feature',
@@ -591,8 +602,19 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
// The import format is now only controlled by settings.memoryImportFormat
// We no longer accept it as a CLI argument
// Apply ACP fallback: if experimental-acp is present but no explicit --channel, treat as ACP
if (result['experimentalAcp'] && !result['channel']) {
// Handle deprecated --experimental-acp flag
if (result['experimentalAcp']) {
console.warn(
'\x1b[33m⚠ Warning: --experimental-acp is deprecated and will be removed in a future release. Please use --acp instead.\x1b[0m',
);
// Map experimental-acp to acp if acp is not explicitly set
if (!result['acp']) {
(result as Record<string, unknown>)['acp'] = true;
}
}
// Apply ACP fallback: if acp or experimental-acp is present but no explicit --channel, treat as ACP
if ((result['acp'] || result['experimentalAcp']) && !result['channel']) {
(result as Record<string, unknown>)['channel'] = 'ACP';
}
@@ -906,28 +928,25 @@ export async function loadCliConfig(
const selectedAuthType =
(argv.authType as AuthType | undefined) ||
settings.security?.auth?.selectedType;
settings.security?.auth?.selectedType ||
/* getAuthTypeFromEnv means no authType was explicitly provided, we infer the authType from env vars */
getAuthTypeFromEnv();
const apiKey =
(selectedAuthType === AuthType.USE_OPENAI
? argv.openaiApiKey ||
process.env['OPENAI_API_KEY'] ||
settings.security?.auth?.apiKey
: '') || '';
const baseUrl =
(selectedAuthType === AuthType.USE_OPENAI
? argv.openaiBaseUrl ||
process.env['OPENAI_BASE_URL'] ||
settings.security?.auth?.baseUrl
: '') || '';
const resolvedModel =
argv.model ||
(selectedAuthType === AuthType.USE_OPENAI
? process.env['OPENAI_MODEL'] ||
process.env['QWEN_MODEL'] ||
settings.model?.name
: '') ||
'';
// Unified resolution of generation config with source attribution
const resolvedCliConfig = resolveCliGenerationConfig({
argv: {
model: argv.model,
openaiApiKey: argv.openaiApiKey,
openaiBaseUrl: argv.openaiBaseUrl,
openaiLogging: argv.openaiLogging,
openaiLoggingDir: argv.openaiLoggingDir,
},
settings,
selectedAuthType,
env: process.env as Record<string, string | undefined>,
});
const { model: resolvedModel } = resolvedCliConfig;
const sandboxConfig = await loadSandboxConfig(settings, argv);
const screenReader =
@@ -961,6 +980,8 @@ export async function loadCliConfig(
}
}
const modelProvidersConfig = settings.modelProviders;
return new Config({
sessionId,
sessionData,
@@ -1008,7 +1029,7 @@ export async function loadCliConfig(
sessionTokenLimit: settings.model?.sessionTokenLimit ?? -1,
maxSessionTurns:
argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1,
experimentalZedIntegration: argv.experimentalAcp || false,
experimentalZedIntegration: argv.acp || argv.experimentalAcp || false,
experimentalSkills: argv.experimentalSkills || false,
listExtensions: argv.listExtensions || false,
extensions: allExtensions,
@@ -1018,24 +1039,11 @@ export async function loadCliConfig(
inputFormat,
outputFormat,
includePartialMessages,
generationConfig: {
...(settings.model?.generationConfig || {}),
model: resolvedModel,
apiKey,
baseUrl,
enableOpenAILogging:
(typeof argv.openaiLogging === 'undefined'
? settings.model?.enableOpenAILogging
: argv.openaiLogging) ?? false,
openAILoggingDir:
argv.openaiLoggingDir || settings.model?.openAILoggingDir,
},
modelProvidersConfig,
generationConfigSources: resolvedCliConfig.sources,
generationConfig: resolvedCliConfig.generationConfig,
cliVersion: await getCliVersion(),
webSearch: buildWebSearchConfig(
argv,
settings,
settings.security?.auth?.selectedType,
),
webSearch: buildWebSearchConfig(argv, settings, selectedAuthType),
summarizeToolOutput: settings.model?.summarizeToolOutput,
ideMode,
chatCompression: settings.model?.chatCompression,

View File

@@ -0,0 +1,87 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { SettingScope } from './settings.js';
import { getPersistScopeForModelSelection } from './modelProvidersScope.js';
function makeSettings({
isTrusted,
userModelProviders,
workspaceModelProviders,
}: {
isTrusted: boolean;
userModelProviders?: unknown;
workspaceModelProviders?: unknown;
}) {
const userSettings: Record<string, unknown> = {};
const workspaceSettings: Record<string, unknown> = {};
// When undefined, treat as "not present in this scope" (the key is omitted),
// matching how LoadedSettings is shaped when a settings file doesn't define it.
if (userModelProviders !== undefined) {
userSettings['modelProviders'] = userModelProviders;
}
if (workspaceModelProviders !== undefined) {
workspaceSettings['modelProviders'] = workspaceModelProviders;
}
return {
isTrusted,
user: { settings: userSettings },
workspace: { settings: workspaceSettings },
} as unknown as import('./settings.js').LoadedSettings;
}
describe('getPersistScopeForModelSelection', () => {
it('prefers workspace when trusted and workspace defines modelProviders', () => {
const settings = makeSettings({
isTrusted: true,
workspaceModelProviders: {},
userModelProviders: { anything: true },
});
expect(getPersistScopeForModelSelection(settings)).toBe(
SettingScope.Workspace,
);
});
it('falls back to user when workspace does not define modelProviders', () => {
const settings = makeSettings({
isTrusted: true,
workspaceModelProviders: undefined,
userModelProviders: {},
});
expect(getPersistScopeForModelSelection(settings)).toBe(SettingScope.User);
});
it('ignores workspace modelProviders when workspace is untrusted', () => {
const settings = makeSettings({
isTrusted: false,
workspaceModelProviders: {},
userModelProviders: undefined,
});
expect(getPersistScopeForModelSelection(settings)).toBe(SettingScope.User);
});
it('falls back to legacy trust heuristic when neither scope defines modelProviders', () => {
const trusted = makeSettings({
isTrusted: true,
userModelProviders: undefined,
workspaceModelProviders: undefined,
});
expect(getPersistScopeForModelSelection(trusted)).toBe(SettingScope.User);
const untrusted = makeSettings({
isTrusted: false,
userModelProviders: undefined,
workspaceModelProviders: undefined,
});
expect(getPersistScopeForModelSelection(untrusted)).toBe(SettingScope.User);
});
});

View File

@@ -0,0 +1,48 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { SettingScope, type LoadedSettings } from './settings.js';
function hasOwnModelProviders(settingsObj: unknown): boolean {
if (!settingsObj || typeof settingsObj !== 'object') {
return false;
}
const obj = settingsObj as Record<string, unknown>;
// Treat an explicitly configured empty object (modelProviders: {}) as "owned"
// by this scope, which is important when mergeStrategy is REPLACE.
return Object.prototype.hasOwnProperty.call(obj, 'modelProviders');
}
/**
* Returns which writable scope (Workspace/User) owns the effective modelProviders
* configuration.
*
* Note: Workspace scope is only considered when the workspace is trusted.
*/
export function getModelProvidersOwnerScope(
settings: LoadedSettings,
): SettingScope | undefined {
if (settings.isTrusted && hasOwnModelProviders(settings.workspace.settings)) {
return SettingScope.Workspace;
}
if (hasOwnModelProviders(settings.user.settings)) {
return SettingScope.User;
}
return undefined;
}
/**
* Choose the settings scope to persist a model selection.
* Prefer persisting back to the scope that contains the effective modelProviders
* config, otherwise fall back to the legacy trust-based heuristic.
*/
export function getPersistScopeForModelSelection(
settings: LoadedSettings,
): SettingScope {
return getModelProvidersOwnerScope(settings) ?? SettingScope.User;
}

View File

@@ -10,6 +10,7 @@ import type {
TelemetrySettings,
AuthType,
ChatCompressionSettings,
ModelProvidersConfig,
} from '@qwen-code/qwen-code-core';
import {
ApprovalMode,
@@ -102,6 +103,19 @@ const SETTINGS_SCHEMA = {
mergeStrategy: MergeStrategy.SHALLOW_MERGE,
},
// Model providers configuration grouped by authType
modelProviders: {
type: 'object',
label: 'Model Providers',
category: 'Model',
requiresRestart: false,
default: {} as ModelProvidersConfig,
description:
'Model providers configuration grouped by authType. Each authType contains an array of model configurations.',
showInDialog: false,
mergeStrategy: MergeStrategy.REPLACE,
},
general: {
type: 'object',
label: 'General',
@@ -202,6 +216,7 @@ const SETTINGS_SCHEMA = {
{ value: 'en', label: 'English' },
{ value: 'zh', label: '中文 (Chinese)' },
{ value: 'ru', label: 'Русский (Russian)' },
{ value: 'de', label: 'Deutsch (German)' },
],
},
terminalBell: {

View File

@@ -45,7 +45,9 @@ export async function initializeApp(
// Auto-detect and set LLM output language on first use
initializeLlmOutputLanguage();
const authType = settings.merged.security?.auth?.selectedType;
// Use authType from modelsConfig which respects CLI --auth-type argument
// over settings.security.auth.selectedType
const authType = config.modelsConfig.getCurrentAuthType();
const authError = await performInitialAuth(config, authType);
// Fallback to user select when initial authentication fails
@@ -59,7 +61,7 @@ export async function initializeApp(
const themeError = validateTheme(settings);
const shouldOpenAuthDialog =
settings.merged.security?.auth?.selectedType === undefined || !!authError;
!config.modelsConfig.wasAuthTypeExplicitlyProvided() || !!authError;
if (config.getIdeMode()) {
const ideClient = await IdeClient.getInstance();

View File

@@ -87,6 +87,15 @@ vi.mock('./config/sandboxConfig.js', () => ({
loadSandboxConfig: vi.fn(),
}));
vi.mock('./core/initializer.js', () => ({
initializeApp: vi.fn().mockResolvedValue({
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
geminiMdFileCount: 0,
}),
}));
describe('gemini.tsx main function', () => {
let originalEnvGeminiSandbox: string | undefined;
let originalEnvSandbox: string | undefined;
@@ -362,7 +371,6 @@ describe('gemini.tsx main function', () => {
expect(inputArg).toBe('hello stream');
expect(validateAuthSpy).toHaveBeenCalledWith(
undefined,
undefined,
configStub,
expect.any(Object),
@@ -460,6 +468,7 @@ describe('gemini.tsx main function kitty protocol', () => {
telemetryOutfile: undefined,
allowedMcpServerNames: undefined,
allowedTools: undefined,
acp: undefined,
experimentalAcp: undefined,
experimentalSkills: undefined,
extensions: undefined,
@@ -639,4 +648,37 @@ describe('startInteractiveUI', () => {
await new Promise((resolve) => setTimeout(resolve, 0));
expect(checkForUpdates).toHaveBeenCalledTimes(1);
});
it('should not check for updates when update nag is disabled', async () => {
const { checkForUpdates } = await import('./ui/utils/updateCheck.js');
const mockInitializationResult = {
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
geminiMdFileCount: 0,
};
const settingsWithUpdateNagDisabled = {
merged: {
general: {
disableUpdateNag: true,
},
ui: {
hideWindowTitle: false,
},
},
} as LoadedSettings;
await startInteractiveUI(
mockConfig,
settingsWithUpdateNagDisabled,
mockStartupWarnings,
mockWorkspaceRoot,
mockInitializationResult,
);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(checkForUpdates).not.toHaveBeenCalled();
});
});

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config, AuthType } from '@qwen-code/qwen-code-core';
import type { Config } from '@qwen-code/qwen-code-core';
import { InputFormat, logUserPrompt } from '@qwen-code/qwen-code-core';
import { render } from 'ink';
import dns from 'node:dns';
@@ -183,16 +183,18 @@ export async function startInteractiveUI(
},
);
checkForUpdates()
.then((info) => {
handleAutoUpdate(info, settings, config.getProjectRoot());
})
.catch((err) => {
// Silently ignore update check errors.
if (config.getDebugMode()) {
console.error('Update check failed:', err);
}
});
if (!settings.merged.general?.disableUpdateNag) {
checkForUpdates()
.then((info) => {
handleAutoUpdate(info, settings, config.getProjectRoot());
})
.catch((err) => {
// Silently ignore update check errors.
if (config.getDebugMode()) {
console.error('Update check failed:', err);
}
});
}
registerCleanup(() => instance.unmount());
}
@@ -250,22 +252,16 @@ export async function main() {
argv,
);
if (
settings.merged.security?.auth?.selectedType &&
!settings.merged.security?.auth?.useExternal
) {
if (!settings.merged.security?.auth?.useExternal) {
// Validate authentication here because the sandbox will interfere with the Oauth2 web redirect.
try {
const err = validateAuthMethod(
settings.merged.security.auth.selectedType,
);
const authType = partialConfig.modelsConfig.getCurrentAuthType();
const err = validateAuthMethod(authType, partialConfig);
if (err) {
throw new Error(err);
}
await partialConfig.refreshAuth(
settings.merged.security.auth.selectedType,
);
await partialConfig.refreshAuth(authType);
} catch (err) {
console.error('Error authenticating:', err);
process.exit(1);
@@ -438,8 +434,6 @@ export async function main() {
}
const nonInteractiveConfig = await validateNonInteractiveAuth(
(argv.authType as AuthType) ||
settings.merged.security?.auth?.selectedType,
settings.merged.security?.auth?.useExternal,
config,
settings,

File diff suppressed because it is too large Load Diff

View File

@@ -89,6 +89,9 @@ export default {
'No tools available': 'No tools available',
'View or change the approval mode for tool usage':
'View or change the approval mode for tool usage',
'Invalid approval mode "{{arg}}". Valid modes: {{modes}}':
'Invalid approval mode "{{arg}}". Valid modes: {{modes}}',
'Approval mode set to "{{mode}}"': 'Approval mode set to "{{mode}}"',
'View or change the language setting': 'View or change the language setting',
'change the theme': 'change the theme',
'Select Theme': 'Select Theme',
@@ -767,6 +770,21 @@ export default {
'Authentication timed out. Please try again.',
'Waiting for auth... (Press ESC or CTRL+C to cancel)':
'Waiting for auth... (Press ESC or CTRL+C to cancel)',
'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.':
'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.',
'{{envKeyHint}} environment variable not found.':
'{{envKeyHint}} environment variable not found.',
'{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.':
'{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.',
'{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.':
'{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.',
'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.':
'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.',
'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.':
'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.',
'ANTHROPIC_BASE_URL environment variable not found.':
'ANTHROPIC_BASE_URL environment variable not found.',
'Invalid auth method selected.': 'Invalid auth method selected.',
'Failed to authenticate. Message: {{message}}':
'Failed to authenticate. Message: {{message}}',
'Authenticated successfully with {{authType}} credentials.':
@@ -788,6 +806,15 @@ export default {
// ============================================================================
'Select Model': 'Select Model',
'(Press Esc to close)': '(Press Esc to close)',
'Current (effective) configuration': 'Current (effective) configuration',
AuthType: 'AuthType',
'API Key': 'API Key',
unset: 'unset',
'(default)': '(default)',
'(set)': '(set)',
'(not set)': '(not set)',
"Failed to switch model to '{{modelId}}'.\n\n{{error}}":
"Failed to switch model to '{{modelId}}'.\n\n{{error}}",
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)':
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)',
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)':
@@ -1037,7 +1064,6 @@ export default {
'Applying percussive maintenance...',
'Searching for the correct USB orientation...',
'Ensuring the magic smoke stays inside the wires...',
'Rewriting in Rust for no particular reason...',
'Trying to exit Vim...',
'Spinning up the hamster wheel...',
"That's not a bug, it's an undocumented feature...",

View File

@@ -89,6 +89,10 @@ export default {
'No tools available': 'Нет доступных инструментов',
'View or change the approval mode for tool usage':
'Просмотр или изменение режима подтверждения для использования инструментов',
'Invalid approval mode "{{arg}}". Valid modes: {{modes}}':
'Недопустимый режим подтверждения "{{arg}}". Допустимые режимы: {{modes}}',
'Approval mode set to "{{mode}}"':
'Режим подтверждения установлен на "{{mode}}"',
'View or change the language setting':
'Просмотр или изменение настроек языка',
'change the theme': 'Изменение темы',
@@ -782,6 +786,21 @@ export default {
'Время ожидания авторизации истекло. Пожалуйста, попробуйте снова.',
'Waiting for auth... (Press ESC or CTRL+C to cancel)':
'Ожидание авторизации... (Нажмите ESC или CTRL+C для отмены)',
'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.':
'Отсутствует API-ключ для аутентификации, совместимой с OpenAI. Укажите settings.security.auth.apiKey или переменную окружения {{envKeyHint}}.',
'{{envKeyHint}} environment variable not found.':
'Переменная окружения {{envKeyHint}} не найдена.',
'{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.':
'Переменная окружения {{envKeyHint}} не найдена. Укажите её в файле .env или среди системных переменных.',
'{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.':
'Переменная окружения {{envKeyHint}} не найдена (или установите settings.security.auth.apiKey). Укажите её в файле .env или среди системных переменных.',
'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.':
'Отсутствует API-ключ для аутентификации, совместимой с OpenAI. Установите переменную окружения {{envKeyHint}}.',
'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.':
'У провайдера Anthropic отсутствует обязательный baseUrl в modelProviders[].baseUrl.',
'ANTHROPIC_BASE_URL environment variable not found.':
'Переменная окружения ANTHROPIC_BASE_URL не найдена.',
'Invalid auth method selected.': 'Выбран недопустимый метод авторизации.',
'Failed to authenticate. Message: {{message}}':
'Не удалось авторизоваться. Сообщение: {{message}}',
'Authenticated successfully with {{authType}} credentials.':
@@ -803,6 +822,15 @@ export default {
// ============================================================================
'Select Model': 'Выбрать модель',
'(Press Esc to close)': '(Нажмите Esc для закрытия)',
'Current (effective) configuration': 'Текущая (фактическая) конфигурация',
AuthType: 'Тип авторизации',
'API Key': 'API-ключ',
unset: 'не задано',
'(default)': '(по умолчанию)',
'(set)': '(установлено)',
'(not set)': '(не задано)',
"Failed to switch model to '{{modelId}}'.\n\n{{error}}":
"Не удалось переключиться на модель '{{modelId}}'.\n\n{{error}}",
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)':
'Последняя модель Qwen Coder от Alibaba Cloud ModelStudio (версия: qwen3-coder-plus-2025-09-23)',
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)':
@@ -1056,7 +1084,6 @@ export default {
'Провожу настройку методом тыка...',
'Ищем, какой стороной вставлять флешку...',
'Следим, чтобы волшебный дым не вышел из проводов...',
'Переписываем всё на Rust без особой причины...',
'Пытаемся выйти из Vim...',
'Раскручиваем колесо для хомяка...',
'Это не баг, а фича...',

View File

@@ -88,6 +88,9 @@ export default {
'No tools available': '没有可用工具',
'View or change the approval mode for tool usage':
'查看或更改工具使用的审批模式',
'Invalid approval mode "{{arg}}". Valid modes: {{modes}}':
'无效的审批模式 "{{arg}}"。有效模式:{{modes}}',
'Approval mode set to "{{mode}}"': '审批模式已设置为 "{{mode}}"',
'View or change the language setting': '查看或更改语言设置',
'change the theme': '更改主题',
'Select Theme': '选择主题',
@@ -725,6 +728,21 @@ export default {
'Authentication timed out. Please try again.': '认证超时。请重试。',
'Waiting for auth... (Press ESC or CTRL+C to cancel)':
'正在等待认证...(按 ESC 或 CTRL+C 取消)',
'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.':
'缺少 OpenAI 兼容认证的 API 密钥。请设置 settings.security.auth.apiKey 或设置 {{envKeyHint}} 环境变量。',
'{{envKeyHint}} environment variable not found.':
'未找到 {{envKeyHint}} 环境变量。',
'{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.':
'未找到 {{envKeyHint}} 环境变量。请在 .env 文件或系统环境变量中进行设置。',
'{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.':
'未找到 {{envKeyHint}} 环境变量(或设置 settings.security.auth.apiKey。请在 .env 文件或系统环境变量中进行设置。',
'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.':
'缺少 OpenAI 兼容认证的 API 密钥。请设置 {{envKeyHint}} 环境变量。',
'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.':
'Anthropic 提供商缺少必需的 baseUrl请在 modelProviders[].baseUrl 中配置。',
'ANTHROPIC_BASE_URL environment variable not found.':
'未找到 ANTHROPIC_BASE_URL 环境变量。',
'Invalid auth method selected.': '选择了无效的认证方式。',
'Failed to authenticate. Message: {{message}}': '认证失败。消息:{{message}}',
'Authenticated successfully with {{authType}} credentials.':
'使用 {{authType}} 凭据成功认证。',
@@ -744,6 +762,15 @@ export default {
// ============================================================================
'Select Model': '选择模型',
'(Press Esc to close)': '(按 Esc 关闭)',
'Current (effective) configuration': '当前(实际生效)配置',
AuthType: '认证方式',
'API Key': 'API 密钥',
unset: '未设置',
'(default)': '(默认)',
'(set)': '(已设置)',
'(not set)': '(未设置)',
"Failed to switch model to '{{modelId}}'.\n\n{{error}}":
"无法切换到模型 '{{modelId}}'.\n\n{{error}}",
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)':
'来自阿里云 ModelStudio 的最新 Qwen Coder 模型版本qwen3-coder-plus-2025-09-23',
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)':

View File

@@ -630,6 +630,67 @@ describe('BaseJsonOutputAdapter', () => {
expect(state.blocks).toHaveLength(0);
});
it('should preserve whitespace in thinking content', () => {
const state = adapter.exposeCreateMessageState();
adapter.startAssistantMessage();
adapter.exposeAppendThinking(
state,
'',
'The user just said "Hello"',
null,
);
expect(state.blocks).toHaveLength(1);
expect(state.blocks[0]).toMatchObject({
type: 'thinking',
thinking: 'The user just said "Hello"',
});
// Verify spaces are preserved
const block = state.blocks[0] as { thinking: string };
expect(block.thinking).toContain('user just');
expect(block.thinking).not.toContain('userjust');
});
it('should preserve whitespace when appending multiple thinking fragments', () => {
const state = adapter.exposeCreateMessageState();
adapter.startAssistantMessage();
// Simulate streaming thinking content in fragments
adapter.exposeAppendThinking(state, '', 'The user just', null);
adapter.exposeAppendThinking(state, '', ' said "Hello"', null);
adapter.exposeAppendThinking(
state,
'',
'. This is a simple greeting',
null,
);
expect(state.blocks).toHaveLength(1);
const block = state.blocks[0] as { thinking: string };
// Verify the complete text with all spaces preserved
expect(block.thinking).toBe(
'The user just said "Hello". This is a simple greeting',
);
// Verify specific space preservation
expect(block.thinking).toContain('user just ');
expect(block.thinking).toContain(' said');
expect(block.thinking).toContain('". This');
expect(block.thinking).not.toContain('userjust');
expect(block.thinking).not.toContain('justsaid');
});
it('should preserve leading and trailing whitespace in description', () => {
const state = adapter.exposeCreateMessageState();
adapter.startAssistantMessage();
adapter.exposeAppendThinking(state, '', ' content with spaces ', null);
expect(state.blocks).toHaveLength(1);
const block = state.blocks[0] as { thinking: string };
expect(block.thinking).toBe(' content with spaces ');
});
});
describe('appendToolUse', () => {

View File

@@ -816,9 +816,18 @@ export abstract class BaseJsonOutputAdapter {
parentToolUseId?: string | null,
): void {
const actualParentToolUseId = parentToolUseId ?? null;
const fragment = [subject?.trim(), description?.trim()]
.filter((value) => value && value.length > 0)
.join(': ');
// Build fragment without trimming to preserve whitespace in streaming content
// Only filter out null/undefined/empty values
const parts: string[] = [];
if (subject && subject.length > 0) {
parts.push(subject);
}
if (description && description.length > 0) {
parts.push(description);
}
const fragment = parts.join(': ');
if (!fragment) {
return;
}

View File

@@ -323,6 +323,68 @@ describe('StreamJsonOutputAdapter', () => {
});
});
it('should preserve whitespace in thinking content (issue #1356)', () => {
adapter.processEvent({
type: GeminiEventType.Thought,
value: {
subject: '',
description: 'The user just said "Hello"',
},
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toHaveLength(1);
const block = message.message.content[0] as {
type: string;
thinking: string;
};
expect(block.type).toBe('thinking');
expect(block.thinking).toBe('The user just said "Hello"');
// Verify spaces are preserved
expect(block.thinking).toContain('user just');
expect(block.thinking).not.toContain('userjust');
});
it('should preserve whitespace when streaming multiple thinking fragments (issue #1356)', () => {
// Simulate streaming thinking content in multiple events
adapter.processEvent({
type: GeminiEventType.Thought,
value: {
subject: '',
description: 'The user just',
},
});
adapter.processEvent({
type: GeminiEventType.Thought,
value: {
subject: '',
description: ' said "Hello"',
},
});
adapter.processEvent({
type: GeminiEventType.Thought,
value: {
subject: '',
description: '. This is a simple greeting',
},
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toHaveLength(1);
const block = message.message.content[0] as {
type: string;
thinking: string;
};
expect(block.thinking).toBe(
'The user just said "Hello". This is a simple greeting',
);
// Verify specific spaces are preserved
expect(block.thinking).toContain('user just ');
expect(block.thinking).toContain(' said');
expect(block.thinking).not.toContain('userjust');
expect(block.thinking).not.toContain('justsaid');
});
it('should append tool use from ToolCallRequest events', () => {
adapter.processEvent({
type: GeminiEventType.ToolCallRequest,

View File

@@ -298,7 +298,9 @@ describe('runNonInteractive', () => {
mockConfig,
expect.objectContaining({ name: 'testTool' }),
expect.any(AbortSignal),
undefined,
expect.objectContaining({
outputUpdateHandler: expect.any(Function),
}),
);
// Verify first call has isContinuation: false
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
@@ -771,6 +773,52 @@ describe('runNonInteractive', () => {
);
});
it('should handle API errors in text mode and exit with error code', async () => {
(mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.TEXT);
setupMetricsMock();
// Simulate an API error event (like 401 unauthorized)
const apiErrorEvent: ServerGeminiStreamEvent = {
type: GeminiEventType.Error,
value: {
error: {
message: '401 Incorrect API key provided',
status: 401,
},
},
};
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents([apiErrorEvent]),
);
let thrownError: Error | null = null;
try {
await runNonInteractive(
mockConfig,
mockSettings,
'Test input',
'prompt-id-api-error',
);
// Should not reach here
expect.fail('Expected error to be thrown');
} catch (error) {
thrownError = error as Error;
}
// Should throw with the API error message
expect(thrownError).toBeTruthy();
expect(thrownError?.message).toContain('401');
expect(thrownError?.message).toContain('Incorrect API key provided');
// Verify error was written to stderr
expect(processStderrSpy).toHaveBeenCalled();
const stderrCalls = processStderrSpy.mock.calls;
const errorOutput = stderrCalls.map((call) => call[0]).join('');
expect(errorOutput).toContain('401');
expect(errorOutput).toContain('Incorrect API key provided');
});
it('should handle FatalInputError with custom exit code in JSON format', async () => {
(mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON);
setupMetricsMock();
@@ -1777,4 +1825,84 @@ describe('runNonInteractive', () => {
{ isContinuation: false },
);
});
it('should print tool output to console in text mode (non-Task tools)', async () => {
// Test that tool output is printed to stdout in text mode
const toolCallEvent: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'tool-1',
name: 'run_in_terminal',
args: { command: 'npm outdated' },
isClientInitiated: false,
prompt_id: 'prompt-id-tool-output',
},
};
// Mock tool execution with outputUpdateHandler being called
mockCoreExecuteToolCall.mockImplementation(
async (_config, _request, _signal, options) => {
// Simulate tool calling outputUpdateHandler with output chunks
if (options?.outputUpdateHandler) {
options.outputUpdateHandler('tool-1', 'Package outdated\n');
options.outputUpdateHandler('tool-1', 'npm@1.0.0 -> npm@2.0.0\n');
}
return {
responseParts: [
{
functionResponse: {
id: 'tool-1',
name: 'run_in_terminal',
response: {
output: 'Package outdated\nnpm@1.0.0 -> npm@2.0.0',
},
},
},
],
};
},
);
const firstCallEvents: ServerGeminiStreamEvent[] = [
toolCallEvent,
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
},
];
const secondCallEvents: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Dependencies checked' },
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 3 } },
},
];
mockGeminiClient.sendMessageStream
.mockReturnValueOnce(createStreamFromEvents(firstCallEvents))
.mockReturnValueOnce(createStreamFromEvents(secondCallEvents));
await runNonInteractive(
mockConfig,
mockSettings,
'Check dependencies',
'prompt-id-tool-output',
);
// Verify that executeToolCall was called with outputUpdateHandler
expect(mockCoreExecuteToolCall).toHaveBeenCalledWith(
mockConfig,
expect.objectContaining({ name: 'run_in_terminal' }),
expect.any(AbortSignal),
expect.objectContaining({
outputUpdateHandler: expect.any(Function),
}),
);
// Verify tool output was written to stdout
expect(processStdoutSpy).toHaveBeenCalledWith('Package outdated\n');
expect(processStdoutSpy).toHaveBeenCalledWith('npm@1.0.0 -> npm@2.0.0\n');
expect(processStdoutSpy).toHaveBeenCalledWith('Dependencies checked');
});
});

View File

@@ -4,7 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config, ToolCallRequestInfo } from '@qwen-code/qwen-code-core';
import type {
Config,
ToolCallRequestInfo,
ToolResultDisplay,
} from '@qwen-code/qwen-code-core';
import { isSlashCommand } from './ui/utils/commandUtils.js';
import type { LoadedSettings } from './config/settings.js';
import {
@@ -308,6 +312,8 @@ export async function runNonInteractive(
config.getContentGeneratorConfig()?.authType,
);
process.stderr.write(`${errorText}\n`);
// Throw error to exit with non-zero code
throw new Error(errorText);
}
}
}
@@ -333,7 +339,7 @@ export async function runNonInteractive(
? options.controlService.permission.getToolCallUpdateCallback()
: undefined;
// Only pass outputUpdateHandler for Task tool
// Create output handler for Task tool (for subagent execution)
const isTaskTool = finalRequestInfo.name === 'task';
const taskToolProgress = isTaskTool
? createTaskToolProgressHandler(
@@ -343,20 +349,41 @@ export async function runNonInteractive(
)
: undefined;
const taskToolProgressHandler = taskToolProgress?.handler;
// Create output handler for non-Task tools in text mode (for console output)
const nonTaskOutputHandler =
!isTaskTool && !adapter
? (callId: string, outputChunk: ToolResultDisplay) => {
// Print tool output to console in text mode
if (typeof outputChunk === 'string') {
process.stdout.write(outputChunk);
} else if (
outputChunk &&
typeof outputChunk === 'object' &&
'ansiOutput' in outputChunk
) {
// Handle ANSI output - just print as string for now
process.stdout.write(String(outputChunk.ansiOutput));
}
}
: undefined;
// Combine output handlers
const outputUpdateHandler =
taskToolProgressHandler || nonTaskOutputHandler;
const toolResponse = await executeToolCall(
config,
finalRequestInfo,
abortController.signal,
isTaskTool && taskToolProgressHandler
outputUpdateHandler || toolCallUpdateCallback
? {
outputUpdateHandler: taskToolProgressHandler,
onToolCallsUpdate: toolCallUpdateCallback,
}
: toolCallUpdateCallback
? {
...(outputUpdateHandler && { outputUpdateHandler }),
...(toolCallUpdateCallback && {
onToolCallsUpdate: toolCallUpdateCallback,
}
: undefined,
}),
}
: undefined,
);
// Note: In JSON mode, subagent messages are automatically added to the main

View File

@@ -6,10 +6,12 @@
import { render } from 'ink-testing-library';
import type React from 'react';
import type { Config } from '@qwen-code/qwen-code-core';
import { LoadedSettings } from '../config/settings.js';
import { KeypressProvider } from '../ui/contexts/KeypressContext.js';
import { SettingsContext } from '../ui/contexts/SettingsContext.js';
import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js';
import { ConfigContext } from '../ui/contexts/ConfigContext.js';
const mockSettings = new LoadedSettings(
{ path: '', settings: {}, originalSettings: {} },
@@ -22,14 +24,24 @@ const mockSettings = new LoadedSettings(
export const renderWithProviders = (
component: React.ReactElement,
{ shellFocus = true, settings = mockSettings } = {},
{
shellFocus = true,
settings = mockSettings,
config = undefined,
}: {
shellFocus?: boolean;
settings?: LoadedSettings;
config?: Config;
} = {},
): ReturnType<typeof render> =>
render(
<SettingsContext.Provider value={settings}>
<ShellFocusContext.Provider value={shellFocus}>
<KeypressProvider kittyProtocolEnabled={true}>
{component}
</KeypressProvider>
</ShellFocusContext.Provider>
<ConfigContext.Provider value={config}>
<ShellFocusContext.Provider value={shellFocus}>
<KeypressProvider kittyProtocolEnabled={true}>
{component}
</KeypressProvider>
</ShellFocusContext.Provider>
</ConfigContext.Provider>
</SettingsContext.Provider>,
);

View File

@@ -32,7 +32,6 @@ import {
type Config,
type IdeInfo,
type IdeContext,
DEFAULT_GEMINI_FLASH_MODEL,
IdeClient,
ideContextStore,
getErrorMessage,
@@ -180,15 +179,10 @@ export const AppContainer = (props: AppContainerProps) => {
[],
);
// Helper to determine the effective model, considering the fallback state.
const getEffectiveModel = useCallback(() => {
if (config.isInFallbackMode()) {
return DEFAULT_GEMINI_FLASH_MODEL;
}
return config.getModel();
}, [config]);
// Helper to determine the current model (polled, since Config has no model-change event).
const getCurrentModel = useCallback(() => config.getModel(), [config]);
const [currentModel, setCurrentModel] = useState(getEffectiveModel());
const [currentModel, setCurrentModel] = useState(getCurrentModel());
const [isConfigInitialized, setConfigInitialized] = useState(false);
@@ -241,12 +235,12 @@ export const AppContainer = (props: AppContainerProps) => {
[historyManager.addItem],
);
// Watch for model changes (e.g., from Flash fallback)
// Watch for model changes (e.g., user switches model via /model)
useEffect(() => {
const checkModelChange = () => {
const effectiveModel = getEffectiveModel();
if (effectiveModel !== currentModel) {
setCurrentModel(effectiveModel);
const model = getCurrentModel();
if (model !== currentModel) {
setCurrentModel(model);
}
};
@@ -254,7 +248,7 @@ export const AppContainer = (props: AppContainerProps) => {
const interval = setInterval(checkModelChange, 1000); // Check every second
return () => clearInterval(interval);
}, [config, currentModel, getEffectiveModel]);
}, [config, currentModel, getCurrentModel]);
const {
consoleMessages,
@@ -379,34 +373,32 @@ export const AppContainer = (props: AppContainerProps) => {
if (
settings.merged.security?.auth?.enforcedType &&
settings.merged.security?.auth.selectedType &&
config.modelsConfig.getCurrentAuthType() &&
settings.merged.security?.auth.enforcedType !==
settings.merged.security?.auth.selectedType
config.modelsConfig.getCurrentAuthType()
) {
onAuthError(
t(
'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.',
{
enforcedType: settings.merged.security?.auth.enforcedType,
currentType: settings.merged.security?.auth.selectedType,
currentType: config.modelsConfig.getCurrentAuthType(),
},
),
);
} else if (
settings.merged.security?.auth?.selectedType &&
!settings.merged.security?.auth?.useExternal
) {
} else if (!settings.merged.security?.auth?.useExternal) {
const error = validateAuthMethod(
settings.merged.security.auth.selectedType,
config.modelsConfig.getCurrentAuthType(),
config,
);
if (error) {
onAuthError(error);
}
}
}, [
settings.merged.security?.auth?.selectedType,
settings.merged.security?.auth?.enforcedType,
settings.merged.security?.auth?.useExternal,
config,
onAuthError,
]);
@@ -925,7 +917,12 @@ export const AppContainer = (props: AppContainerProps) => {
const handleIdePromptComplete = useCallback(
(result: IdeIntegrationNudgeResult) => {
if (result.userSelection === 'yes') {
handleSlashCommand('/ide install');
// Check whether the extension has been pre-installed
if (result.isExtensionPreInstalled) {
handleSlashCommand('/ide enable');
} else {
handleSlashCommand('/ide install');
}
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
} else if (result.userSelection === 'dismiss') {
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);

View File

@@ -38,6 +38,7 @@ export function IdeIntegrationNudge({
);
const { displayName: ideName } = ide;
const isInSandbox = !!process.env['SANDBOX'];
// Assume extension is already installed if the env variables are set.
const isExtensionPreInstalled =
!!process.env['QWEN_CODE_IDE_SERVER_PORT'] &&
@@ -70,13 +71,15 @@ export function IdeIntegrationNudge({
},
];
const installText = isExtensionPreInstalled
? `If you select Yes, the CLI will have access to your open files and display diffs directly in ${
ideName ?? 'your editor'
}.`
: `If you select Yes, we'll install an extension that allows the CLI to access your open files and display diffs directly in ${
ideName ?? 'your editor'
}.`;
const installText = isInSandbox
? `Note: In sandbox environments, IDE integration requires manual setup on the host system. If you select Yes, you'll receive instructions on how to set this up.`
: isExtensionPreInstalled
? `If you select Yes, the CLI will connect to your ${
ideName ?? 'editor'
} and have access to your open files and display diffs directly.`
: `If you select Yes, we'll install an extension that allows the CLI to access your open files and display diffs directly in ${
ideName ?? 'your editor'
}.`;
return (
<Box

View File

@@ -6,7 +6,8 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { AuthDialog } from './AuthDialog.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { LoadedSettings } from '../../config/settings.js';
import type { Config } from '@qwen-code/qwen-code-core';
import { AuthType } from '@qwen-code/qwen-code-core';
import { renderWithProviders } from '../../test-utils/render.js';
import { UIStateContext } from '../contexts/UIStateContext.js';
@@ -43,17 +44,24 @@ const renderAuthDialog = (
settings: LoadedSettings,
uiStateOverrides: Partial<UIState> = {},
uiActionsOverrides: Partial<UIActions> = {},
configAuthType: AuthType | undefined = undefined,
configApiKey: string | undefined = undefined,
) => {
const uiState = createMockUIState(uiStateOverrides);
const uiActions = createMockUIActions(uiActionsOverrides);
const mockConfig = {
getAuthType: vi.fn(() => configAuthType),
getContentGeneratorConfig: vi.fn(() => ({ apiKey: configApiKey })),
} as unknown as Config;
return renderWithProviders(
<UIStateContext.Provider value={uiState}>
<UIActionsContext.Provider value={uiActions}>
<AuthDialog />
</UIActionsContext.Provider>
</UIStateContext.Provider>,
{ settings },
{ settings, config: mockConfig },
);
};
@@ -421,6 +429,7 @@ describe('AuthDialog', () => {
settings,
{},
{ handleAuthSelect },
undefined, // config.getAuthType() returns undefined
);
await wait();
@@ -475,6 +484,7 @@ describe('AuthDialog', () => {
settings,
{ authError: 'Initial error' },
{ handleAuthSelect },
undefined, // config.getAuthType() returns undefined
);
await wait();
@@ -528,6 +538,7 @@ describe('AuthDialog', () => {
settings,
{},
{ handleAuthSelect },
AuthType.USE_OPENAI, // config.getAuthType() returns USE_OPENAI
);
await wait();
@@ -536,7 +547,7 @@ describe('AuthDialog', () => {
await wait();
// Should call handleAuthSelect with undefined to exit
expect(handleAuthSelect).toHaveBeenCalledWith(undefined, SettingScope.User);
expect(handleAuthSelect).toHaveBeenCalledWith(undefined);
unmount();
});
});

View File

@@ -8,13 +8,12 @@ import type React from 'react';
import { useState } from 'react';
import { AuthType } from '@qwen-code/qwen-code-core';
import { Box, Text } from 'ink';
import { SettingScope } from '../../config/settings.js';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { t } from '../../i18n/index.js';
function parseDefaultAuthType(
@@ -32,7 +31,7 @@ function parseDefaultAuthType(
export function AuthDialog(): React.JSX.Element {
const { pendingAuthType, authError } = useUIState();
const { handleAuthSelect: onAuthSelect } = useUIActions();
const settings = useSettings();
const config = useConfig();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
@@ -58,9 +57,10 @@ export function AuthDialog(): React.JSX.Element {
return item.value === pendingAuthType;
}
// Priority 2: settings.merged.security?.auth?.selectedType
if (settings.merged.security?.auth?.selectedType) {
return item.value === settings.merged.security?.auth?.selectedType;
// Priority 2: config.getAuthType() - the source of truth
const currentAuthType = config.getAuthType();
if (currentAuthType) {
return item.value === currentAuthType;
}
// Priority 3: QWEN_DEFAULT_AUTH_TYPE env var
@@ -76,7 +76,7 @@ export function AuthDialog(): React.JSX.Element {
}),
);
const hasApiKey = Boolean(settings.merged.security?.auth?.apiKey);
const hasApiKey = Boolean(config.getContentGeneratorConfig()?.apiKey);
const currentSelectedAuthType =
selectedIndex !== null
? items[selectedIndex]?.value
@@ -84,7 +84,7 @@ export function AuthDialog(): React.JSX.Element {
const handleAuthSelect = async (authMethod: AuthType) => {
setErrorMessage(null);
await onAuthSelect(authMethod, SettingScope.User);
await onAuthSelect(authMethod);
};
const handleHighlight = (authMethod: AuthType) => {
@@ -100,7 +100,7 @@ export function AuthDialog(): React.JSX.Element {
if (errorMessage) {
return;
}
if (settings.merged.security?.auth?.selectedType === undefined) {
if (config.getAuthType() === undefined) {
// Prevent exiting if no auth method is set
setErrorMessage(
t(
@@ -109,7 +109,7 @@ export function AuthDialog(): React.JSX.Element {
);
return;
}
onAuthSelect(undefined, SettingScope.User);
onAuthSelect(undefined);
}
},
{ isActive: true },

View File

@@ -4,16 +4,16 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '@qwen-code/qwen-code-core';
import type { Config, ModelProvidersConfig } from '@qwen-code/qwen-code-core';
import {
AuthEvent,
AuthType,
clearCachedCredentialFile,
getErrorMessage,
logAuth,
} from '@qwen-code/qwen-code-core';
import { useCallback, useEffect, useState } from 'react';
import type { LoadedSettings, SettingScope } from '../../config/settings.js';
import type { LoadedSettings } from '../../config/settings.js';
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
import { useQwenAuth } from '../hooks/useQwenAuth.js';
import { AuthState, MessageType } from '../types.js';
@@ -27,8 +27,7 @@ export const useAuthCommand = (
config: Config,
addItem: (item: Omit<HistoryItem, 'id'>, timestamp: number) => void,
) => {
const unAuthenticated =
settings.merged.security?.auth?.selectedType === undefined;
const unAuthenticated = config.getAuthType() === undefined;
const [authState, setAuthState] = useState<AuthState>(
unAuthenticated ? AuthState.Updating : AuthState.Unauthenticated,
@@ -81,35 +80,35 @@ export const useAuthCommand = (
);
const handleAuthSuccess = useCallback(
async (
authType: AuthType,
scope: SettingScope,
credentials?: OpenAICredentials,
) => {
async (authType: AuthType, credentials?: OpenAICredentials) => {
try {
settings.setValue(scope, 'security.auth.selectedType', authType);
const authTypeScope = getPersistScopeForModelSelection(settings);
settings.setValue(
authTypeScope,
'security.auth.selectedType',
authType,
);
// 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) {
if (credentials?.apiKey != null) {
settings.setValue(
scope,
authTypeScope,
'security.auth.apiKey',
credentials.apiKey,
);
}
if (credentials?.baseUrl != null) {
settings.setValue(
scope,
authTypeScope,
'security.auth.baseUrl',
credentials.baseUrl,
);
}
if (credentials?.model != null) {
settings.setValue(scope, 'model.name', credentials.model);
settings.setValue(authTypeScope, 'model.name', credentials.model);
}
await clearCachedCredentialFile();
}
} catch (error) {
handleAuthFailure(error);
@@ -141,14 +140,10 @@ export const useAuthCommand = (
);
const performAuth = useCallback(
async (
authType: AuthType,
scope: SettingScope,
credentials?: OpenAICredentials,
) => {
async (authType: AuthType, credentials?: OpenAICredentials) => {
try {
await config.refreshAuth(authType);
handleAuthSuccess(authType, scope, credentials);
handleAuthSuccess(authType, credentials);
} catch (e) {
handleAuthFailure(e);
}
@@ -156,18 +151,51 @@ export const useAuthCommand = (
[config, handleAuthSuccess, handleAuthFailure],
);
const isProviderManagedModel = useCallback(
(authType: AuthType, modelId: string | undefined) => {
if (!modelId) {
return false;
}
const modelProviders = settings.merged.modelProviders as
| ModelProvidersConfig
| undefined;
if (!modelProviders) {
return false;
}
const providerModels = modelProviders[authType];
if (!Array.isArray(providerModels)) {
return false;
}
return providerModels.some(
(providerModel) => providerModel.id === modelId,
);
},
[settings],
);
const handleAuthSelect = useCallback(
async (
authType: AuthType | undefined,
scope: SettingScope,
credentials?: OpenAICredentials,
) => {
async (authType: AuthType | undefined, credentials?: OpenAICredentials) => {
if (!authType) {
setIsAuthDialogOpen(false);
setAuthError(null);
return;
}
if (
authType === AuthType.USE_OPENAI &&
credentials?.model &&
isProviderManagedModel(authType, credentials.model)
) {
onAuthError(
t(
'Model "{{modelName}}" is managed via settings.modelProviders. Please complete the fields in settings, or use another model id.',
{ modelName: credentials.model },
),
);
return;
}
setPendingAuthType(authType);
setAuthError(null);
setIsAuthDialogOpen(false);
@@ -180,14 +208,14 @@ export const useAuthCommand = (
baseUrl: credentials.baseUrl,
model: credentials.model,
});
await performAuth(authType, scope, credentials);
await performAuth(authType, credentials);
}
return;
}
await performAuth(authType, scope);
await performAuth(authType);
},
[config, performAuth],
[config, performAuth, isProviderManagedModel, onAuthError],
);
const openAuthDialog = useCallback(() => {

View File

@@ -4,31 +4,28 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { approvalModeCommand } from './approvalModeCommand.js';
import {
type CommandContext,
CommandKind,
type OpenDialogActionReturn,
type MessageActionReturn,
} from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import type { LoadedSettings } from '../../config/settings.js';
describe('approvalModeCommand', () => {
let mockContext: CommandContext;
let mockSetApprovalMode: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockSetApprovalMode = vi.fn();
mockContext = createMockCommandContext({
services: {
config: {
getApprovalMode: () => 'default',
setApprovalMode: () => {},
setApprovalMode: mockSetApprovalMode,
},
settings: {
merged: {},
setValue: () => {},
forScope: () => ({}),
} as unknown as LoadedSettings,
},
});
});
@@ -41,7 +38,7 @@ describe('approvalModeCommand', () => {
expect(approvalModeCommand.kind).toBe(CommandKind.BUILT_IN);
});
it('should open approval mode dialog when invoked', async () => {
it('should open approval mode dialog when invoked without arguments', async () => {
const result = (await approvalModeCommand.action?.(
mockContext,
'',
@@ -51,16 +48,123 @@ describe('approvalModeCommand', () => {
expect(result.dialog).toBe('approval-mode');
});
it('should open approval mode dialog with arguments (ignored)', async () => {
it('should open approval mode dialog when invoked with whitespace only', async () => {
const result = (await approvalModeCommand.action?.(
mockContext,
'some arguments',
' ',
)) as OpenDialogActionReturn;
expect(result.type).toBe('dialog');
expect(result.dialog).toBe('approval-mode');
});
describe('direct mode setting (session-only)', () => {
it('should set approval mode to "plan" when argument is "plan"', async () => {
const result = (await approvalModeCommand.action?.(
mockContext,
'plan',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('info');
expect(result.content).toContain('plan');
expect(mockSetApprovalMode).toHaveBeenCalledWith('plan');
});
it('should set approval mode to "yolo" when argument is "yolo"', async () => {
const result = (await approvalModeCommand.action?.(
mockContext,
'yolo',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('info');
expect(result.content).toContain('yolo');
expect(mockSetApprovalMode).toHaveBeenCalledWith('yolo');
});
it('should set approval mode to "auto-edit" when argument is "auto-edit"', async () => {
const result = (await approvalModeCommand.action?.(
mockContext,
'auto-edit',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('info');
expect(result.content).toContain('auto-edit');
expect(mockSetApprovalMode).toHaveBeenCalledWith('auto-edit');
});
it('should set approval mode to "default" when argument is "default"', async () => {
const result = (await approvalModeCommand.action?.(
mockContext,
'default',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('info');
expect(result.content).toContain('default');
expect(mockSetApprovalMode).toHaveBeenCalledWith('default');
});
it('should be case-insensitive for mode argument', async () => {
const result = (await approvalModeCommand.action?.(
mockContext,
'YOLO',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('info');
expect(mockSetApprovalMode).toHaveBeenCalledWith('yolo');
});
it('should handle argument with leading/trailing whitespace', async () => {
const result = (await approvalModeCommand.action?.(
mockContext,
' plan ',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('info');
expect(mockSetApprovalMode).toHaveBeenCalledWith('plan');
});
});
describe('invalid mode argument', () => {
it('should return error for invalid mode', async () => {
const result = (await approvalModeCommand.action?.(
mockContext,
'invalid-mode',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('error');
expect(result.content).toContain('invalid-mode');
expect(result.content).toContain('plan');
expect(result.content).toContain('yolo');
expect(mockSetApprovalMode).not.toHaveBeenCalled();
});
});
describe('untrusted folder handling', () => {
it('should return error when setApprovalMode throws (e.g., untrusted folder)', async () => {
const errorMessage =
'Cannot enable privileged approval modes in an untrusted folder.';
mockSetApprovalMode.mockImplementation(() => {
throw new Error(errorMessage);
});
const result = (await approvalModeCommand.action?.(
mockContext,
'yolo',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('error');
expect(result.content).toBe(errorMessage);
});
});
it('should not have subcommands', () => {
expect(approvalModeCommand.subCommands).toBeUndefined();
});

View File

@@ -8,9 +8,25 @@ import type {
SlashCommand,
CommandContext,
OpenDialogActionReturn,
MessageActionReturn,
} from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
import type { ApprovalMode } from '@qwen-code/qwen-code-core';
import { APPROVAL_MODES } from '@qwen-code/qwen-code-core';
/**
* Parses the argument string and returns the corresponding ApprovalMode if valid.
* Returns undefined if the argument is empty or not a valid mode.
*/
function parseApprovalModeArg(arg: string): ApprovalMode | undefined {
const trimmed = arg.trim().toLowerCase();
if (!trimmed) {
return undefined;
}
// Match against valid approval modes (case-insensitive)
return APPROVAL_MODES.find((mode) => mode.toLowerCase() === trimmed);
}
export const approvalModeCommand: SlashCommand = {
name: 'approval-mode',
@@ -19,10 +35,49 @@ export const approvalModeCommand: SlashCommand = {
},
kind: CommandKind.BUILT_IN,
action: async (
_context: CommandContext,
_args: string,
): Promise<OpenDialogActionReturn> => ({
type: 'dialog',
dialog: 'approval-mode',
}),
context: CommandContext,
args: string,
): Promise<OpenDialogActionReturn | MessageActionReturn> => {
const mode = parseApprovalModeArg(args);
// If no argument provided, open the dialog
if (!args.trim()) {
return {
type: 'dialog',
dialog: 'approval-mode',
};
}
// If invalid argument, return error message with valid options
if (!mode) {
return {
type: 'message',
messageType: 'error',
content: t('Invalid approval mode "{{arg}}". Valid modes: {{modes}}', {
arg: args.trim(),
modes: APPROVAL_MODES.join(', '),
}),
};
}
// Set the mode for current session only (not persisted)
const { config } = context.services;
if (config) {
try {
config.setApprovalMode(mode);
} catch (e) {
return {
type: 'message',
messageType: 'error',
content: (e as Error).message,
};
}
}
return {
type: 'message',
messageType: 'info',
content: t('Approval mode set to "{{mode}}"', { mode }),
};
},
};

View File

@@ -191,11 +191,23 @@ export const ideCommand = async (): Promise<SlashCommand> => {
kind: CommandKind.BUILT_IN,
action: async (context) => {
const installer = getIdeInstaller(currentIDE);
const isSandBox = !!process.env['SANDBOX'];
if (isSandBox) {
context.ui.addItem(
{
type: 'info',
text: `IDE integration needs to be installed on the host. If you have already installed it, you can directly connect the ide`,
},
Date.now(),
);
return;
}
if (!installer) {
const ideName = ideClient.getDetectedIdeDisplayName();
context.ui.addItem(
{
type: 'error',
text: `No installer is available for ${ideClient.getDetectedIdeDisplayName()}. Please install the '${QWEN_CODE_COMPANION_EXTENSION_NAME}' extension manually from the marketplace.`,
text: `Automatic installation is not supported for ${ideName}. Please install the '${QWEN_CODE_COMPANION_EXTENSION_NAME}' extension manually from the marketplace.`,
},
Date.now(),
);

View File

@@ -13,12 +13,6 @@ import {
type ContentGeneratorConfig,
type Config,
} from '@qwen-code/qwen-code-core';
import * as availableModelsModule from '../models/availableModels.js';
// Mock the availableModels module
vi.mock('../models/availableModels.js', () => ({
getAvailableModelsForAuthType: vi.fn(),
}));
// Helper function to create a mock config
function createMockConfig(
@@ -31,9 +25,6 @@ function createMockConfig(
describe('modelCommand', () => {
let mockContext: CommandContext;
const mockGetAvailableModelsForAuthType = vi.mocked(
availableModelsModule.getAvailableModelsForAuthType,
);
beforeEach(() => {
mockContext = createMockCommandContext();
@@ -87,10 +78,6 @@ describe('modelCommand', () => {
});
it('should return dialog action for QWEN_OAUTH auth type', async () => {
mockGetAvailableModelsForAuthType.mockReturnValue([
{ id: 'qwen3-coder-plus', label: 'qwen3-coder-plus' },
]);
const mockConfig = createMockConfig({
model: 'test-model',
authType: AuthType.QWEN_OAUTH,
@@ -105,11 +92,7 @@ describe('modelCommand', () => {
});
});
it('should return dialog action for USE_OPENAI auth type when model is available', async () => {
mockGetAvailableModelsForAuthType.mockReturnValue([
{ id: 'gpt-4', label: 'gpt-4' },
]);
it('should return dialog action for USE_OPENAI auth type', async () => {
const mockConfig = createMockConfig({
model: 'test-model',
authType: AuthType.USE_OPENAI,
@@ -124,28 +107,7 @@ describe('modelCommand', () => {
});
});
it('should return error for USE_OPENAI auth type when no model is available', async () => {
mockGetAvailableModelsForAuthType.mockReturnValue([]);
const mockConfig = createMockConfig({
model: 'test-model',
authType: AuthType.USE_OPENAI,
});
mockContext.services.config = mockConfig as Config;
const result = await modelCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content:
'No models available for the current authentication type (openai).',
});
});
it('should return error for unsupported auth types', async () => {
mockGetAvailableModelsForAuthType.mockReturnValue([]);
it('should return dialog action for unsupported auth types', async () => {
const mockConfig = createMockConfig({
model: 'test-model',
authType: 'UNSUPPORTED_AUTH_TYPE' as AuthType,
@@ -155,10 +117,8 @@ describe('modelCommand', () => {
const result = await modelCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content:
'No models available for the current authentication type (UNSUPPORTED_AUTH_TYPE).',
type: 'dialog',
dialog: 'model',
});
});

View File

@@ -11,7 +11,6 @@ import type {
MessageActionReturn,
} from './types.js';
import { CommandKind } from './types.js';
import { getAvailableModelsForAuthType } from '../models/availableModels.js';
import { t } from '../../i18n/index.js';
export const modelCommand: SlashCommand = {
@@ -30,7 +29,7 @@ export const modelCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
content: t('Configuration not available.'),
};
}
@@ -52,22 +51,6 @@ export const modelCommand: SlashCommand = {
};
}
const availableModels = getAvailableModelsForAuthType(authType);
if (availableModels.length === 0) {
return {
type: 'message',
messageType: 'error',
content: t(
'No models available for the current authentication type ({{authType}}).',
{
authType,
},
),
};
}
// Trigger model selection dialog
return {
type: 'dialog',
dialog: 'model',

View File

@@ -25,7 +25,6 @@ import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { SettingScope } from '../../config/settings.js';
import { AuthState } from '../types.js';
import { AuthType } from '@qwen-code/qwen-code-core';
import process from 'node:process';
@@ -202,7 +201,7 @@ export const DialogManager = ({
return (
<OpenAIKeyPrompt
onSubmit={(apiKey, baseUrl, model) => {
uiActions.handleAuthSelect(AuthType.USE_OPENAI, SettingScope.User, {
uiActions.handleAuthSelect(AuthType.USE_OPENAI, {
apiKey,
baseUrl,
model,

View File

@@ -10,7 +10,11 @@ import { ModelDialog } from './ModelDialog.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import type { Config } from '@qwen-code/qwen-code-core';
import { AuthType } from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../../config/settings.js';
import { SettingScope } from '../../config/settings.js';
import {
AVAILABLE_MODELS_QWEN,
MAINLINE_CODER,
@@ -36,18 +40,29 @@ const renderComponent = (
};
const combinedProps = { ...defaultProps, ...props };
const mockSettings = {
isTrusted: true,
user: { settings: {} },
workspace: { settings: {} },
setValue: vi.fn(),
} as unknown as LoadedSettings;
const mockConfig = contextValue
? ({
// --- Functions used by ModelDialog ---
getModel: vi.fn(() => MAINLINE_CODER),
setModel: vi.fn(),
setModel: vi.fn().mockResolvedValue(undefined),
switchModel: vi.fn().mockResolvedValue(undefined),
getAuthType: vi.fn(() => 'qwen-oauth'),
// --- Functions used by ClearcutLogger ---
getUsageStatisticsEnabled: vi.fn(() => true),
getSessionId: vi.fn(() => 'mock-session-id'),
getDebugMode: vi.fn(() => false),
getContentGeneratorConfig: vi.fn(() => ({ authType: 'mock' })),
getContentGeneratorConfig: vi.fn(() => ({
authType: AuthType.QWEN_OAUTH,
model: MAINLINE_CODER,
})),
getUseSmartEdit: vi.fn(() => false),
getUseModelRouter: vi.fn(() => false),
getProxy: vi.fn(() => undefined),
@@ -58,21 +73,27 @@ const renderComponent = (
: undefined;
const renderResult = render(
<ConfigContext.Provider value={mockConfig}>
<ModelDialog {...combinedProps} />
</ConfigContext.Provider>,
<SettingsContext.Provider value={mockSettings}>
<ConfigContext.Provider value={mockConfig}>
<ModelDialog {...combinedProps} />
</ConfigContext.Provider>
</SettingsContext.Provider>,
);
return {
...renderResult,
props: combinedProps,
mockConfig,
mockSettings,
};
};
describe('<ModelDialog />', () => {
beforeEach(() => {
vi.clearAllMocks();
// Ensure env-based fallback models don't leak into this suite from the developer environment.
delete process.env['OPENAI_MODEL'];
delete process.env['ANTHROPIC_MODEL'];
});
afterEach(() => {
@@ -91,8 +112,12 @@ describe('<ModelDialog />', () => {
const props = mockedSelect.mock.calls[0][0];
expect(props.items).toHaveLength(AVAILABLE_MODELS_QWEN.length);
expect(props.items[0].value).toBe(MAINLINE_CODER);
expect(props.items[1].value).toBe(MAINLINE_VLM);
expect(props.items[0].value).toBe(
`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`,
);
expect(props.items[1].value).toBe(
`${AuthType.QWEN_OAUTH}::${MAINLINE_VLM}`,
);
expect(props.showNumbers).toBe(true);
});
@@ -139,16 +164,93 @@ describe('<ModelDialog />', () => {
expect(mockedSelect).toHaveBeenCalledTimes(1);
});
it('calls config.setModel and onClose when DescriptiveRadioButtonSelect.onSelect is triggered', () => {
const { props, mockConfig } = renderComponent({}, {}); // Pass empty object for contextValue
it('calls config.switchModel and onClose when DescriptiveRadioButtonSelect.onSelect is triggered', async () => {
const { props, mockConfig, mockSettings } = renderComponent({}, {}); // Pass empty object for contextValue
const childOnSelect = mockedSelect.mock.calls[0][0].onSelect;
expect(childOnSelect).toBeDefined();
childOnSelect(MAINLINE_CODER);
await childOnSelect(`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`);
// Assert against the default mock provided by renderComponent
expect(mockConfig?.setModel).toHaveBeenCalledWith(MAINLINE_CODER);
expect(mockConfig?.switchModel).toHaveBeenCalledWith(
AuthType.QWEN_OAUTH,
MAINLINE_CODER,
undefined,
{
reason: 'user_manual',
context: 'Model switched via /model dialog',
},
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'model.name',
MAINLINE_CODER,
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'security.auth.selectedType',
AuthType.QWEN_OAUTH,
);
expect(props.onClose).toHaveBeenCalledTimes(1);
});
it('calls config.switchModel and persists authType+model when selecting a different authType', async () => {
const switchModel = vi.fn().mockResolvedValue(undefined);
const getAuthType = vi.fn(() => AuthType.USE_OPENAI);
const getAvailableModelsForAuthType = vi.fn((t: AuthType) => {
if (t === AuthType.USE_OPENAI) {
return [{ id: 'gpt-4', label: 'GPT-4', authType: t }];
}
if (t === AuthType.QWEN_OAUTH) {
return AVAILABLE_MODELS_QWEN.map((m) => ({
id: m.id,
label: m.label,
authType: AuthType.QWEN_OAUTH,
}));
}
return [];
});
const mockConfigWithSwitchAuthType = {
getAuthType,
getModel: vi.fn(() => 'gpt-4'),
getContentGeneratorConfig: vi.fn(() => ({
authType: AuthType.QWEN_OAUTH,
model: MAINLINE_CODER,
})),
// Add switchModel to the mock object (not the type)
switchModel,
getAvailableModelsForAuthType,
};
const { props, mockSettings } = renderComponent(
{},
// Cast to Config to bypass type checking, matching the runtime behavior
mockConfigWithSwitchAuthType as unknown as Partial<Config>,
);
const childOnSelect = mockedSelect.mock.calls[0][0].onSelect;
await childOnSelect(`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`);
expect(switchModel).toHaveBeenCalledWith(
AuthType.QWEN_OAUTH,
MAINLINE_CODER,
{ requireCachedCredentials: true },
{
reason: 'user_manual',
context: 'AuthType+model switched via /model dialog',
},
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'model.name',
MAINLINE_CODER,
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'security.auth.selectedType',
AuthType.QWEN_OAUTH,
);
expect(props.onClose).toHaveBeenCalledTimes(1);
});
@@ -193,17 +295,25 @@ describe('<ModelDialog />', () => {
it('updates initialIndex when config context changes', () => {
const mockGetModel = vi.fn(() => MAINLINE_CODER);
const mockGetAuthType = vi.fn(() => 'qwen-oauth');
const mockSettings = {
isTrusted: true,
user: { settings: {} },
workspace: { settings: {} },
setValue: vi.fn(),
} as unknown as LoadedSettings;
const { rerender } = render(
<ConfigContext.Provider
value={
{
getModel: mockGetModel,
getAuthType: mockGetAuthType,
} as unknown as Config
}
>
<ModelDialog onClose={vi.fn()} />
</ConfigContext.Provider>,
<SettingsContext.Provider value={mockSettings}>
<ConfigContext.Provider
value={
{
getModel: mockGetModel,
getAuthType: mockGetAuthType,
} as unknown as Config
}
>
<ModelDialog onClose={vi.fn()} />
</ConfigContext.Provider>
</SettingsContext.Provider>,
);
expect(mockedSelect.mock.calls[0][0].initialIndex).toBe(0);
@@ -215,9 +325,11 @@ describe('<ModelDialog />', () => {
} as unknown as Config;
rerender(
<ConfigContext.Provider value={newMockConfig}>
<ModelDialog onClose={vi.fn()} />
</ConfigContext.Provider>,
<SettingsContext.Provider value={mockSettings}>
<ConfigContext.Provider value={newMockConfig}>
<ModelDialog onClose={vi.fn()} />
</ConfigContext.Provider>
</SettingsContext.Provider>,
);
// Should be called at least twice: initial render + re-render after context change

View File

@@ -5,52 +5,210 @@
*/
import type React from 'react';
import { useCallback, useContext, useMemo } from 'react';
import { useCallback, useContext, useMemo, useState } from 'react';
import { Box, Text } from 'ink';
import {
AuthType,
ModelSlashCommandEvent,
logModelSlashCommand,
type ContentGeneratorConfig,
type ContentGeneratorConfigSource,
type ContentGeneratorConfigSources,
} from '@qwen-code/qwen-code-core';
import { useKeypress } from '../hooks/useKeypress.js';
import { theme } from '../semantic-colors.js';
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { UIStateContext } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import {
getAvailableModelsForAuthType,
MAINLINE_CODER,
} from '../models/availableModels.js';
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
import { t } from '../../i18n/index.js';
interface ModelDialogProps {
onClose: () => void;
}
function formatSourceBadge(
source: ContentGeneratorConfigSource | undefined,
): string | undefined {
if (!source) return undefined;
switch (source.kind) {
case 'cli':
return source.detail ? `CLI ${source.detail}` : 'CLI';
case 'env':
return source.envKey ? `ENV ${source.envKey}` : 'ENV';
case 'settings':
return source.settingsPath
? `Settings ${source.settingsPath}`
: 'Settings';
case 'modelProviders': {
const suffix =
source.authType && source.modelId
? `${source.authType}:${source.modelId}`
: source.authType
? `${source.authType}`
: source.modelId
? `${source.modelId}`
: '';
return suffix ? `ModelProviders ${suffix}` : 'ModelProviders';
}
case 'default':
return source.detail ? `Default ${source.detail}` : 'Default';
case 'computed':
return source.detail ? `Computed ${source.detail}` : 'Computed';
case 'programmatic':
return source.detail ? `Programmatic ${source.detail}` : 'Programmatic';
case 'unknown':
default:
return undefined;
}
}
function readSourcesFromConfig(config: unknown): ContentGeneratorConfigSources {
if (!config) {
return {};
}
const maybe = config as {
getContentGeneratorConfigSources?: () => ContentGeneratorConfigSources;
};
return maybe.getContentGeneratorConfigSources?.() ?? {};
}
function maskApiKey(apiKey: string | undefined): string {
if (!apiKey) return '(not set)';
const trimmed = apiKey.trim();
if (trimmed.length === 0) return '(not set)';
if (trimmed.length <= 6) return '***';
const head = trimmed.slice(0, 3);
const tail = trimmed.slice(-4);
return `${head}${tail}`;
}
function persistModelSelection(
settings: ReturnType<typeof useSettings>,
modelId: string,
): void {
const scope = getPersistScopeForModelSelection(settings);
settings.setValue(scope, 'model.name', modelId);
}
function persistAuthTypeSelection(
settings: ReturnType<typeof useSettings>,
authType: AuthType,
): void {
const scope = getPersistScopeForModelSelection(settings);
settings.setValue(scope, 'security.auth.selectedType', authType);
}
function ConfigRow({
label,
value,
badge,
}: {
label: string;
value: React.ReactNode;
badge?: string;
}): React.JSX.Element {
return (
<Box flexDirection="column">
<Box>
<Box minWidth={12} flexShrink={0}>
<Text color={theme.text.secondary}>{label}:</Text>
</Box>
<Box flexGrow={1} flexDirection="row" flexWrap="wrap">
<Text>{value}</Text>
</Box>
</Box>
{badge ? (
<Box>
<Box minWidth={12} flexShrink={0}>
<Text> </Text>
</Box>
<Box flexGrow={1}>
<Text color={theme.text.secondary}>{badge}</Text>
</Box>
</Box>
) : null}
</Box>
);
}
export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
const config = useContext(ConfigContext);
const uiState = useContext(UIStateContext);
const settings = useSettings();
// Local error state for displaying errors within the dialog
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Get auth type from config, default to QWEN_OAUTH if not available
const authType = config?.getAuthType() ?? AuthType.QWEN_OAUTH;
const effectiveConfig =
(config?.getContentGeneratorConfig?.() as
| ContentGeneratorConfig
| undefined) ?? undefined;
const sources = readSourcesFromConfig(config);
// Get available models based on auth type
const availableModels = useMemo(
() => getAvailableModelsForAuthType(authType),
[authType],
);
const availableModelEntries = useMemo(() => {
const allAuthTypes = Object.values(AuthType) as AuthType[];
const modelsByAuthType = allAuthTypes
.map((t) => ({
authType: t,
models: getAvailableModelsForAuthType(t, config ?? undefined),
}))
.filter((x) => x.models.length > 0);
// Fixed order: qwen-oauth first, then others in a stable order
const authTypeOrder: AuthType[] = [
AuthType.QWEN_OAUTH,
AuthType.USE_OPENAI,
AuthType.USE_ANTHROPIC,
AuthType.USE_GEMINI,
AuthType.USE_VERTEX_AI,
];
// Filter to only include authTypes that have models
const availableAuthTypes = new Set(modelsByAuthType.map((x) => x.authType));
const orderedAuthTypes = authTypeOrder.filter((t) =>
availableAuthTypes.has(t),
);
return orderedAuthTypes.flatMap((t) => {
const models =
modelsByAuthType.find((x) => x.authType === t)?.models ?? [];
return models.map((m) => ({ authType: t, model: m }));
});
}, [config]);
const MODEL_OPTIONS = useMemo(
() =>
availableModels.map((model) => ({
value: model.id,
title: model.label,
description: model.description || '',
key: model.id,
})),
[availableModels],
availableModelEntries.map(({ authType: t2, model }) => {
const value = `${t2}::${model.id}`;
const title = (
<Text>
<Text bold color={theme.text.accent}>
[{t2}]
</Text>
<Text>{` ${model.label}`}</Text>
</Text>
);
const description = model.description || '';
return {
value,
title,
description,
key: value,
};
}),
[availableModelEntries],
);
// Determine the Preferred Model (read once when the dialog opens).
const preferredModel = config?.getModel() || MAINLINE_CODER;
const preferredModelId = config?.getModel() || MAINLINE_CODER;
const preferredKey = `${authType}::${preferredModelId}`;
useKeypress(
(key) => {
@@ -61,25 +219,81 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
{ isActive: true },
);
// Calculate the initial index based on the preferred model.
const initialIndex = useMemo(
() => MODEL_OPTIONS.findIndex((option) => option.value === preferredModel),
[MODEL_OPTIONS, preferredModel],
() => MODEL_OPTIONS.findIndex((option) => option.value === preferredKey),
[MODEL_OPTIONS, preferredKey],
);
// Handle selection internally (Autonomous Dialog).
const handleSelect = useCallback(
(model: string) => {
async (selected: string) => {
// Clear any previous error
setErrorMessage(null);
const sep = '::';
const idx = selected.indexOf(sep);
const selectedAuthType = (
idx >= 0 ? selected.slice(0, idx) : authType
) as AuthType;
const modelId = idx >= 0 ? selected.slice(idx + sep.length) : selected;
if (config) {
config.setModel(model);
const event = new ModelSlashCommandEvent(model);
try {
await config.switchModel(
selectedAuthType,
modelId,
selectedAuthType !== authType &&
selectedAuthType === AuthType.QWEN_OAUTH
? { requireCachedCredentials: true }
: undefined,
{
reason: 'user_manual',
context:
selectedAuthType === authType
? 'Model switched via /model dialog'
: 'AuthType+model switched via /model dialog',
},
);
} catch (e) {
const baseErrorMessage = e instanceof Error ? e.message : String(e);
setErrorMessage(
`Failed to switch model to '${modelId}'.\n\n${baseErrorMessage}`,
);
return;
}
const event = new ModelSlashCommandEvent(modelId);
logModelSlashCommand(config, event);
const after = config.getContentGeneratorConfig?.() as
| ContentGeneratorConfig
| undefined;
const effectiveAuthType =
after?.authType ?? selectedAuthType ?? authType;
const effectiveModelId = after?.model ?? modelId;
persistModelSelection(settings, effectiveModelId);
persistAuthTypeSelection(settings, effectiveAuthType);
const baseUrl = after?.baseUrl ?? '(default)';
const maskedKey = maskApiKey(after?.apiKey);
uiState?.historyManager.addItem(
{
type: 'info',
text:
`authType: ${effectiveAuthType}\n` +
`Using model: ${effectiveModelId}\n` +
`Base URL: ${baseUrl}\n` +
`API key: ${maskedKey}`,
},
Date.now(),
);
}
onClose();
},
[config, onClose],
[authType, config, onClose, settings, uiState, setErrorMessage],
);
const hasModels = MODEL_OPTIONS.length > 0;
return (
<Box
borderStyle="round"
@@ -89,14 +303,73 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
width="100%"
>
<Text bold>{t('Select Model')}</Text>
<Box marginTop={1}>
<DescriptiveRadioButtonSelect
items={MODEL_OPTIONS}
onSelect={handleSelect}
initialIndex={initialIndex}
showNumbers={true}
/>
<Box marginTop={1} flexDirection="column">
<Text color={theme.text.secondary}>
{t('Current (effective) configuration')}
</Text>
<Box flexDirection="column" marginTop={1}>
<ConfigRow label="AuthType" value={authType} />
<ConfigRow
label="Model"
value={effectiveConfig?.model ?? config?.getModel?.() ?? ''}
badge={formatSourceBadge(sources['model'])}
/>
{authType !== AuthType.QWEN_OAUTH && (
<>
<ConfigRow
label="Base URL"
value={effectiveConfig?.baseUrl ?? ''}
badge={formatSourceBadge(sources['baseUrl'])}
/>
<ConfigRow
label="API Key"
value={effectiveConfig?.apiKey ? t('(set)') : t('(not set)')}
badge={formatSourceBadge(sources['apiKey'])}
/>
</>
)}
</Box>
</Box>
{!hasModels ? (
<Box marginTop={1} flexDirection="column">
<Text color={theme.status.warning}>
{t(
'No models available for the current authentication type ({{authType}}).',
{
authType,
},
)}
</Text>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t(
'Please configure models in settings.modelProviders or use environment variables.',
)}
</Text>
</Box>
</Box>
) : (
<Box marginTop={1}>
<DescriptiveRadioButtonSelect
items={MODEL_OPTIONS}
onSelect={handleSelect}
initialIndex={initialIndex}
showNumbers={true}
/>
</Box>
)}
{errorMessage && (
<Box marginTop={1} flexDirection="column" paddingX={1}>
<Text color={theme.status.error} wrap="wrap">
{errorMessage}
</Text>
</Box>
)}
<Box marginTop={1} flexDirection="column">
<Text color={theme.text.secondary}>{t('(Press Esc to close)')}</Text>
</Box>

View File

@@ -87,7 +87,13 @@ export async function showResumeSessionPicker(
let selectedId: string | undefined;
const { unmount, waitUntilExit } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<KeypressProvider
kittyProtocolEnabled={false}
pasteWorkaround={
process.platform === 'win32' ||
parseInt(process.versions.node.split('.')[0], 10) < 20
}
>
<StandalonePickerScreen
sessionService={sessionService}
onSelect={(id) => {

View File

@@ -11,7 +11,7 @@ import { BaseSelectionList } from './BaseSelectionList.js';
import type { SelectionListItem } from '../../hooks/useSelectionList.js';
export interface DescriptiveRadioSelectItem<T> extends SelectionListItem<T> {
title: string;
title: React.ReactNode;
description: string;
}

View File

@@ -30,7 +30,6 @@ export interface UIActions {
) => void;
handleAuthSelect: (
authType: AuthType | undefined,
scope: SettingScope,
credentials?: OpenAICredentials,
) => Promise<void>;
setAuthState: (state: AuthState) => void;

View File

@@ -25,7 +25,6 @@ export interface DialogCloseOptions {
isAuthDialogOpen: boolean;
handleAuthSelect: (
authType: AuthType | undefined,
scope: SettingScope,
credentials?: OpenAICredentials,
) => Promise<void>;
pendingAuthType: AuthType | undefined;

View File

@@ -912,7 +912,7 @@ export const useGeminiStream = (
// Reset quota error flag when starting a new query (not a continuation)
if (!options?.isContinuation) {
setModelSwitchedFromQuotaError(false);
config.setQuotaErrorOccurred(false);
// No quota-error / fallback routing mechanism currently; keep state minimal.
}
abortControllerRef.current = new AbortController();

View File

@@ -62,7 +62,7 @@ const mockConfig = {
getAllowedTools: vi.fn(() => []),
getContentGeneratorConfig: () => ({
model: 'test-model',
authType: 'gemini-api-key',
authType: 'gemini',
}),
getUseSmartEdit: () => false,
getUseModelRouter: () => false,

View File

@@ -0,0 +1,205 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
getAvailableModelsForAuthType,
getFilteredQwenModels,
getOpenAIAvailableModelFromEnv,
isVisionModel,
getDefaultVisionModel,
AVAILABLE_MODELS_QWEN,
MAINLINE_VLM,
MAINLINE_CODER,
} from './availableModels.js';
import { AuthType, type Config } from '@qwen-code/qwen-code-core';
describe('availableModels', () => {
describe('AVAILABLE_MODELS_QWEN', () => {
it('should include coder model', () => {
const coderModel = AVAILABLE_MODELS_QWEN.find(
(m) => m.id === MAINLINE_CODER,
);
expect(coderModel).toBeDefined();
expect(coderModel?.isVision).toBeFalsy();
});
it('should include vision model', () => {
const visionModel = AVAILABLE_MODELS_QWEN.find(
(m) => m.id === MAINLINE_VLM,
);
expect(visionModel).toBeDefined();
expect(visionModel?.isVision).toBe(true);
});
});
describe('getFilteredQwenModels', () => {
it('should return all models when vision preview is enabled', () => {
const models = getFilteredQwenModels(true);
expect(models.length).toBe(AVAILABLE_MODELS_QWEN.length);
});
it('should filter out vision models when preview is disabled', () => {
const models = getFilteredQwenModels(false);
expect(models.every((m) => !m.isVision)).toBe(true);
});
});
describe('getOpenAIAvailableModelFromEnv', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
it('should return null when OPENAI_MODEL is not set', () => {
delete process.env['OPENAI_MODEL'];
expect(getOpenAIAvailableModelFromEnv()).toBeNull();
});
it('should return model from OPENAI_MODEL env var', () => {
process.env['OPENAI_MODEL'] = 'gpt-4-turbo';
const model = getOpenAIAvailableModelFromEnv();
expect(model?.id).toBe('gpt-4-turbo');
expect(model?.label).toBe('gpt-4-turbo');
});
it('should trim whitespace from env var', () => {
process.env['OPENAI_MODEL'] = ' gpt-4 ';
const model = getOpenAIAvailableModelFromEnv();
expect(model?.id).toBe('gpt-4');
});
});
describe('getAvailableModelsForAuthType', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
it('should return hard-coded qwen models for qwen-oauth', () => {
const models = getAvailableModelsForAuthType(AuthType.QWEN_OAUTH);
expect(models).toEqual(AVAILABLE_MODELS_QWEN);
});
it('should return hard-coded qwen models even when config is provided', () => {
const mockConfig = {
getAvailableModels: vi
.fn()
.mockReturnValue([
{ id: 'custom', label: 'Custom', authType: AuthType.QWEN_OAUTH },
]),
} as unknown as Config;
const models = getAvailableModelsForAuthType(
AuthType.QWEN_OAUTH,
mockConfig,
);
expect(models).toEqual(AVAILABLE_MODELS_QWEN);
});
it('should use config.getAvailableModels for openai authType when available', () => {
const mockModels = [
{
id: 'gpt-4',
label: 'GPT-4',
description: 'Test',
authType: AuthType.USE_OPENAI,
isVision: false,
},
];
const getAvailableModelsForAuthType = vi.fn().mockReturnValue(mockModels);
const mockConfigWithMethod = {
// Prefer the newer API when available.
getAvailableModelsForAuthType,
};
const models = getAvailableModelsForAuthType(
AuthType.USE_OPENAI,
mockConfigWithMethod as unknown as Config,
);
expect(getAvailableModelsForAuthType).toHaveBeenCalled();
expect(models[0].id).toBe('gpt-4');
});
it('should fallback to env var for openai when config returns empty', () => {
process.env['OPENAI_MODEL'] = 'fallback-model';
const mockConfig = {
getAvailableModelsForAuthType: vi.fn().mockReturnValue([]),
} as unknown as Config;
const models = getAvailableModelsForAuthType(
AuthType.USE_OPENAI,
mockConfig,
);
expect(models).toEqual([]);
});
it('should fallback to env var for openai when config throws', () => {
process.env['OPENAI_MODEL'] = 'fallback-model';
const mockConfig = {
getAvailableModelsForAuthType: vi.fn().mockImplementation(() => {
throw new Error('Registry not initialized');
}),
} as unknown as Config;
const models = getAvailableModelsForAuthType(
AuthType.USE_OPENAI,
mockConfig,
);
expect(models).toEqual([]);
});
it('should return env model for openai without config', () => {
process.env['OPENAI_MODEL'] = 'gpt-4-turbo';
const models = getAvailableModelsForAuthType(AuthType.USE_OPENAI);
expect(models[0].id).toBe('gpt-4-turbo');
});
it('should return empty array for openai without config or env', () => {
delete process.env['OPENAI_MODEL'];
const models = getAvailableModelsForAuthType(AuthType.USE_OPENAI);
expect(models).toEqual([]);
});
it('should return empty array for other auth types', () => {
const models = getAvailableModelsForAuthType(AuthType.USE_GEMINI);
expect(models).toEqual([]);
});
});
describe('isVisionModel', () => {
it('should return true for vision model', () => {
expect(isVisionModel(MAINLINE_VLM)).toBe(true);
});
it('should return false for non-vision model', () => {
expect(isVisionModel(MAINLINE_CODER)).toBe(false);
});
it('should return false for unknown model', () => {
expect(isVisionModel('unknown-model')).toBe(false);
});
});
describe('getDefaultVisionModel', () => {
it('should return the vision model ID', () => {
expect(getDefaultVisionModel()).toBe(MAINLINE_VLM);
});
});
});

View File

@@ -4,7 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { AuthType, DEFAULT_QWEN_MODEL } from '@qwen-code/qwen-code-core';
import {
AuthType,
DEFAULT_QWEN_MODEL,
type Config,
type AvailableModel as CoreAvailableModel,
} from '@qwen-code/qwen-code-core';
import { t } from '../../i18n/index.js';
export type AvailableModel = {
@@ -57,20 +62,78 @@ export function getFilteredQwenModels(
*/
export function getOpenAIAvailableModelFromEnv(): AvailableModel | null {
const id = process.env['OPENAI_MODEL']?.trim();
return id ? { id, label: id } : null;
return id
? {
id,
label: id,
get description() {
return t('Configured via OPENAI_MODEL environment variable');
},
}
: null;
}
export function getAnthropicAvailableModelFromEnv(): AvailableModel | null {
const id = process.env['ANTHROPIC_MODEL']?.trim();
return id ? { id, label: id } : null;
return id
? {
id,
label: id,
get description() {
return t('Configured via ANTHROPIC_MODEL environment variable');
},
}
: null;
}
/**
* Convert core AvailableModel to CLI AvailableModel format
*/
function convertCoreModelToCliModel(
coreModel: CoreAvailableModel,
): AvailableModel {
return {
id: coreModel.id,
label: coreModel.label,
description: coreModel.description,
isVision: coreModel.isVision ?? coreModel.capabilities?.vision ?? false,
};
}
/**
* Get available models for the given authType.
*
* If a Config object is provided, uses config.getAvailableModelsForAuthType().
* For qwen-oauth, always returns the hard-coded models.
* Falls back to environment variables only when no config is provided.
*/
export function getAvailableModelsForAuthType(
authType: AuthType,
config?: Config,
): AvailableModel[] {
// For qwen-oauth, always use hard-coded models, this aligns with the API gateway.
if (authType === AuthType.QWEN_OAUTH) {
return AVAILABLE_MODELS_QWEN;
}
// Use config's model registry when available
if (config) {
try {
const models = config.getAvailableModelsForAuthType(authType);
if (models.length > 0) {
return models.map(convertCoreModelToCliModel);
}
} catch {
// If config throws (e.g., not initialized), return empty array
}
// When a Config object is provided, we intentionally do NOT fall back to env-based
// "raw" models. These may reflect the currently effective config but should not be
// presented as selectable options in /model.
return [];
}
// Fall back to environment variables for specific auth types (no config provided)
switch (authType) {
case AuthType.QWEN_OAUTH:
return AVAILABLE_MODELS_QWEN;
case AuthType.USE_OPENAI: {
const openAIModel = getOpenAIAvailableModelFromEnv();
return openAIModel ? [openAIModel] : [];
@@ -80,13 +143,10 @@ export function getAvailableModelsForAuthType(
return anthropicModel ? [anthropicModel] : [];
}
default:
// For other auth types, return empty array for now
// This can be expanded later according to the design doc
return [];
}
}
/**
/**
* Hard code the default vision model as a string literal,
* until our coding model supports multimodal.

View File

@@ -6,7 +6,11 @@
import { vi, type Mock, type MockInstance } from 'vitest';
import type { Config } from '@qwen-code/qwen-code-core';
import { OutputFormat, FatalInputError } from '@qwen-code/qwen-code-core';
import {
OutputFormat,
FatalInputError,
ToolErrorType,
} from '@qwen-code/qwen-code-core';
import {
getErrorMessage,
handleError,
@@ -65,6 +69,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
describe('errors', () => {
let mockConfig: Config;
let processExitSpy: MockInstance;
let processStderrWriteSpy: MockInstance;
let consoleErrorSpy: MockInstance;
beforeEach(() => {
@@ -74,6 +79,11 @@ describe('errors', () => {
// Mock console.error
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
// Mock process.stderr.write
processStderrWriteSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
// Mock process.exit to throw instead of actually exiting
processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new Error(`process.exit called with code: ${code}`);
@@ -84,11 +94,13 @@ describe('errors', () => {
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'test' }),
getDebugMode: vi.fn().mockReturnValue(true),
isInteractive: vi.fn().mockReturnValue(false),
} as unknown as Config;
});
afterEach(() => {
consoleErrorSpy.mockRestore();
processStderrWriteSpy.mockRestore();
processExitSpy.mockRestore();
});
@@ -432,6 +444,87 @@ describe('errors', () => {
expect(processExitSpy).not.toHaveBeenCalled();
});
});
describe('permission denied warnings', () => {
it('should show warning when EXECUTION_DENIED in non-interactive text mode', () => {
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
(mockConfig.isInteractive as Mock).mockReturnValue(false);
(
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
).mockReturnValue(OutputFormat.TEXT);
handleToolError(
toolName,
toolError,
mockConfig,
ToolErrorType.EXECUTION_DENIED,
);
expect(processStderrWriteSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Warning: Tool "test-tool" requires user approval',
),
);
expect(processStderrWriteSpy).toHaveBeenCalledWith(
expect.stringContaining('use the -y flag (YOLO mode)'),
);
expect(processExitSpy).not.toHaveBeenCalled();
});
it('should not show warning when EXECUTION_DENIED in interactive mode', () => {
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
(mockConfig.isInteractive as Mock).mockReturnValue(true);
(
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
).mockReturnValue(OutputFormat.TEXT);
handleToolError(
toolName,
toolError,
mockConfig,
ToolErrorType.EXECUTION_DENIED,
);
expect(processStderrWriteSpy).not.toHaveBeenCalled();
expect(processExitSpy).not.toHaveBeenCalled();
});
it('should not show warning when EXECUTION_DENIED in JSON mode', () => {
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
(mockConfig.isInteractive as Mock).mockReturnValue(false);
(
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
).mockReturnValue(OutputFormat.JSON);
handleToolError(
toolName,
toolError,
mockConfig,
ToolErrorType.EXECUTION_DENIED,
);
expect(processStderrWriteSpy).not.toHaveBeenCalled();
expect(processExitSpy).not.toHaveBeenCalled();
});
it('should not show warning for non-EXECUTION_DENIED errors', () => {
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
(mockConfig.isInteractive as Mock).mockReturnValue(false);
(
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
).mockReturnValue(OutputFormat.TEXT);
handleToolError(
toolName,
toolError,
mockConfig,
ToolErrorType.FILE_NOT_FOUND,
);
expect(processStderrWriteSpy).not.toHaveBeenCalled();
expect(processExitSpy).not.toHaveBeenCalled();
});
});
});
describe('handleCancellationError', () => {

View File

@@ -11,6 +11,7 @@ import {
parseAndFormatApiError,
FatalTurnLimitedError,
FatalCancellationError,
ToolErrorType,
} from '@qwen-code/qwen-code-core';
export function getErrorMessage(error: unknown): string {
@@ -102,10 +103,24 @@ export function handleToolError(
toolName: string,
toolError: Error,
config: Config,
_errorCode?: string | number,
errorCode?: string | number,
resultDisplay?: string,
): void {
// Always just log to stderr; JSON/streaming formatting happens in the tool_result block elsewhere
// Check if this is a permission denied error in non-interactive mode
const isExecutionDenied = errorCode === ToolErrorType.EXECUTION_DENIED;
const isNonInteractive = !config.isInteractive();
const isTextMode = config.getOutputFormat() === OutputFormat.TEXT;
// Show warning for permission denied errors in non-interactive text mode
if (isExecutionDenied && isNonInteractive && isTextMode) {
const warningMessage =
`Warning: Tool "${toolName}" requires user approval but cannot execute in non-interactive mode.\n` +
`To enable automatic tool execution, use the -y flag (YOLO mode):\n` +
`Example: qwen -p 'your prompt' -y\n\n`;
process.stderr.write(warningMessage);
}
// Always log detailed error in debug mode
if (config.getDebugMode()) {
console.error(
`Error executing tool ${toolName}: ${resultDisplay || toolError.message}`,

View File

@@ -0,0 +1,133 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import {
AuthType,
type ContentGeneratorConfig,
type ContentGeneratorConfigSources,
resolveModelConfig,
type ModelConfigSourcesInput,
} from '@qwen-code/qwen-code-core';
import type { Settings } from '../config/settings.js';
export interface CliGenerationConfigInputs {
argv: {
model?: string | undefined;
openaiApiKey?: string | undefined;
openaiBaseUrl?: string | undefined;
openaiLogging?: boolean | undefined;
openaiLoggingDir?: string | undefined;
};
settings: Settings;
selectedAuthType: AuthType | undefined;
/**
* Injectable env for testability. Defaults to process.env at callsites.
*/
env?: Record<string, string | undefined>;
}
export interface ResolvedCliGenerationConfig {
/** The resolved model id (may be empty string if not resolvable at CLI layer) */
model: string;
/** API key for OpenAI-compatible auth */
apiKey: string;
/** Base URL for OpenAI-compatible auth */
baseUrl: string;
/** The full generation config to pass to core Config */
generationConfig: Partial<ContentGeneratorConfig>;
/** Source attribution for each resolved field */
sources: ContentGeneratorConfigSources;
}
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['GEMINI_API_KEY']) {
return AuthType.USE_GEMINI;
}
if (process.env['GOOGLE_API_KEY']) {
return AuthType.USE_VERTEX_AI;
}
if (process.env['ANTHROPIC_API_KEY']) {
return AuthType.USE_ANTHROPIC;
}
return undefined;
}
/**
* Unified resolver for CLI generation config.
*
* Precedence (for OpenAI auth):
* - model: argv.model > OPENAI_MODEL > QWEN_MODEL > settings.model.name
* - apiKey: argv.openaiApiKey > OPENAI_API_KEY > settings.security.auth.apiKey
* - baseUrl: argv.openaiBaseUrl > OPENAI_BASE_URL > settings.security.auth.baseUrl
*
* For non-OpenAI auth, only argv.model override is respected at CLI layer.
*/
export function resolveCliGenerationConfig(
inputs: CliGenerationConfigInputs,
): ResolvedCliGenerationConfig {
const { argv, settings, selectedAuthType } = inputs;
const env = inputs.env ?? (process.env as Record<string, string | undefined>);
const authType = selectedAuthType ?? AuthType.QWEN_OAUTH;
const configSources: ModelConfigSourcesInput = {
authType,
cli: {
model: argv.model,
apiKey: argv.openaiApiKey,
baseUrl: argv.openaiBaseUrl,
},
settings: {
model: settings.model?.name,
apiKey: settings.security?.auth?.apiKey,
baseUrl: settings.security?.auth?.baseUrl,
generationConfig: settings.model?.generationConfig as
| Partial<ContentGeneratorConfig>
| undefined,
},
env,
};
const resolved = resolveModelConfig(configSources);
// Log warnings if any
for (const warning of resolved.warnings) {
console.warn(`[modelProviderUtils] ${warning}`);
}
// Resolve OpenAI logging config (CLI-specific, not part of core resolver)
const enableOpenAILogging =
(typeof argv.openaiLogging === 'undefined'
? settings.model?.enableOpenAILogging
: argv.openaiLogging) ?? false;
const openAILoggingDir =
argv.openaiLoggingDir || settings.model?.openAILoggingDir;
// Build the full generation config
// Note: we merge the resolved config with logging settings
const generationConfig: Partial<ContentGeneratorConfig> = {
...resolved.config,
enableOpenAILogging,
openAILoggingDir,
};
return {
model: resolved.config.model || '',
apiKey: resolved.config.apiKey || '',
baseUrl: resolved.config.baseUrl || '',
generationConfig,
sources: resolved.sources,
};
}

View File

@@ -57,6 +57,7 @@ describe('systemInfo', () => {
getModel: vi.fn().mockReturnValue('test-model'),
getIdeMode: vi.fn().mockReturnValue(true),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getAuthType: vi.fn().mockReturnValue('test-auth'),
getContentGeneratorConfig: vi.fn().mockReturnValue({
baseUrl: 'https://api.openai.com',
}),
@@ -273,6 +274,9 @@ describe('systemInfo', () => {
// Update the mock context to use OpenAI auth
mockContext.services.settings.merged.security!.auth!.selectedType =
AuthType.USE_OPENAI;
vi.mocked(mockContext.services.config!.getAuthType).mockReturnValue(
AuthType.USE_OPENAI,
);
const extendedInfo = await getExtendedSystemInfo(mockContext);

View File

@@ -115,8 +115,7 @@ export async function getSystemInfo(
const sandboxEnv = getSandboxEnv();
const modelVersion = context.services.config?.getModel() || 'Unknown';
const cliVersion = await getCliVersion();
const selectedAuthType =
context.services.settings.merged.security?.auth?.selectedType || '';
const selectedAuthType = context.services.config?.getAuthType() || '';
const ideClient = await getIdeClientName(context);
const sessionId = context.services.config?.getSessionId() || 'unknown';

View File

@@ -14,6 +14,20 @@ import * as JsonOutputAdapterModule from './nonInteractive/io/JsonOutputAdapter.
import * as StreamJsonOutputAdapterModule from './nonInteractive/io/StreamJsonOutputAdapter.js';
import * as cleanupModule from './utils/cleanup.js';
// Helper to create a mock Config with modelsConfig
function createMockConfig(overrides?: Partial<Config>): Config {
return {
refreshAuth: vi.fn().mockResolvedValue('refreshed'),
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: undefined }),
modelsConfig: {
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH),
},
...overrides,
} as unknown as Config;
}
describe('validateNonInterActiveAuth', () => {
let originalEnvGeminiApiKey: string | undefined;
let originalEnvVertexAi: string | undefined;
@@ -107,17 +121,20 @@ describe('validateNonInterActiveAuth', () => {
vi.restoreAllMocks();
});
it('exits if no auth type is configured or env vars set', async () => {
const nonInteractiveConfig = {
it('exits if validateAuthMethod fails for default auth type', async () => {
// Mock validateAuthMethod to return error (e.g., missing API key)
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue(
'Missing API key for authentication',
);
const nonInteractiveConfig = createMockConfig({
refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
getContentGeneratorConfig: vi
.fn()
.mockReturnValue({ authType: undefined }),
} as unknown as Config;
modelsConfig: {
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH),
},
});
try {
await validateNonInteractiveAuth(
undefined,
undefined,
nonInteractiveConfig,
mockSettings,
@@ -127,22 +144,21 @@ describe('validateNonInterActiveAuth', () => {
expect((e as Error).message).toContain('process.exit(1) called');
}
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Please set an Auth method'),
expect.stringContaining('Missing API key'),
);
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it('uses USE_OPENAI if OPENAI_API_KEY is set', async () => {
process.env['OPENAI_API_KEY'] = 'fake-openai-key';
const nonInteractiveConfig = {
const nonInteractiveConfig = createMockConfig({
refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
getContentGeneratorConfig: vi
.fn()
.mockReturnValue({ authType: undefined }),
} as unknown as Config;
modelsConfig: {
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI),
},
});
await validateNonInteractiveAuth(
undefined,
undefined,
nonInteractiveConfig,
mockSettings,
@@ -151,15 +167,14 @@ describe('validateNonInterActiveAuth', () => {
});
it('uses configured QWEN_OAUTH if provided', async () => {
const nonInteractiveConfig = {
const nonInteractiveConfig = createMockConfig({
refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
getContentGeneratorConfig: vi
.fn()
.mockReturnValue({ authType: undefined }),
} as unknown as Config;
modelsConfig: {
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH),
},
});
await validateNonInteractiveAuth(
AuthType.QWEN_OAUTH,
undefined,
nonInteractiveConfig,
mockSettings,
@@ -170,16 +185,11 @@ describe('validateNonInterActiveAuth', () => {
it('exits if validateAuthMethod returns error', async () => {
// Mock validateAuthMethod to return error
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!');
const nonInteractiveConfig = {
const nonInteractiveConfig = createMockConfig({
refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
getContentGeneratorConfig: vi
.fn()
.mockReturnValue({ authType: undefined }),
} as unknown as Config;
});
try {
await validateNonInteractiveAuth(
AuthType.USE_GEMINI,
undefined,
nonInteractiveConfig,
mockSettings,
@@ -197,14 +207,13 @@ describe('validateNonInterActiveAuth', () => {
const validateAuthMethodSpy = vi
.spyOn(auth, 'validateAuthMethod')
.mockReturnValue('Auth error!');
const nonInteractiveConfig = {
const nonInteractiveConfig = createMockConfig({
refreshAuth: refreshAuthMock,
} as unknown as Config;
});
// Even with an invalid auth type, it should not exit
// because validation is skipped.
// Even with validation errors, it should not exit
// because validation is skipped when useExternalAuth is true.
await validateNonInteractiveAuth(
'invalid-auth-type' as AuthType,
true, // useExternalAuth = true
nonInteractiveConfig,
mockSettings,
@@ -213,8 +222,8 @@ describe('validateNonInterActiveAuth', () => {
expect(validateAuthMethodSpy).not.toHaveBeenCalled();
expect(consoleErrorSpy).not.toHaveBeenCalled();
expect(processExitSpy).not.toHaveBeenCalled();
// We still expect refreshAuth to be called with the (invalid) type
expect(refreshAuthMock).toHaveBeenCalledWith('invalid-auth-type');
// refreshAuth is called with the authType from config.modelsConfig.getCurrentAuthType()
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.QWEN_OAUTH);
});
it('uses enforcedAuthType if provided', async () => {
@@ -222,11 +231,14 @@ describe('validateNonInterActiveAuth', () => {
mockSettings.merged.security!.auth!.selectedType = AuthType.USE_OPENAI;
// Set required env var for USE_OPENAI to ensure enforcedAuthType takes precedence
process.env['OPENAI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = {
const nonInteractiveConfig = createMockConfig({
refreshAuth: refreshAuthMock,
} as unknown as Config;
modelsConfig: {
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI),
},
});
await validateNonInteractiveAuth(
AuthType.USE_OPENAI,
undefined,
nonInteractiveConfig,
mockSettings,
@@ -237,16 +249,15 @@ describe('validateNonInterActiveAuth', () => {
it('exits if currentAuthType does not match enforcedAuthType', async () => {
mockSettings.merged.security!.auth!.enforcedType = AuthType.QWEN_OAUTH;
process.env['OPENAI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = {
const nonInteractiveConfig = createMockConfig({
refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
getContentGeneratorConfig: vi
.fn()
.mockReturnValue({ authType: undefined }),
} as unknown as Config;
modelsConfig: {
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI),
},
});
try {
await validateNonInteractiveAuth(
AuthType.USE_OPENAI,
undefined,
nonInteractiveConfig,
mockSettings,
@@ -279,18 +290,21 @@ describe('validateNonInterActiveAuth', () => {
);
});
it('emits error result and exits when no auth is configured', async () => {
const nonInteractiveConfig = {
it('emits error result and exits when validateAuthMethod fails', async () => {
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue(
'Missing API key for authentication',
);
const nonInteractiveConfig = createMockConfig({
refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON),
getContentGeneratorConfig: vi
.fn()
.mockReturnValue({ authType: undefined }),
} as unknown as Config;
modelsConfig: {
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH),
},
});
try {
await validateNonInteractiveAuth(
undefined,
undefined,
nonInteractiveConfig,
mockSettings,
@@ -302,9 +316,7 @@ describe('validateNonInterActiveAuth', () => {
expect(emitResultMock).toHaveBeenCalledWith({
isError: true,
errorMessage: expect.stringContaining(
'Please set an Auth method in your',
),
errorMessage: expect.stringContaining('Missing API key'),
durationMs: 0,
apiDurationMs: 0,
numTurns: 0,
@@ -319,17 +331,17 @@ describe('validateNonInterActiveAuth', () => {
mockSettings.merged.security!.auth!.enforcedType = AuthType.QWEN_OAUTH;
process.env['OPENAI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = {
const nonInteractiveConfig = createMockConfig({
refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON),
getContentGeneratorConfig: vi
.fn()
.mockReturnValue({ authType: undefined }),
} as unknown as Config;
modelsConfig: {
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI),
},
});
try {
await validateNonInteractiveAuth(
undefined,
undefined,
nonInteractiveConfig,
mockSettings,
@@ -354,21 +366,21 @@ describe('validateNonInterActiveAuth', () => {
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('emits error result and exits when validateAuthMethod fails', async () => {
it('emits error result and exits when API key validation fails', async () => {
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!');
process.env['OPENAI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = {
const nonInteractiveConfig = createMockConfig({
refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON),
getContentGeneratorConfig: vi
.fn()
.mockReturnValue({ authType: undefined }),
} as unknown as Config;
modelsConfig: {
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI),
},
});
try {
await validateNonInteractiveAuth(
AuthType.USE_OPENAI,
undefined,
nonInteractiveConfig,
mockSettings,
@@ -413,19 +425,22 @@ describe('validateNonInterActiveAuth', () => {
);
});
it('emits error result and exits when no auth is configured', async () => {
const nonInteractiveConfig = {
it('emits error result and exits when validateAuthMethod fails', async () => {
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue(
'Missing API key for authentication',
);
const nonInteractiveConfig = createMockConfig({
refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON),
getIncludePartialMessages: vi.fn().mockReturnValue(false),
getContentGeneratorConfig: vi
.fn()
.mockReturnValue({ authType: undefined }),
} as unknown as Config;
modelsConfig: {
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH),
},
});
try {
await validateNonInteractiveAuth(
undefined,
undefined,
nonInteractiveConfig,
mockSettings,
@@ -437,9 +452,7 @@ describe('validateNonInterActiveAuth', () => {
expect(emitResultMock).toHaveBeenCalledWith({
isError: true,
errorMessage: expect.stringContaining(
'Please set an Auth method in your',
),
errorMessage: expect.stringContaining('Missing API key'),
durationMs: 0,
apiDurationMs: 0,
numTurns: 0,
@@ -454,18 +467,18 @@ describe('validateNonInterActiveAuth', () => {
mockSettings.merged.security!.auth!.enforcedType = AuthType.QWEN_OAUTH;
process.env['OPENAI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = {
const nonInteractiveConfig = createMockConfig({
refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON),
getIncludePartialMessages: vi.fn().mockReturnValue(false),
getContentGeneratorConfig: vi
.fn()
.mockReturnValue({ authType: undefined }),
} as unknown as Config;
modelsConfig: {
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI),
},
});
try {
await validateNonInteractiveAuth(
undefined,
undefined,
nonInteractiveConfig,
mockSettings,
@@ -490,22 +503,22 @@ describe('validateNonInterActiveAuth', () => {
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('emits error result and exits when validateAuthMethod fails', async () => {
it('emits error result and exits when API key validation fails', async () => {
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!');
process.env['OPENAI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = {
const nonInteractiveConfig = createMockConfig({
refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON),
getIncludePartialMessages: vi.fn().mockReturnValue(false),
getContentGeneratorConfig: vi
.fn()
.mockReturnValue({ authType: undefined }),
} as unknown as Config;
modelsConfig: {
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI),
},
});
try {
await validateNonInteractiveAuth(
AuthType.USE_OPENAI,
undefined,
nonInteractiveConfig,
mockSettings,

View File

@@ -5,63 +5,30 @@
*/
import type { Config } from '@qwen-code/qwen-code-core';
import { AuthType, OutputFormat } from '@qwen-code/qwen-code-core';
import { USER_SETTINGS_PATH } from './config/settings.js';
import { OutputFormat } from '@qwen-code/qwen-code-core';
import { validateAuthMethod } from './config/auth.js';
import { type LoadedSettings } from './config/settings.js';
import { JsonOutputAdapter } from './nonInteractive/io/JsonOutputAdapter.js';
import { StreamJsonOutputAdapter } from './nonInteractive/io/StreamJsonOutputAdapter.js';
import { runExitCleanup } from './utils/cleanup.js';
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['GEMINI_API_KEY']) {
return AuthType.USE_GEMINI;
}
if (process.env['GOOGLE_API_KEY']) {
return AuthType.USE_VERTEX_AI;
}
if (process.env['ANTHROPIC_API_KEY']) {
return AuthType.USE_ANTHROPIC;
}
return undefined;
}
export async function validateNonInteractiveAuth(
configuredAuthType: AuthType | undefined,
useExternalAuth: boolean | undefined,
nonInteractiveConfig: Config,
settings: LoadedSettings,
): Promise<Config> {
try {
// Get the actual authType from config which has already resolved CLI args, env vars, and settings
const authType = nonInteractiveConfig.modelsConfig.getCurrentAuthType();
const enforcedType = settings.merged.security?.auth?.enforcedType;
if (enforcedType) {
const currentAuthType = getAuthTypeFromEnv();
if (currentAuthType !== enforcedType) {
const message = `The configured auth type is ${enforcedType}, but the current auth type is ${currentAuthType}. Please re-authenticate with the correct type.`;
throw new Error(message);
}
}
const effectiveAuthType =
enforcedType || configuredAuthType || getAuthTypeFromEnv();
if (!effectiveAuthType) {
const message = `Please set an Auth method in your ${USER_SETTINGS_PATH} or specify one of the following environment variables before running: QWEN_OAUTH, OPENAI_API_KEY`;
if (enforcedType && enforcedType !== authType) {
const message = `The configured auth type is ${enforcedType}, but the current auth type is ${authType}. Please re-authenticate with the correct type.`;
throw new Error(message);
}
const authType: AuthType = effectiveAuthType as AuthType;
if (!useExternalAuth) {
const err = validateAuthMethod(String(authType));
const err = validateAuthMethod(authType, nonInteractiveConfig);
if (err != null) {
throw new Error(err);
}

View File

@@ -8,12 +8,8 @@ export * from './src/index.js';
export { Storage } from './src/config/storage.js';
export {
DEFAULT_QWEN_MODEL,
DEFAULT_QWEN_FLASH_MODEL,
DEFAULT_QWEN_EMBEDDING_MODEL,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL_AUTO,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
DEFAULT_GEMINI_EMBEDDING_MODEL,
} from './src/config/models.js';
export {
serializeTerminalToObject,

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-core",
"version": "0.6.0",
"version": "0.6.1",
"description": "Qwen Code Core",
"repository": {
"type": "git",

View File

@@ -15,10 +15,16 @@ import {
DEFAULT_OTLP_ENDPOINT,
QwenLogger,
} from '../telemetry/index.js';
import type { ContentGeneratorConfig } from '../core/contentGenerator.js';
import type {
ContentGenerator,
ContentGeneratorConfig,
} from '../core/contentGenerator.js';
import { DEFAULT_DASHSCOPE_BASE_URL } from '../core/openaiContentGenerator/constants.js';
import {
AuthType,
createContentGenerator,
createContentGeneratorConfig,
resolveContentGeneratorConfigWithSources,
} from '../core/contentGenerator.js';
import { GeminiClient } from '../core/client.js';
import { GitService } from '../services/gitService.js';
@@ -208,6 +214,19 @@ describe('Server Config (config.ts)', () => {
vi.spyOn(QwenLogger.prototype, 'logStartSessionEvent').mockImplementation(
async () => undefined,
);
// Setup default mock for resolveContentGeneratorConfigWithSources
vi.mocked(resolveContentGeneratorConfigWithSources).mockImplementation(
(_config, authType, generationConfig) => ({
config: {
...generationConfig,
authType,
model: generationConfig?.model || MODEL,
apiKey: 'test-key',
} as ContentGeneratorConfig,
sources: {},
}),
);
});
describe('initialize', () => {
@@ -255,31 +274,28 @@ describe('Server Config (config.ts)', () => {
const mockContentConfig = {
apiKey: 'test-key',
model: 'qwen3-coder-plus',
authType,
};
vi.mocked(createContentGeneratorConfig).mockReturnValue(
mockContentConfig,
);
// Set fallback mode to true to ensure it gets reset
config.setFallbackMode(true);
expect(config.isInFallbackMode()).toBe(true);
vi.mocked(resolveContentGeneratorConfigWithSources).mockReturnValue({
config: mockContentConfig as ContentGeneratorConfig,
sources: {},
});
await config.refreshAuth(authType);
expect(createContentGeneratorConfig).toHaveBeenCalledWith(
expect(resolveContentGeneratorConfigWithSources).toHaveBeenCalledWith(
config,
authType,
{
expect.objectContaining({
model: MODEL,
baseUrl: undefined,
},
}),
expect.anything(),
expect.anything(),
);
// Verify that contentGeneratorConfig is updated
expect(config.getContentGeneratorConfig()).toEqual(mockContentConfig);
expect(GeminiClient).toHaveBeenCalledWith(config);
// Verify that fallback mode is reset
expect(config.isInFallbackMode()).toBe(false);
});
it('should not strip thoughts when switching from Vertex to GenAI', async () => {
@@ -300,6 +316,129 @@ describe('Server Config (config.ts)', () => {
});
});
describe('model switching optimization (QWEN_OAUTH)', () => {
it('should switch qwen-oauth model in-place without refreshing auth when safe', async () => {
const config = new Config(baseParams);
const mockContentConfig: ContentGeneratorConfig = {
authType: AuthType.QWEN_OAUTH,
model: 'coder-model',
apiKey: 'QWEN_OAUTH_DYNAMIC_TOKEN',
baseUrl: DEFAULT_DASHSCOPE_BASE_URL,
timeout: 60000,
maxRetries: 3,
} as ContentGeneratorConfig;
vi.mocked(resolveContentGeneratorConfigWithSources).mockImplementation(
(_config, authType, generationConfig) => ({
config: {
...mockContentConfig,
authType,
model: generationConfig?.model ?? mockContentConfig.model,
} as ContentGeneratorConfig,
sources: {},
}),
);
vi.mocked(createContentGenerator).mockResolvedValue({
generateContent: vi.fn(),
generateContentStream: vi.fn(),
countTokens: vi.fn(),
embedContent: vi.fn(),
} as unknown as ContentGenerator);
// Establish initial qwen-oauth content generator config/content generator.
await config.refreshAuth(AuthType.QWEN_OAUTH);
// Spy after initial refresh to ensure model switch does not re-trigger refreshAuth.
const refreshSpy = vi.spyOn(config, 'refreshAuth');
await config.switchModel(AuthType.QWEN_OAUTH, 'vision-model');
expect(config.getModel()).toBe('vision-model');
expect(refreshSpy).not.toHaveBeenCalled();
// Called once during initial refreshAuth + once during handleModelChange diffing.
expect(
vi.mocked(resolveContentGeneratorConfigWithSources),
).toHaveBeenCalledTimes(2);
expect(vi.mocked(createContentGenerator)).toHaveBeenCalledTimes(1);
});
});
describe('model switching with different credentials (OpenAI)', () => {
it('should refresh auth when switching to model with different envKey', async () => {
// This test verifies the fix for switching between modelProvider models
// with different envKeys (e.g., deepseek-chat with DEEPSEEK_API_KEY)
const configWithModelProviders = new Config({
...baseParams,
authType: AuthType.USE_OPENAI,
modelProvidersConfig: {
openai: [
{
id: 'model-a',
name: 'Model A',
baseUrl: 'https://api.example.com/v1',
envKey: 'API_KEY_A',
},
{
id: 'model-b',
name: 'Model B',
baseUrl: 'https://api.example.com/v1',
envKey: 'API_KEY_B',
},
],
},
});
const mockContentConfigA: ContentGeneratorConfig = {
authType: AuthType.USE_OPENAI,
model: 'model-a',
apiKey: 'key-a',
baseUrl: 'https://api.example.com/v1',
} as ContentGeneratorConfig;
const mockContentConfigB: ContentGeneratorConfig = {
authType: AuthType.USE_OPENAI,
model: 'model-b',
apiKey: 'key-b',
baseUrl: 'https://api.example.com/v1',
} as ContentGeneratorConfig;
vi.mocked(resolveContentGeneratorConfigWithSources).mockImplementation(
(_config, _authType, generationConfig) => {
const model = generationConfig?.model;
return {
config:
model === 'model-b' ? mockContentConfigB : mockContentConfigA,
sources: {},
};
},
);
vi.mocked(createContentGenerator).mockResolvedValue({
generateContent: vi.fn(),
generateContentStream: vi.fn(),
countTokens: vi.fn(),
embedContent: vi.fn(),
} as unknown as ContentGenerator);
// Initialize with model-a
await configWithModelProviders.refreshAuth(AuthType.USE_OPENAI);
// Spy on refreshAuth to verify it's called when switching to model-b
const refreshSpy = vi.spyOn(configWithModelProviders, 'refreshAuth');
// Switch to model-b (different envKey)
await configWithModelProviders.switchModel(
AuthType.USE_OPENAI,
'model-b',
);
// Should trigger full refresh because envKey changed
expect(refreshSpy).toHaveBeenCalledWith(AuthType.USE_OPENAI);
expect(configWithModelProviders.getModel()).toBe('model-b');
});
});
it('Config constructor should store userMemory correctly', () => {
const config = new Config(baseParams);

View File

@@ -16,9 +16,8 @@ import { ProxyAgent, setGlobalDispatcher } from 'undici';
import type {
ContentGenerator,
ContentGeneratorConfig,
AuthType,
} from '../core/contentGenerator.js';
import type { FallbackModelHandler } from '../fallback/types.js';
import type { ContentGeneratorConfigSources } from '../core/contentGenerator.js';
import type { MCPOAuthConfig } from '../mcp/oauth-provider.js';
import type { ShellExecutionConfig } from '../services/shellExecutionService.js';
import type { AnyToolInvocation } from '../tools/tools.js';
@@ -27,8 +26,9 @@ import type { AnyToolInvocation } from '../tools/tools.js';
import { BaseLlmClient } from '../core/baseLlmClient.js';
import { GeminiClient } from '../core/client.js';
import {
AuthType,
createContentGenerator,
createContentGeneratorConfig,
resolveContentGeneratorConfigWithSources,
} from '../core/contentGenerator.js';
import { tokenLimit } from '../core/tokenLimits.js';
@@ -94,7 +94,7 @@ import {
DEFAULT_FILE_FILTERING_OPTIONS,
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
} from './constants.js';
import { DEFAULT_QWEN_EMBEDDING_MODEL, DEFAULT_QWEN_MODEL } from './models.js';
import { DEFAULT_QWEN_EMBEDDING_MODEL } from './models.js';
import { Storage } from './storage.js';
import { ChatRecordingService } from '../services/chatRecordingService.js';
import {
@@ -103,6 +103,12 @@ import {
} from '../services/sessionService.js';
import { randomUUID } from 'node:crypto';
import {
ModelsConfig,
type ModelProvidersConfig,
type AvailableModel,
} from '../models/index.js';
// Re-export types
export type { AnyToolInvocation, FileFilteringOptions, MCPOAuthConfig };
export {
@@ -318,6 +324,11 @@ export interface ConfigParameters {
ideMode?: boolean;
authType?: AuthType;
generationConfig?: Partial<ContentGeneratorConfig>;
/**
* Optional source map for generationConfig fields (e.g. CLI/env/settings attribution).
* This is used to produce per-field source badges in the UI.
*/
generationConfigSources?: ContentGeneratorConfigSources;
cliVersion?: string;
loadMemoryFromIncludeDirectories?: boolean;
chatRecording?: boolean;
@@ -353,6 +364,8 @@ export interface ConfigParameters {
sdkMode?: boolean;
sessionSubagents?: SubagentConfig[];
channel?: string;
/** Model providers configuration grouped by authType */
modelProvidersConfig?: ModelProvidersConfig;
}
function normalizeConfigOutputFormat(
@@ -394,9 +407,12 @@ export class Config {
private skillManager!: SkillManager;
private fileSystemService: FileSystemService;
private contentGeneratorConfig!: ContentGeneratorConfig;
private contentGeneratorConfigSources: ContentGeneratorConfigSources = {};
private contentGenerator!: ContentGenerator;
private _generationConfig: Partial<ContentGeneratorConfig>;
private readonly embeddingModel: string;
private _modelsConfig!: ModelsConfig;
private readonly modelProvidersConfig?: ModelProvidersConfig;
private readonly sandbox: SandboxConfig | undefined;
private readonly targetDir: string;
private workspaceContext: WorkspaceContext;
@@ -445,7 +461,6 @@ export class Config {
private readonly folderTrust: boolean;
private ideMode: boolean;
private inFallbackMode = false;
private readonly maxSessionTurns: number;
private readonly sessionTokenLimit: number;
private readonly listExtensions: boolean;
@@ -454,8 +469,6 @@ export class Config {
name: string;
extensionName: string;
}>;
fallbackModelHandler?: FallbackModelHandler;
private quotaErrorOccurred: boolean = false;
private readonly summarizeToolOutput:
| Record<string, SummarizeToolOutputSettings>
| undefined;
@@ -570,13 +583,7 @@ export class Config {
this.folderTrustFeature = params.folderTrustFeature ?? false;
this.folderTrust = params.folderTrust ?? false;
this.ideMode = params.ideMode ?? false;
this._generationConfig = {
model: params.model,
...(params.generationConfig || {}),
baseUrl: params.generationConfig?.baseUrl,
};
this.contentGeneratorConfig = this
._generationConfig as ContentGeneratorConfig;
this.modelProvidersConfig = params.modelProvidersConfig;
this.cliVersion = params.cliVersion;
this.chatRecordingEnabled = params.chatRecording ?? true;
@@ -619,6 +626,22 @@ export class Config {
setGeminiMdFilename(params.contextFileName);
}
// Create ModelsConfig for centralized model management
// Prefer params.authType over generationConfig.authType because:
// - params.authType preserves undefined (user hasn't selected yet)
// - generationConfig.authType may have a default value from resolvers
this._modelsConfig = new ModelsConfig({
initialAuthType: params.authType ?? params.generationConfig?.authType,
modelProvidersConfig: this.modelProvidersConfig,
generationConfig: {
model: params.model,
...(params.generationConfig || {}),
baseUrl: params.generationConfig?.baseUrl,
},
generationConfigSources: params.generationConfigSources,
onModelChange: this.handleModelChange.bind(this),
});
if (this.telemetrySettings.enabled) {
initializeTelemetry(this);
}
@@ -669,45 +692,61 @@ export class Config {
return this.contentGenerator;
}
/**
* Get the ModelsConfig instance for model-related operations.
* External code (e.g., CLI) can use this to access model configuration.
*/
get modelsConfig(): ModelsConfig {
return this._modelsConfig;
}
/**
* Updates the credentials in the generation config.
* This is needed when credentials are set after Config construction.
* Exclusive for `OpenAIKeyPrompt` to update credentials via `/auth`
* Delegates to ModelsConfig.
*/
updateCredentials(credentials: {
apiKey?: string;
baseUrl?: string;
model?: string;
}): void {
if (credentials.apiKey) {
this._generationConfig.apiKey = credentials.apiKey;
}
if (credentials.baseUrl) {
this._generationConfig.baseUrl = credentials.baseUrl;
}
if (credentials.model) {
this._generationConfig.model = credentials.model;
}
this._modelsConfig.updateCredentials(credentials);
}
/**
* Refresh authentication and rebuild ContentGenerator.
*/
async refreshAuth(authMethod: AuthType, isInitialAuth?: boolean) {
const newContentGeneratorConfig = createContentGeneratorConfig(
// Sync modelsConfig state for this auth refresh
const modelId = this._modelsConfig.getModel();
this._modelsConfig.syncAfterAuthRefresh(authMethod, modelId);
// Check and consume cached credentials flag
const requireCached =
this._modelsConfig.consumeRequireCachedCredentialsFlag();
const { config, sources } = resolveContentGeneratorConfigWithSources(
this,
authMethod,
this._generationConfig,
this._modelsConfig.getGenerationConfig(),
this._modelsConfig.getGenerationConfigSources(),
{
strictModelProvider:
this._modelsConfig.isStrictModelProviderSelection(),
},
);
const newContentGeneratorConfig = config;
this.contentGenerator = await createContentGenerator(
newContentGeneratorConfig,
this,
isInitialAuth,
requireCached ? true : isInitialAuth,
);
// Only assign to instance properties after successful initialization
this.contentGeneratorConfig = newContentGeneratorConfig;
this.contentGeneratorConfigSources = sources;
// Initialize BaseLlmClient now that the ContentGenerator is available
this.baseLlmClient = new BaseLlmClient(this.contentGenerator, this);
// Reset the session flag since we're explicitly changing auth and using default model
this.inFallbackMode = false;
}
/**
@@ -767,31 +806,125 @@ export class Config {
return this.contentGeneratorConfig;
}
getModel(): string {
return this.contentGeneratorConfig?.model || DEFAULT_QWEN_MODEL;
getContentGeneratorConfigSources(): ContentGeneratorConfigSources {
// If contentGeneratorConfigSources is empty (before initializeAuth),
// get sources from ModelsConfig
if (
Object.keys(this.contentGeneratorConfigSources).length === 0 &&
this._modelsConfig
) {
return this._modelsConfig.getGenerationConfigSources();
}
return this.contentGeneratorConfigSources;
}
getModel(): string {
return this.contentGeneratorConfig?.model || this._modelsConfig.getModel();
}
/**
* Set model programmatically (e.g., VLM auto-switch, fallback).
* Delegates to ModelsConfig.
*/
async setModel(
newModel: string,
_metadata?: { reason?: string; context?: string },
metadata?: { reason?: string; context?: string },
): Promise<void> {
await this._modelsConfig.setModel(newModel, metadata);
// Also update contentGeneratorConfig for hot-update compatibility
if (this.contentGeneratorConfig) {
this.contentGeneratorConfig.model = newModel;
}
// TODO: Log _metadata for telemetry if needed
// This _metadata can be used for tracking model switches (reason, context)
}
isInFallbackMode(): boolean {
return this.inFallbackMode;
/**
* Handle model change from ModelsConfig.
* This updates the content generator config with the new model settings.
*/
private async handleModelChange(
authType: AuthType,
requiresRefresh: boolean,
): Promise<void> {
if (!this.contentGeneratorConfig) {
return;
}
// Hot update path: only supported for qwen-oauth.
// For other auth types we always refresh to recreate the ContentGenerator.
//
// Rationale:
// - Non-qwen providers may need to re-validate credentials / baseUrl / envKey.
// - ModelsConfig.applyResolvedModelDefaults can clear or change credentials sources.
// - Refresh keeps runtime behavior consistent and centralized.
if (authType === AuthType.QWEN_OAUTH && !requiresRefresh) {
const { config, sources } = resolveContentGeneratorConfigWithSources(
this,
authType,
this._modelsConfig.getGenerationConfig(),
this._modelsConfig.getGenerationConfigSources(),
{
strictModelProvider:
this._modelsConfig.isStrictModelProviderSelection(),
},
);
// Hot-update fields (qwen-oauth models share the same auth + client).
this.contentGeneratorConfig.model = config.model;
this.contentGeneratorConfig.samplingParams = config.samplingParams;
this.contentGeneratorConfig.disableCacheControl =
config.disableCacheControl;
if ('model' in sources) {
this.contentGeneratorConfigSources['model'] = sources['model'];
}
if ('samplingParams' in sources) {
this.contentGeneratorConfigSources['samplingParams'] =
sources['samplingParams'];
}
if ('disableCacheControl' in sources) {
this.contentGeneratorConfigSources['disableCacheControl'] =
sources['disableCacheControl'];
}
return;
}
// Full refresh path
await this.refreshAuth(authType);
}
setFallbackMode(active: boolean): void {
this.inFallbackMode = active;
/**
* Get available models for the current authType.
* Delegates to ModelsConfig.
*/
getAvailableModels(): AvailableModel[] {
return this._modelsConfig.getAvailableModels();
}
setFallbackModelHandler(handler: FallbackModelHandler): void {
this.fallbackModelHandler = handler;
/**
* Get available models for a specific authType.
* Delegates to ModelsConfig.
*/
getAvailableModelsForAuthType(authType: AuthType): AvailableModel[] {
return this._modelsConfig.getAvailableModelsForAuthType(authType);
}
/**
* Switch authType+model via registry-backed selection.
* This triggers a refresh of the ContentGenerator when required (always on authType changes).
* For qwen-oauth model switches that are hot-update safe, this may update in place.
*
* @param authType - Target authentication type
* @param modelId - Target model ID
* @param options - Additional options like requireCachedCredentials
* @param metadata - Metadata for logging/tracking
*/
async switchModel(
authType: AuthType,
modelId: string,
options?: { requireCachedCredentials?: boolean },
metadata?: { reason?: string; context?: string },
): Promise<void> {
await this._modelsConfig.switchModel(authType, modelId, options, metadata);
}
getMaxSessionTurns(): number {
@@ -802,14 +935,6 @@ export class Config {
return this.sessionTokenLimit;
}
setQuotaErrorOccurred(value: boolean): void {
this.quotaErrorOccurred = value;
}
getQuotaErrorOccurred(): boolean {
return this.quotaErrorOccurred;
}
getEmbeddingModel(): string {
return this.embeddingModel;
}

View File

@@ -1,99 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Config } from './config.js';
import { DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_FLASH_MODEL } from './models.js';
import fs from 'node:fs';
vi.mock('node:fs');
describe('Flash Model Fallback Configuration', () => {
let config: Config;
beforeEach(() => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({
isDirectory: () => true,
} as fs.Stats);
config = new Config({
targetDir: '/test',
debugMode: false,
cwd: '/test',
model: DEFAULT_GEMINI_MODEL,
});
// Initialize contentGeneratorConfig for testing
(
config as unknown as { contentGeneratorConfig: unknown }
).contentGeneratorConfig = {
model: DEFAULT_GEMINI_MODEL,
authType: 'gemini-api-key',
};
});
// These tests do not actually test fallback. isInFallbackMode() only returns true,
// when setFallbackMode is marked as true. This is to decouple setting a model
// with the fallback mechanism. This will be necessary we introduce more
// intelligent model routing.
describe('setModel', () => {
it('should only mark as switched if contentGeneratorConfig exists', async () => {
// Create config without initializing contentGeneratorConfig
const newConfig = new Config({
targetDir: '/test',
debugMode: false,
cwd: '/test',
model: DEFAULT_GEMINI_MODEL,
});
// Should not crash when contentGeneratorConfig is undefined
await newConfig.setModel(DEFAULT_GEMINI_FLASH_MODEL);
expect(newConfig.isInFallbackMode()).toBe(false);
});
});
describe('getModel', () => {
it('should return contentGeneratorConfig model if available', async () => {
// Simulate initialized content generator config
await config.setModel(DEFAULT_GEMINI_FLASH_MODEL);
expect(config.getModel()).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should fall back to initial model if contentGeneratorConfig is not available', () => {
// Test with fresh config where contentGeneratorConfig might not be set
const newConfig = new Config({
targetDir: '/test',
debugMode: false,
cwd: '/test',
model: 'custom-model',
});
expect(newConfig.getModel()).toBe('custom-model');
});
});
describe('isInFallbackMode', () => {
it('should start as false for new session', () => {
expect(config.isInFallbackMode()).toBe(false);
});
it('should remain false if no model switch occurs', () => {
// Perform other operations that don't involve model switching
expect(config.isInFallbackMode()).toBe(false);
});
it('should persist switched state throughout session', async () => {
await config.setModel(DEFAULT_GEMINI_FLASH_MODEL);
// Setting state for fallback mode as is expected of clients
config.setFallbackMode(true);
expect(config.isInFallbackMode()).toBe(true);
// Should remain true even after getting model
config.getModel();
expect(config.isInFallbackMode()).toBe(true);
});
});
});

View File

@@ -1,83 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
getEffectiveModel,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
} from './models.js';
describe('getEffectiveModel', () => {
describe('When NOT in fallback mode', () => {
const isInFallbackMode = false;
it('should return the Pro model when Pro is requested', () => {
const model = getEffectiveModel(isInFallbackMode, DEFAULT_GEMINI_MODEL);
expect(model).toBe(DEFAULT_GEMINI_MODEL);
});
it('should return the Flash model when Flash is requested', () => {
const model = getEffectiveModel(
isInFallbackMode,
DEFAULT_GEMINI_FLASH_MODEL,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should return the Lite model when Lite is requested', () => {
const model = getEffectiveModel(
isInFallbackMode,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
});
it('should return a custom model name when requested', () => {
const customModel = 'custom-model-v1';
const model = getEffectiveModel(isInFallbackMode, customModel);
expect(model).toBe(customModel);
});
});
describe('When IN fallback mode', () => {
const isInFallbackMode = true;
it('should downgrade the Pro model to the Flash model', () => {
const model = getEffectiveModel(isInFallbackMode, DEFAULT_GEMINI_MODEL);
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should return the Flash model when Flash is requested', () => {
const model = getEffectiveModel(
isInFallbackMode,
DEFAULT_GEMINI_FLASH_MODEL,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should HONOR the Lite model when Lite is requested', () => {
const model = getEffectiveModel(
isInFallbackMode,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
});
it('should HONOR any model with "lite" in its name', () => {
const customLiteModel = 'gemini-2.5-custom-lite-vNext';
const model = getEffectiveModel(isInFallbackMode, customLiteModel);
expect(model).toBe(customLiteModel);
});
it('should downgrade any other custom model to the Flash model', () => {
const customModel = 'custom-model-v1-unlisted';
const model = getEffectiveModel(isInFallbackMode, customModel);
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
});
});

View File

@@ -7,46 +7,3 @@
export const DEFAULT_QWEN_MODEL = 'coder-model';
export const DEFAULT_QWEN_FLASH_MODEL = 'coder-model';
export const DEFAULT_QWEN_EMBEDDING_MODEL = 'text-embedding-v4';
export const DEFAULT_GEMINI_MODEL = 'coder-model';
export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash';
export const DEFAULT_GEMINI_FLASH_LITE_MODEL = 'gemini-2.5-flash-lite';
export const DEFAULT_GEMINI_MODEL_AUTO = 'auto';
export const DEFAULT_GEMINI_EMBEDDING_MODEL = 'gemini-embedding-001';
// Some thinking models do not default to dynamic thinking which is done by a value of -1
export const DEFAULT_THINKING_MODE = -1;
/**
* Determines the effective model to use, applying fallback logic if necessary.
*
* When fallback mode is active, this function enforces the use of the standard
* fallback model. However, it makes an exception for "lite" models (any model
* with "lite" in its name), allowing them to be used to preserve cost savings.
* This ensures that "pro" models are always downgraded, while "lite" model
* requests are honored.
*
* @param isInFallbackMode Whether the application is in fallback mode.
* @param requestedModel The model that was originally requested.
* @returns The effective model name.
*/
export function getEffectiveModel(
isInFallbackMode: boolean,
requestedModel: string,
): string {
// If we are not in fallback mode, simply use the requested model.
if (!isInFallbackMode) {
return requestedModel;
}
// If a "lite" model is requested, honor it. This allows for variations of
// lite models without needing to list them all as constants.
if (requestedModel.includes('lite')) {
return requestedModel;
}
// Default fallback for Gemini CLI.
return DEFAULT_GEMINI_FLASH_MODEL;
}

View File

@@ -32,7 +32,7 @@ import {
type ChatCompressionInfo,
} from './turn.js';
import { getCoreSystemPrompt } from './prompts.js';
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
import { DEFAULT_QWEN_FLASH_MODEL } from '../config/models.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { setSimulate429 } from '../utils/testUtils.js';
import { tokenLimit } from './tokenLimits.js';
@@ -302,8 +302,6 @@ describe('Gemini Client (client.ts)', () => {
getFileService: vi.fn().mockReturnValue(fileService),
getMaxSessionTurns: vi.fn().mockReturnValue(0),
getSessionTokenLimit: vi.fn().mockReturnValue(32000),
getQuotaErrorOccurred: vi.fn().mockReturnValue(false),
setQuotaErrorOccurred: vi.fn(),
getNoBrowser: vi.fn().mockReturnValue(false),
getUsageStatisticsEnabled: vi.fn().mockReturnValue(true),
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
@@ -317,8 +315,6 @@ describe('Gemini Client (client.ts)', () => {
getModelRouterService: vi.fn().mockReturnValue({
route: vi.fn().mockResolvedValue({ model: 'default-routed-model' }),
}),
isInFallbackMode: vi.fn().mockReturnValue(false),
setFallbackMode: vi.fn(),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
getChatCompression: vi.fn().mockReturnValue(undefined),
getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false),
@@ -2262,12 +2258,12 @@ ${JSON.stringify(
contents,
generationConfig,
abortSignal,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_QWEN_FLASH_MODEL,
);
expect(mockContentGenerator.generateContent).toHaveBeenCalledWith(
expect.objectContaining({
model: DEFAULT_GEMINI_FLASH_MODEL,
model: DEFAULT_QWEN_FLASH_MODEL,
config: expect.objectContaining({
abortSignal,
systemInstruction: getCoreSystemPrompt(''),
@@ -2290,7 +2286,7 @@ ${JSON.stringify(
contents,
{},
new AbortController().signal,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_QWEN_FLASH_MODEL,
);
expect(mockContentGenerator.generateContent).not.toHaveBeenCalledWith({
@@ -2300,7 +2296,7 @@ ${JSON.stringify(
});
expect(mockContentGenerator.generateContent).toHaveBeenCalledWith(
{
model: DEFAULT_GEMINI_FLASH_MODEL,
model: DEFAULT_QWEN_FLASH_MODEL,
config: expect.any(Object),
contents,
},
@@ -2308,28 +2304,7 @@ ${JSON.stringify(
);
});
it('should use the Flash model when fallback mode is active', async () => {
const contents = [{ role: 'user', parts: [{ text: 'hello' }] }];
const generationConfig = { temperature: 0.5 };
const abortSignal = new AbortController().signal;
const requestedModel = 'gemini-2.5-pro'; // A non-flash model
// Mock config to be in fallback mode
vi.spyOn(client['config'], 'isInFallbackMode').mockReturnValue(true);
await client.generateContent(
contents,
generationConfig,
abortSignal,
requestedModel,
);
expect(mockGenerateContentFn).toHaveBeenCalledWith(
expect.objectContaining({
model: DEFAULT_GEMINI_FLASH_MODEL,
}),
'test-session-id',
);
});
// Note: there is currently no "fallback mode" model routing; the model used
// is always the one explicitly requested by the caller.
});
});

View File

@@ -15,7 +15,6 @@ import type {
// Config
import { ApprovalMode, type Config } from '../config/config.js';
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
// Core modules
import type { ContentGenerator } from './contentGenerator.js';
@@ -542,11 +541,6 @@ export class GeminiClient {
}
}
if (!turn.pendingToolCalls.length && signal && !signal.aborted) {
// Check if next speaker check is needed
if (this.config.getQuotaErrorOccurred()) {
return turn;
}
if (this.config.getSkipNextSpeakerCheck()) {
return turn;
}
@@ -602,14 +596,11 @@ export class GeminiClient {
};
const apiCall = () => {
const modelToUse = this.config.isInFallbackMode()
? DEFAULT_GEMINI_FLASH_MODEL
: model;
currentAttemptModel = modelToUse;
currentAttemptModel = model;
return this.getContentGeneratorOrFail().generateContent(
{
model: modelToUse,
model,
config: requestConfig,
contents,
},

View File

@@ -5,7 +5,11 @@
*/
import { describe, it, expect, vi } from 'vitest';
import { createContentGenerator, AuthType } from './contentGenerator.js';
import {
createContentGenerator,
createContentGeneratorConfig,
AuthType,
} from './contentGenerator.js';
import { GoogleGenAI } from '@google/genai';
import type { Config } from '../config/config.js';
import { LoggingContentGenerator } from './loggingContentGenerator/index.js';
@@ -78,3 +82,32 @@ describe('createContentGenerator', () => {
expect(generator).toBeInstanceOf(LoggingContentGenerator);
});
});
describe('createContentGeneratorConfig', () => {
const mockConfig = {
getProxy: () => undefined,
} as unknown as Config;
it('should preserve provided fields and set authType for QWEN_OAUTH', () => {
const cfg = createContentGeneratorConfig(mockConfig, AuthType.QWEN_OAUTH, {
model: 'vision-model',
apiKey: 'QWEN_OAUTH_DYNAMIC_TOKEN',
});
expect(cfg.authType).toBe(AuthType.QWEN_OAUTH);
expect(cfg.model).toBe('vision-model');
expect(cfg.apiKey).toBe('QWEN_OAUTH_DYNAMIC_TOKEN');
});
it('should not warn or fallback for QWEN_OAUTH (resolution handled by ModelConfigResolver)', () => {
const warnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
const cfg = createContentGeneratorConfig(mockConfig, AuthType.QWEN_OAUTH, {
model: 'some-random-model',
});
expect(cfg.model).toBe('some-random-model');
expect(cfg.apiKey).toBeUndefined();
expect(warnSpy).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
});

View File

@@ -12,9 +12,24 @@ import type {
GenerateContentParameters,
GenerateContentResponse,
} from '@google/genai';
import { DEFAULT_QWEN_MODEL } from '../config/models.js';
import type { Config } from '../config/config.js';
import { LoggingContentGenerator } from './loggingContentGenerator/index.js';
import type {
ConfigSource,
ConfigSourceKind,
ConfigSources,
} from '../utils/configResolver.js';
import {
getDefaultApiKeyEnvVar,
getDefaultModelEnvVar,
MissingAnthropicBaseUrlEnvError,
MissingApiKeyError,
MissingBaseUrlError,
MissingModelError,
StrictMissingCredentialsError,
StrictMissingModelIdError,
} from '../models/modelConfigErrors.js';
import { PROVIDER_SOURCED_FIELDS } from '../models/modelsConfig.js';
/**
* Interface abstracting the core functionalities for generating content and counting tokens.
@@ -48,6 +63,7 @@ export enum AuthType {
export type ContentGeneratorConfig = {
model: string;
apiKey?: string;
apiKeyEnvKey?: string;
baseUrl?: string;
vertexai?: boolean;
authType?: AuthType | undefined;
@@ -77,102 +93,178 @@ export type ContentGeneratorConfig = {
schemaCompliance?: 'auto' | 'openapi_30';
};
export function createContentGeneratorConfig(
// Keep the public ContentGeneratorConfigSources API, but reuse the generic
// source-tracking types from utils/configResolver to avoid duplication.
export type ContentGeneratorConfigSourceKind = ConfigSourceKind;
export type ContentGeneratorConfigSource = ConfigSource;
export type ContentGeneratorConfigSources = ConfigSources;
export type ResolvedContentGeneratorConfig = {
config: ContentGeneratorConfig;
sources: ContentGeneratorConfigSources;
};
function setSource(
sources: ContentGeneratorConfigSources,
path: string,
source: ContentGeneratorConfigSource,
): void {
sources[path] = source;
}
function getSeedSource(
seed: ContentGeneratorConfigSources | undefined,
path: string,
): ContentGeneratorConfigSource | undefined {
return seed?.[path];
}
/**
* Resolve ContentGeneratorConfig while tracking the source of each effective field.
*
* This function now primarily validates and finalizes the configuration that has
* already been resolved by ModelConfigResolver. The env fallback logic has been
* moved to the unified resolver to eliminate duplication.
*
* Note: The generationConfig passed here should already be fully resolved with
* proper source tracking from the caller (CLI/SDK layer).
*/
export function resolveContentGeneratorConfigWithSources(
config: Config,
authType: AuthType | undefined,
generationConfig?: Partial<ContentGeneratorConfig>,
): ContentGeneratorConfig {
let newContentGeneratorConfig: Partial<ContentGeneratorConfig> = {
seedSources?: ContentGeneratorConfigSources,
options?: { strictModelProvider?: boolean },
): ResolvedContentGeneratorConfig {
const sources: ContentGeneratorConfigSources = { ...(seedSources || {}) };
const strictModelProvider = options?.strictModelProvider === true;
// Build config with computed fields
const newContentGeneratorConfig: Partial<ContentGeneratorConfig> = {
...(generationConfig || {}),
authType,
proxy: config?.getProxy(),
};
if (authType === AuthType.QWEN_OAUTH) {
// For Qwen OAuth, we'll handle the API key dynamically in createContentGenerator
// Set a special marker to indicate this is Qwen OAuth
return {
...newContentGeneratorConfig,
model: DEFAULT_QWEN_MODEL,
apiKey: 'QWEN_OAUTH_DYNAMIC_TOKEN',
} as ContentGeneratorConfig;
// Set sources for computed fields
setSource(sources, 'authType', {
kind: 'computed',
detail: 'provided by caller',
});
if (config?.getProxy()) {
setSource(sources, 'proxy', {
kind: 'computed',
detail: 'Config.getProxy()',
});
}
if (authType === AuthType.USE_OPENAI) {
newContentGeneratorConfig = {
...newContentGeneratorConfig,
apiKey: newContentGeneratorConfig.apiKey || process.env['OPENAI_API_KEY'],
baseUrl:
newContentGeneratorConfig.baseUrl || process.env['OPENAI_BASE_URL'],
model: newContentGeneratorConfig.model || process.env['OPENAI_MODEL'],
};
// Preserve seed sources for fields that were passed in
const seedOrUnknown = (path: string): ContentGeneratorConfigSource =>
getSeedSource(seedSources, path) ?? { kind: 'unknown' };
if (!newContentGeneratorConfig.apiKey) {
throw new Error('OPENAI_API_KEY environment variable not found.');
}
return {
...newContentGeneratorConfig,
model: newContentGeneratorConfig?.model || 'qwen3-coder-plus',
} as ContentGeneratorConfig;
}
if (authType === AuthType.USE_ANTHROPIC) {
newContentGeneratorConfig = {
...newContentGeneratorConfig,
apiKey:
newContentGeneratorConfig.apiKey || process.env['ANTHROPIC_API_KEY'],
baseUrl:
newContentGeneratorConfig.baseUrl || process.env['ANTHROPIC_BASE_URL'],
model: newContentGeneratorConfig.model || process.env['ANTHROPIC_MODEL'],
};
if (!newContentGeneratorConfig.apiKey) {
throw new Error('ANTHROPIC_API_KEY environment variable not found.');
}
if (!newContentGeneratorConfig.baseUrl) {
throw new Error('ANTHROPIC_BASE_URL environment variable not found.');
}
if (!newContentGeneratorConfig.model) {
throw new Error('ANTHROPIC_MODEL environment variable not found.');
for (const field of PROVIDER_SOURCED_FIELDS) {
if (generationConfig && field in generationConfig && !sources[field]) {
setSource(sources, field, seedOrUnknown(field));
}
}
if (authType === AuthType.USE_GEMINI) {
newContentGeneratorConfig = {
...newContentGeneratorConfig,
apiKey: newContentGeneratorConfig.apiKey || process.env['GEMINI_API_KEY'],
model: newContentGeneratorConfig.model || process.env['GEMINI_MODEL'],
};
// Validate required fields based on authType. This does not perform any
// fallback resolution (resolution is handled by ModelConfigResolver).
const validation = validateModelConfig(
newContentGeneratorConfig as ContentGeneratorConfig,
strictModelProvider,
);
if (!validation.valid) {
throw new Error(validation.errors.map((e) => e.message).join('\n'));
}
if (!newContentGeneratorConfig.apiKey) {
throw new Error('GEMINI_API_KEY environment variable not found.');
}
return {
config: newContentGeneratorConfig as ContentGeneratorConfig,
sources,
};
}
if (!newContentGeneratorConfig.model) {
throw new Error('GEMINI_MODEL environment variable not found.');
export interface ModelConfigValidationResult {
valid: boolean;
errors: Error[];
}
/**
* Validate a resolved model configuration.
* This is the single validation entry point used across Core.
*/
export function validateModelConfig(
config: ContentGeneratorConfig,
isStrictModelProvider: boolean = false,
): ModelConfigValidationResult {
const errors: Error[] = [];
// Qwen OAuth doesn't need validation - it uses dynamic tokens
if (config.authType === AuthType.QWEN_OAUTH) {
return { valid: true, errors: [] };
}
// API key is required for all other auth types
if (!config.apiKey) {
if (isStrictModelProvider) {
errors.push(
new StrictMissingCredentialsError(
config.authType,
config.model,
config.apiKeyEnvKey,
),
);
} else {
const envKey =
config.apiKeyEnvKey || getDefaultApiKeyEnvVar(config.authType);
errors.push(
new MissingApiKeyError({
authType: config.authType,
model: config.model,
baseUrl: config.baseUrl,
envKey,
}),
);
}
}
if (authType === AuthType.USE_VERTEX_AI) {
newContentGeneratorConfig = {
...newContentGeneratorConfig,
apiKey: newContentGeneratorConfig.apiKey || process.env['GOOGLE_API_KEY'],
model: newContentGeneratorConfig.model || process.env['GOOGLE_MODEL'],
};
if (!newContentGeneratorConfig.apiKey) {
throw new Error('GOOGLE_API_KEY environment variable not found.');
}
if (!newContentGeneratorConfig.model) {
throw new Error('GOOGLE_MODEL environment variable not found.');
// Model is required
if (!config.model) {
if (isStrictModelProvider) {
errors.push(new StrictMissingModelIdError(config.authType));
} else {
const envKey = getDefaultModelEnvVar(config.authType);
errors.push(new MissingModelError({ authType: config.authType, envKey }));
}
}
return newContentGeneratorConfig as ContentGeneratorConfig;
// Explicit baseUrl is required for Anthropic; Migrated from existing code.
if (config.authType === AuthType.USE_ANTHROPIC && !config.baseUrl) {
if (isStrictModelProvider) {
errors.push(
new MissingBaseUrlError({
authType: config.authType,
model: config.model,
}),
);
} else if (config.authType === AuthType.USE_ANTHROPIC) {
errors.push(new MissingAnthropicBaseUrlEnvError());
}
}
return { valid: errors.length === 0, errors };
}
export function createContentGeneratorConfig(
config: Config,
authType: AuthType | undefined,
generationConfig?: Partial<ContentGeneratorConfig>,
): ContentGeneratorConfig {
return resolveContentGeneratorConfigWithSources(
config,
authType,
generationConfig,
).config;
}
export async function createContentGenerator(
@@ -180,11 +272,12 @@ export async function createContentGenerator(
gcConfig: Config,
isInitialAuth?: boolean,
): Promise<ContentGenerator> {
if (config.authType === AuthType.USE_OPENAI) {
if (!config.apiKey) {
throw new Error('OPENAI_API_KEY environment variable not found.');
}
const validation = validateModelConfig(config, false);
if (!validation.valid) {
throw new Error(validation.errors.map((e) => e.message).join('\n'));
}
if (config.authType === AuthType.USE_OPENAI) {
// Import OpenAIContentGenerator dynamically to avoid circular dependencies
const { createOpenAIContentGenerator } = await import(
'./openaiContentGenerator/index.js'
@@ -223,10 +316,6 @@ export async function createContentGenerator(
}
if (config.authType === AuthType.USE_ANTHROPIC) {
if (!config.apiKey) {
throw new Error('ANTHROPIC_API_KEY environment variable not found.');
}
const { createAnthropicContentGenerator } = await import(
'./anthropicContentGenerator/index.js'
);

View File

@@ -240,7 +240,7 @@ describe('CoreToolScheduler', () => {
getAllowedTools: () => [],
getContentGeneratorConfig: () => ({
model: 'test-model',
authType: 'gemini-api-key',
authType: 'gemini',
}),
getShellExecutionConfig: () => ({
terminalWidth: 90,
@@ -318,7 +318,7 @@ describe('CoreToolScheduler', () => {
getAllowedTools: () => [],
getContentGeneratorConfig: () => ({
model: 'test-model',
authType: 'gemini-api-key',
authType: 'gemini',
}),
getShellExecutionConfig: () => ({
terminalWidth: 90,
@@ -497,7 +497,7 @@ describe('CoreToolScheduler', () => {
getExcludeTools: () => ['write_file', 'edit', 'run_shell_command'],
getContentGeneratorConfig: () => ({
model: 'test-model',
authType: 'gemini-api-key',
authType: 'gemini',
}),
getShellExecutionConfig: () => ({
terminalWidth: 90,
@@ -584,7 +584,7 @@ describe('CoreToolScheduler', () => {
getExcludeTools: () => ['write_file', 'edit'], // Different excluded tools
getContentGeneratorConfig: () => ({
model: 'test-model',
authType: 'gemini-api-key',
authType: 'gemini',
}),
getShellExecutionConfig: () => ({
terminalWidth: 90,
@@ -674,7 +674,7 @@ describe('CoreToolScheduler with payload', () => {
getAllowedTools: () => [],
getContentGeneratorConfig: () => ({
model: 'test-model',
authType: 'gemini-api-key',
authType: 'gemini',
}),
getShellExecutionConfig: () => ({
terminalWidth: 90,
@@ -1001,7 +1001,7 @@ describe('CoreToolScheduler edit cancellation', () => {
getAllowedTools: () => [],
getContentGeneratorConfig: () => ({
model: 'test-model',
authType: 'gemini-api-key',
authType: 'gemini',
}),
getShellExecutionConfig: () => ({
terminalWidth: 90,
@@ -1108,7 +1108,7 @@ describe('CoreToolScheduler YOLO mode', () => {
getAllowedTools: () => [],
getContentGeneratorConfig: () => ({
model: 'test-model',
authType: 'gemini-api-key',
authType: 'gemini',
}),
getShellExecutionConfig: () => ({
terminalWidth: 90,
@@ -1258,7 +1258,7 @@ describe('CoreToolScheduler cancellation during executing with live output', ()
getApprovalMode: () => ApprovalMode.DEFAULT,
getContentGeneratorConfig: () => ({
model: 'test-model',
authType: 'gemini-api-key',
authType: 'gemini',
}),
getToolRegistry: () => mockToolRegistry,
getShellExecutionConfig: () => ({
@@ -1350,7 +1350,7 @@ describe('CoreToolScheduler request queueing', () => {
getAllowedTools: () => [],
getContentGeneratorConfig: () => ({
model: 'test-model',
authType: 'gemini-api-key',
authType: 'gemini',
}),
getShellExecutionConfig: () => ({
terminalWidth: 90,
@@ -1482,7 +1482,7 @@ describe('CoreToolScheduler request queueing', () => {
getToolRegistry: () => toolRegistry,
getContentGeneratorConfig: () => ({
model: 'test-model',
authType: 'gemini-api-key',
authType: 'gemini',
}),
getShellExecutionConfig: () => ({
terminalWidth: 80,
@@ -1586,7 +1586,7 @@ describe('CoreToolScheduler request queueing', () => {
getAllowedTools: () => [],
getContentGeneratorConfig: () => ({
model: 'test-model',
authType: 'gemini-api-key',
authType: 'gemini',
}),
getShellExecutionConfig: () => ({
terminalWidth: 90,
@@ -1854,7 +1854,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
getAllowedTools: () => [],
getContentGeneratorConfig: () => ({
model: 'test-model',
authType: 'gemini-api-key',
authType: 'gemini',
}),
getShellExecutionConfig: () => ({
terminalWidth: 90,
@@ -1975,7 +1975,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
getAllowedTools: () => [],
getContentGeneratorConfig: () => ({
model: 'test-model',
authType: 'gemini-api-key',
authType: 'gemini',
}),
getShellExecutionConfig: () => ({
terminalWidth: 90,

View File

@@ -20,7 +20,6 @@ import {
} from './geminiChat.js';
import type { Config } from '../config/config.js';
import { setSimulate429 } from '../utils/testUtils.js';
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
import { AuthType } from './contentGenerator.js';
import { type RetryOptions } from '../utils/retry.js';
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
@@ -112,15 +111,11 @@ describe('GeminiChat', () => {
getUsageStatisticsEnabled: () => true,
getDebugMode: () => false,
getContentGeneratorConfig: vi.fn().mockReturnValue({
authType: 'gemini-api-key', // Ensure this is set for fallback tests
authType: 'gemini', // Ensure this is set for fallback tests
model: 'test-model',
}),
getModel: vi.fn().mockReturnValue('gemini-pro'),
setModel: vi.fn(),
isInFallbackMode: vi.fn().mockReturnValue(false),
getQuotaErrorOccurred: vi.fn().mockReturnValue(false),
setQuotaErrorOccurred: vi.fn(),
flashFallbackHandler: undefined,
getProjectRoot: vi.fn().mockReturnValue('/test/project/root'),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
storage: {
@@ -1349,9 +1344,8 @@ describe('GeminiChat', () => {
],
} as unknown as GenerateContentResponse;
it('should use the FLASH model when in fallback mode (sendMessageStream)', async () => {
it('should pass the requested model through to generateContentStream', async () => {
vi.mocked(mockConfig.getModel).mockReturnValue('gemini-pro');
vi.mocked(mockConfig.isInFallbackMode).mockReturnValue(true);
vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(
async () =>
(async function* () {
@@ -1370,7 +1364,7 @@ describe('GeminiChat', () => {
expect(mockContentGenerator.generateContentStream).toHaveBeenCalledWith(
expect.objectContaining({
model: DEFAULT_GEMINI_FLASH_MODEL,
model: 'test-model',
}),
'prompt-id-res3',
);
@@ -1422,9 +1416,6 @@ describe('GeminiChat', () => {
authType,
});
const isInFallbackModeSpy = vi.spyOn(mockConfig, 'isInFallbackMode');
isInFallbackModeSpy.mockReturnValue(false);
vi.mocked(mockContentGenerator.generateContentStream)
.mockRejectedValueOnce(error429) // Attempt 1 fails
.mockResolvedValueOnce(
@@ -1441,10 +1432,7 @@ describe('GeminiChat', () => {
})(),
);
mockHandleFallback.mockImplementation(async () => {
isInFallbackModeSpy.mockReturnValue(true);
return true; // Signal retry
});
mockHandleFallback.mockImplementation(async () => true);
const stream = await chat.sendMessageStream(
'test-model',

View File

@@ -19,10 +19,6 @@ import type {
import { ApiError, createUserContent } from '@google/genai';
import { retryWithBackoff } from '../utils/retry.js';
import type { Config } from '../config/config.js';
import {
DEFAULT_GEMINI_FLASH_MODEL,
getEffectiveModel,
} from '../config/models.js';
import { hasCycleInSchema } from '../tools/tools.js';
import type { StructuredError } from './turn.js';
import {
@@ -352,31 +348,15 @@ export class GeminiChat {
params: SendMessageParameters,
prompt_id: string,
): Promise<AsyncGenerator<GenerateContentResponse>> {
const apiCall = () => {
const modelToUse = getEffectiveModel(
this.config.isInFallbackMode(),
model,
);
if (
this.config.getQuotaErrorOccurred() &&
modelToUse === DEFAULT_GEMINI_FLASH_MODEL
) {
throw new Error(
'Please submit a new query to continue with the Flash model.',
);
}
return this.config.getContentGenerator().generateContentStream(
const apiCall = () =>
this.config.getContentGenerator().generateContentStream(
{
model: modelToUse,
model,
contents: requestContents,
config: { ...this.generationConfig, ...params.config },
},
prompt_id,
);
};
const onPersistent429Callback = async (
authType?: string,
error?: unknown,

View File

@@ -47,7 +47,7 @@ describe('executeToolCall', () => {
getDebugMode: () => false,
getContentGeneratorConfig: () => ({
model: 'test-model',
authType: 'gemini-api-key',
authType: 'gemini',
}),
getShellExecutionConfig: () => ({
terminalWidth: 90,

View File

@@ -93,6 +93,14 @@ export class OpenAIContentConverter {
this.schemaCompliance = schemaCompliance;
}
/**
* Update the model used for response metadata (modelVersion/logging) and any
* model-specific conversion behavior.
*/
setModel(model: string): void {
this.model = model;
}
/**
* Reset streaming tool calls parser for new stream processing
* This should be called at the beginning of each stream to prevent
@@ -752,6 +760,8 @@ export class OpenAIContentConverter {
usage.prompt_tokens_details?.cached_tokens ??
extendedUsage.cached_tokens ??
0;
const thinkingTokens =
usage.completion_tokens_details?.reasoning_tokens || 0;
// If we only have total tokens but no breakdown, estimate the split
// Typically input is ~70% and output is ~30% for most conversations
@@ -769,6 +779,7 @@ export class OpenAIContentConverter {
candidatesTokenCount: finalCompletionTokens,
totalTokenCount: totalTokens,
cachedContentTokenCount: cachedTokens,
thoughtsTokenCount: thinkingTokens,
};
}

View File

@@ -46,6 +46,7 @@ describe('ContentGenerationPipeline', () => {
// Mock converter
mockConverter = {
setModel: vi.fn(),
convertGeminiRequestToOpenAI: vi.fn(),
convertOpenAIResponseToGemini: vi.fn(),
convertOpenAIChunkToGemini: vi.fn(),
@@ -99,6 +100,7 @@ describe('ContentGenerationPipeline', () => {
describe('constructor', () => {
it('should initialize with correct configuration', () => {
expect(mockProvider.buildClient).toHaveBeenCalled();
// Converter is constructed once and the model is updated per-request via setModel().
expect(OpenAIContentConverter).toHaveBeenCalledWith(
'test-model',
undefined,
@@ -144,6 +146,9 @@ describe('ContentGenerationPipeline', () => {
// Assert
expect(result).toBe(mockGeminiResponse);
expect(
(mockConverter as unknown as { setModel: Mock }).setModel,
).toHaveBeenCalledWith('test-model');
expect(mockConverter.convertGeminiRequestToOpenAI).toHaveBeenCalledWith(
request,
);
@@ -164,6 +169,53 @@ describe('ContentGenerationPipeline', () => {
);
});
it('should ignore request.model override and always use configured model', async () => {
// Arrange
const request: GenerateContentParameters = {
model: 'override-model',
contents: [{ parts: [{ text: 'Hello' }], role: 'user' }],
};
const userPromptId = 'test-prompt-id';
const mockMessages = [
{ role: 'user', content: 'Hello' },
] as OpenAI.Chat.ChatCompletionMessageParam[];
const mockOpenAIResponse = {
id: 'response-id',
choices: [
{ message: { content: 'Hello response' }, finish_reason: 'stop' },
],
created: Date.now(),
model: 'override-model',
} as OpenAI.Chat.ChatCompletion;
const mockGeminiResponse = new GenerateContentResponse();
(mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue(
mockMessages,
);
(mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue(
mockGeminiResponse,
);
(mockClient.chat.completions.create as Mock).mockResolvedValue(
mockOpenAIResponse,
);
// Act
const result = await pipeline.execute(request, userPromptId);
// Assert
expect(result).toBe(mockGeminiResponse);
expect(
(mockConverter as unknown as { setModel: Mock }).setModel,
).toHaveBeenCalledWith('test-model');
expect(mockClient.chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
model: 'test-model',
}),
expect.any(Object),
);
});
it('should handle tools in request', async () => {
// Arrange
const request: GenerateContentParameters = {
@@ -217,6 +269,9 @@ describe('ContentGenerationPipeline', () => {
// Assert
expect(result).toBe(mockGeminiResponse);
expect(
(mockConverter as unknown as { setModel: Mock }).setModel,
).toHaveBeenCalledWith('test-model');
expect(mockConverter.convertGeminiToolsToOpenAI).toHaveBeenCalledWith(
request.config!.tools,
);

View File

@@ -40,10 +40,16 @@ export class ContentGenerationPipeline {
request: GenerateContentParameters,
userPromptId: string,
): Promise<GenerateContentResponse> {
// For OpenAI-compatible providers, the configured model is the single source of truth.
// We intentionally ignore request.model because upstream callers may pass a model string
// that is not valid/available for the OpenAI-compatible backend.
const effectiveModel = this.contentGeneratorConfig.model;
this.converter.setModel(effectiveModel);
return this.executeWithErrorHandling(
request,
userPromptId,
false,
effectiveModel,
async (openaiRequest) => {
const openaiResponse = (await this.client.chat.completions.create(
openaiRequest,
@@ -64,10 +70,13 @@ export class ContentGenerationPipeline {
request: GenerateContentParameters,
userPromptId: string,
): Promise<AsyncGenerator<GenerateContentResponse>> {
const effectiveModel = this.contentGeneratorConfig.model;
this.converter.setModel(effectiveModel);
return this.executeWithErrorHandling(
request,
userPromptId,
true,
effectiveModel,
async (openaiRequest, context) => {
// Stage 1: Create OpenAI stream
const stream = (await this.client.chat.completions.create(
@@ -224,12 +233,13 @@ export class ContentGenerationPipeline {
request: GenerateContentParameters,
userPromptId: string,
streaming: boolean = false,
effectiveModel: string,
): Promise<OpenAI.Chat.ChatCompletionCreateParams> {
const messages = this.converter.convertGeminiRequestToOpenAI(request);
// Apply provider-specific enhancements
const baseRequest: OpenAI.Chat.ChatCompletionCreateParams = {
model: this.contentGeneratorConfig.model,
model: effectiveModel,
messages,
...this.buildGenerateContentConfig(request),
};
@@ -317,15 +327,22 @@ export class ContentGenerationPipeline {
}
private buildReasoningConfig(): Record<string, unknown> {
const reasoning = this.contentGeneratorConfig.reasoning;
// Reasoning configuration for OpenAI-compatible endpoints is highly fragmented.
// For example, across common providers and models:
//
// - deepseek-reasoner — thinking is enabled by default and cannot be disabled
// - glm-4.7 — thinking is enabled by default; can be disabled via `extra_body.thinking.enabled`
// - kimi-k2-thinking — thinking is enabled by default and cannot be disabled
// - gpt-5.x series — thinking is enabled by default; can be disabled via `reasoning.effort`
// - qwen3 series — model-dependent; can be manually disabled via `extra_body.enable_thinking`
//
// Given this inconsistency, we choose not to set any reasoning config here and
// instead rely on each models default behavior.
if (reasoning === false) {
return {};
}
// We plan to introduce provider- and model-specific settings to enable more
// fine-grained control over reasoning configuration.
return {
reasoning_effort: reasoning?.effort ?? 'medium',
};
return {};
}
/**
@@ -335,18 +352,24 @@ export class ContentGenerationPipeline {
request: GenerateContentParameters,
userPromptId: string,
isStreaming: boolean,
effectiveModel: string,
executor: (
openaiRequest: OpenAI.Chat.ChatCompletionCreateParams,
context: RequestContext,
) => Promise<T>,
): Promise<T> {
const context = this.createRequestContext(userPromptId, isStreaming);
const context = this.createRequestContext(
userPromptId,
isStreaming,
effectiveModel,
);
try {
const openaiRequest = await this.buildRequest(
request,
userPromptId,
isStreaming,
effectiveModel,
);
const result = await executor(openaiRequest, context);
@@ -378,10 +401,11 @@ export class ContentGenerationPipeline {
private createRequestContext(
userPromptId: string,
isStreaming: boolean,
effectiveModel: string,
): RequestContext {
return {
userPromptId,
model: this.contentGeneratorConfig.model,
model: effectiveModel,
authType: this.contentGeneratorConfig.authType || 'unknown',
startTime: Date.now(),
duration: 0,

View File

@@ -58,8 +58,6 @@ export class DefaultOpenAICompatibleProvider
}
getDefaultGenerationConfig(): GenerateContentConfig {
return {
topP: 0.95,
};
return {};
}
}

View File

@@ -1,23 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Defines the intent returned by the UI layer during a fallback scenario.
*/
export type FallbackIntent =
| 'retry' // Immediately retry the current request with the fallback model.
| 'stop' // Switch to fallback for future requests, but stop the current request.
| 'auth'; // Stop the current request; user intends to change authentication.
/**
* The interface for the handler provided by the UI layer (e.g., the CLI)
* to interact with the user during a fallback scenario.
*/
export type FallbackModelHandler = (
failedModel: string,
fallbackModel: string,
error?: unknown,
) => Promise<FallbackIntent | null>;

View File

@@ -9,6 +9,30 @@ export * from './config/config.js';
export * from './output/types.js';
export * from './output/json-formatter.js';
// Export models
export {
type ModelCapabilities,
type ModelGenerationConfig,
type ModelConfig as ProviderModelConfig,
type ModelProvidersConfig,
type ResolvedModelConfig,
type AvailableModel,
type ModelSwitchMetadata,
QWEN_OAUTH_MODELS,
ModelRegistry,
ModelsConfig,
type ModelsConfigOptions,
type OnModelChangeCallback,
// Model configuration resolver
resolveModelConfig,
validateModelConfig,
type ModelConfigSourcesInput,
type ModelConfigCliInput,
type ModelConfigSettingsInput,
type ModelConfigResolutionResult,
type ModelConfigValidationResult,
} from './models/index.js';
// Export Core Logic
export * from './core/client.js';
export * from './core/contentGenerator.js';
@@ -21,8 +45,6 @@ export * from './core/geminiRequest.js';
export * from './core/coreToolScheduler.js';
export * from './core/nonInteractiveToolExecutor.js';
export * from './fallback/types.js';
export * from './qwen/qwenOAuth2.js';
// Export utilities
@@ -55,6 +77,9 @@ export * from './utils/projectSummary.js';
export * from './utils/promptIdContext.js';
export * from './utils/thoughtUtils.js';
// Config resolution utilities
export * from './utils/configResolver.js';
// Export services
export * from './services/fileDiscoveryService.js';
export * from './services/gitService.js';

View File

@@ -0,0 +1,134 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { DEFAULT_QWEN_MODEL } from '../config/models.js';
import type { ModelConfig } from './types.js';
type AuthType = import('../core/contentGenerator.js').AuthType;
type ContentGeneratorConfig =
import('../core/contentGenerator.js').ContentGeneratorConfig;
/**
* Field keys for model-scoped generation config.
*
* Kept in a small standalone module to avoid circular deps. The `import('...')`
* usage is type-only and does not emit runtime imports.
*/
export const MODEL_GENERATION_CONFIG_FIELDS = [
'samplingParams',
'timeout',
'maxRetries',
'disableCacheControl',
'schemaCompliance',
'reasoning',
] as const satisfies ReadonlyArray<keyof ContentGeneratorConfig>;
/**
* Credential-related fields that are part of ContentGeneratorConfig
* but not ModelGenerationConfig.
*/
export const CREDENTIAL_FIELDS = [
'model',
'apiKey',
'apiKeyEnvKey',
'baseUrl',
] as const satisfies ReadonlyArray<keyof ContentGeneratorConfig>;
/**
* All provider-sourced fields that need to be tracked for source attribution
* and cleared when switching from provider to manual credentials.
*/
export const PROVIDER_SOURCED_FIELDS = [
...CREDENTIAL_FIELDS,
...MODEL_GENERATION_CONFIG_FIELDS,
] as const;
/**
* Environment variable mappings per authType.
*/
export interface AuthEnvMapping {
apiKey: string[];
baseUrl: string[];
model: string[];
}
export const AUTH_ENV_MAPPINGS = {
openai: {
apiKey: ['OPENAI_API_KEY'],
baseUrl: ['OPENAI_BASE_URL'],
model: ['OPENAI_MODEL', 'QWEN_MODEL'],
},
anthropic: {
apiKey: ['ANTHROPIC_API_KEY'],
baseUrl: ['ANTHROPIC_BASE_URL'],
model: ['ANTHROPIC_MODEL'],
},
gemini: {
apiKey: ['GEMINI_API_KEY'],
baseUrl: [],
model: ['GEMINI_MODEL'],
},
'vertex-ai': {
apiKey: ['GOOGLE_API_KEY'],
baseUrl: [],
model: ['GOOGLE_MODEL'],
},
'qwen-oauth': {
apiKey: [],
baseUrl: [],
model: [],
},
} as const satisfies Record<AuthType, AuthEnvMapping>;
export const DEFAULT_MODELS = {
openai: 'qwen3-coder-plus',
'qwen-oauth': DEFAULT_QWEN_MODEL,
} as Partial<Record<AuthType, string>>;
export const QWEN_OAUTH_ALLOWED_MODELS = [
DEFAULT_QWEN_MODEL,
'vision-model',
] as const;
/**
* Hard-coded Qwen OAuth models that are always available.
* These cannot be overridden by user configuration.
*/
export const QWEN_OAUTH_MODELS: ModelConfig[] = [
{
id: 'coder-model',
name: 'Qwen Coder',
description:
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)',
capabilities: { vision: false },
generationConfig: {
samplingParams: {
temperature: 0.7,
top_p: 0.9,
max_tokens: 8192,
},
timeout: 60000,
maxRetries: 3,
},
},
{
id: 'vision-model',
name: 'Qwen Vision',
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

@@ -0,0 +1,44 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export {
type ModelCapabilities,
type ModelGenerationConfig,
type ModelConfig,
type ModelProvidersConfig,
type ResolvedModelConfig,
type AvailableModel,
type ModelSwitchMetadata,
} from './types.js';
export { ModelRegistry } from './modelRegistry.js';
export {
ModelsConfig,
type ModelsConfigOptions,
type OnModelChangeCallback,
} from './modelsConfig.js';
export {
AUTH_ENV_MAPPINGS,
CREDENTIAL_FIELDS,
DEFAULT_MODELS,
MODEL_GENERATION_CONFIG_FIELDS,
PROVIDER_SOURCED_FIELDS,
QWEN_OAUTH_ALLOWED_MODELS,
QWEN_OAUTH_MODELS,
} from './constants.js';
// Model configuration resolver
export {
resolveModelConfig,
validateModelConfig,
type ModelConfigSourcesInput,
type ModelConfigCliInput,
type ModelConfigSettingsInput,
type ModelConfigResolutionResult,
type ModelConfigValidationResult,
} from './modelConfigResolver.js';

View File

@@ -0,0 +1,125 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export function getDefaultApiKeyEnvVar(authType: string | undefined): string {
switch (authType) {
case 'openai':
return 'OPENAI_API_KEY';
case 'anthropic':
return 'ANTHROPIC_API_KEY';
case 'gemini':
return 'GEMINI_API_KEY';
case 'vertex-ai':
return 'GOOGLE_API_KEY';
default:
return 'API_KEY';
}
}
export function getDefaultModelEnvVar(authType: string | undefined): string {
switch (authType) {
case 'openai':
return 'OPENAI_MODEL';
case 'anthropic':
return 'ANTHROPIC_MODEL';
case 'gemini':
return 'GEMINI_MODEL';
case 'vertex-ai':
return 'GOOGLE_MODEL';
default:
return 'MODEL';
}
}
export abstract class ModelConfigError extends Error {
abstract readonly code: string;
protected constructor(message: string) {
super(message);
this.name = new.target.name;
Object.setPrototypeOf(this, new.target.prototype);
}
}
export class StrictMissingCredentialsError extends ModelConfigError {
readonly code = 'STRICT_MISSING_CREDENTIALS';
constructor(
authType: string | undefined,
model: string | undefined,
envKey?: string,
) {
const providerKey = authType || '(unknown)';
const modelName = model || '(unknown)';
super(
`Missing credentials for modelProviders model '${modelName}'. ` +
(envKey
? `Current configured envKey: '${envKey}'. Set that environment variable, or update modelProviders.${providerKey}[].envKey.`
: `Configure modelProviders.${providerKey}[].envKey and set that environment variable.`),
);
}
}
export class StrictMissingModelIdError extends ModelConfigError {
readonly code = 'STRICT_MISSING_MODEL_ID';
constructor(authType: string | undefined) {
super(
`Missing model id for strict modelProviders resolution (authType: ${authType}).`,
);
}
}
export class MissingApiKeyError extends ModelConfigError {
readonly code = 'MISSING_API_KEY';
constructor(params: {
authType: string | undefined;
model: string | undefined;
baseUrl: string | undefined;
envKey: string;
}) {
super(
`Missing API key for ${params.authType} auth. ` +
`Current model: '${params.model || '(unknown)'}', baseUrl: '${params.baseUrl || '(default)'}'. ` +
`Provide an API key via settings (security.auth.apiKey), ` +
`or set the environment variable '${params.envKey}'.`,
);
}
}
export class MissingModelError extends ModelConfigError {
readonly code = 'MISSING_MODEL';
constructor(params: { authType: string | undefined; envKey: string }) {
super(
`Missing model for ${params.authType} auth. ` +
`Set the environment variable '${params.envKey}'.`,
);
}
}
export class MissingBaseUrlError extends ModelConfigError {
readonly code = 'MISSING_BASE_URL';
constructor(params: {
authType: string | undefined;
model: string | undefined;
}) {
super(
`Missing baseUrl for modelProviders model '${params.model || '(unknown)'}'. ` +
`Configure modelProviders.${params.authType || '(unknown)'}[].baseUrl.`,
);
}
}
export class MissingAnthropicBaseUrlEnvError extends ModelConfigError {
readonly code = 'MISSING_ANTHROPIC_BASE_URL_ENV';
constructor() {
super('ANTHROPIC_BASE_URL environment variable not found.');
}
}

View File

@@ -0,0 +1,355 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
resolveModelConfig,
validateModelConfig,
} from './modelConfigResolver.js';
import { AuthType } from '../core/contentGenerator.js';
import { DEFAULT_QWEN_MODEL } from '../config/models.js';
describe('modelConfigResolver', () => {
describe('resolveModelConfig', () => {
describe('OpenAI auth type', () => {
it('resolves from CLI with highest priority', () => {
const result = resolveModelConfig({
authType: AuthType.USE_OPENAI,
cli: {
model: 'cli-model',
apiKey: 'cli-key',
baseUrl: 'https://cli.example.com',
},
settings: {
model: 'settings-model',
apiKey: 'settings-key',
baseUrl: 'https://settings.example.com',
},
env: {
OPENAI_MODEL: 'env-model',
OPENAI_API_KEY: 'env-key',
OPENAI_BASE_URL: 'https://env.example.com',
},
});
expect(result.config.model).toBe('cli-model');
expect(result.config.apiKey).toBe('cli-key');
expect(result.config.baseUrl).toBe('https://cli.example.com');
expect(result.sources['model'].kind).toBe('cli');
expect(result.sources['apiKey'].kind).toBe('cli');
expect(result.sources['baseUrl'].kind).toBe('cli');
});
it('falls back to env when CLI not provided', () => {
const result = resolveModelConfig({
authType: AuthType.USE_OPENAI,
cli: {},
settings: {
model: 'settings-model',
},
env: {
OPENAI_MODEL: 'env-model',
OPENAI_API_KEY: 'env-key',
},
});
expect(result.config.model).toBe('env-model');
expect(result.config.apiKey).toBe('env-key');
expect(result.sources['model'].kind).toBe('env');
expect(result.sources['apiKey'].kind).toBe('env');
});
it('falls back to settings when env not provided', () => {
const result = resolveModelConfig({
authType: AuthType.USE_OPENAI,
cli: {},
settings: {
model: 'settings-model',
apiKey: 'settings-key',
baseUrl: 'https://settings.example.com',
},
env: {},
});
expect(result.config.model).toBe('settings-model');
expect(result.config.apiKey).toBe('settings-key');
expect(result.config.baseUrl).toBe('https://settings.example.com');
expect(result.sources['model'].kind).toBe('settings');
expect(result.sources['apiKey'].kind).toBe('settings');
expect(result.sources['baseUrl'].kind).toBe('settings');
});
it('uses default model when nothing provided', () => {
const result = resolveModelConfig({
authType: AuthType.USE_OPENAI,
cli: {},
settings: {},
env: {
OPENAI_API_KEY: 'some-key', // need key to be valid
},
});
expect(result.config.model).toBe('qwen3-coder-plus');
expect(result.sources['model'].kind).toBe('default');
});
it('prioritizes modelProvider over CLI', () => {
const result = resolveModelConfig({
authType: AuthType.USE_OPENAI,
cli: {
model: 'cli-model',
},
settings: {},
env: {
MY_CUSTOM_KEY: 'provider-key',
},
modelProvider: {
id: 'provider-model',
name: 'Provider Model',
authType: AuthType.USE_OPENAI,
envKey: 'MY_CUSTOM_KEY',
baseUrl: 'https://provider.example.com',
generationConfig: {},
capabilities: {},
},
});
expect(result.config.model).toBe('provider-model');
expect(result.config.apiKey).toBe('provider-key');
expect(result.config.baseUrl).toBe('https://provider.example.com');
expect(result.sources['model'].kind).toBe('modelProviders');
expect(result.sources['apiKey'].kind).toBe('env');
expect(result.sources['apiKey'].via?.kind).toBe('modelProviders');
});
it('reads QWEN_MODEL as fallback for OPENAI_MODEL', () => {
const result = resolveModelConfig({
authType: AuthType.USE_OPENAI,
cli: {},
settings: {},
env: {
QWEN_MODEL: 'qwen-model',
OPENAI_API_KEY: 'key',
},
});
expect(result.config.model).toBe('qwen-model');
expect(result.sources['model'].envKey).toBe('QWEN_MODEL');
});
});
describe('Qwen OAuth auth type', () => {
it('uses default model for Qwen OAuth', () => {
const result = resolveModelConfig({
authType: AuthType.QWEN_OAUTH,
cli: {},
settings: {},
env: {},
});
expect(result.config.model).toBe(DEFAULT_QWEN_MODEL);
expect(result.config.apiKey).toBe('QWEN_OAUTH_DYNAMIC_TOKEN');
expect(result.sources['apiKey'].kind).toBe('computed');
});
it('allows vision-model for Qwen OAuth', () => {
const result = resolveModelConfig({
authType: AuthType.QWEN_OAUTH,
cli: {
model: 'vision-model',
},
settings: {},
env: {},
});
expect(result.config.model).toBe('vision-model');
expect(result.sources['model'].kind).toBe('cli');
});
it('warns and falls back for unsupported Qwen OAuth models', () => {
const result = resolveModelConfig({
authType: AuthType.QWEN_OAUTH,
cli: {
model: 'unsupported-model',
},
settings: {},
env: {},
});
expect(result.config.model).toBe(DEFAULT_QWEN_MODEL);
expect(result.warnings).toHaveLength(1);
expect(result.warnings[0]).toContain('unsupported-model');
});
});
describe('Anthropic auth type', () => {
it('resolves Anthropic config from env', () => {
const result = resolveModelConfig({
authType: AuthType.USE_ANTHROPIC,
cli: {},
settings: {},
env: {
ANTHROPIC_API_KEY: 'anthropic-key',
ANTHROPIC_BASE_URL: 'https://anthropic.example.com',
ANTHROPIC_MODEL: 'claude-3',
},
});
expect(result.config.model).toBe('claude-3');
expect(result.config.apiKey).toBe('anthropic-key');
expect(result.config.baseUrl).toBe('https://anthropic.example.com');
});
});
describe('generation config resolution', () => {
it('merges generation config from settings', () => {
const result = resolveModelConfig({
authType: AuthType.USE_OPENAI,
cli: {},
settings: {
apiKey: 'key',
generationConfig: {
timeout: 60000,
maxRetries: 5,
samplingParams: {
temperature: 0.7,
},
},
},
env: {},
});
expect(result.config.timeout).toBe(60000);
expect(result.config.maxRetries).toBe(5);
expect(result.config.samplingParams?.temperature).toBe(0.7);
expect(result.sources['timeout'].kind).toBe('settings');
expect(result.sources['samplingParams'].kind).toBe('settings');
});
it('modelProvider config overrides settings', () => {
const result = resolveModelConfig({
authType: AuthType.USE_OPENAI,
cli: {},
settings: {
generationConfig: {
timeout: 30000,
},
},
env: {
MY_KEY: 'key',
},
modelProvider: {
id: 'model',
name: 'Model',
authType: AuthType.USE_OPENAI,
envKey: 'MY_KEY',
baseUrl: 'https://api.example.com',
generationConfig: {
timeout: 60000,
},
capabilities: {},
},
});
expect(result.config.timeout).toBe(60000);
expect(result.sources['timeout'].kind).toBe('modelProviders');
});
});
describe('proxy handling', () => {
it('includes proxy in config when provided', () => {
const result = resolveModelConfig({
authType: AuthType.USE_OPENAI,
cli: {},
settings: { apiKey: 'key' },
env: {},
proxy: 'http://proxy.example.com:8080',
});
expect(result.config.proxy).toBe('http://proxy.example.com:8080');
expect(result.sources['proxy'].kind).toBe('computed');
});
});
});
describe('validateModelConfig', () => {
it('passes for valid OpenAI config', () => {
const result = validateModelConfig({
authType: AuthType.USE_OPENAI,
model: 'gpt-4',
apiKey: 'sk-xxx',
});
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it('fails when API key missing', () => {
const result = validateModelConfig({
authType: AuthType.USE_OPENAI,
model: 'gpt-4',
});
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].message).toContain('Missing API key');
});
it('fails when model missing', () => {
const result = validateModelConfig({
authType: AuthType.USE_OPENAI,
model: '',
apiKey: 'sk-xxx',
});
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].message).toContain('Missing model');
});
it('always passes for Qwen OAuth', () => {
const result = validateModelConfig({
authType: AuthType.QWEN_OAUTH,
model: DEFAULT_QWEN_MODEL,
apiKey: 'QWEN_OAUTH_DYNAMIC_TOKEN',
});
expect(result.valid).toBe(true);
});
it('requires baseUrl for Anthropic', () => {
const result = validateModelConfig({
authType: AuthType.USE_ANTHROPIC,
model: 'claude-3',
apiKey: 'key',
// missing baseUrl
});
expect(result.valid).toBe(false);
expect(result.errors[0].message).toContain('ANTHROPIC_BASE_URL');
});
it('uses strict error messages for modelProvider', () => {
const result = validateModelConfig(
{
authType: AuthType.USE_OPENAI,
model: 'my-model',
// missing apiKey
},
true, // isStrictModelProvider
);
expect(result.valid).toBe(false);
expect(result.errors[0].message).toContain('modelProviders');
expect(result.errors[0].message).toContain('envKey');
});
});
});

View File

@@ -0,0 +1,362 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* ModelConfigResolver - Unified resolver for model-related configuration.
*
* This module consolidates all model configuration resolution logic,
* eliminating duplicate code between CLI and Core layers.
*
* Configuration priority (highest to lowest):
* 1. modelProvider - Explicit selection from ModelProviders config
* 2. CLI arguments - Command line flags (--model, --openaiApiKey, etc.)
* 3. Environment variables - OPENAI_API_KEY, OPENAI_MODEL, etc.
* 4. Settings - User/workspace settings file
* 5. Defaults - Built-in default values
*/
import { AuthType } from '../core/contentGenerator.js';
import type { ContentGeneratorConfig } from '../core/contentGenerator.js';
import { DEFAULT_QWEN_MODEL } from '../config/models.js';
import {
resolveField,
resolveOptionalField,
layer,
envLayer,
cliSource,
settingsSource,
modelProvidersSource,
defaultSource,
computedSource,
type ConfigSource,
type ConfigSources,
type ConfigLayer,
} from '../utils/configResolver.js';
import {
AUTH_ENV_MAPPINGS,
DEFAULT_MODELS,
QWEN_OAUTH_ALLOWED_MODELS,
MODEL_GENERATION_CONFIG_FIELDS,
} from './constants.js';
import type { ResolvedModelConfig } from './types.js';
export {
validateModelConfig,
type ModelConfigValidationResult,
} from '../core/contentGenerator.js';
/**
* CLI-provided configuration values
*/
export interface ModelConfigCliInput {
model?: string;
apiKey?: string;
baseUrl?: string;
}
/**
* Settings-provided configuration values
*/
export interface ModelConfigSettingsInput {
/** Model name from settings.model.name */
model?: string;
/** API key from settings.security.auth.apiKey */
apiKey?: string;
/** Base URL from settings.security.auth.baseUrl */
baseUrl?: string;
/** Generation config from settings.model.generationConfig */
generationConfig?: Partial<ContentGeneratorConfig>;
}
/**
* All input sources for model configuration resolution
*/
export interface ModelConfigSourcesInput {
/** Authentication type */
authType: AuthType;
/** CLI arguments (highest priority for user-provided values) */
cli?: ModelConfigCliInput;
/** Settings file configuration */
settings?: ModelConfigSettingsInput;
/** Environment variables (injected for testability) */
env: Record<string, string | undefined>;
/** Resolved model from ModelProviders (explicit selection, highest priority) */
modelProvider?: ResolvedModelConfig;
/** Proxy URL (computed from Config) */
proxy?: string;
}
/**
* Result of model configuration resolution
*/
export interface ModelConfigResolutionResult {
/** The fully resolved configuration */
config: ContentGeneratorConfig;
/** Source attribution for each field */
sources: ConfigSources;
/** Warnings generated during resolution */
warnings: string[];
}
/**
* Resolve model configuration from all input sources.
*
* This is the single entry point for model configuration resolution.
* It replaces the duplicate logic in:
* - packages/cli/src/utils/modelProviderUtils.ts (resolveCliGenerationConfig)
* - packages/core/src/core/contentGenerator.ts (resolveContentGeneratorConfigWithSources)
*
* @param input - All configuration sources
* @returns Resolved configuration with source tracking
*/
export function resolveModelConfig(
input: ModelConfigSourcesInput,
): ModelConfigResolutionResult {
const { authType, cli, settings, env, modelProvider, proxy } = input;
const warnings: string[] = [];
const sources: ConfigSources = {};
// Special handling for Qwen OAuth
if (authType === AuthType.QWEN_OAUTH) {
return resolveQwenOAuthConfig(input, warnings);
}
// Get auth-specific env var mappings
const envMapping =
AUTH_ENV_MAPPINGS[authType] || AUTH_ENV_MAPPINGS[AuthType.USE_OPENAI];
// Build layers for each field in priority order
// Priority: modelProvider > cli > env > settings > default
// ---- Model ----
const modelLayers: Array<ConfigLayer<string>> = [];
if (modelProvider) {
modelLayers.push(
layer(
modelProvider.id,
modelProvidersSource(authType, modelProvider.id, 'model.id'),
),
);
}
if (cli?.model) {
modelLayers.push(layer(cli.model, cliSource('--model')));
}
for (const envKey of envMapping.model) {
modelLayers.push(envLayer(env, envKey));
}
if (settings?.model) {
modelLayers.push(layer(settings.model, settingsSource('model.name')));
}
const defaultModel = DEFAULT_MODELS[authType] || '';
const modelResult = resolveField(
modelLayers,
defaultModel,
defaultSource(defaultModel),
);
sources['model'] = modelResult.source;
// ---- API Key ----
const apiKeyLayers: Array<ConfigLayer<string>> = [];
// For modelProvider, read from the specified envKey
if (modelProvider?.envKey) {
const apiKeyFromEnv = env[modelProvider.envKey];
if (apiKeyFromEnv) {
apiKeyLayers.push(
layer(apiKeyFromEnv, {
kind: 'env',
envKey: modelProvider.envKey,
via: modelProvidersSource(authType, modelProvider.id, 'envKey'),
}),
);
}
}
if (cli?.apiKey) {
apiKeyLayers.push(layer(cli.apiKey, cliSource('--openaiApiKey')));
}
for (const envKey of envMapping.apiKey) {
apiKeyLayers.push(envLayer(env, envKey));
}
if (settings?.apiKey) {
apiKeyLayers.push(
layer(settings.apiKey, settingsSource('security.auth.apiKey')),
);
}
const apiKeyResult = resolveOptionalField(apiKeyLayers);
if (apiKeyResult) {
sources['apiKey'] = apiKeyResult.source;
}
// ---- Base URL ----
const baseUrlLayers: Array<ConfigLayer<string>> = [];
if (modelProvider?.baseUrl) {
baseUrlLayers.push(
layer(
modelProvider.baseUrl,
modelProvidersSource(authType, modelProvider.id, 'baseUrl'),
),
);
}
if (cli?.baseUrl) {
baseUrlLayers.push(layer(cli.baseUrl, cliSource('--openaiBaseUrl')));
}
for (const envKey of envMapping.baseUrl) {
baseUrlLayers.push(envLayer(env, envKey));
}
if (settings?.baseUrl) {
baseUrlLayers.push(
layer(settings.baseUrl, settingsSource('security.auth.baseUrl')),
);
}
const baseUrlResult = resolveOptionalField(baseUrlLayers);
if (baseUrlResult) {
sources['baseUrl'] = baseUrlResult.source;
}
// ---- API Key Env Key (for error messages) ----
let apiKeyEnvKey: string | undefined;
if (modelProvider?.envKey) {
apiKeyEnvKey = modelProvider.envKey;
sources['apiKeyEnvKey'] = modelProvidersSource(
authType,
modelProvider.id,
'envKey',
);
}
// ---- Generation Config (from settings or modelProvider) ----
const generationConfig = resolveGenerationConfig(
settings?.generationConfig,
modelProvider?.generationConfig,
authType,
modelProvider?.id,
sources,
);
// Build final config
const config: ContentGeneratorConfig = {
authType,
model: modelResult.value,
apiKey: apiKeyResult?.value,
apiKeyEnvKey,
baseUrl: baseUrlResult?.value,
proxy,
...generationConfig,
};
// Add proxy source
if (proxy) {
sources['proxy'] = computedSource('Config.getProxy()');
}
// Add authType source
sources['authType'] = computedSource('provided by caller');
return { config, sources, warnings };
}
/**
* Special resolver for Qwen OAuth authentication.
* Qwen OAuth has fixed model options and uses dynamic tokens.
*/
function resolveQwenOAuthConfig(
input: ModelConfigSourcesInput,
warnings: string[],
): ModelConfigResolutionResult {
const { cli, settings, proxy } = input;
const sources: ConfigSources = {};
// Qwen OAuth only allows specific models
const allowedModels = new Set<string>(QWEN_OAUTH_ALLOWED_MODELS);
// Determine requested model
const requestedModel = cli?.model || settings?.model;
let resolvedModel: string;
let modelSource: ConfigSource;
if (requestedModel && allowedModels.has(requestedModel)) {
resolvedModel = requestedModel;
modelSource = cli?.model
? cliSource('--model')
: settingsSource('model.name');
} else {
if (requestedModel) {
warnings.push(
`Unsupported Qwen OAuth model '${requestedModel}', falling back to '${DEFAULT_QWEN_MODEL}'.`,
);
}
resolvedModel = DEFAULT_QWEN_MODEL;
modelSource = defaultSource(`fallback to '${DEFAULT_QWEN_MODEL}'`);
}
sources['model'] = modelSource;
sources['apiKey'] = computedSource('Qwen OAuth dynamic token');
sources['authType'] = computedSource('provided by caller');
if (proxy) {
sources['proxy'] = computedSource('Config.getProxy()');
}
// Resolve generation config from settings
const generationConfig = resolveGenerationConfig(
settings?.generationConfig,
undefined,
AuthType.QWEN_OAUTH,
resolvedModel,
sources,
);
const config: ContentGeneratorConfig = {
authType: AuthType.QWEN_OAUTH,
model: resolvedModel,
apiKey: 'QWEN_OAUTH_DYNAMIC_TOKEN',
proxy,
...generationConfig,
};
return { config, sources, warnings };
}
/**
* Resolve generation config fields (samplingParams, timeout, etc.)
*/
function resolveGenerationConfig(
settingsConfig: Partial<ContentGeneratorConfig> | undefined,
modelProviderConfig: Partial<ContentGeneratorConfig> | undefined,
authType: AuthType,
modelId: string | undefined,
sources: ConfigSources,
): Partial<ContentGeneratorConfig> {
const result: Partial<ContentGeneratorConfig> = {};
for (const field of MODEL_GENERATION_CONFIG_FIELDS) {
// ModelProvider config takes priority
if (modelProviderConfig && field in modelProviderConfig) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(result as any)[field] = modelProviderConfig[field];
sources[field] = modelProvidersSource(
authType,
modelId || '',
`generationConfig.${field}`,
);
} else if (settingsConfig && field in settingsConfig) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(result as any)[field] = settingsConfig[field];
sources[field] = settingsSource(`model.generationConfig.${field}`);
}
}
return result;
}

View File

@@ -0,0 +1,388 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ModelRegistry, QWEN_OAUTH_MODELS } from './modelRegistry.js';
import { AuthType } from '../core/contentGenerator.js';
import type { ModelProvidersConfig } from './types.js';
describe('ModelRegistry', () => {
describe('initialization', () => {
it('should always include hard-coded qwen-oauth models', () => {
const registry = new ModelRegistry();
const qwenModels = registry.getModelsForAuthType(AuthType.QWEN_OAUTH);
expect(qwenModels.length).toBe(QWEN_OAUTH_MODELS.length);
expect(qwenModels[0].id).toBe('coder-model');
expect(qwenModels[1].id).toBe('vision-model');
});
it('should initialize with empty config', () => {
const registry = new ModelRegistry();
expect(registry.getModelsForAuthType(AuthType.QWEN_OAUTH).length).toBe(
QWEN_OAUTH_MODELS.length,
);
expect(registry.getModelsForAuthType(AuthType.USE_OPENAI).length).toBe(0);
});
it('should initialize with custom models config', () => {
const modelProvidersConfig: ModelProvidersConfig = {
openai: [
{
id: 'gpt-4-turbo',
name: 'GPT-4 Turbo',
baseUrl: 'https://api.openai.com/v1',
},
],
};
const registry = new ModelRegistry(modelProvidersConfig);
const openaiModels = registry.getModelsForAuthType(AuthType.USE_OPENAI);
expect(openaiModels.length).toBe(1);
expect(openaiModels[0].id).toBe('gpt-4-turbo');
});
it('should ignore qwen-oauth models in config (hard-coded)', () => {
const modelProvidersConfig: ModelProvidersConfig = {
'qwen-oauth': [
{
id: 'custom-qwen',
name: 'Custom Qwen',
},
],
};
const registry = new ModelRegistry(modelProvidersConfig);
// Should still use hard-coded qwen-oauth models
const qwenModels = registry.getModelsForAuthType(AuthType.QWEN_OAUTH);
expect(qwenModels.length).toBe(QWEN_OAUTH_MODELS.length);
expect(qwenModels.find((m) => m.id === 'custom-qwen')).toBeUndefined();
});
});
describe('getModelsForAuthType', () => {
let registry: ModelRegistry;
beforeEach(() => {
const modelProvidersConfig: ModelProvidersConfig = {
openai: [
{
id: 'gpt-4-turbo',
name: 'GPT-4 Turbo',
description: 'Most capable GPT-4',
baseUrl: 'https://api.openai.com/v1',
capabilities: { vision: true },
},
{
id: 'gpt-3.5-turbo',
name: 'GPT-3.5 Turbo',
capabilities: { vision: false },
},
],
};
registry = new ModelRegistry(modelProvidersConfig);
});
it('should return models for existing authType', () => {
const models = registry.getModelsForAuthType(AuthType.USE_OPENAI);
expect(models.length).toBe(2);
});
it('should return empty array for non-existent authType', () => {
const models = registry.getModelsForAuthType(AuthType.USE_VERTEX_AI);
expect(models.length).toBe(0);
});
it('should return AvailableModel format with correct fields', () => {
const models = registry.getModelsForAuthType(AuthType.USE_OPENAI);
const gpt4 = models.find((m) => m.id === 'gpt-4-turbo');
expect(gpt4).toBeDefined();
expect(gpt4?.label).toBe('GPT-4 Turbo');
expect(gpt4?.description).toBe('Most capable GPT-4');
expect(gpt4?.isVision).toBe(true);
expect(gpt4?.authType).toBe(AuthType.USE_OPENAI);
});
});
describe('getModel', () => {
let registry: ModelRegistry;
beforeEach(() => {
const modelProvidersConfig: ModelProvidersConfig = {
openai: [
{
id: 'gpt-4-turbo',
name: 'GPT-4 Turbo',
baseUrl: 'https://api.openai.com/v1',
generationConfig: {
samplingParams: {
temperature: 0.8,
max_tokens: 4096,
},
},
},
],
};
registry = new ModelRegistry(modelProvidersConfig);
});
it('should return resolved model config', () => {
const model = registry.getModel(AuthType.USE_OPENAI, 'gpt-4-turbo');
expect(model).toBeDefined();
expect(model?.id).toBe('gpt-4-turbo');
expect(model?.name).toBe('GPT-4 Turbo');
expect(model?.authType).toBe(AuthType.USE_OPENAI);
expect(model?.baseUrl).toBe('https://api.openai.com/v1');
});
it('should preserve generationConfig without applying defaults', () => {
const model = registry.getModel(AuthType.USE_OPENAI, 'gpt-4-turbo');
expect(model?.generationConfig.samplingParams?.temperature).toBe(0.8);
expect(model?.generationConfig.samplingParams?.max_tokens).toBe(4096);
// No defaults are applied - only the configured values are present
expect(model?.generationConfig.samplingParams?.top_p).toBeUndefined();
expect(model?.generationConfig.timeout).toBeUndefined();
});
it('should return undefined for non-existent model', () => {
const model = registry.getModel(AuthType.USE_OPENAI, 'non-existent');
expect(model).toBeUndefined();
});
it('should return undefined for non-existent authType', () => {
const model = registry.getModel(AuthType.USE_VERTEX_AI, 'some-model');
expect(model).toBeUndefined();
});
});
describe('hasModel', () => {
let registry: ModelRegistry;
beforeEach(() => {
registry = new ModelRegistry({
openai: [{ id: 'gpt-4', name: 'GPT-4' }],
});
});
it('should return true for existing model', () => {
expect(registry.hasModel(AuthType.USE_OPENAI, 'gpt-4')).toBe(true);
});
it('should return false for non-existent model', () => {
expect(registry.hasModel(AuthType.USE_OPENAI, 'non-existent')).toBe(
false,
);
});
it('should return false for non-existent authType', () => {
expect(registry.hasModel(AuthType.USE_VERTEX_AI, 'gpt-4')).toBe(false);
});
});
describe('getDefaultModelForAuthType', () => {
it('should return coder-model for qwen-oauth', () => {
const registry = new ModelRegistry();
const defaultModel = registry.getDefaultModelForAuthType(
AuthType.QWEN_OAUTH,
);
expect(defaultModel?.id).toBe('coder-model');
});
it('should return first model for other authTypes', () => {
const registry = new ModelRegistry({
openai: [
{ id: 'gpt-4', name: 'GPT-4' },
{ id: 'gpt-3.5', name: 'GPT-3.5' },
],
});
const defaultModel = registry.getDefaultModelForAuthType(
AuthType.USE_OPENAI,
);
expect(defaultModel?.id).toBe('gpt-4');
});
});
describe('validation', () => {
it('should throw error for model without id', () => {
expect(
() =>
new ModelRegistry({
openai: [{ id: '', name: 'No ID' }],
}),
).toThrow('missing required field: id');
});
});
describe('default base URLs', () => {
it('should apply default dashscope URL for qwen-oauth', () => {
const registry = new ModelRegistry();
const model = registry.getModel(AuthType.QWEN_OAUTH, 'coder-model');
expect(model?.baseUrl).toBe('DYNAMIC_QWEN_OAUTH_BASE_URL');
});
it('should apply default openai URL when not specified', () => {
const registry = new ModelRegistry({
openai: [{ id: 'gpt-4', name: 'GPT-4' }],
});
const model = registry.getModel(AuthType.USE_OPENAI, 'gpt-4');
expect(model?.baseUrl).toBe('https://api.openai.com/v1');
});
it('should use custom baseUrl when specified', () => {
const registry = new ModelRegistry({
openai: [
{
id: 'deepseek',
name: 'DeepSeek',
baseUrl: 'https://api.deepseek.com/v1',
},
],
});
const model = registry.getModel(AuthType.USE_OPENAI, 'deepseek');
expect(model?.baseUrl).toBe('https://api.deepseek.com/v1');
});
});
describe('authType key validation', () => {
it('should accept valid authType keys', () => {
const registry = new ModelRegistry({
openai: [{ id: 'gpt-4', name: 'GPT-4' }],
gemini: [{ id: 'gemini-pro', name: 'Gemini Pro' }],
});
const openaiModels = registry.getModelsForAuthType(AuthType.USE_OPENAI);
expect(openaiModels.length).toBe(1);
expect(openaiModels[0].id).toBe('gpt-4');
const geminiModels = registry.getModelsForAuthType(AuthType.USE_GEMINI);
expect(geminiModels.length).toBe(1);
expect(geminiModels[0].id).toBe('gemini-pro');
});
it('should skip invalid authType keys with warning', () => {
const consoleWarnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
const registry = new ModelRegistry({
openai: [{ id: 'gpt-4', name: 'GPT-4' }],
'invalid-key': [{ id: 'some-model', name: 'Some Model' }],
} as unknown as ModelProvidersConfig);
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('[ModelRegistry] Invalid authType key'),
);
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('invalid-key'),
);
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('Expected one of:'),
);
// Valid key should be registered
expect(registry.getModelsForAuthType(AuthType.USE_OPENAI).length).toBe(1);
// Invalid key should be skipped (no crash)
const openaiModels = registry.getModelsForAuthType(AuthType.USE_OPENAI);
expect(openaiModels.length).toBe(1);
consoleWarnSpy.mockRestore();
});
it('should handle mixed valid and invalid keys', () => {
const consoleWarnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
const registry = new ModelRegistry({
openai: [{ id: 'gpt-4', name: 'GPT-4' }],
'bad-key-1': [{ id: 'model-1', name: 'Model 1' }],
gemini: [{ id: 'gemini-pro', name: 'Gemini Pro' }],
'bad-key-2': [{ id: 'model-2', name: 'Model 2' }],
} as unknown as ModelProvidersConfig);
// Should warn twice for the two invalid keys
expect(consoleWarnSpy).toHaveBeenCalledTimes(2);
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('bad-key-1'),
);
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('bad-key-2'),
);
// Valid keys should be registered
expect(registry.getModelsForAuthType(AuthType.USE_OPENAI).length).toBe(1);
expect(registry.getModelsForAuthType(AuthType.USE_GEMINI).length).toBe(1);
// Invalid keys should be skipped
const openaiModels = registry.getModelsForAuthType(AuthType.USE_OPENAI);
expect(openaiModels.length).toBe(1);
const geminiModels = registry.getModelsForAuthType(AuthType.USE_GEMINI);
expect(geminiModels.length).toBe(1);
consoleWarnSpy.mockRestore();
});
it('should list all valid AuthType values in warning message', () => {
const consoleWarnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
new ModelRegistry({
'invalid-auth': [{ id: 'model', name: 'Model' }],
} as unknown as ModelProvidersConfig);
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('openai'),
);
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('qwen-oauth'),
);
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('gemini'),
);
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('vertex-ai'),
);
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('anthropic'),
);
consoleWarnSpy.mockRestore();
});
it('should work correctly with getModelsForAuthType after validation', () => {
const consoleWarnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
const registry = new ModelRegistry({
openai: [
{ id: 'gpt-4', name: 'GPT-4' },
{ id: 'gpt-3.5', name: 'GPT-3.5' },
],
'invalid-key': [{ id: 'invalid-model', name: 'Invalid Model' }],
} as unknown as ModelProvidersConfig);
const models = registry.getModelsForAuthType(AuthType.USE_OPENAI);
expect(models.length).toBe(2);
expect(models.find((m) => m.id === 'gpt-4')).toBeDefined();
expect(models.find((m) => m.id === 'gpt-3.5')).toBeDefined();
expect(models.find((m) => m.id === 'invalid-model')).toBeUndefined();
consoleWarnSpy.mockRestore();
});
});
});

View File

@@ -0,0 +1,180 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { AuthType } from '../core/contentGenerator.js';
import { DEFAULT_OPENAI_BASE_URL } from '../core/openaiContentGenerator/constants.js';
import {
type ModelConfig,
type ModelProvidersConfig,
type ResolvedModelConfig,
type AvailableModel,
} from './types.js';
import { DEFAULT_QWEN_MODEL } from '../config/models.js';
import { QWEN_OAUTH_MODELS } from './constants.js';
export { QWEN_OAUTH_MODELS } from './constants.js';
/**
* Validates if a string key is a valid AuthType enum value.
* @param key - The key to validate
* @returns The validated AuthType or undefined if invalid
*/
function validateAuthTypeKey(key: string): AuthType | undefined {
// Check if the key is a valid AuthType enum value
if (Object.values(AuthType).includes(key as AuthType)) {
return key as AuthType;
}
// Invalid key
return undefined;
}
/**
* Central registry for managing model configurations.
* Models are organized by authType.
*/
export class ModelRegistry {
private modelsByAuthType: Map<AuthType, Map<string, ResolvedModelConfig>>;
private getDefaultBaseUrl(authType: AuthType): string {
switch (authType) {
case AuthType.QWEN_OAUTH:
return 'DYNAMIC_QWEN_OAUTH_BASE_URL';
case AuthType.USE_OPENAI:
return DEFAULT_OPENAI_BASE_URL;
default:
return '';
}
}
constructor(modelProvidersConfig?: ModelProvidersConfig) {
this.modelsByAuthType = new Map();
// Always register qwen-oauth models (hard-coded, cannot be overridden)
this.registerAuthTypeModels(AuthType.QWEN_OAUTH, QWEN_OAUTH_MODELS);
// Register user-configured models for other authTypes
if (modelProvidersConfig) {
for (const [rawKey, models] of Object.entries(modelProvidersConfig)) {
const authType = validateAuthTypeKey(rawKey);
if (!authType) {
console.warn(
`[ModelRegistry] Invalid authType key "${rawKey}" in modelProviders config. Expected one of: ${Object.values(AuthType).join(', ')}. Skipping.`,
);
continue;
}
// Skip qwen-oauth as it uses hard-coded models
if (authType === AuthType.QWEN_OAUTH) {
continue;
}
this.registerAuthTypeModels(authType, models);
}
}
}
/**
* Register models for an authType
*/
private registerAuthTypeModels(
authType: AuthType,
models: ModelConfig[],
): void {
const modelMap = new Map<string, ResolvedModelConfig>();
for (const config of models) {
const resolved = this.resolveModelConfig(config, authType);
modelMap.set(config.id, resolved);
}
this.modelsByAuthType.set(authType, modelMap);
}
/**
* Get all models for a specific authType.
* This is used by /model command to show only relevant models.
*/
getModelsForAuthType(authType: AuthType): AvailableModel[] {
const models = this.modelsByAuthType.get(authType);
if (!models) return [];
return Array.from(models.values()).map((model) => ({
id: model.id,
label: model.name,
description: model.description,
capabilities: model.capabilities,
authType: model.authType,
isVision: model.capabilities?.vision ?? false,
}));
}
/**
* Get model configuration by authType and modelId
*/
getModel(
authType: AuthType,
modelId: string,
): ResolvedModelConfig | undefined {
const models = this.modelsByAuthType.get(authType);
return models?.get(modelId);
}
/**
* Check if model exists for given authType
*/
hasModel(authType: AuthType, modelId: string): boolean {
const models = this.modelsByAuthType.get(authType);
return models?.has(modelId) ?? false;
}
/**
* Get default model for an authType.
* For qwen-oauth, returns the coder model.
* For others, returns the first configured model.
*/
getDefaultModelForAuthType(
authType: AuthType,
): ResolvedModelConfig | undefined {
if (authType === AuthType.QWEN_OAUTH) {
return this.getModel(authType, DEFAULT_QWEN_MODEL);
}
const models = this.modelsByAuthType.get(authType);
if (!models || models.size === 0) return undefined;
return Array.from(models.values())[0];
}
/**
* Resolve model config by applying defaults
*/
private resolveModelConfig(
config: ModelConfig,
authType: AuthType,
): ResolvedModelConfig {
this.validateModelConfig(config, authType);
return {
...config,
authType,
name: config.name || config.id,
baseUrl: config.baseUrl || this.getDefaultBaseUrl(authType),
generationConfig: config.generationConfig ?? {},
capabilities: config.capabilities || {},
};
}
/**
* Validate model configuration
*/
private validateModelConfig(config: ModelConfig, authType: AuthType): void {
if (!config.id) {
throw new Error(
`Model config in authType '${authType}' missing required field: id`,
);
}
}
}

View File

@@ -0,0 +1,583 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { ModelsConfig } from './modelsConfig.js';
import { AuthType } from '../core/contentGenerator.js';
import type { ContentGeneratorConfig } from '../core/contentGenerator.js';
import type { ModelProvidersConfig } from './types.js';
describe('ModelsConfig', () => {
function deepClone<T>(value: T): T {
if (value === null || typeof value !== 'object') return value;
if (Array.isArray(value)) return value.map((v) => deepClone(v)) as T;
const out: Record<string, unknown> = {};
for (const key of Object.keys(value as Record<string, unknown>)) {
out[key] = deepClone((value as Record<string, unknown>)[key]);
}
return out as T;
}
function snapshotGenerationConfig(
modelsConfig: ModelsConfig,
): ContentGeneratorConfig {
return deepClone<ContentGeneratorConfig>(
modelsConfig.getGenerationConfig() as ContentGeneratorConfig,
);
}
function currentGenerationConfig(
modelsConfig: ModelsConfig,
): ContentGeneratorConfig {
return modelsConfig.getGenerationConfig() as ContentGeneratorConfig;
}
it('should fully rollback state when switchModel fails after applying defaults (authType change)', async () => {
const modelProvidersConfig: ModelProvidersConfig = {
openai: [
{
id: 'openai-a',
name: 'OpenAI A',
baseUrl: 'https://api.openai.example.com/v1',
envKey: 'OPENAI_API_KEY',
generationConfig: {
samplingParams: { temperature: 0.2, max_tokens: 123 },
timeout: 111,
maxRetries: 1,
},
},
],
anthropic: [
{
id: 'anthropic-b',
name: 'Anthropic B',
baseUrl: 'https://api.anthropic.example.com/v1',
envKey: 'ANTHROPIC_API_KEY',
generationConfig: {
samplingParams: { temperature: 0.7, max_tokens: 456 },
timeout: 222,
maxRetries: 2,
},
},
],
};
const modelsConfig = new ModelsConfig({
initialAuthType: AuthType.USE_OPENAI,
modelProvidersConfig,
});
// Establish a known baseline state via a successful switch.
await modelsConfig.switchModel(AuthType.USE_OPENAI, 'openai-a');
const baselineAuthType = modelsConfig.getCurrentAuthType();
const baselineModel = modelsConfig.getModel();
const baselineStrict = modelsConfig.isStrictModelProviderSelection();
const baselineGc = snapshotGenerationConfig(modelsConfig);
const baselineSources = deepClone(
modelsConfig.getGenerationConfigSources(),
);
modelsConfig.setOnModelChange(async () => {
throw new Error('refresh failed');
});
await expect(
modelsConfig.switchModel(AuthType.USE_ANTHROPIC, 'anthropic-b'),
).rejects.toThrow('refresh failed');
// Ensure state is fully rolled back (selection + generation config + flags).
expect(modelsConfig.getCurrentAuthType()).toBe(baselineAuthType);
expect(modelsConfig.getModel()).toBe(baselineModel);
expect(modelsConfig.isStrictModelProviderSelection()).toBe(baselineStrict);
const gc = currentGenerationConfig(modelsConfig);
expect(gc).toMatchObject({
model: baselineGc.model,
baseUrl: baselineGc.baseUrl,
apiKeyEnvKey: baselineGc.apiKeyEnvKey,
samplingParams: baselineGc.samplingParams,
timeout: baselineGc.timeout,
maxRetries: baselineGc.maxRetries,
});
const sources = modelsConfig.getGenerationConfigSources();
expect(sources).toEqual(baselineSources);
});
it('should fully rollback state when switchModel fails after applying defaults', async () => {
const modelProvidersConfig: ModelProvidersConfig = {
openai: [
{
id: 'model-a',
name: 'Model A',
baseUrl: 'https://api.example.com/v1',
envKey: 'API_KEY_A',
},
{
id: 'model-b',
name: 'Model B',
baseUrl: 'https://api.example.com/v1',
envKey: 'API_KEY_B',
},
],
};
const modelsConfig = new ModelsConfig({
initialAuthType: AuthType.USE_OPENAI,
modelProvidersConfig,
});
await modelsConfig.switchModel(AuthType.USE_OPENAI, 'model-a');
const baselineModel = modelsConfig.getModel();
const baselineGc = snapshotGenerationConfig(modelsConfig);
const baselineSources = deepClone(
modelsConfig.getGenerationConfigSources(),
);
modelsConfig.setOnModelChange(async () => {
throw new Error('hot-update failed');
});
await expect(
modelsConfig.switchModel(AuthType.USE_OPENAI, 'model-b'),
).rejects.toThrow('hot-update failed');
expect(modelsConfig.getModel()).toBe(baselineModel);
expect(modelsConfig.getGenerationConfig()).toMatchObject({
model: baselineGc.model,
baseUrl: baselineGc.baseUrl,
apiKeyEnvKey: baselineGc.apiKeyEnvKey,
});
expect(modelsConfig.getGenerationConfigSources()).toEqual(baselineSources);
});
it('should require provider-sourced apiKey when switching models even if envKey is missing', async () => {
const modelProvidersConfig: ModelProvidersConfig = {
openai: [
{
id: 'model-a',
name: 'Model A',
baseUrl: 'https://api.example.com/v1',
envKey: 'API_KEY_SHARED',
},
{
id: 'model-b',
name: 'Model B',
baseUrl: 'https://api.example.com/v1',
envKey: 'API_KEY_SHARED',
},
],
};
const modelsConfig = new ModelsConfig({
initialAuthType: AuthType.USE_OPENAI,
modelProvidersConfig,
generationConfig: {
model: 'model-a',
},
});
// Simulate key prompt flow / explicit key provided via CLI/settings.
modelsConfig.updateCredentials({ apiKey: 'manual-key', model: 'model-a' });
await modelsConfig.switchModel(AuthType.USE_OPENAI, 'model-b');
const gc = currentGenerationConfig(modelsConfig);
expect(gc.model).toBe('model-b');
expect(gc.apiKey).toBeUndefined();
expect(gc.apiKeyEnvKey).toBe('API_KEY_SHARED');
});
it('should preserve settings generationConfig when model is updated via updateCredentials even if it matches modelProviders', () => {
const modelProvidersConfig: ModelProvidersConfig = {
openai: [
{
id: 'model-a',
name: 'Model A',
baseUrl: 'https://api.example.com/v1',
envKey: 'API_KEY_A',
generationConfig: {
samplingParams: { temperature: 0.1, max_tokens: 123 },
timeout: 111,
maxRetries: 1,
},
},
],
};
// Simulate settings.model.generationConfig being resolved into ModelsConfig.generationConfig
const modelsConfig = new ModelsConfig({
initialAuthType: AuthType.USE_OPENAI,
modelProvidersConfig,
generationConfig: {
model: 'model-a',
samplingParams: { temperature: 0.9, max_tokens: 999 },
timeout: 9999,
maxRetries: 9,
},
generationConfigSources: {
model: { kind: 'settings', detail: 'settings.model.name' },
samplingParams: {
kind: 'settings',
detail: 'settings.model.generationConfig.samplingParams',
},
timeout: {
kind: 'settings',
detail: 'settings.model.generationConfig.timeout',
},
maxRetries: {
kind: 'settings',
detail: 'settings.model.generationConfig.maxRetries',
},
},
});
// 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' });
modelsConfig.syncAfterAuthRefresh(
AuthType.USE_OPENAI,
modelsConfig.getModel(),
);
const gc = currentGenerationConfig(modelsConfig);
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);
expect(gc.maxRetries).toBe(9);
});
it('should preserve settings generationConfig across multiple auth refreshes after updateCredentials', () => {
const modelProvidersConfig: ModelProvidersConfig = {
openai: [
{
id: 'model-a',
name: 'Model A',
baseUrl: 'https://api.example.com/v1',
envKey: 'API_KEY_A',
generationConfig: {
samplingParams: { temperature: 0.1, max_tokens: 123 },
timeout: 111,
maxRetries: 1,
},
},
],
};
const modelsConfig = new ModelsConfig({
initialAuthType: AuthType.USE_OPENAI,
modelProvidersConfig,
generationConfig: {
model: 'model-a',
samplingParams: { temperature: 0.9, max_tokens: 999 },
timeout: 9999,
maxRetries: 9,
},
generationConfigSources: {
model: { kind: 'settings', detail: 'settings.model.name' },
samplingParams: {
kind: 'settings',
detail: 'settings.model.generationConfig.samplingParams',
},
timeout: {
kind: 'settings',
detail: 'settings.model.generationConfig.timeout',
},
maxRetries: {
kind: 'settings',
detail: 'settings.model.generationConfig.maxRetries',
},
},
});
modelsConfig.updateCredentials({
apiKey: 'manual-key',
baseUrl: 'https://manual.example.com/v1',
model: 'model-a',
});
// First auth refresh
modelsConfig.syncAfterAuthRefresh(
AuthType.USE_OPENAI,
modelsConfig.getModel(),
);
// Second auth refresh should still preserve settings generationConfig
modelsConfig.syncAfterAuthRefresh(
AuthType.USE_OPENAI,
modelsConfig.getModel(),
);
const gc = currentGenerationConfig(modelsConfig);
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);
expect(gc.maxRetries).toBe(9);
});
it('should clear provider-sourced config when updateCredentials is called after switchModel', async () => {
const modelProvidersConfig: ModelProvidersConfig = {
openai: [
{
id: 'provider-model',
name: 'Provider Model',
baseUrl: 'https://provider.example.com/v1',
envKey: 'PROVIDER_API_KEY',
generationConfig: {
samplingParams: { temperature: 0.1, max_tokens: 100 },
timeout: 1000,
maxRetries: 2,
},
},
],
};
const modelsConfig = new ModelsConfig({
initialAuthType: AuthType.USE_OPENAI,
modelProvidersConfig,
});
// Step 1: Switch to a provider model - this applies provider config
await modelsConfig.switchModel(AuthType.USE_OPENAI, 'provider-model');
// Verify provider config is applied
let gc = currentGenerationConfig(modelsConfig);
expect(gc.model).toBe('provider-model');
expect(gc.baseUrl).toBe('https://provider.example.com/v1');
expect(gc.samplingParams?.temperature).toBe(0.1);
expect(gc.samplingParams?.max_tokens).toBe(100);
expect(gc.timeout).toBe(1000);
expect(gc.maxRetries).toBe(2);
// Verify sources are from modelProviders
let sources = modelsConfig.getGenerationConfigSources();
expect(sources['model']?.kind).toBe('modelProviders');
expect(sources['baseUrl']?.kind).toBe('modelProviders');
expect(sources['samplingParams']?.kind).toBe('modelProviders');
expect(sources['timeout']?.kind).toBe('modelProviders');
expect(sources['maxRetries']?.kind).toBe('modelProviders');
// Step 2: User manually sets credentials via updateCredentials
// This should clear all provider-sourced config
modelsConfig.updateCredentials({
apiKey: 'manual-api-key',
model: 'custom-model',
});
// Verify provider-sourced config is cleared
gc = currentGenerationConfig(modelsConfig);
expect(gc.model).toBe('custom-model'); // Set by updateCredentials
expect(gc.apiKey).toBe('manual-api-key'); // Set by updateCredentials
expect(gc.baseUrl).toBeUndefined(); // Cleared (was from provider)
expect(gc.samplingParams).toBeUndefined(); // Cleared (was from provider)
expect(gc.timeout).toBeUndefined(); // Cleared (was from provider)
expect(gc.maxRetries).toBeUndefined(); // Cleared (was from provider)
// Verify sources are updated
sources = modelsConfig.getGenerationConfigSources();
expect(sources['model']?.kind).toBe('programmatic');
expect(sources['apiKey']?.kind).toBe('programmatic');
expect(sources['baseUrl']).toBeUndefined(); // Source cleared
expect(sources['samplingParams']).toBeUndefined(); // Source cleared
expect(sources['timeout']).toBeUndefined(); // Source cleared
expect(sources['maxRetries']).toBeUndefined(); // Source cleared
});
it('should preserve non-provider config when updateCredentials clears provider config', async () => {
const modelProvidersConfig: ModelProvidersConfig = {
openai: [
{
id: 'provider-model',
name: 'Provider Model',
baseUrl: 'https://provider.example.com/v1',
envKey: 'PROVIDER_API_KEY',
generationConfig: {
samplingParams: { temperature: 0.1, max_tokens: 100 },
timeout: 1000,
maxRetries: 2,
},
},
],
};
// Initialize with settings-sourced config
const modelsConfig = new ModelsConfig({
initialAuthType: AuthType.USE_OPENAI,
modelProvidersConfig,
generationConfig: {
samplingParams: { temperature: 0.8, max_tokens: 500 },
timeout: 5000,
},
generationConfigSources: {
samplingParams: {
kind: 'settings',
detail: 'settings.model.generationConfig.samplingParams',
},
timeout: {
kind: 'settings',
detail: 'settings.model.generationConfig.timeout',
},
},
});
// Switch to provider model - this overwrites with provider config
await modelsConfig.switchModel(AuthType.USE_OPENAI, 'provider-model');
// Verify provider config is applied (overwriting settings)
let gc = currentGenerationConfig(modelsConfig);
expect(gc.samplingParams?.temperature).toBe(0.1);
expect(gc.timeout).toBe(1000);
// User manually sets credentials - clears provider-sourced config
modelsConfig.updateCredentials({
apiKey: 'manual-key',
});
// Provider-sourced config should be cleared
gc = currentGenerationConfig(modelsConfig);
expect(gc.samplingParams).toBeUndefined();
expect(gc.timeout).toBeUndefined();
// The original settings-sourced config is NOT restored automatically;
// it should be re-resolved by other layers in refreshAuth
});
it('should always force Qwen OAuth apiKey placeholder when applying model defaults', async () => {
// Simulate a stale/explicit apiKey existing before switching models.
const modelsConfig = new ModelsConfig({
initialAuthType: AuthType.QWEN_OAUTH,
generationConfig: {
apiKey: 'manual-key-should-not-leak',
},
});
// Switching within qwen-oauth triggers applyResolvedModelDefaults().
await modelsConfig.switchModel(AuthType.QWEN_OAUTH, 'vision-model');
const gc = currentGenerationConfig(modelsConfig);
expect(gc.apiKey).toBe('QWEN_OAUTH_DYNAMIC_TOKEN');
expect(gc.apiKeyEnvKey).toBeUndefined();
});
it('should maintain consistency between currentModelId and _generationConfig.model after initialization', () => {
const modelProvidersConfig: ModelProvidersConfig = {
openai: [
{
id: 'test-model',
name: 'Test Model',
baseUrl: 'https://api.example.com/v1',
envKey: 'TEST_API_KEY',
},
],
};
// Test case 1: generationConfig.model provided with other config
const config1 = new ModelsConfig({
initialAuthType: AuthType.USE_OPENAI,
modelProvidersConfig,
generationConfig: {
model: 'test-model',
samplingParams: { temperature: 0.5 },
},
});
expect(config1.getModel()).toBe('test-model');
expect(config1.getGenerationConfig().model).toBe('test-model');
// Test case 2: generationConfig.model provided
const config2 = new ModelsConfig({
initialAuthType: AuthType.USE_OPENAI,
modelProvidersConfig,
generationConfig: {
model: 'test-model',
},
});
expect(config2.getModel()).toBe('test-model');
expect(config2.getGenerationConfig().model).toBe('test-model');
// Test case 3: no model provided (empty string fallback)
const config3 = new ModelsConfig({
initialAuthType: AuthType.USE_OPENAI,
modelProvidersConfig,
generationConfig: {},
});
expect(config3.getModel()).toBe('coder-model'); // Falls back to DEFAULT_QWEN_MODEL
expect(config3.getGenerationConfig().model).toBeUndefined();
});
it('should maintain consistency between currentModelId and _generationConfig.model during syncAfterAuthRefresh', () => {
const modelProvidersConfig: ModelProvidersConfig = {
openai: [
{
id: 'model-a',
name: 'Model A',
baseUrl: 'https://api.example.com/v1',
envKey: 'API_KEY_A',
},
],
};
const modelsConfig = new ModelsConfig({
initialAuthType: AuthType.USE_OPENAI,
modelProvidersConfig,
generationConfig: {
model: 'model-a',
},
});
// Manually set credentials to trigger preserveManualCredentials path
modelsConfig.updateCredentials({ apiKey: 'manual-key' });
// syncAfterAuthRefresh with a different modelId
modelsConfig.syncAfterAuthRefresh(AuthType.USE_OPENAI, 'model-a');
// Both should be consistent
expect(modelsConfig.getModel()).toBe('model-a');
expect(modelsConfig.getGenerationConfig().model).toBe('model-a');
});
it('should maintain consistency between currentModelId and _generationConfig.model during setModel', async () => {
const modelProvidersConfig: ModelProvidersConfig = {
openai: [
{
id: 'model-a',
name: 'Model A',
baseUrl: 'https://api.example.com/v1',
envKey: 'API_KEY_A',
},
],
};
const modelsConfig = new ModelsConfig({
initialAuthType: AuthType.USE_OPENAI,
modelProvidersConfig,
});
// setModel with a raw model ID
await modelsConfig.setModel('custom-model');
// Both should be consistent
expect(modelsConfig.getModel()).toBe('custom-model');
expect(modelsConfig.getGenerationConfig().model).toBe('custom-model');
});
it('should maintain consistency between currentModelId and _generationConfig.model during updateCredentials', () => {
const modelsConfig = new ModelsConfig({
initialAuthType: AuthType.USE_OPENAI,
});
// updateCredentials with model
modelsConfig.updateCredentials({
apiKey: 'test-key',
model: 'updated-model',
});
// Both should be consistent
expect(modelsConfig.getModel()).toBe('updated-model');
expect(modelsConfig.getGenerationConfig().model).toBe('updated-model');
});
});

View File

@@ -0,0 +1,619 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import process from 'node:process';
import { AuthType } from '../core/contentGenerator.js';
import type { ContentGeneratorConfig } from '../core/contentGenerator.js';
import type { ContentGeneratorConfigSources } from '../core/contentGenerator.js';
import { DEFAULT_QWEN_MODEL } from '../config/models.js';
import { ModelRegistry } from './modelRegistry.js';
import {
type ModelProvidersConfig,
type ResolvedModelConfig,
type AvailableModel,
type ModelSwitchMetadata,
} from './types.js';
import {
MODEL_GENERATION_CONFIG_FIELDS,
CREDENTIAL_FIELDS,
PROVIDER_SOURCED_FIELDS,
} from './constants.js';
export {
MODEL_GENERATION_CONFIG_FIELDS,
CREDENTIAL_FIELDS,
PROVIDER_SOURCED_FIELDS,
};
/**
* Callback for when the model changes.
* Used by Config to refresh auth/ContentGenerator when needed.
*/
export type OnModelChangeCallback = (
authType: AuthType,
requiresRefresh: boolean,
) => Promise<void>;
/**
* Options for creating ModelsConfig
*/
export interface ModelsConfigOptions {
/** Initial authType from settings */
initialAuthType?: AuthType;
/** Model providers configuration */
modelProvidersConfig?: ModelProvidersConfig;
/** Generation config from CLI/settings */
generationConfig?: Partial<ContentGeneratorConfig>;
/** Source tracking for generation config */
generationConfigSources?: ContentGeneratorConfigSources;
/** Callback when model changes require refresh */
onModelChange?: OnModelChangeCallback;
}
/**
* ModelsConfig manages all model selection logic and state.
*
* This class encapsulates:
* - ModelRegistry for model configuration storage
* - Current authType and modelId selection
* - Generation config management
* - Model switching logic
*
* Config uses this as a thin entry point for all model-related operations.
*/
export class ModelsConfig {
private readonly modelRegistry: ModelRegistry;
// Current selection state
private currentAuthType: AuthType;
// Generation config state
private _generationConfig: Partial<ContentGeneratorConfig>;
private generationConfigSources: ContentGeneratorConfigSources;
// Flag for strict model provider selection
private strictModelProviderSelection: boolean = false;
// One-shot flag for qwen-oauth credential caching
private requireCachedQwenCredentialsOnce: boolean = false;
// One-shot flag indicating credentials were manually set via updateCredentials()
// When true, syncAfterAuthRefresh should NOT override these credentials with
// modelProviders defaults (even if the model ID matches a registry entry).
//
// This must be persistent across auth refreshes, because refreshAuth() can be
// triggered multiple times after a credential prompt flow. We only clear this
// flag when we explicitly apply modelProvider defaults (i.e. when the user
// switches to a registry model via switchModel).
private hasManualCredentials: boolean = false;
// Callback for notifying Config of model changes
private onModelChange?: OnModelChangeCallback;
// Flag indicating whether authType was explicitly provided (not defaulted)
private readonly authTypeWasExplicitlyProvided: boolean;
private static deepClone<T>(value: T): T {
if (value === null || typeof value !== 'object') {
return value;
}
if (Array.isArray(value)) {
return value.map((v) => ModelsConfig.deepClone(v)) as T;
}
const out: Record<string, unknown> = {};
for (const key of Object.keys(value as Record<string, unknown>)) {
out[key] = ModelsConfig.deepClone(
(value as Record<string, unknown>)[key],
);
}
return out as T;
}
private snapshotState(): {
currentAuthType: AuthType;
generationConfig: Partial<ContentGeneratorConfig>;
generationConfigSources: ContentGeneratorConfigSources;
strictModelProviderSelection: boolean;
requireCachedQwenCredentialsOnce: boolean;
hasManualCredentials: boolean;
} {
return {
currentAuthType: this.currentAuthType,
generationConfig: ModelsConfig.deepClone(this._generationConfig),
generationConfigSources: ModelsConfig.deepClone(
this.generationConfigSources,
),
strictModelProviderSelection: this.strictModelProviderSelection,
requireCachedQwenCredentialsOnce: this.requireCachedQwenCredentialsOnce,
hasManualCredentials: this.hasManualCredentials,
};
}
private restoreState(
snapshot: ReturnType<ModelsConfig['snapshotState']>,
): void {
this.currentAuthType = snapshot.currentAuthType;
this._generationConfig = snapshot.generationConfig;
this.generationConfigSources = snapshot.generationConfigSources;
this.strictModelProviderSelection = snapshot.strictModelProviderSelection;
this.requireCachedQwenCredentialsOnce =
snapshot.requireCachedQwenCredentialsOnce;
this.hasManualCredentials = snapshot.hasManualCredentials;
}
constructor(options: ModelsConfigOptions = {}) {
this.modelRegistry = new ModelRegistry(options.modelProvidersConfig);
this.onModelChange = options.onModelChange;
// Initialize generation config
// Note: generationConfig.model should already be fully resolved by ModelConfigResolver
// before ModelsConfig is instantiated, so we use it as the single source of truth
this._generationConfig = {
...(options.generationConfig || {}),
};
this.generationConfigSources = options.generationConfigSources || {};
// Track if authType was explicitly provided
this.authTypeWasExplicitlyProvided = options.initialAuthType !== undefined;
// Initialize selection state
this.currentAuthType = options.initialAuthType || AuthType.QWEN_OAUTH;
}
/**
* Get current model ID
*/
getModel(): string {
return this._generationConfig.model || DEFAULT_QWEN_MODEL;
}
/**
* Get current authType
*/
getCurrentAuthType(): AuthType {
return this.currentAuthType;
}
/**
* Check if authType was explicitly provided (via CLI or settings).
* If false, the default QWEN_OAUTH is being used.
*/
wasAuthTypeExplicitlyProvided(): boolean {
return this.authTypeWasExplicitlyProvided;
}
/**
* Get available models for current authType
*/
getAvailableModels(): AvailableModel[] {
return this.modelRegistry.getModelsForAuthType(this.currentAuthType);
}
/**
* Get available models for a specific authType
*/
getAvailableModelsForAuthType(authType: AuthType): AvailableModel[] {
return this.modelRegistry.getModelsForAuthType(authType);
}
/**
* Check if a model exists for the given authType
*/
hasModel(authType: AuthType, modelId: string): boolean {
return this.modelRegistry.hasModel(authType, modelId);
}
/**
* Set model programmatically (e.g., VLM auto-switch, fallback).
* Supports both registry models and raw model IDs.
*/
async setModel(
newModel: string,
metadata?: ModelSwitchMetadata,
): Promise<void> {
// Special case: qwen-oauth VLM auto-switch - hot update in place
if (
this.currentAuthType === AuthType.QWEN_OAUTH &&
(newModel === DEFAULT_QWEN_MODEL || newModel === 'vision-model')
) {
this.strictModelProviderSelection = false;
this._generationConfig.model = newModel;
this.generationConfigSources['model'] = {
kind: 'programmatic',
detail: metadata?.reason || 'setModel',
};
return;
}
// If model exists in registry, use full switch logic
if (this.modelRegistry.hasModel(this.currentAuthType, newModel)) {
await this.switchModel(this.currentAuthType, newModel);
return;
}
// Raw model override: update generation config in-place
this.strictModelProviderSelection = false;
this._generationConfig.model = newModel;
this.generationConfigSources['model'] = {
kind: 'programmatic',
detail: metadata?.reason || 'setModel',
};
}
/**
* Switch model (and optionally authType) via registry-backed selection.
* This is a superset of the previous split APIs for model-only vs authType+model switching.
*/
async switchModel(
authType: AuthType,
modelId: string,
options?: { requireCachedCredentials?: boolean },
_metadata?: ModelSwitchMetadata,
): Promise<void> {
const snapshot = this.snapshotState();
if (authType === AuthType.QWEN_OAUTH && options?.requireCachedCredentials) {
this.requireCachedQwenCredentialsOnce = true;
}
try {
const isAuthTypeChange = authType !== this.currentAuthType;
this.currentAuthType = authType;
const model = this.modelRegistry.getModel(authType, modelId);
if (!model) {
throw new Error(
`Model '${modelId}' not found for authType '${authType}'`,
);
}
// Apply model defaults
this.applyResolvedModelDefaults(model);
const requiresRefresh = isAuthTypeChange
? true
: this.checkRequiresRefresh(snapshot.generationConfig.model || '');
if (this.onModelChange) {
await this.onModelChange(authType, requiresRefresh);
}
} catch (error) {
// Rollback on error
this.restoreState(snapshot);
throw error;
}
}
/**
* Get generation config for ContentGenerator creation
*/
getGenerationConfig(): Partial<ContentGeneratorConfig> {
return this._generationConfig;
}
/**
* Get generation config sources for debugging/UI
*/
getGenerationConfigSources(): ContentGeneratorConfigSources {
return this.generationConfigSources;
}
/**
* Update credentials in generation config.
* Sets a flag to prevent syncAfterAuthRefresh from overriding these credentials.
*
* 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.
*/
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.
*
* Clear all provider-sourced configuration to maintain provider atomicity.
* This ensures that when user manually sets credentials, the provider config
* is either fully applied (via switchModel) or not at all.
*/
if (credentials.apiKey || credentials.baseUrl || credentials.model) {
this.hasManualCredentials = true;
this.clearProviderSourcedConfig();
}
if (credentials.apiKey) {
this._generationConfig.apiKey = credentials.apiKey;
this.generationConfigSources['apiKey'] = {
kind: 'programmatic',
detail: 'updateCredentials',
};
}
if (credentials.baseUrl) {
this._generationConfig.baseUrl = credentials.baseUrl;
this.generationConfigSources['baseUrl'] = {
kind: 'programmatic',
detail: 'updateCredentials',
};
}
if (credentials.model) {
this._generationConfig.model = credentials.model;
this.generationConfigSources['model'] = {
kind: 'programmatic',
detail: 'updateCredentials',
};
}
// When credentials are manually set, disable strict model provider selection
// so validation doesn't require envKey-based credentials
this.strictModelProviderSelection = false;
// Clear apiKeyEnvKey to prevent validation from requiring environment variable
this._generationConfig.apiKeyEnvKey = undefined;
}
/**
* Clear configuration fields that were sourced from modelProviders.
* This ensures provider config atomicity when user manually sets credentials.
* Other layers (CLI, env, settings, defaults) will participate in resolve.
*/
private clearProviderSourcedConfig(): void {
for (const field of PROVIDER_SOURCED_FIELDS) {
const source = this.generationConfigSources[field];
if (source?.kind === 'modelProviders') {
// Clear the value - let other layers resolve it
delete (this._generationConfig as Record<string, unknown>)[field];
delete this.generationConfigSources[field];
}
}
}
/**
* Get whether strict model provider selection is enabled
*/
isStrictModelProviderSelection(): boolean {
return this.strictModelProviderSelection;
}
/**
* Reset strict model provider selection flag
*/
resetStrictModelProviderSelection(): void {
this.strictModelProviderSelection = false;
}
/**
* Check and consume the one-shot cached credentials flag
*/
consumeRequireCachedCredentialsFlag(): boolean {
const value = this.requireCachedQwenCredentialsOnce;
this.requireCachedQwenCredentialsOnce = false;
return value;
}
/**
* Apply resolved model config to generation config
*/
private applyResolvedModelDefaults(model: ResolvedModelConfig): void {
this.strictModelProviderSelection = true;
// We're explicitly applying modelProvider defaults now, so manual overrides
// should no longer block syncAfterAuthRefresh from applying provider defaults.
this.hasManualCredentials = false;
this._generationConfig.model = model.id;
this.generationConfigSources['model'] = {
kind: 'modelProviders',
authType: model.authType,
modelId: model.id,
detail: 'model.id',
};
// Clear credentials to avoid reusing previous model's API key
// For Qwen OAuth, apiKey must always be a placeholder. It will be dynamically
// replaced when building requests. Do not preserve any previous key or read
// from envKey.
//
// (OpenAI client instantiation requires an apiKey even though it will be
// replaced later.)
if (this.currentAuthType === AuthType.QWEN_OAUTH) {
this._generationConfig.apiKey = 'QWEN_OAUTH_DYNAMIC_TOKEN';
this.generationConfigSources['apiKey'] = {
kind: 'computed',
detail: 'Qwen OAuth placeholder token',
};
this._generationConfig.apiKeyEnvKey = undefined;
delete this.generationConfigSources['apiKeyEnvKey'];
} else {
this._generationConfig.apiKey = undefined;
this._generationConfig.apiKeyEnvKey = undefined;
}
// Read API key from environment variable if envKey is specified
if (model.envKey !== undefined) {
const apiKey = process.env[model.envKey];
if (apiKey) {
this._generationConfig.apiKey = apiKey;
this.generationConfigSources['apiKey'] = {
kind: 'env',
envKey: model.envKey,
via: {
kind: 'modelProviders',
authType: model.authType,
modelId: model.id,
detail: 'envKey',
},
};
}
this._generationConfig.apiKeyEnvKey = model.envKey;
this.generationConfigSources['apiKeyEnvKey'] = {
kind: 'modelProviders',
authType: model.authType,
modelId: model.id,
detail: 'envKey',
};
}
// Base URL
this._generationConfig.baseUrl = model.baseUrl;
this.generationConfigSources['baseUrl'] = {
kind: 'modelProviders',
authType: model.authType,
modelId: model.id,
detail: 'baseUrl',
};
// Generation config
const gc = model.generationConfig;
this._generationConfig.samplingParams = { ...(gc.samplingParams || {}) };
this.generationConfigSources['samplingParams'] = {
kind: 'modelProviders',
authType: model.authType,
modelId: model.id,
detail: 'generationConfig.samplingParams',
};
this._generationConfig.timeout = gc.timeout;
this.generationConfigSources['timeout'] = {
kind: 'modelProviders',
authType: model.authType,
modelId: model.id,
detail: 'generationConfig.timeout',
};
this._generationConfig.maxRetries = gc.maxRetries;
this.generationConfigSources['maxRetries'] = {
kind: 'modelProviders',
authType: model.authType,
modelId: model.id,
detail: 'generationConfig.maxRetries',
};
this._generationConfig.disableCacheControl = gc.disableCacheControl;
this.generationConfigSources['disableCacheControl'] = {
kind: 'modelProviders',
authType: model.authType,
modelId: model.id,
detail: 'generationConfig.disableCacheControl',
};
this._generationConfig.schemaCompliance = gc.schemaCompliance;
this.generationConfigSources['schemaCompliance'] = {
kind: 'modelProviders',
authType: model.authType,
modelId: model.id,
detail: 'generationConfig.schemaCompliance',
};
this._generationConfig.reasoning = gc.reasoning;
this.generationConfigSources['reasoning'] = {
kind: 'modelProviders',
authType: model.authType,
modelId: model.id,
detail: 'generationConfig.reasoning',
};
}
/**
* Check if model switch requires ContentGenerator refresh.
*
* Note: This method is ONLY called by switchModel() for same-authType model switches.
* Cross-authType switches use switchModel(authType, modelId), which always requires full refresh.
*
* When this method is called:
* - this.currentAuthType is already the target authType
* - We're checking if switching between two models within the SAME authType needs refresh
*
* Examples:
* - Qwen OAuth: coder-model -> vision-model (same authType, hot-update safe)
* - OpenAI: model-a -> model-b with same envKey (same authType, hot-update safe)
* - OpenAI: gpt-4 -> deepseek-chat with different envKey (same authType, needs refresh)
*
* Cross-authType scenarios:
* - OpenAI -> Qwen OAuth: handled by switchModel(authType, modelId), always refreshes
* - Qwen OAuth -> OpenAI: handled by switchModel(authType, modelId), always refreshes
*/
private checkRequiresRefresh(previousModelId: string): boolean {
// For Qwen OAuth, model switches within the same authType can always be hot-updated
// (coder-model <-> vision-model don't require ContentGenerator recreation)
if (this.currentAuthType === AuthType.QWEN_OAUTH) {
return false;
}
// Get previous and current model configs
const previousModel = this.modelRegistry.getModel(
this.currentAuthType,
previousModelId,
);
const currentModel = this.modelRegistry.getModel(
this.currentAuthType,
this._generationConfig.model || '',
);
// If either model is not in registry, require refresh to be safe
if (!previousModel || !currentModel) {
return true;
}
// Check if critical fields changed that require ContentGenerator recreation
const criticalFieldsChanged =
previousModel.envKey !== currentModel.envKey ||
previousModel.baseUrl !== currentModel.baseUrl;
if (criticalFieldsChanged) {
return true;
}
// For other auth types with strict model provider selection,
// if no critical fields changed, we can still hot-update
// (e.g., switching between two OpenAI models with same envKey and baseUrl)
return false;
}
/**
* 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 {
// 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;
if (modelId && this.modelRegistry.hasModel(authType, modelId)) {
const resolved = this.modelRegistry.getModel(authType, modelId);
if (resolved) {
this.applyResolvedModelDefaults(resolved);
this.currentAuthType = authType;
}
} else {
this.currentAuthType = authType;
}
}
/**
* Update callback for model changes
*/
setOnModelChange(callback: OnModelChangeCallback): void {
this.onModelChange = callback;
}
}

View File

@@ -0,0 +1,101 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type {
AuthType,
ContentGeneratorConfig,
} from '../core/contentGenerator.js';
/**
* Model capabilities configuration
*/
export interface ModelCapabilities {
/** Supports image/vision inputs */
vision?: boolean;
}
/**
* Model-scoped generation configuration.
*
* Keep this consistent with {@link ContentGeneratorConfig} so modelProviders can
* feed directly into content generator resolution without shape conversion.
*/
export type ModelGenerationConfig = Pick<
ContentGeneratorConfig,
| 'samplingParams'
| 'timeout'
| 'maxRetries'
| 'disableCacheControl'
| 'schemaCompliance'
| 'reasoning'
>;
/**
* Model configuration for a single model within an authType
*/
export interface ModelConfig {
/** Unique model ID within authType (e.g., "qwen-coder", "gpt-4-turbo") */
id: string;
/** Display name (defaults to id) */
name?: string;
/** Model description */
description?: string;
/** Environment variable name to read API key from (e.g., "OPENAI_API_KEY") */
envKey?: string;
/** API endpoint override */
baseUrl?: string;
/** Model capabilities, reserve for future use. Now we do not read this to determine multi-modal support or other capabilities. */
capabilities?: ModelCapabilities;
/** Generation configuration (sampling parameters) */
generationConfig?: ModelGenerationConfig;
}
/**
* Model providers configuration grouped by authType
*/
export type ModelProvidersConfig = {
[authType: string]: ModelConfig[];
};
/**
* Resolved model config with all defaults applied
*/
export interface ResolvedModelConfig extends ModelConfig {
/** AuthType this model belongs to (always present from map key) */
authType: AuthType;
/** Display name (always present, defaults to id) */
name: string;
/** Environment variable name to read API key from (optional, provider-specific) */
envKey?: string;
/** API base URL (always present, has default per authType) */
baseUrl: string;
/** Generation config (always present, merged with defaults) */
generationConfig: ModelGenerationConfig;
/** Capabilities (always present, defaults to {}) */
capabilities: ModelCapabilities;
}
/**
* Model info for UI display
*/
export interface AvailableModel {
id: string;
label: string;
description?: string;
capabilities?: ModelCapabilities;
authType: AuthType;
isVision?: boolean;
}
/**
* Metadata for model switch operations
*/
export interface ModelSwitchMetadata {
/** Reason for the switch */
reason?: string;
/** Additional context */
context?: string;
}

View File

@@ -589,7 +589,7 @@ describe('ShellExecutionService child_process fallback', () => {
expect(result.error).toBeNull();
expect(result.aborted).toBe(false);
expect(result.output).toBe('file1.txt\na warning');
expect(handle.pid).toBe(undefined);
expect(handle.pid).toBe(12345);
expect(onOutputEventMock).toHaveBeenCalledWith({
type: 'data',
@@ -829,7 +829,7 @@ describe('ShellExecutionService child_process fallback', () => {
[],
expect.objectContaining({
shell: true,
detached: false,
detached: true,
}),
);
});

View File

@@ -7,7 +7,7 @@
import stripAnsi from 'strip-ansi';
import type { PtyImplementation } from '../utils/getPty.js';
import { getPty } from '../utils/getPty.js';
import { spawn as cpSpawn } from 'node:child_process';
import { spawn as cpSpawn, spawnSync } from 'node:child_process';
import { TextDecoder } from 'node:util';
import os from 'node:os';
import type { IPty } from '@lydell/node-pty';
@@ -98,6 +98,48 @@ const getFullBufferText = (terminal: pkg.Terminal): string => {
return lines.join('\n').trimEnd();
};
interface ProcessCleanupStrategy {
killPty(pid: number, pty: ActivePty): void;
killChildProcesses(pids: Set<number>): void;
}
const windowsStrategy: ProcessCleanupStrategy = {
killPty: (_pid, pty) => {
pty.ptyProcess.kill();
},
killChildProcesses: (pids) => {
if (pids.size > 0) {
try {
const args = ['/f', '/t'];
for (const pid of pids) {
args.push('/pid', pid.toString());
}
spawnSync('taskkill', args);
} catch {
// ignore
}
}
},
};
const posixStrategy: ProcessCleanupStrategy = {
killPty: (pid, _pty) => {
process.kill(-pid, 'SIGKILL');
},
killChildProcesses: (pids) => {
for (const pid of pids) {
try {
process.kill(-pid, 'SIGKILL');
} catch {
// ignore
}
}
},
};
const getCleanupStrategy = () =>
os.platform() === 'win32' ? windowsStrategy : posixStrategy;
/**
* A centralized service for executing shell commands with robust process
* management, cross-platform compatibility, and streaming output capabilities.
@@ -106,6 +148,29 @@ const getFullBufferText = (terminal: pkg.Terminal): string => {
export class ShellExecutionService {
private static activePtys = new Map<number, ActivePty>();
private static activeChildProcesses = new Set<number>();
static cleanup() {
const strategy = getCleanupStrategy();
// Cleanup PTYs
for (const [pid, pty] of this.activePtys) {
try {
strategy.killPty(pid, pty);
} catch {
// ignore
}
}
// Cleanup child processes
strategy.killChildProcesses(this.activeChildProcesses);
}
static {
process.on('exit', () => {
ShellExecutionService.cleanup();
});
}
/**
* Executes a shell command using `node-pty`, capturing all output and lifecycle events.
*
@@ -164,7 +229,7 @@ export class ShellExecutionService {
stdio: ['ignore', 'pipe', 'pipe'],
windowsVerbatimArguments: true,
shell: isWindows ? true : 'bash',
detached: !isWindows,
detached: true,
env: {
...process.env,
QWEN_CODE: '1',
@@ -281,9 +346,13 @@ export class ShellExecutionService {
abortSignal.addEventListener('abort', abortHandler, { once: true });
if (child.pid) {
this.activeChildProcesses.add(child.pid);
}
child.on('exit', (code, signal) => {
if (child.pid) {
this.activePtys.delete(child.pid);
this.activeChildProcesses.delete(child.pid);
}
handleExit(code, signal);
});
@@ -310,7 +379,7 @@ export class ShellExecutionService {
}
});
return { pid: undefined, result };
return { pid: child.pid, result };
} catch (e) {
const error = e as Error;
return {

View File

@@ -22,10 +22,11 @@ import {
type Mock,
} from 'vitest';
import { Config, type ConfigParameters } from '../config/config.js';
import { DEFAULT_GEMINI_MODEL } from '../config/models.js';
import { DEFAULT_QWEN_MODEL } from '../config/models.js';
import {
createContentGenerator,
createContentGeneratorConfig,
resolveContentGeneratorConfigWithSources,
AuthType,
} from '../core/contentGenerator.js';
import { GeminiChat } from '../core/geminiChat.js';
@@ -42,7 +43,33 @@ import type {
import { SubagentTerminateMode } from './types.js';
vi.mock('../core/geminiChat.js');
vi.mock('../core/contentGenerator.js');
vi.mock('../core/contentGenerator.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../core/contentGenerator.js')>();
const { DEFAULT_QWEN_MODEL } = await import('../config/models.js');
return {
...actual,
createContentGenerator: vi.fn().mockResolvedValue({
generateContent: vi.fn(),
generateContentStream: vi.fn(),
countTokens: vi.fn().mockResolvedValue({ totalTokens: 100 }),
embedContent: vi.fn(),
useSummarizedThinking: vi.fn().mockReturnValue(false),
}),
createContentGeneratorConfig: vi.fn().mockReturnValue({
model: DEFAULT_QWEN_MODEL,
authType: actual.AuthType.USE_GEMINI,
}),
resolveContentGeneratorConfigWithSources: vi.fn().mockReturnValue({
config: {
model: DEFAULT_QWEN_MODEL,
authType: actual.AuthType.USE_GEMINI,
apiKey: 'test-api-key',
},
sources: {},
}),
};
});
vi.mock('../utils/environmentContext.js', () => ({
getEnvironmentContext: vi.fn().mockResolvedValue([{ text: 'Env Context' }]),
getInitialChatHistory: vi.fn(async (_config, extraHistory) => [
@@ -65,7 +92,7 @@ async function createMockConfig(
toolRegistryMocks = {},
): Promise<{ config: Config; toolRegistry: ToolRegistry }> {
const configParams: ConfigParameters = {
model: DEFAULT_GEMINI_MODEL,
model: DEFAULT_QWEN_MODEL,
targetDir: '.',
debugMode: false,
cwd: process.cwd(),
@@ -89,7 +116,7 @@ async function createMockConfig(
// Mock getContentGeneratorConfig to return a valid config
vi.spyOn(config, 'getContentGeneratorConfig').mockReturnValue({
model: DEFAULT_GEMINI_MODEL,
model: DEFAULT_QWEN_MODEL,
authType: AuthType.USE_GEMINI,
});
@@ -192,9 +219,17 @@ describe('subagent.ts', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
vi.mocked(createContentGeneratorConfig).mockReturnValue({
model: DEFAULT_GEMINI_MODEL,
model: DEFAULT_QWEN_MODEL,
authType: undefined,
});
vi.mocked(resolveContentGeneratorConfigWithSources).mockReturnValue({
config: {
model: DEFAULT_QWEN_MODEL,
authType: AuthType.USE_GEMINI,
apiKey: 'test-api-key',
},
sources: {},
});
mockSendMessageStream = vi.fn();
vi.mocked(GeminiChat).mockImplementation(

View File

@@ -248,7 +248,7 @@ describe('ShellTool', () => {
wrappedCommand,
'/test/dir',
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -275,7 +275,7 @@ describe('ShellTool', () => {
wrappedCommand,
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -300,7 +300,7 @@ describe('ShellTool', () => {
wrappedCommand,
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -325,7 +325,7 @@ describe('ShellTool', () => {
wrappedCommand,
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -350,7 +350,7 @@ describe('ShellTool', () => {
wrappedCommand,
'/test/dir/subdir',
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -378,7 +378,7 @@ describe('ShellTool', () => {
'dir',
'/test/dir',
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -471,7 +471,7 @@ describe('ShellTool', () => {
expect(summarizer.summarizeToolOutput).toHaveBeenCalledWith(
expect.any(String),
mockConfig.getGeminiClient(),
mockAbortSignal,
expect.any(AbortSignal),
1000,
);
expect(result.llmContent).toBe('summarized output');
@@ -580,7 +580,7 @@ describe('ShellTool', () => {
),
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -610,7 +610,7 @@ describe('ShellTool', () => {
),
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -640,7 +640,7 @@ describe('ShellTool', () => {
),
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -699,7 +699,7 @@ describe('ShellTool', () => {
expect.stringContaining('npm install'),
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -728,7 +728,7 @@ describe('ShellTool', () => {
expect.stringContaining('git commit'),
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -758,7 +758,7 @@ describe('ShellTool', () => {
),
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -794,7 +794,7 @@ describe('ShellTool', () => {
expect.stringContaining('git commit -m "Initial commit"'),
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -831,7 +831,7 @@ describe('ShellTool', () => {
),
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -962,4 +962,41 @@ spanning multiple lines"`;
expect(shellTool.description).toMatchSnapshot();
});
});
describe('Windows background execution', () => {
it('should clean up trailing ampersand on Windows for background tasks', async () => {
vi.mocked(os.platform).mockReturnValue('win32');
const mockAbortSignal = new AbortController().signal;
const invocation = shellTool.build({
command: 'npm start &',
is_background: true,
});
const promise = invocation.execute(mockAbortSignal);
// Simulate immediate success (process started)
resolveExecutionPromise({
rawOutput: Buffer.from(''),
output: '',
exitCode: 0,
signal: null,
error: null,
aborted: false,
pid: 12345,
executionMethod: 'child_process',
});
await promise;
expect(mockShellExecutionService).toHaveBeenCalledWith(
'npm start',
expect.any(String),
expect.any(Function),
expect.any(AbortSignal),
false,
{},
);
});
});
});

View File

@@ -143,11 +143,24 @@ export class ShellToolInvocation extends BaseToolInvocation<
const shouldRunInBackground = this.params.is_background;
let finalCommand = processedCommand;
// If explicitly marked as background and doesn't already end with &, add it
if (shouldRunInBackground && !finalCommand.trim().endsWith('&')) {
// On non-Windows, use & to run in background.
// On Windows, we don't use start /B because it creates a detached process that
// doesn't die when the parent dies. Instead, we rely on the race logic below
// to return early while keeping the process attached (detached: false).
if (
!isWindows &&
shouldRunInBackground &&
!finalCommand.trim().endsWith('&')
) {
finalCommand = finalCommand.trim() + ' &';
}
// On Windows, we rely on the race logic below to handle background tasks.
// We just ensure the command string is clean.
if (isWindows && shouldRunInBackground) {
finalCommand = finalCommand.trim().replace(/&+$/, '').trim();
}
// pgrep is not available on Windows, so we can't get background PIDs
const commandToExecute = isWindows
? finalCommand
@@ -169,10 +182,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
commandToExecute,
cwd,
(event: ShellOutputEvent) => {
if (!updateOutput) {
return;
}
let shouldUpdate = false;
switch (event.type) {
@@ -201,7 +210,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
}
}
if (shouldUpdate) {
if (shouldUpdate && updateOutput) {
updateOutput(
typeof cumulativeOutput === 'string'
? cumulativeOutput
@@ -219,6 +228,21 @@ export class ShellToolInvocation extends BaseToolInvocation<
setPidCallback(pid);
}
if (shouldRunInBackground) {
// For background tasks, return immediately with PID info
// Note: We cannot reliably detect startup errors for background processes
// since their stdio is typically detached/ignored
const pidMsg = pid ? ` PID: ${pid}` : '';
const killHint = isWindows
? ' (Use taskkill /F /T /PID <pid> to stop)'
: ' (Use kill <pid> to stop)';
return {
llmContent: `Background command started.${pidMsg}${killHint}`,
returnDisplay: `Background command started.${pidMsg}${killHint}`,
};
}
const result = await resultPromise;
const backgroundPIDs: number[] = [];

View File

@@ -0,0 +1,141 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
resolveField,
resolveOptionalField,
layer,
envLayer,
cliSource,
settingsSource,
defaultSource,
} from './configResolver.js';
describe('configResolver', () => {
describe('resolveField', () => {
it('returns first present value from layers', () => {
const result = resolveField(
[
layer(undefined, cliSource('--model')),
envLayer({ MODEL: 'from-env' }, 'MODEL'),
layer('from-settings', settingsSource('model.name')),
],
'default-model',
);
expect(result.value).toBe('from-env');
expect(result.source).toEqual({ kind: 'env', envKey: 'MODEL' });
});
it('returns default when all layers are undefined', () => {
const result = resolveField(
[layer(undefined, cliSource('--model')), envLayer({}, 'MODEL')],
'default-model',
defaultSource('default-model'),
);
expect(result.value).toBe('default-model');
expect(result.source).toEqual({
kind: 'default',
detail: 'default-model',
});
});
it('respects layer priority order', () => {
const result = resolveField(
[
layer('cli-value', cliSource('--model')),
envLayer({ MODEL: 'env-value' }, 'MODEL'),
layer('settings-value', settingsSource('model.name')),
],
'default',
);
expect(result.value).toBe('cli-value');
expect(result.source.kind).toBe('cli');
});
it('skips empty strings', () => {
const result = resolveField(
[
layer('', cliSource('--model')),
envLayer({ MODEL: 'env-value' }, 'MODEL'),
],
'default',
);
expect(result.value).toBe('env-value');
});
});
describe('resolveOptionalField', () => {
it('returns undefined when no value present', () => {
const result = resolveOptionalField([
layer(undefined, cliSource('--key')),
envLayer({}, 'KEY'),
]);
expect(result).toBeUndefined();
});
it('returns first present value', () => {
const result = resolveOptionalField([
layer(undefined, cliSource('--key')),
envLayer({ KEY: 'found' }, 'KEY'),
]);
expect(result).toBeDefined();
expect(result!.value).toBe('found');
expect(result!.source.kind).toBe('env');
});
});
describe('envLayer', () => {
it('creates layer from environment variable', () => {
const env = { MY_VAR: 'my-value' };
const result = envLayer(env, 'MY_VAR');
expect(result.value).toBe('my-value');
expect(result.source).toEqual({ kind: 'env', envKey: 'MY_VAR' });
});
it('handles missing environment variable', () => {
const env = {};
const result = envLayer(env, 'MISSING_VAR');
expect(result.value).toBeUndefined();
expect(result.source).toEqual({ kind: 'env', envKey: 'MISSING_VAR' });
});
it('supports transform function', () => {
const env = { PORT: '3000' };
const result = envLayer(env, 'PORT', (v) => parseInt(v, 10));
expect(result.value).toBe(3000);
});
});
describe('source factory functions', () => {
it('creates CLI source', () => {
expect(cliSource('--model')).toEqual({ kind: 'cli', detail: '--model' });
});
it('creates settings source', () => {
expect(settingsSource('model.name')).toEqual({
kind: 'settings',
settingsPath: 'model.name',
});
});
it('creates default source', () => {
expect(defaultSource('my-default')).toEqual({
kind: 'default',
detail: 'my-default',
});
});
});
});

View File

@@ -0,0 +1,222 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Generic multi-source configuration resolver utilities.
*
* This module provides reusable tools for resolving configuration values
* from multiple sources (CLI, env, settings, etc.) with priority ordering
* and source tracking.
*/
/**
* Known source kinds for configuration values.
* Extensible for domain-specific needs.
*/
export type ConfigSourceKind =
| 'cli'
| 'env'
| 'settings'
| 'modelProviders'
| 'default'
| 'computed'
| 'programmatic'
| 'unknown';
/**
* Source metadata for a configuration value.
* Tracks where the value came from for debugging and UI display.
*/
export interface ConfigSource {
/** The kind/category of the source */
kind: ConfigSourceKind;
/** Additional detail about the source (e.g., '--model' for CLI) */
detail?: string;
/** Environment variable key if kind is 'env' */
envKey?: string;
/** Settings path if kind is 'settings' (e.g., 'model.name') */
settingsPath?: string;
/** Auth type if relevant (for modelProviders) */
authType?: string;
/** Model ID if relevant (for modelProviders) */
modelId?: string;
/** Indirect source - when a value is derived via another source */
via?: Omit<ConfigSource, 'via'>;
}
/**
* Map of field names to their sources
*/
export type ConfigSources = Record<string, ConfigSource>;
/**
* A configuration layer represents a potential source for a value.
* Layers are evaluated in priority order (first non-undefined wins).
*/
export interface ConfigLayer<T> {
/** The value from this layer (undefined means not present) */
value: T | undefined;
/** Source metadata for this layer */
source: ConfigSource;
}
/**
* Result of resolving a single field
*/
export interface ResolvedField<T> {
/** The resolved value */
value: T;
/** Source metadata indicating where the value came from */
source: ConfigSource;
}
/**
* Resolve a single configuration field from multiple layers.
*
* Layers are evaluated in order. The first layer with a defined,
* non-empty value wins. If no layer has a value, the default is used.
*
* @param layers - Configuration layers in priority order (highest first)
* @param defaultValue - Default value if no layer provides one
* @param defaultSource - Source metadata for the default value
* @returns The resolved value and its source
*
* @example
* ```typescript
* const model = resolveField(
* [
* { value: argv.model, source: { kind: 'cli', detail: '--model' } },
* { value: env['OPENAI_MODEL'], source: { kind: 'env', envKey: 'OPENAI_MODEL' } },
* { value: settings.model, source: { kind: 'settings', settingsPath: 'model.name' } },
* ],
* 'default-model',
* { kind: 'default', detail: 'default-model' }
* );
* ```
*/
export function resolveField<T>(
layers: Array<ConfigLayer<T>>,
defaultValue: T,
defaultSource: ConfigSource = { kind: 'default' },
): ResolvedField<T> {
for (const layer of layers) {
if (isValuePresent(layer.value)) {
return { value: layer.value, source: layer.source };
}
}
return { value: defaultValue, source: defaultSource };
}
/**
* Resolve a field that may not have a default (optional field).
*
* @param layers - Configuration layers in priority order
* @returns The resolved value and source, or undefined if not found
*/
export function resolveOptionalField<T>(
layers: Array<ConfigLayer<T>>,
): ResolvedField<T> | undefined {
for (const layer of layers) {
if (isValuePresent(layer.value)) {
return { value: layer.value, source: layer.source };
}
}
return undefined;
}
/**
* Check if a value is "present" (not undefined, not null, not empty string).
*
* @param value - The value to check
* @returns true if the value should be considered present
*/
function isValuePresent<T>(value: T | undefined | null): value is T {
if (value === undefined || value === null) {
return false;
}
// Treat empty strings as not present
if (typeof value === 'string' && value.trim() === '') {
return false;
}
return true;
}
/**
* Create a CLI source descriptor
*/
export function cliSource(detail: string): ConfigSource {
return { kind: 'cli', detail };
}
/**
* Create an environment variable source descriptor
*/
function envSource(envKey: string): ConfigSource {
return { kind: 'env', envKey };
}
/**
* Create a settings source descriptor
*/
export function settingsSource(settingsPath: string): ConfigSource {
return { kind: 'settings', settingsPath };
}
/**
* Create a modelProviders source descriptor
*/
export function modelProvidersSource(
authType: string,
modelId: string,
detail?: string,
): ConfigSource {
return { kind: 'modelProviders', authType, modelId, detail };
}
/**
* Create a default value source descriptor
*/
export function defaultSource(detail?: string): ConfigSource {
return { kind: 'default', detail };
}
/**
* Create a computed value source descriptor
*/
export function computedSource(detail?: string): ConfigSource {
return { kind: 'computed', detail };
}
/**
* Create a layer from an environment variable
*/
export function envLayer<T = string>(
env: Record<string, string | undefined>,
key: string,
transform?: (value: string) => T,
): ConfigLayer<T> {
const rawValue = env[key];
const value =
rawValue !== undefined
? transform
? transform(rawValue)
: (rawValue as unknown as T)
: undefined;
return {
value,
source: envSource(key),
};
}
/**
* Create a layer with a static value and source
*/
export function layer<T>(
value: T | undefined,
source: ConfigSource,
): ConfigLayer<T> {
return { value, source };
}

View File

@@ -1,75 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Config } from '../config/config.js';
import fs from 'node:fs';
import {
setSimulate429,
disableSimulationAfterFallback,
shouldSimulate429,
resetRequestCounter,
} from './testUtils.js';
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
// Import the new types (Assuming this test file is in packages/core/src/utils/)
import type { FallbackModelHandler } from '../fallback/types.js';
vi.mock('node:fs');
// Update the description to reflect that this tests the retry utility's integration
describe('Retry Utility Fallback Integration', () => {
let config: Config;
beforeEach(() => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({
isDirectory: () => true,
} as fs.Stats);
config = new Config({
targetDir: '/test',
debugMode: false,
cwd: '/test',
model: 'gemini-2.5-pro',
});
// Reset simulation state for each test
setSimulate429(false);
resetRequestCounter();
});
// This test validates the Config's ability to store and execute the handler contract.
it('should execute the injected FallbackHandler contract correctly', async () => {
// Set up a minimal handler for testing, ensuring it matches the new type.
const fallbackHandler: FallbackModelHandler = async () => 'retry';
// Use the generalized setter
config.setFallbackModelHandler(fallbackHandler);
// Call the handler directly via the config property
const result = await config.fallbackModelHandler!(
'gemini-2.5-pro',
DEFAULT_GEMINI_FLASH_MODEL,
);
// Verify it returns the correct intent
expect(result).toBe('retry');
});
// This test validates the test utilities themselves.
it('should properly disable simulation state after fallback (Test Utility)', () => {
// Enable simulation
setSimulate429(true);
// Verify simulation is enabled
expect(shouldSimulate429()).toBe(true);
// Disable simulation after fallback
disableSimulationAfterFallback();
// Verify simulation is now disabled
expect(shouldSimulate429()).toBe(false);
});
});

View File

@@ -8,7 +8,7 @@ import { createHash } from 'node:crypto';
import { type Content, Type } from '@google/genai';
import { type BaseLlmClient } from '../core/baseLlmClient.js';
import { LruCache } from './LruCache.js';
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
import { DEFAULT_QWEN_FLASH_MODEL } from '../config/models.js';
import { promptIdContext } from './promptIdContext.js';
const MAX_CACHE_SIZE = 50;
@@ -149,7 +149,7 @@ export async function FixLLMEditWithInstruction(
contents,
schema: SearchReplaceEditSchema,
abortSignal,
model: DEFAULT_GEMINI_FLASH_MODEL,
model: DEFAULT_QWEN_FLASH_MODEL,
systemInstruction: EDIT_SYS_PROMPT,
promptId,
maxAttempts: 1,

View File

@@ -11,7 +11,7 @@ import type {
GenerateContentResponse,
} from '@google/genai';
import type { GeminiClient } from '../core/client.js';
import { DEFAULT_GEMINI_FLASH_LITE_MODEL } from '../config/models.js';
import { DEFAULT_QWEN_FLASH_MODEL } from '../config/models.js';
import { getResponseText, partToString } from './partUtils.js';
/**
@@ -86,7 +86,7 @@ export async function summarizeToolOutput(
contents,
toolOutputSummarizerConfig,
abortSignal,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
DEFAULT_QWEN_FLASH_MODEL,
)) as unknown as GenerateContentResponse;
return getResponseText(parsedResponse) || textToSummarize;
} catch (error) {

Some files were not shown because too many files have changed in this diff Show More