Compare commits

..

2 Commits

Author SHA1 Message Date
mingholy.lmh
37b65a1940 chore: update release workflows using bot PAT 2026-01-06 21:19:03 +08:00
mingholy.lmh
b950578990 chore: update release workflows to improve versioning and safety. 2025-12-30 14:20:04 +08:00
186 changed files with 1446 additions and 18054 deletions

View File

@@ -34,7 +34,8 @@ on:
default: false default: false
concurrency: concurrency:
group: '${{ github.workflow }}' # Serialize all release workflows (CLI + SDK) to avoid racing on `main` pushes.
group: 'release-main'
cancel-in-progress: false cancel-in-progress: false
jobs: jobs:
@@ -50,7 +51,6 @@ jobs:
packages: 'write' packages: 'write'
id-token: 'write' id-token: 'write'
issues: 'write' issues: 'write'
pull-requests: 'write'
outputs: outputs:
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
@@ -128,12 +128,13 @@ jobs:
IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}' IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}'
MANUAL_VERSION: '${{ inputs.version }}' MANUAL_VERSION: '${{ inputs.version }}'
- name: 'Set SDK package version (local only)' - name: 'Set SDK package version'
env: env:
RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}' RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}'
run: |- run: |-
# Ensure the package version matches the computed release version. # Ensure the package version matches the computed release version.
# This is required for nightly/preview because npm does not allow re-publishing the same version. # This is required for nightly/preview because npm does not allow re-publishing the same version.
# Using --no-git-tag-version because we create tags via GitHub Release, not npm.
npm version -w @qwen-code/sdk "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version npm version -w @qwen-code/sdk "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version
- name: 'Build CLI Bundle' - name: 'Build CLI Bundle'
@@ -168,37 +169,40 @@ jobs:
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"
- name: 'Build SDK' - name: 'Create and switch to a release branch (stable only)'
working-directory: 'packages/sdk-typescript'
run: |-
npm run build
- name: 'Publish @qwen-code/sdk'
working-directory: 'packages/sdk-typescript'
run: |-
npm publish --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-run' || '' }}
env:
NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}'
- name: 'Create and switch to a release branch'
if: |- if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }} ${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
id: 'release_branch' id: 'release_branch'
env: env:
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
run: |- run: |-
set -euo pipefail
BRANCH_NAME="release/sdk-typescript/${RELEASE_TAG}" BRANCH_NAME="release/sdk-typescript/${RELEASE_TAG}"
git switch -c "${BRANCH_NAME}"
# Make reruns idempotent: reuse an existing remote branch if it already exists.
if git show-ref --verify --quiet "refs/heads/${BRANCH_NAME}"; then
git switch "${BRANCH_NAME}"
elif git ls-remote --exit-code --heads origin "${BRANCH_NAME}" >/dev/null 2>&1; then
git fetch origin "${BRANCH_NAME}:${BRANCH_NAME}"
git switch "${BRANCH_NAME}"
else
git switch -c "${BRANCH_NAME}"
fi
echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}" echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}"
- name: 'Commit and Push package version (stable only)' - name: 'Build SDK'
working-directory: 'packages/sdk-typescript'
run: |-
npm run build
- name: 'Commit and Push package version to release branch (stable only)'
if: |- if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }} ${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
env: env:
BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}' BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
run: |- run: |-
# Only persist version bumps after a successful publish.
git add packages/sdk-typescript/package.json package-lock.json git add packages/sdk-typescript/package.json package-lock.json
if git diff --staged --quiet; then if git diff --staged --quiet; then
echo "No version changes to commit" echo "No version changes to commit"
@@ -208,9 +212,47 @@ jobs:
echo "Pushing release branch to remote..." echo "Pushing release branch to remote..."
git push --set-upstream origin "${BRANCH_NAME}" --follow-tags git push --set-upstream origin "${BRANCH_NAME}" --follow-tags
- name: 'Create GitHub Release and Tag' - name: 'Check if @qwen-code/sdk version is already published (rerun safety)'
id: 'npm_check'
env:
RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}'
run: |-
set -euo pipefail
if npm view "@qwen-code/sdk@${RELEASE_VERSION}" version >/dev/null 2>&1; then
echo "already_published=true" >> "${GITHUB_OUTPUT}"
echo "@qwen-code/sdk@${RELEASE_VERSION} already exists on npm."
else
echo "already_published=false" >> "${GITHUB_OUTPUT}"
fi
- name: 'Publish @qwen-code/sdk'
working-directory: 'packages/sdk-typescript'
if: |-
${{ steps.vars.outputs.is_dry_run == 'true' || steps.npm_check.outputs.already_published != 'true' }}
run: |-
npm publish --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-run' || '' }}
env:
NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}'
- name: 'Check if GitHub Release already exists (rerun safety)'
if: |- if: |-
${{ steps.vars.outputs.is_dry_run == 'false' }} ${{ steps.vars.outputs.is_dry_run == 'false' }}
id: 'gh_release_check'
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
run: |-
set -euo pipefail
if gh release view "sdk-typescript-${RELEASE_TAG}" >/dev/null 2>&1; then
echo "already_exists=true" >> "${GITHUB_OUTPUT}"
echo "GitHub Release sdk-typescript-${RELEASE_TAG} already exists."
else
echo "already_exists=false" >> "${GITHUB_OUTPUT}"
fi
- name: 'Create GitHub Release and Tag'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.gh_release_check.outputs.already_exists != 'true' }}
env: env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}' RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
@@ -236,48 +278,27 @@ jobs:
--generate-notes \ --generate-notes \
${PRERELEASE_FLAG} ${PRERELEASE_FLAG}
- name: 'Create PR to merge release branch into main' - name: 'Create release PR for SDK version bump'
if: |- if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }} ${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
id: 'pr'
env: env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' GH_TOKEN: '${{ secrets.CI_BOT_PAT }}'
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
run: |- run: |-
set -euo pipefail set -euo pipefail
pr_url="$(gh pr list --head "${RELEASE_BRANCH}" --base main --json url --jq '.[0].url')" pr_exists=$(gh pr list --head "${RELEASE_BRANCH}" --state open --json number --jq 'length')
if [[ -z "${pr_url}" ]]; then if [[ "${pr_exists}" != "0" ]]; then
pr_url="$(gh pr create \ echo "Open PR already exists for ${RELEASE_BRANCH}; skipping creation."
--base main \ exit 0
--head "${RELEASE_BRANCH}" \
--title "chore(release): sdk-typescript ${RELEASE_TAG}" \
--body "Automated release PR for sdk-typescript ${RELEASE_TAG}.")"
fi fi
echo "PR_URL=${pr_url}" >> "${GITHUB_OUTPUT}" gh pr create \
--base main \
- name: 'Wait for CI checks to complete' --head "${RELEASE_BRANCH}" \
if: |- --title "chore(release): sdk-typescript ${RELEASE_TAG}" \
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }} --body "Automated SDK version bump for ${RELEASE_TAG}."
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
PR_URL: '${{ steps.pr.outputs.PR_URL }}'
run: |-
set -euo pipefail
echo "Waiting for CI checks to complete..."
gh pr checks "${PR_URL}" --watch --interval 30
- name: 'Enable auto-merge for release PR'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
PR_URL: '${{ steps.pr.outputs.PR_URL }}'
run: |-
set -euo pipefail
gh pr merge "${PR_URL}" --merge --auto
- name: 'Create Issue on Failure' - name: 'Create Issue on Failure'
if: |- if: |-

View File

@@ -38,6 +38,11 @@ on:
type: 'boolean' type: 'boolean'
default: false default: false
concurrency:
# Serialize all release workflows (CLI + SDK) to avoid racing on `main` pushes.
group: 'release-main'
cancel-in-progress: false
jobs: jobs:
release: release:
runs-on: 'ubuntu-latest' runs-on: 'ubuntu-latest'
@@ -150,8 +155,19 @@ jobs:
env: env:
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
run: |- run: |-
set -euo pipefail
BRANCH_NAME="release/${RELEASE_TAG}" BRANCH_NAME="release/${RELEASE_TAG}"
git switch -c "${BRANCH_NAME}"
# Make reruns idempotent: reuse an existing remote branch if it already exists.
if git show-ref --verify --quiet "refs/heads/${BRANCH_NAME}"; then
git switch "${BRANCH_NAME}"
elif git ls-remote --exit-code --heads origin "${BRANCH_NAME}" >/dev/null 2>&1; then
git fetch origin "${BRANCH_NAME}:${BRANCH_NAME}"
git switch "${BRANCH_NAME}"
else
git switch -c "${BRANCH_NAME}"
fi
echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}" echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}"
- name: 'Update package versions' - name: 'Update package versions'
@@ -191,16 +207,47 @@ jobs:
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
scope: '@qwen-code' scope: '@qwen-code'
- name: 'Check if @qwen-code/qwen-code version is already published (rerun safety)'
id: 'npm_check'
env:
RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}'
run: |-
set -euo pipefail
if npm view "@qwen-code/qwen-code@${RELEASE_VERSION}" version >/dev/null 2>&1; then
echo "already_published=true" >> "${GITHUB_OUTPUT}"
echo "@qwen-code/qwen-code@${RELEASE_VERSION} already exists on npm."
else
echo "already_published=false" >> "${GITHUB_OUTPUT}"
fi
- name: 'Publish @qwen-code/qwen-code' - name: 'Publish @qwen-code/qwen-code'
working-directory: 'dist' working-directory: 'dist'
if: |-
${{ steps.vars.outputs.is_dry_run == 'true' || steps.npm_check.outputs.already_published != 'true' }}
run: |- run: |-
npm publish --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-run' || '' }} npm publish --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-run' || '' }}
env: env:
NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}' NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}'
- name: 'Create GitHub Release and Tag' - name: 'Check if GitHub Release already exists (rerun safety)'
if: |- if: |-
${{ steps.vars.outputs.is_dry_run == 'false' }} ${{ steps.vars.outputs.is_dry_run == 'false' }}
id: 'gh_release_check'
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
run: |-
set -euo pipefail
if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then
echo "already_exists=true" >> "${GITHUB_OUTPUT}"
echo "GitHub Release ${RELEASE_TAG} already exists."
else
echo "already_exists=false" >> "${GITHUB_OUTPUT}"
fi
- name: 'Create GitHub Release and Tag'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.gh_release_check.outputs.already_exists != 'true' }}
env: env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}' RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
@@ -214,12 +261,34 @@ jobs:
--notes-start-tag "$PREVIOUS_RELEASE_TAG" \ --notes-start-tag "$PREVIOUS_RELEASE_TAG" \
--generate-notes --generate-notes
- name: 'Create release PR for version bump'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
env:
GH_TOKEN: '${{ secrets.CI_BOT_PAT }}'
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
run: |-
set -euo pipefail
pr_exists=$(gh pr list --head "${RELEASE_BRANCH}" --state open --json number --jq 'length')
if [[ "${pr_exists}" != "0" ]]; then
echo "Open PR already exists for ${RELEASE_BRANCH}; skipping creation."
exit 0
fi
gh pr create \
--base main \
--head "${RELEASE_BRANCH}" \
--title "chore(release): ${RELEASE_TAG}" \
--body "Automated version bump for ${RELEASE_TAG}."
- name: 'Create Issue on Failure' - name: 'Create Issue on Failure'
if: |- if: |-
${{ failure() }} ${{ failure() }}
env: env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }} || "N/A"' RELEASE_TAG: "${{ steps.version.outputs.RELEASE_TAG || 'N/A' }}"
DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
run: |- run: |-
gh issue create \ gh issue create \

1
.gitignore vendored
View File

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

View File

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

View File

@@ -1,312 +0,0 @@
# 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,95 +136,6 @@ Settings are organized into categories. All settings should be placed within the
- `"./custom-logs"` - Logs to `./custom-logs` relative to current directory - `"./custom-logs"` - Logs to `./custom-logs` relative to current directory
- `"/tmp/openai-logs"` - Logs to absolute path `/tmp/openai-logs` - `"/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 #### context
| Setting | Type | Description | Default | | Setting | Type | Description | Default |
@@ -470,7 +381,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-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. | | `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
| `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | | | `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | |
| `--acp` | | Enables ACP mode (Agent Control Protocol). Useful for IDE/editor integrations like [Zed](../integration-zed). | | Stable. Replaces the deprecated `--experimental-acp` flag. | | `--experimental-acp` | | Enables ACP mode (Agent Control Protocol). Useful for IDE/editor integrations like [Zed](../integration-zed). | | Experimental. |
| `--experimental-skills` | | Enables experimental [Agent Skills](../features/skills) (registers the `skill` tool and loads Skills from `.qwen/skills/` and `~/.qwen/skills/`). | | Experimental. | | `--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` | | `--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. | | | | `--list-extensions` | `-l` | Lists all available extensions and exits. | | |

View File

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

View File

@@ -1,6 +1,5 @@
# Qwen Code overview # 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) [![@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. > 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,11 +80,10 @@ type PermissionHandler = (
/** /**
* Sets up an ACP test environment with all necessary utilities. * 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( function setupAcpTest(
rig: TestRig, rig: TestRig,
options?: { permissionHandler?: PermissionHandler; useNewFlag?: boolean }, options?: { permissionHandler?: PermissionHandler },
) { ) {
const pending = new Map<number, PendingRequest>(); const pending = new Map<number, PendingRequest>();
let nextRequestId = 1; let nextRequestId = 1;
@@ -96,13 +95,9 @@ function setupAcpTest(
const permissionHandler = const permissionHandler =
options?.permissionHandler ?? (() => ({ optionId: 'proceed_once' })); 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( const agent = spawn(
'node', 'node',
[rig.bundlePath, acpFlag, '--no-chat-recording'], [rig.bundlePath, '--experimental-acp', '--no-chat-recording'],
{ {
cwd: rig.testDir!, cwd: rig.testDir!,
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
@@ -626,99 +621,3 @@ 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", "name": "@qwen-code/qwen-code",
"version": "0.7.0", "version": "0.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@qwen-code/qwen-code", "name": "@qwen-code/qwen-code",
"version": "0.7.0", "version": "0.6.0",
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
], ],
@@ -17316,7 +17316,7 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@qwen-code/qwen-code", "name": "@qwen-code/qwen-code",
"version": "0.7.0", "version": "0.6.0",
"dependencies": { "dependencies": {
"@google/genai": "1.30.0", "@google/genai": "1.30.0",
"@iarna/toml": "^2.2.5", "@iarna/toml": "^2.2.5",
@@ -17953,7 +17953,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@qwen-code/qwen-code-core", "name": "@qwen-code/qwen-code-core",
"version": "0.7.0", "version": "0.6.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.36.1", "@anthropic-ai/sdk": "^0.36.1",
@@ -21413,7 +21413,7 @@
}, },
"packages/test-utils": { "packages/test-utils": {
"name": "@qwen-code/qwen-code-test-utils", "name": "@qwen-code/qwen-code-test-utils",
"version": "0.7.0", "version": "0.6.0",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"devDependencies": { "devDependencies": {
@@ -21425,7 +21425,7 @@
}, },
"packages/vscode-ide-companion": { "packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion", "name": "qwen-code-vscode-ide-companion",
"version": "0.7.0", "version": "0.6.0",
"license": "LICENSE", "license": "LICENSE",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.25.1", "@modelcontextprotocol/sdk": "^1.25.1",

View File

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

View File

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

View File

@@ -1,112 +1,41 @@
/** /**
* @license * @license
* Copyright 2025 Qwen Team * Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { AuthType } from '@qwen-code/qwen-code-core'; import { AuthType } from '@qwen-code/qwen-code-core';
import { vi } from 'vitest'; import { vi } from 'vitest';
import { validateAuthMethod } from './auth.js'; import { validateAuthMethod } from './auth.js';
import * as settings from './settings.js';
vi.mock('./settings.js', () => ({ vi.mock('./settings.js', () => ({
loadEnvironment: vi.fn(), loadEnvironment: vi.fn(),
loadSettings: vi.fn().mockReturnValue({ loadSettings: vi.fn().mockReturnValue({
merged: {}, merged: vi.fn().mockReturnValue({}),
}), }),
})); }));
describe('validateAuthMethod', () => { describe('validateAuthMethod', () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules(); vi.resetModules();
// Reset mock to default
vi.mocked(settings.loadSettings).mockReturnValue({
merged: {},
} as ReturnType<typeof settings.loadSettings>);
}); });
afterEach(() => { afterEach(() => {
vi.unstubAllEnvs(); 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 with default env key', () => { it('should return null for USE_OPENAI', () => {
process.env['OPENAI_API_KEY'] = 'fake-key'; process.env['OPENAI_API_KEY'] = 'fake-key';
expect(validateAuthMethod(AuthType.USE_OPENAI)).toBeNull(); expect(validateAuthMethod(AuthType.USE_OPENAI)).toBeNull();
}); });
it('should return an error message for USE_OPENAI if no API key is available', () => { it('should return an error message for USE_OPENAI if OPENAI_API_KEY is not set', () => {
delete process.env['OPENAI_API_KEY'];
expect(validateAuthMethod(AuthType.USE_OPENAI)).toBe( expect(validateAuthMethod(AuthType.USE_OPENAI)).toBe(
"Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the 'OPENAI_API_KEY' environment variable.", 'OPENAI_API_KEY environment variable not found. You can enter it interactively or add it to your .env file.',
); );
}); });
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', () => { it('should return null for QWEN_OAUTH', () => {
expect(validateAuthMethod(AuthType.QWEN_OAUTH)).toBeNull(); expect(validateAuthMethod(AuthType.QWEN_OAUTH)).toBeNull();
}); });
@@ -116,115 +45,4 @@ describe('validateAuthMethod', () => {
'Invalid auth method selected.', '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,169 +1,21 @@
/** /**
* @license * @license
* Copyright 2025 Qwen Team * Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { import { AuthType } from '@qwen-code/qwen-code-core';
AuthType, import { loadEnvironment, loadSettings } from './settings.js';
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(); const settings = loadSettings();
loadEnvironment(settings.merged); loadEnvironment(settings.merged);
if (authMethod === AuthType.USE_OPENAI) { if (authMethod === AuthType.USE_OPENAI) {
const { hasKey, checkedEnvKey, isExplicitEnvKey } = hasApiKeyForAuth( const hasApiKey =
authMethod, process.env['OPENAI_API_KEY'] || settings.merged.security?.auth?.apiKey;
settings.merged, if (!hasApiKey) {
config, return 'OPENAI_API_KEY environment variable not found. You can enter it interactively or add it to your .env file.';
);
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; return null;
} }
@@ -175,49 +27,36 @@ export function validateAuthMethod(
} }
if (authMethod === AuthType.USE_ANTHROPIC) { if (authMethod === AuthType.USE_ANTHROPIC) {
const apiKeyError = getApiKeyError(authMethod, settings.merged, config); const hasApiKey = process.env['ANTHROPIC_API_KEY'];
if (apiKeyError) { if (!hasApiKey) {
return apiKeyError; return 'ANTHROPIC_API_KEY environment variable not found.';
} }
// Check baseUrl - can come from modelProviders or environment const hasBaseUrl = process.env['ANTHROPIC_BASE_URL'];
const modelProviders = settings.merged.modelProviders as if (!hasBaseUrl) {
| ModelProvidersConfig return 'ANTHROPIC_BASE_URL environment variable not found.';
| 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; return null;
} }
if (authMethod === AuthType.USE_GEMINI) { if (authMethod === AuthType.USE_GEMINI) {
const apiKeyError = getApiKeyError(authMethod, settings.merged, config); const hasApiKey = process.env['GEMINI_API_KEY'];
if (apiKeyError) { if (!hasApiKey) {
return apiKeyError; return 'GEMINI_API_KEY environment variable not found. Please set it in your .env file or environment variables.';
} }
return null; return null;
} }
if (authMethod === AuthType.USE_VERTEX_AI) { if (authMethod === AuthType.USE_VERTEX_AI) {
const apiKeyError = getApiKeyError(authMethod, settings.merged, config); const hasApiKey = process.env['GOOGLE_API_KEY'];
if (apiKeyError) { if (!hasApiKey) {
return apiKeyError; return 'GOOGLE_API_KEY environment variable not found. Please set it in your .env file or environment variables.';
} }
process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true'; process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true';
return null; return null;
} }
return t('Invalid auth method selected.'); return 'Invalid auth method selected.';
} }

View File

@@ -77,8 +77,10 @@ vi.mock('read-package-up', () => ({
), ),
})); }));
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { vi.mock('@qwen-code/qwen-code-core', async () => {
const actualServer = await importOriginal<typeof ServerConfig>(); const actualServer = await vi.importActual<typeof ServerConfig>(
'@qwen-code/qwen-code-core',
);
return { return {
...actualServer, ...actualServer,
IdeClient: { IdeClient: {
@@ -1595,58 +1597,6 @@ describe('Approval mode tool exclusion logic', () => {
expect(excludedTools).toContain(WriteFileTool.Name); expect(excludedTools).toContain(WriteFileTool.Name);
}); });
it('should not exclude a tool explicitly allowed in tools.allowed', async () => {
process.argv = ['node', 'script.js', '-p', 'test'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {
tools: {
allowed: [ShellTool.Name],
},
};
const extensions: Extension[] = [];
const config = await loadCliConfig(
settings,
extensions,
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
argv,
);
const excludedTools = config.getExcludeTools();
expect(excludedTools).not.toContain(ShellTool.Name);
expect(excludedTools).toContain(EditTool.Name);
expect(excludedTools).toContain(WriteFileTool.Name);
});
it('should not exclude a tool explicitly allowed in tools.core', async () => {
process.argv = ['node', 'script.js', '-p', 'test'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {
tools: {
core: [ShellTool.Name],
},
};
const extensions: Extension[] = [];
const config = await loadCliConfig(
settings,
extensions,
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
argv,
);
const excludedTools = config.getExcludeTools();
expect(excludedTools).not.toContain(ShellTool.Name);
expect(excludedTools).toContain(EditTool.Name);
expect(excludedTools).toContain(WriteFileTool.Name);
});
it('should exclude only shell tools in non-interactive mode with auto-edit approval mode', async () => { it('should exclude only shell tools in non-interactive mode with auto-edit approval mode', async () => {
process.argv = [ process.argv = [
'node', 'node',

View File

@@ -10,31 +10,25 @@ import {
Config, Config,
DEFAULT_QWEN_EMBEDDING_MODEL, DEFAULT_QWEN_EMBEDDING_MODEL,
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
EditTool,
FileDiscoveryService, FileDiscoveryService,
getCurrentGeminiMdFilename, getCurrentGeminiMdFilename,
loadServerHierarchicalMemory, loadServerHierarchicalMemory,
setGeminiMdFilename as setServerGeminiMdFilename, setGeminiMdFilename as setServerGeminiMdFilename,
ShellTool,
WriteFileTool,
resolveTelemetrySettings, resolveTelemetrySettings,
FatalConfigError, FatalConfigError,
Storage, Storage,
InputFormat, InputFormat,
OutputFormat, OutputFormat,
isToolEnabled,
SessionService, SessionService,
type ResumedSessionData, type ResumedSessionData,
type FileFilteringOptions, type FileFilteringOptions,
type MCPServerConfig, type MCPServerConfig,
type ToolName,
EditTool,
ShellTool,
WriteFileTool,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import { extensionsCommand } from '../commands/extensions.js'; import { extensionsCommand } from '../commands/extensions.js';
import type { Settings } from './settings.js'; import type { Settings } from './settings.js';
import {
resolveCliGenerationConfig,
getAuthTypeFromEnv,
} from '../utils/modelConfigUtils.js';
import yargs, { type Argv } from 'yargs'; import yargs, { type Argv } from 'yargs';
import { hideBin } from 'yargs/helpers'; import { hideBin } from 'yargs/helpers';
import * as fs from 'node:fs'; import * as fs from 'node:fs';
@@ -117,7 +111,6 @@ export interface CliArgs {
telemetryOutfile: string | undefined; telemetryOutfile: string | undefined;
allowedMcpServerNames: string[] | undefined; allowedMcpServerNames: string[] | undefined;
allowedTools: string[] | undefined; allowedTools: string[] | undefined;
acp: boolean | undefined;
experimentalAcp: boolean | undefined; experimentalAcp: boolean | undefined;
experimentalSkills: boolean | undefined; experimentalSkills: boolean | undefined;
extensions: string[] | undefined; extensions: string[] | undefined;
@@ -311,15 +304,9 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
description: 'Enables checkpointing of file edits', description: 'Enables checkpointing of file edits',
default: false, default: false,
}) })
.option('acp', {
type: 'boolean',
description: 'Starts the agent in ACP mode',
})
.option('experimental-acp', { .option('experimental-acp', {
type: 'boolean', type: 'boolean',
description: description: 'Starts the agent in ACP mode',
'Starts the agent in ACP mode (deprecated, use --acp instead)',
hidden: true,
}) })
.option('experimental-skills', { .option('experimental-skills', {
type: 'boolean', type: 'boolean',
@@ -602,19 +589,8 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
// The import format is now only controlled by settings.memoryImportFormat // The import format is now only controlled by settings.memoryImportFormat
// We no longer accept it as a CLI argument // We no longer accept it as a CLI argument
// Handle deprecated --experimental-acp flag // Apply ACP fallback: if experimental-acp is present but no explicit --channel, treat as ACP
if (result['experimentalAcp']) { if (result['experimentalAcp'] && !result['channel']) {
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'; (result as Record<string, unknown>)['channel'] = 'ACP';
} }
@@ -842,28 +818,6 @@ export async function loadCliConfig(
// However, if stream-json input is used, control can be requested via JSON messages, // However, if stream-json input is used, control can be requested via JSON messages,
// so tools should not be excluded in that case. // so tools should not be excluded in that case.
const extraExcludes: string[] = []; const extraExcludes: string[] = [];
const resolvedCoreTools = argv.coreTools || settings.tools?.core || [];
const resolvedAllowedTools =
argv.allowedTools || settings.tools?.allowed || [];
const isExplicitlyEnabled = (toolName: ToolName): boolean => {
if (resolvedCoreTools.length > 0) {
if (isToolEnabled(toolName, resolvedCoreTools, [])) {
return true;
}
}
if (resolvedAllowedTools.length > 0) {
if (isToolEnabled(toolName, resolvedAllowedTools, [])) {
return true;
}
}
return false;
};
const excludeUnlessExplicit = (toolName: ToolName): void => {
if (!isExplicitlyEnabled(toolName)) {
extraExcludes.push(toolName);
}
};
if ( if (
!interactive && !interactive &&
!argv.experimentalAcp && !argv.experimentalAcp &&
@@ -872,15 +826,12 @@ export async function loadCliConfig(
switch (approvalMode) { switch (approvalMode) {
case ApprovalMode.PLAN: case ApprovalMode.PLAN:
case ApprovalMode.DEFAULT: case ApprovalMode.DEFAULT:
// In default non-interactive mode, all tools that require approval are excluded, // In default non-interactive mode, all tools that require approval are excluded.
// unless explicitly enabled via coreTools/allowedTools. extraExcludes.push(ShellTool.Name, EditTool.Name, WriteFileTool.Name);
excludeUnlessExplicit(ShellTool.Name as ToolName);
excludeUnlessExplicit(EditTool.Name as ToolName);
excludeUnlessExplicit(WriteFileTool.Name as ToolName);
break; break;
case ApprovalMode.AUTO_EDIT: case ApprovalMode.AUTO_EDIT:
// In auto-edit non-interactive mode, only tools that still require a prompt are excluded. // In auto-edit non-interactive mode, only tools that still require a prompt are excluded.
excludeUnlessExplicit(ShellTool.Name as ToolName); extraExcludes.push(ShellTool.Name);
break; break;
case ApprovalMode.YOLO: case ApprovalMode.YOLO:
// No extra excludes for YOLO mode. // No extra excludes for YOLO mode.
@@ -928,25 +879,28 @@ export async function loadCliConfig(
const selectedAuthType = const selectedAuthType =
(argv.authType as AuthType | undefined) || (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();
// Unified resolution of generation config with source attribution const apiKey =
const resolvedCliConfig = resolveCliGenerationConfig({ (selectedAuthType === AuthType.USE_OPENAI
argv: { ? argv.openaiApiKey ||
model: argv.model, process.env['OPENAI_API_KEY'] ||
openaiApiKey: argv.openaiApiKey, settings.security?.auth?.apiKey
openaiBaseUrl: argv.openaiBaseUrl, : '') || '';
openaiLogging: argv.openaiLogging, const baseUrl =
openaiLoggingDir: argv.openaiLoggingDir, (selectedAuthType === AuthType.USE_OPENAI
}, ? argv.openaiBaseUrl ||
settings, process.env['OPENAI_BASE_URL'] ||
selectedAuthType, settings.security?.auth?.baseUrl
env: process.env as Record<string, string | undefined>, : '') || '';
}); const resolvedModel =
argv.model ||
const { model: resolvedModel } = resolvedCliConfig; (selectedAuthType === AuthType.USE_OPENAI
? process.env['OPENAI_MODEL'] ||
process.env['QWEN_MODEL'] ||
settings.model?.name
: '') ||
'';
const sandboxConfig = await loadSandboxConfig(settings, argv); const sandboxConfig = await loadSandboxConfig(settings, argv);
const screenReader = const screenReader =
@@ -980,8 +934,6 @@ export async function loadCliConfig(
} }
} }
const modelProvidersConfig = settings.modelProviders;
return new Config({ return new Config({
sessionId, sessionId,
sessionData, sessionData,
@@ -1029,7 +981,7 @@ export async function loadCliConfig(
sessionTokenLimit: settings.model?.sessionTokenLimit ?? -1, sessionTokenLimit: settings.model?.sessionTokenLimit ?? -1,
maxSessionTurns: maxSessionTurns:
argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1, argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1,
experimentalZedIntegration: argv.acp || argv.experimentalAcp || false, experimentalZedIntegration: argv.experimentalAcp || false,
experimentalSkills: argv.experimentalSkills || false, experimentalSkills: argv.experimentalSkills || false,
listExtensions: argv.listExtensions || false, listExtensions: argv.listExtensions || false,
extensions: allExtensions, extensions: allExtensions,
@@ -1039,11 +991,24 @@ export async function loadCliConfig(
inputFormat, inputFormat,
outputFormat, outputFormat,
includePartialMessages, includePartialMessages,
modelProvidersConfig, generationConfig: {
generationConfigSources: resolvedCliConfig.sources, ...(settings.model?.generationConfig || {}),
generationConfig: resolvedCliConfig.generationConfig, model: resolvedModel,
apiKey,
baseUrl,
enableOpenAILogging:
(typeof argv.openaiLogging === 'undefined'
? settings.model?.enableOpenAILogging
: argv.openaiLogging) ?? false,
openAILoggingDir:
argv.openaiLoggingDir || settings.model?.openAILoggingDir,
},
cliVersion: await getCliVersion(), cliVersion: await getCliVersion(),
webSearch: buildWebSearchConfig(argv, settings, selectedAuthType), webSearch: buildWebSearchConfig(
argv,
settings,
settings.security?.auth?.selectedType,
),
summarizeToolOutput: settings.model?.summarizeToolOutput, summarizeToolOutput: settings.model?.summarizeToolOutput,
ideMode, ideMode,
chatCompression: settings.model?.chatCompression, chatCompression: settings.model?.chatCompression,

View File

@@ -1,87 +0,0 @@
/**
* @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

@@ -1,48 +0,0 @@
/**
* @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,7 +10,6 @@ import type {
TelemetrySettings, TelemetrySettings,
AuthType, AuthType,
ChatCompressionSettings, ChatCompressionSettings,
ModelProvidersConfig,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import { import {
ApprovalMode, ApprovalMode,
@@ -103,19 +102,6 @@ const SETTINGS_SCHEMA = {
mergeStrategy: MergeStrategy.SHALLOW_MERGE, 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: { general: {
type: 'object', type: 'object',
label: 'General', label: 'General',
@@ -216,7 +202,6 @@ const SETTINGS_SCHEMA = {
{ value: 'en', label: 'English' }, { value: 'en', label: 'English' },
{ value: 'zh', label: '中文 (Chinese)' }, { value: 'zh', label: '中文 (Chinese)' },
{ value: 'ru', label: 'Русский (Russian)' }, { value: 'ru', label: 'Русский (Russian)' },
{ value: 'de', label: 'Deutsch (German)' },
], ],
}, },
terminalBell: { terminalBell: {

View File

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

View File

@@ -87,15 +87,6 @@ vi.mock('./config/sandboxConfig.js', () => ({
loadSandboxConfig: vi.fn(), 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', () => { describe('gemini.tsx main function', () => {
let originalEnvGeminiSandbox: string | undefined; let originalEnvGeminiSandbox: string | undefined;
let originalEnvSandbox: string | undefined; let originalEnvSandbox: string | undefined;
@@ -371,6 +362,7 @@ describe('gemini.tsx main function', () => {
expect(inputArg).toBe('hello stream'); expect(inputArg).toBe('hello stream');
expect(validateAuthSpy).toHaveBeenCalledWith( expect(validateAuthSpy).toHaveBeenCalledWith(
undefined,
undefined, undefined,
configStub, configStub,
expect.any(Object), expect.any(Object),
@@ -468,7 +460,6 @@ describe('gemini.tsx main function kitty protocol', () => {
telemetryOutfile: undefined, telemetryOutfile: undefined,
allowedMcpServerNames: undefined, allowedMcpServerNames: undefined,
allowedTools: undefined, allowedTools: undefined,
acp: undefined,
experimentalAcp: undefined, experimentalAcp: undefined,
experimentalSkills: undefined, experimentalSkills: undefined,
extensions: undefined, extensions: undefined,
@@ -648,37 +639,4 @@ describe('startInteractiveUI', () => {
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
expect(checkForUpdates).toHaveBeenCalledTimes(1); 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 * SPDX-License-Identifier: Apache-2.0
*/ */
import type { Config } from '@qwen-code/qwen-code-core'; import type { Config, AuthType } from '@qwen-code/qwen-code-core';
import { InputFormat, logUserPrompt } from '@qwen-code/qwen-code-core'; import { InputFormat, logUserPrompt } from '@qwen-code/qwen-code-core';
import { render } from 'ink'; import { render } from 'ink';
import dns from 'node:dns'; import dns from 'node:dns';
@@ -183,18 +183,16 @@ export async function startInteractiveUI(
}, },
); );
if (!settings.merged.general?.disableUpdateNag) { checkForUpdates()
checkForUpdates() .then((info) => {
.then((info) => { handleAutoUpdate(info, settings, config.getProjectRoot());
handleAutoUpdate(info, settings, config.getProjectRoot()); })
}) .catch((err) => {
.catch((err) => { // Silently ignore update check errors.
// Silently ignore update check errors. if (config.getDebugMode()) {
if (config.getDebugMode()) { console.error('Update check failed:', err);
console.error('Update check failed:', err); }
} });
});
}
registerCleanup(() => instance.unmount()); registerCleanup(() => instance.unmount());
} }
@@ -252,20 +250,22 @@ export async function main() {
argv, argv,
); );
if (!settings.merged.security?.auth?.useExternal) { if (
settings.merged.security?.auth?.selectedType &&
!settings.merged.security?.auth?.useExternal
) {
// Validate authentication here because the sandbox will interfere with the Oauth2 web redirect. // Validate authentication here because the sandbox will interfere with the Oauth2 web redirect.
try { try {
const authType = partialConfig.modelsConfig.getCurrentAuthType(); const err = validateAuthMethod(
// Fresh users may not have selected/persisted an authType yet. settings.merged.security.auth.selectedType,
// In that case, defer auth prompting/selection to the main interactive flow. );
if (authType) { if (err) {
const err = validateAuthMethod(authType, partialConfig); throw new Error(err);
if (err) {
throw new Error(err);
}
await partialConfig.refreshAuth(authType);
} }
await partialConfig.refreshAuth(
settings.merged.security.auth.selectedType,
);
} catch (err) { } catch (err) {
console.error('Error authenticating:', err); console.error('Error authenticating:', err);
process.exit(1); process.exit(1);
@@ -438,6 +438,8 @@ export async function main() {
} }
const nonInteractiveConfig = await validateNonInteractiveAuth( const nonInteractiveConfig = await validateNonInteractiveAuth(
(argv.authType as AuthType) ||
settings.merged.security?.auth?.selectedType,
settings.merged.security?.auth?.useExternal, settings.merged.security?.auth?.useExternal,
config, config,
settings, settings,

File diff suppressed because it is too large Load Diff

View File

@@ -89,9 +89,6 @@ export default {
'No tools available': 'No tools available', 'No tools available': 'No tools available',
'View or change the approval mode for tool usage': 'View or change the approval mode for tool usage':
'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', 'View or change the language setting': 'View or change the language setting',
'change the theme': 'change the theme', 'change the theme': 'change the theme',
'Select Theme': 'Select Theme', 'Select Theme': 'Select Theme',
@@ -770,21 +767,6 @@ export default {
'Authentication timed out. Please try again.', '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)':
'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}}':
'Failed to authenticate. Message: {{message}}', 'Failed to authenticate. Message: {{message}}',
'Authenticated successfully with {{authType}} credentials.': 'Authenticated successfully with {{authType}} credentials.':
@@ -806,15 +788,6 @@ export default {
// ============================================================================ // ============================================================================
'Select Model': 'Select Model', 'Select Model': 'Select Model',
'(Press Esc to close)': '(Press Esc to close)', '(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 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)': 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)':
@@ -1064,6 +1037,7 @@ export default {
'Applying percussive maintenance...', 'Applying percussive maintenance...',
'Searching for the correct USB orientation...', 'Searching for the correct USB orientation...',
'Ensuring the magic smoke stays inside the wires...', 'Ensuring the magic smoke stays inside the wires...',
'Rewriting in Rust for no particular reason...',
'Trying to exit Vim...', 'Trying to exit Vim...',
'Spinning up the hamster wheel...', 'Spinning up the hamster wheel...',
"That's not a bug, it's an undocumented feature...", "That's not a bug, it's an undocumented feature...",

View File

@@ -89,10 +89,6 @@ 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}}':
'Недопустимый режим подтверждения "{{arg}}". Допустимые режимы: {{modes}}',
'Approval mode set to "{{mode}}"':
'Режим подтверждения установлен на "{{mode}}"',
'View or change the language setting': 'View or change the language setting':
'Просмотр или изменение настроек языка', 'Просмотр или изменение настроек языка',
'change the theme': 'Изменение темы', 'change the theme': 'Изменение темы',
@@ -786,21 +782,6 @@ export default {
'Время ожидания авторизации истекло. Пожалуйста, попробуйте снова.', 'Время ожидания авторизации истекло. Пожалуйста, попробуйте снова.',
'Waiting for auth... (Press ESC or CTRL+C to cancel)': 'Waiting for auth... (Press ESC or CTRL+C to cancel)':
'Ожидание авторизации... (Нажмите ESC или CTRL+C для отмены)', 'Ожидание авторизации... (Нажмите 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}}': 'Failed to authenticate. Message: {{message}}':
'Не удалось авторизоваться. Сообщение: {{message}}', 'Не удалось авторизоваться. Сообщение: {{message}}',
'Authenticated successfully with {{authType}} credentials.': 'Authenticated successfully with {{authType}} credentials.':
@@ -822,15 +803,6 @@ export default {
// ============================================================================ // ============================================================================
'Select Model': 'Выбрать модель', 'Select Model': 'Выбрать модель',
'(Press Esc to close)': '(Нажмите Esc для закрытия)', '(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)': '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)', 'Последняя модель 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)': 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)':
@@ -1084,6 +1056,7 @@ export default {
'Провожу настройку методом тыка...', 'Провожу настройку методом тыка...',
'Ищем, какой стороной вставлять флешку...', 'Ищем, какой стороной вставлять флешку...',
'Следим, чтобы волшебный дым не вышел из проводов...', 'Следим, чтобы волшебный дым не вышел из проводов...',
'Переписываем всё на Rust без особой причины...',
'Пытаемся выйти из Vim...', 'Пытаемся выйти из Vim...',
'Раскручиваем колесо для хомяка...', 'Раскручиваем колесо для хомяка...',
'Это не баг, а фича...', 'Это не баг, а фича...',

View File

@@ -88,9 +88,6 @@ 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}}':
'无效的审批模式 "{{arg}}"。有效模式:{{modes}}',
'Approval mode set to "{{mode}}"': '审批模式已设置为 "{{mode}}"',
'View or change the language setting': '查看或更改语言设置', 'View or change the language setting': '查看或更改语言设置',
'change the theme': '更改主题', 'change the theme': '更改主题',
'Select Theme': '选择主题', 'Select Theme': '选择主题',
@@ -728,21 +725,6 @@ export default {
'Authentication timed out. Please try again.': '认证超时。请重试。', '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)':
'正在等待认证...(按 ESC 或 CTRL+C 取消)', '正在等待认证...(按 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}}', 'Failed to authenticate. Message: {{message}}': '认证失败。消息:{{message}}',
'Authenticated successfully with {{authType}} credentials.': 'Authenticated successfully with {{authType}} credentials.':
'使用 {{authType}} 凭据成功认证。', '使用 {{authType}} 凭据成功认证。',
@@ -762,15 +744,6 @@ export default {
// ============================================================================ // ============================================================================
'Select Model': '选择模型', 'Select Model': '选择模型',
'(Press Esc to close)': '(按 Esc 关闭)', '(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)': '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', '来自阿里云 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)': 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)':

View File

@@ -630,67 +630,6 @@ describe('BaseJsonOutputAdapter', () => {
expect(state.blocks).toHaveLength(0); 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', () => { describe('appendToolUse', () => {

View File

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

View File

@@ -323,68 +323,6 @@ 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', () => { it('should append tool use from ToolCallRequest events', () => {
adapter.processEvent({ adapter.processEvent({
type: GeminiEventType.ToolCallRequest, type: GeminiEventType.ToolCallRequest,

View File

@@ -298,9 +298,7 @@ describe('runNonInteractive', () => {
mockConfig, mockConfig,
expect.objectContaining({ name: 'testTool' }), expect.objectContaining({ name: 'testTool' }),
expect.any(AbortSignal), expect.any(AbortSignal),
expect.objectContaining({ undefined,
outputUpdateHandler: expect.any(Function),
}),
); );
// Verify first call has isContinuation: false // Verify first call has isContinuation: false
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
@@ -773,52 +771,6 @@ 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 () => { it('should handle FatalInputError with custom exit code in JSON format', async () => {
(mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON); (mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON);
setupMetricsMock(); setupMetricsMock();
@@ -1825,84 +1777,4 @@ describe('runNonInteractive', () => {
{ isContinuation: false }, { 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,11 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import type { import type { Config, ToolCallRequestInfo } from '@qwen-code/qwen-code-core';
Config,
ToolCallRequestInfo,
ToolResultDisplay,
} from '@qwen-code/qwen-code-core';
import { isSlashCommand } from './ui/utils/commandUtils.js'; import { isSlashCommand } from './ui/utils/commandUtils.js';
import type { LoadedSettings } from './config/settings.js'; import type { LoadedSettings } from './config/settings.js';
import { import {
@@ -312,8 +308,6 @@ export async function runNonInteractive(
config.getContentGeneratorConfig()?.authType, config.getContentGeneratorConfig()?.authType,
); );
process.stderr.write(`${errorText}\n`); process.stderr.write(`${errorText}\n`);
// Throw error to exit with non-zero code
throw new Error(errorText);
} }
} }
} }
@@ -339,7 +333,7 @@ export async function runNonInteractive(
? options.controlService.permission.getToolCallUpdateCallback() ? options.controlService.permission.getToolCallUpdateCallback()
: undefined; : undefined;
// Create output handler for Task tool (for subagent execution) // Only pass outputUpdateHandler for Task tool
const isTaskTool = finalRequestInfo.name === 'task'; const isTaskTool = finalRequestInfo.name === 'task';
const taskToolProgress = isTaskTool const taskToolProgress = isTaskTool
? createTaskToolProgressHandler( ? createTaskToolProgressHandler(
@@ -349,41 +343,20 @@ export async function runNonInteractive(
) )
: undefined; : undefined;
const taskToolProgressHandler = taskToolProgress?.handler; 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( const toolResponse = await executeToolCall(
config, config,
finalRequestInfo, finalRequestInfo,
abortController.signal, abortController.signal,
outputUpdateHandler || toolCallUpdateCallback isTaskTool && taskToolProgressHandler
? { ? {
...(outputUpdateHandler && { outputUpdateHandler }), outputUpdateHandler: taskToolProgressHandler,
...(toolCallUpdateCallback && { onToolCallsUpdate: toolCallUpdateCallback,
onToolCallsUpdate: toolCallUpdateCallback,
}),
} }
: undefined, : toolCallUpdateCallback
? {
onToolCallsUpdate: toolCallUpdateCallback,
}
: undefined,
); );
// Note: In JSON mode, subagent messages are automatically added to the main // Note: In JSON mode, subagent messages are automatically added to the main

View File

@@ -72,7 +72,6 @@ describe('ShellProcessor', () => {
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
getShouldUseNodePtyShell: vi.fn().mockReturnValue(false), getShouldUseNodePtyShell: vi.fn().mockReturnValue(false),
getShellExecutionConfig: vi.fn().mockReturnValue({}), getShellExecutionConfig: vi.fn().mockReturnValue({}),
getAllowedTools: vi.fn().mockReturnValue([]),
}; };
context = createMockCommandContext({ context = createMockCommandContext({
@@ -197,35 +196,6 @@ describe('ShellProcessor', () => {
); );
}); });
it('should NOT throw ConfirmationRequiredError when a command matches allowedTools', async () => {
const processor = new ShellProcessor('test-command');
const prompt: PromptPipelineContent = createPromptPipelineContent(
'Do something dangerous: !{rm -rf /}',
);
mockCheckCommandPermissions.mockReturnValue({
allAllowed: false,
disallowedCommands: ['rm -rf /'],
});
(mockConfig.getAllowedTools as Mock).mockReturnValue([
'ShellTool(rm -rf /)',
]);
mockShellExecute.mockReturnValue({
result: Promise.resolve({ ...SUCCESS_RESULT, output: 'deleted' }),
});
const result = await processor.process(prompt, context);
expect(mockShellExecute).toHaveBeenCalledWith(
'rm -rf /',
expect.any(String),
expect.any(Function),
expect.any(Object),
false,
expect.any(Object),
);
expect(result).toEqual([{ text: 'Do something dangerous: deleted' }]);
});
it('should NOT throw ConfirmationRequiredError if a command is not allowed but approval mode is YOLO', async () => { it('should NOT throw ConfirmationRequiredError if a command is not allowed but approval mode is YOLO', async () => {
const processor = new ShellProcessor('test-command'); const processor = new ShellProcessor('test-command');
const prompt: PromptPipelineContent = createPromptPipelineContent( const prompt: PromptPipelineContent = createPromptPipelineContent(

View File

@@ -7,13 +7,11 @@
import { import {
ApprovalMode, ApprovalMode,
checkCommandPermissions, checkCommandPermissions,
doesToolInvocationMatch,
escapeShellArg, escapeShellArg,
getShellConfiguration, getShellConfiguration,
ShellExecutionService, ShellExecutionService,
flatMapTextParts, flatMapTextParts,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import type { AnyToolInvocation } from '@qwen-code/qwen-code-core';
import type { CommandContext } from '../../ui/commands/types.js'; import type { CommandContext } from '../../ui/commands/types.js';
import type { IPromptProcessor, PromptPipelineContent } from './types.js'; import type { IPromptProcessor, PromptPipelineContent } from './types.js';
@@ -126,15 +124,6 @@ export class ShellProcessor implements IPromptProcessor {
// Security check on the final, escaped command string. // Security check on the final, escaped command string.
const { allAllowed, disallowedCommands, blockReason, isHardDenial } = const { allAllowed, disallowedCommands, blockReason, isHardDenial } =
checkCommandPermissions(command, config, sessionShellAllowlist); checkCommandPermissions(command, config, sessionShellAllowlist);
const allowedTools = config.getAllowedTools() || [];
const invocation = {
params: { command },
} as AnyToolInvocation;
const isAllowedBySettings = doesToolInvocationMatch(
'run_shell_command',
invocation,
allowedTools,
);
if (!allAllowed) { if (!allAllowed) {
if (isHardDenial) { if (isHardDenial) {
@@ -143,17 +132,10 @@ export class ShellProcessor implements IPromptProcessor {
); );
} }
// If the command is allowed by settings, skip confirmation.
if (isAllowedBySettings) {
continue;
}
// If not a hard denial, respect YOLO mode and auto-approve. // If not a hard denial, respect YOLO mode and auto-approve.
if (config.getApprovalMode() === ApprovalMode.YOLO) { if (config.getApprovalMode() !== ApprovalMode.YOLO) {
continue; disallowedCommands.forEach((uc) => commandsToConfirm.add(uc));
} }
disallowedCommands.forEach((uc) => commandsToConfirm.add(uc));
} }
} }

View File

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

View File

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

View File

@@ -38,7 +38,6 @@ export function IdeIntegrationNudge({
); );
const { displayName: ideName } = ide; const { displayName: ideName } = ide;
const isInSandbox = !!process.env['SANDBOX'];
// Assume extension is already installed if the env variables are set. // Assume extension is already installed if the env variables are set.
const isExtensionPreInstalled = const isExtensionPreInstalled =
!!process.env['QWEN_CODE_IDE_SERVER_PORT'] && !!process.env['QWEN_CODE_IDE_SERVER_PORT'] &&
@@ -71,15 +70,13 @@ export function IdeIntegrationNudge({
}, },
]; ];
const installText = isInSandbox const installText = isExtensionPreInstalled
? `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.` ? `If you select Yes, the CLI will have access to your open files and display diffs directly in ${
: isExtensionPreInstalled ideName ?? 'your editor'
? `If you select Yes, the CLI will connect to your ${ }.`
ideName ?? 'editor' : `If you select Yes, we'll install an extension that allows the CLI to access your open files and display diffs directly in ${
} and have access to your open files and display diffs directly.` 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'
}.`;
return ( return (
<Box <Box

View File

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

View File

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

View File

@@ -4,16 +4,16 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import type { Config, ModelProvidersConfig } from '@qwen-code/qwen-code-core'; import type { Config } from '@qwen-code/qwen-code-core';
import { import {
AuthEvent, AuthEvent,
AuthType, AuthType,
clearCachedCredentialFile,
getErrorMessage, getErrorMessage,
logAuth, logAuth,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import type { LoadedSettings } from '../../config/settings.js'; import type { LoadedSettings, SettingScope } from '../../config/settings.js';
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js'; import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
import { useQwenAuth } from '../hooks/useQwenAuth.js'; import { useQwenAuth } from '../hooks/useQwenAuth.js';
import { AuthState, MessageType } from '../types.js'; import { AuthState, MessageType } from '../types.js';
@@ -27,7 +27,8 @@ export const useAuthCommand = (
config: Config, config: Config,
addItem: (item: Omit<HistoryItem, 'id'>, timestamp: number) => void, addItem: (item: Omit<HistoryItem, 'id'>, timestamp: number) => void,
) => { ) => {
const unAuthenticated = config.getAuthType() === undefined; const unAuthenticated =
settings.merged.security?.auth?.selectedType === undefined;
const [authState, setAuthState] = useState<AuthState>( const [authState, setAuthState] = useState<AuthState>(
unAuthenticated ? AuthState.Updating : AuthState.Unauthenticated, unAuthenticated ? AuthState.Updating : AuthState.Unauthenticated,
@@ -80,35 +81,35 @@ export const useAuthCommand = (
); );
const handleAuthSuccess = useCallback( const handleAuthSuccess = useCallback(
async (authType: AuthType, credentials?: OpenAICredentials) => { async (
authType: AuthType,
scope: SettingScope,
credentials?: OpenAICredentials,
) => {
try { try {
const authTypeScope = getPersistScopeForModelSelection(settings); settings.setValue(scope, 'security.auth.selectedType', authType);
settings.setValue(
authTypeScope,
'security.auth.selectedType',
authType,
);
// Only update credentials if not switching to QWEN_OAUTH, // Only update credentials if not switching to QWEN_OAUTH,
// so that OpenAI credentials are preserved when switching to QWEN_OAUTH. // so that OpenAI credentials are preserved when switching to QWEN_OAUTH.
if (authType !== AuthType.QWEN_OAUTH && credentials) { if (authType !== AuthType.QWEN_OAUTH && credentials) {
if (credentials?.apiKey != null) { if (credentials?.apiKey != null) {
settings.setValue( settings.setValue(
authTypeScope, scope,
'security.auth.apiKey', 'security.auth.apiKey',
credentials.apiKey, credentials.apiKey,
); );
} }
if (credentials?.baseUrl != null) { if (credentials?.baseUrl != null) {
settings.setValue( settings.setValue(
authTypeScope, scope,
'security.auth.baseUrl', 'security.auth.baseUrl',
credentials.baseUrl, credentials.baseUrl,
); );
} }
if (credentials?.model != null) { if (credentials?.model != null) {
settings.setValue(authTypeScope, 'model.name', credentials.model); settings.setValue(scope, 'model.name', credentials.model);
} }
await clearCachedCredentialFile();
} }
} catch (error) { } catch (error) {
handleAuthFailure(error); handleAuthFailure(error);
@@ -140,10 +141,14 @@ export const useAuthCommand = (
); );
const performAuth = useCallback( const performAuth = useCallback(
async (authType: AuthType, credentials?: OpenAICredentials) => { async (
authType: AuthType,
scope: SettingScope,
credentials?: OpenAICredentials,
) => {
try { try {
await config.refreshAuth(authType); await config.refreshAuth(authType);
handleAuthSuccess(authType, credentials); handleAuthSuccess(authType, scope, credentials);
} catch (e) { } catch (e) {
handleAuthFailure(e); handleAuthFailure(e);
} }
@@ -151,51 +156,18 @@ export const useAuthCommand = (
[config, handleAuthSuccess, handleAuthFailure], [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( const handleAuthSelect = useCallback(
async (authType: AuthType | undefined, credentials?: OpenAICredentials) => { async (
authType: AuthType | undefined,
scope: SettingScope,
credentials?: OpenAICredentials,
) => {
if (!authType) { if (!authType) {
setIsAuthDialogOpen(false); setIsAuthDialogOpen(false);
setAuthError(null); setAuthError(null);
return; 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); setPendingAuthType(authType);
setAuthError(null); setAuthError(null);
setIsAuthDialogOpen(false); setIsAuthDialogOpen(false);
@@ -208,14 +180,14 @@ export const useAuthCommand = (
baseUrl: credentials.baseUrl, baseUrl: credentials.baseUrl,
model: credentials.model, model: credentials.model,
}); });
await performAuth(authType, credentials); await performAuth(authType, scope, credentials);
} }
return; return;
} }
await performAuth(authType); await performAuth(authType, scope);
}, },
[config, performAuth, isProviderManagedModel, onAuthError], [config, performAuth],
); );
const openAuthDialog = useCallback(() => { const openAuthDialog = useCallback(() => {

View File

@@ -4,28 +4,31 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect } from 'vitest';
import { approvalModeCommand } from './approvalModeCommand.js'; import { approvalModeCommand } from './approvalModeCommand.js';
import { import {
type CommandContext, type CommandContext,
CommandKind, CommandKind,
type OpenDialogActionReturn, type OpenDialogActionReturn,
type MessageActionReturn,
} from './types.js'; } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import type { LoadedSettings } from '../../config/settings.js';
describe('approvalModeCommand', () => { describe('approvalModeCommand', () => {
let mockContext: CommandContext; let mockContext: CommandContext;
let mockSetApprovalMode: ReturnType<typeof vi.fn>;
beforeEach(() => { beforeEach(() => {
mockSetApprovalMode = vi.fn();
mockContext = createMockCommandContext({ mockContext = createMockCommandContext({
services: { services: {
config: { config: {
getApprovalMode: () => 'default', getApprovalMode: () => 'default',
setApprovalMode: mockSetApprovalMode, setApprovalMode: () => {},
}, },
settings: {
merged: {},
setValue: () => {},
forScope: () => ({}),
} as unknown as LoadedSettings,
}, },
}); });
}); });
@@ -38,7 +41,7 @@ describe('approvalModeCommand', () => {
expect(approvalModeCommand.kind).toBe(CommandKind.BUILT_IN); expect(approvalModeCommand.kind).toBe(CommandKind.BUILT_IN);
}); });
it('should open approval mode dialog when invoked without arguments', async () => { it('should open approval mode dialog when invoked', async () => {
const result = (await approvalModeCommand.action?.( const result = (await approvalModeCommand.action?.(
mockContext, mockContext,
'', '',
@@ -48,123 +51,16 @@ describe('approvalModeCommand', () => {
expect(result.dialog).toBe('approval-mode'); expect(result.dialog).toBe('approval-mode');
}); });
it('should open approval mode dialog when invoked with whitespace only', async () => { it('should open approval mode dialog with arguments (ignored)', async () => {
const result = (await approvalModeCommand.action?.( const result = (await approvalModeCommand.action?.(
mockContext, mockContext,
' ', 'some arguments',
)) as OpenDialogActionReturn; )) as OpenDialogActionReturn;
expect(result.type).toBe('dialog'); expect(result.type).toBe('dialog');
expect(result.dialog).toBe('approval-mode'); 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', () => { it('should not have subcommands', () => {
expect(approvalModeCommand.subCommands).toBeUndefined(); expect(approvalModeCommand.subCommands).toBeUndefined();
}); });

View File

@@ -8,25 +8,9 @@ import type {
SlashCommand, SlashCommand,
CommandContext, CommandContext,
OpenDialogActionReturn, OpenDialogActionReturn,
MessageActionReturn,
} from './types.js'; } from './types.js';
import { CommandKind } from './types.js'; import { CommandKind } from './types.js';
import { t } from '../../i18n/index.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 = { export const approvalModeCommand: SlashCommand = {
name: 'approval-mode', name: 'approval-mode',
@@ -35,49 +19,10 @@ export const approvalModeCommand: SlashCommand = {
}, },
kind: CommandKind.BUILT_IN, kind: CommandKind.BUILT_IN,
action: async ( action: async (
context: CommandContext, _context: CommandContext,
args: string, _args: string,
): Promise<OpenDialogActionReturn | MessageActionReturn> => { ): Promise<OpenDialogActionReturn> => ({
const mode = parseApprovalModeArg(args); type: 'dialog',
dialog: 'approval-mode',
// 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,23 +191,11 @@ export const ideCommand = async (): Promise<SlashCommand> => {
kind: CommandKind.BUILT_IN, kind: CommandKind.BUILT_IN,
action: async (context) => { action: async (context) => {
const installer = getIdeInstaller(currentIDE); 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) { if (!installer) {
const ideName = ideClient.getDetectedIdeDisplayName();
context.ui.addItem( context.ui.addItem(
{ {
type: 'error', type: 'error',
text: `Automatic installation is not supported for ${ideName}. Please install the '${QWEN_CODE_COMPANION_EXTENSION_NAME}' extension manually from the marketplace.`, text: `No installer is available for ${ideClient.getDetectedIdeDisplayName()}. Please install the '${QWEN_CODE_COMPANION_EXTENSION_NAME}' extension manually from the marketplace.`,
}, },
Date.now(), Date.now(),
); );

View File

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

View File

@@ -11,6 +11,7 @@ import type {
MessageActionReturn, MessageActionReturn,
} from './types.js'; } from './types.js';
import { CommandKind } from './types.js'; import { CommandKind } from './types.js';
import { getAvailableModelsForAuthType } from '../models/availableModels.js';
import { t } from '../../i18n/index.js'; import { t } from '../../i18n/index.js';
export const modelCommand: SlashCommand = { export const modelCommand: SlashCommand = {
@@ -29,7 +30,7 @@ export const modelCommand: SlashCommand = {
return { return {
type: 'message', type: 'message',
messageType: 'error', messageType: 'error',
content: t('Configuration not available.'), content: 'Configuration not available.',
}; };
} }
@@ -51,6 +52,22 @@ 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 { return {
type: 'dialog', type: 'dialog',
dialog: 'model', dialog: 'model',

View File

@@ -54,7 +54,7 @@ export function ApprovalModeDialog({
}: ApprovalModeDialogProps): React.JSX.Element { }: ApprovalModeDialogProps): React.JSX.Element {
// Start with User scope by default // Start with User scope by default
const [selectedScope, setSelectedScope] = useState<SettingScope>( const [selectedScope, setSelectedScope] = useState<SettingScope>(
SettingScope.Workspace, SettingScope.User,
); );
// Track the currently highlighted approval mode // Track the currently highlighted approval mode

View File

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

View File

@@ -10,11 +10,7 @@ import { ModelDialog } from './ModelDialog.js';
import { useKeypress } from '../hooks/useKeypress.js'; import { useKeypress } from '../hooks/useKeypress.js';
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js'; import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
import { ConfigContext } from '../contexts/ConfigContext.js'; import { ConfigContext } from '../contexts/ConfigContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import type { Config } from '@qwen-code/qwen-code-core'; 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 { import {
AVAILABLE_MODELS_QWEN, AVAILABLE_MODELS_QWEN,
MAINLINE_CODER, MAINLINE_CODER,
@@ -40,29 +36,18 @@ const renderComponent = (
}; };
const combinedProps = { ...defaultProps, ...props }; const combinedProps = { ...defaultProps, ...props };
const mockSettings = {
isTrusted: true,
user: { settings: {} },
workspace: { settings: {} },
setValue: vi.fn(),
} as unknown as LoadedSettings;
const mockConfig = contextValue const mockConfig = contextValue
? ({ ? ({
// --- Functions used by ModelDialog --- // --- Functions used by ModelDialog ---
getModel: vi.fn(() => MAINLINE_CODER), getModel: vi.fn(() => MAINLINE_CODER),
setModel: vi.fn().mockResolvedValue(undefined), setModel: vi.fn(),
switchModel: vi.fn().mockResolvedValue(undefined),
getAuthType: vi.fn(() => 'qwen-oauth'), getAuthType: vi.fn(() => 'qwen-oauth'),
// --- Functions used by ClearcutLogger --- // --- Functions used by ClearcutLogger ---
getUsageStatisticsEnabled: vi.fn(() => true), getUsageStatisticsEnabled: vi.fn(() => true),
getSessionId: vi.fn(() => 'mock-session-id'), getSessionId: vi.fn(() => 'mock-session-id'),
getDebugMode: vi.fn(() => false), getDebugMode: vi.fn(() => false),
getContentGeneratorConfig: vi.fn(() => ({ getContentGeneratorConfig: vi.fn(() => ({ authType: 'mock' })),
authType: AuthType.QWEN_OAUTH,
model: MAINLINE_CODER,
})),
getUseSmartEdit: vi.fn(() => false), getUseSmartEdit: vi.fn(() => false),
getUseModelRouter: vi.fn(() => false), getUseModelRouter: vi.fn(() => false),
getProxy: vi.fn(() => undefined), getProxy: vi.fn(() => undefined),
@@ -73,27 +58,21 @@ const renderComponent = (
: undefined; : undefined;
const renderResult = render( const renderResult = render(
<SettingsContext.Provider value={mockSettings}> <ConfigContext.Provider value={mockConfig}>
<ConfigContext.Provider value={mockConfig}> <ModelDialog {...combinedProps} />
<ModelDialog {...combinedProps} /> </ConfigContext.Provider>,
</ConfigContext.Provider>
</SettingsContext.Provider>,
); );
return { return {
...renderResult, ...renderResult,
props: combinedProps, props: combinedProps,
mockConfig, mockConfig,
mockSettings,
}; };
}; };
describe('<ModelDialog />', () => { describe('<ModelDialog />', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); 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(() => { afterEach(() => {
@@ -112,12 +91,8 @@ describe('<ModelDialog />', () => {
const props = mockedSelect.mock.calls[0][0]; const props = mockedSelect.mock.calls[0][0];
expect(props.items).toHaveLength(AVAILABLE_MODELS_QWEN.length); expect(props.items).toHaveLength(AVAILABLE_MODELS_QWEN.length);
expect(props.items[0].value).toBe( expect(props.items[0].value).toBe(MAINLINE_CODER);
`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`, expect(props.items[1].value).toBe(MAINLINE_VLM);
);
expect(props.items[1].value).toBe(
`${AuthType.QWEN_OAUTH}::${MAINLINE_VLM}`,
);
expect(props.showNumbers).toBe(true); expect(props.showNumbers).toBe(true);
}); });
@@ -164,93 +139,16 @@ describe('<ModelDialog />', () => {
expect(mockedSelect).toHaveBeenCalledTimes(1); expect(mockedSelect).toHaveBeenCalledTimes(1);
}); });
it('calls config.switchModel and onClose when DescriptiveRadioButtonSelect.onSelect is triggered', async () => { it('calls config.setModel and onClose when DescriptiveRadioButtonSelect.onSelect is triggered', () => {
const { props, mockConfig, mockSettings } = renderComponent({}, {}); // Pass empty object for contextValue const { props, mockConfig } = renderComponent({}, {}); // Pass empty object for contextValue
const childOnSelect = mockedSelect.mock.calls[0][0].onSelect; const childOnSelect = mockedSelect.mock.calls[0][0].onSelect;
expect(childOnSelect).toBeDefined(); expect(childOnSelect).toBeDefined();
await childOnSelect(`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`); childOnSelect(MAINLINE_CODER);
expect(mockConfig?.switchModel).toHaveBeenCalledWith( // Assert against the default mock provided by renderComponent
AuthType.QWEN_OAUTH, expect(mockConfig?.setModel).toHaveBeenCalledWith(MAINLINE_CODER);
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); expect(props.onClose).toHaveBeenCalledTimes(1);
}); });
@@ -295,25 +193,17 @@ describe('<ModelDialog />', () => {
it('updates initialIndex when config context changes', () => { it('updates initialIndex when config context changes', () => {
const mockGetModel = vi.fn(() => MAINLINE_CODER); const mockGetModel = vi.fn(() => MAINLINE_CODER);
const mockGetAuthType = vi.fn(() => 'qwen-oauth'); const mockGetAuthType = vi.fn(() => 'qwen-oauth');
const mockSettings = {
isTrusted: true,
user: { settings: {} },
workspace: { settings: {} },
setValue: vi.fn(),
} as unknown as LoadedSettings;
const { rerender } = render( const { rerender } = render(
<SettingsContext.Provider value={mockSettings}> <ConfigContext.Provider
<ConfigContext.Provider value={
value={ {
{ getModel: mockGetModel,
getModel: mockGetModel, getAuthType: mockGetAuthType,
getAuthType: mockGetAuthType, } as unknown as Config
} as unknown as Config }
} >
> <ModelDialog onClose={vi.fn()} />
<ModelDialog onClose={vi.fn()} /> </ConfigContext.Provider>,
</ConfigContext.Provider>
</SettingsContext.Provider>,
); );
expect(mockedSelect.mock.calls[0][0].initialIndex).toBe(0); expect(mockedSelect.mock.calls[0][0].initialIndex).toBe(0);
@@ -325,11 +215,9 @@ describe('<ModelDialog />', () => {
} as unknown as Config; } as unknown as Config;
rerender( rerender(
<SettingsContext.Provider value={mockSettings}> <ConfigContext.Provider value={newMockConfig}>
<ConfigContext.Provider value={newMockConfig}> <ModelDialog onClose={vi.fn()} />
<ModelDialog onClose={vi.fn()} /> </ConfigContext.Provider>,
</ConfigContext.Provider>
</SettingsContext.Provider>,
); );
// Should be called at least twice: initial render + re-render after context change // Should be called at least twice: initial render + re-render after context change

View File

@@ -5,210 +5,52 @@
*/ */
import type React from 'react'; import type React from 'react';
import { useCallback, useContext, useMemo, useState } from 'react'; import { useCallback, useContext, useMemo } from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { import {
AuthType, AuthType,
ModelSlashCommandEvent, ModelSlashCommandEvent,
logModelSlashCommand, logModelSlashCommand,
type ContentGeneratorConfig,
type ContentGeneratorConfigSource,
type ContentGeneratorConfigSources,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import { useKeypress } from '../hooks/useKeypress.js'; import { useKeypress } from '../hooks/useKeypress.js';
import { theme } from '../semantic-colors.js'; import { theme } from '../semantic-colors.js';
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js'; import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
import { ConfigContext } from '../contexts/ConfigContext.js'; import { ConfigContext } from '../contexts/ConfigContext.js';
import { UIStateContext } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { import {
getAvailableModelsForAuthType, getAvailableModelsForAuthType,
MAINLINE_CODER, MAINLINE_CODER,
} from '../models/availableModels.js'; } from '../models/availableModels.js';
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
import { t } from '../../i18n/index.js'; import { t } from '../../i18n/index.js';
interface ModelDialogProps { interface ModelDialogProps {
onClose: () => void; 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 { export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
const config = useContext(ConfigContext); const config = useContext(ConfigContext);
const uiState = useContext(UIStateContext);
const settings = useSettings();
// Local error state for displaying errors within the dialog // Get auth type from config, default to QWEN_OAUTH if not available
const [errorMessage, setErrorMessage] = useState<string | null>(null); const authType = config?.getAuthType() ?? AuthType.QWEN_OAUTH;
const authType = config?.getAuthType(); // Get available models based on auth type
const effectiveConfig = const availableModels = useMemo(
(config?.getContentGeneratorConfig?.() as () => getAvailableModelsForAuthType(authType),
| ContentGeneratorConfig [authType],
| undefined) ?? undefined; );
const sources = readSourcesFromConfig(config);
const availableModelEntries = useMemo(() => {
const allAuthTypes = Object.values(AuthType) as AuthType[];
const modelsByAuthType = allAuthTypes
.map((t) => ({
authType: t,
models: getAvailableModelsForAuthType(t, config ?? undefined),
}))
.filter((x) => x.models.length > 0);
// 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( const MODEL_OPTIONS = useMemo(
() => () =>
availableModelEntries.map(({ authType: t2, model }) => { availableModels.map((model) => ({
const value = `${t2}::${model.id}`; value: model.id,
const title = ( title: model.label,
<Text> description: model.description || '',
<Text bold color={theme.text.accent}> key: model.id,
[{t2}] })),
</Text> [availableModels],
<Text>{` ${model.label}`}</Text>
</Text>
);
const description = model.description || '';
return {
value,
title,
description,
key: value,
};
}),
[availableModelEntries],
); );
const preferredModelId = config?.getModel() || MAINLINE_CODER; // Determine the Preferred Model (read once when the dialog opens).
const preferredKey = authType ? `${authType}::${preferredModelId}` : ''; const preferredModel = config?.getModel() || MAINLINE_CODER;
useKeypress( useKeypress(
(key) => { (key) => {
@@ -219,83 +61,25 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
{ isActive: true }, { isActive: true },
); );
const initialIndex = useMemo(() => { // Calculate the initial index based on the preferred model.
const index = MODEL_OPTIONS.findIndex( const initialIndex = useMemo(
(option) => option.value === preferredKey, () => MODEL_OPTIONS.findIndex((option) => option.value === preferredModel),
); [MODEL_OPTIONS, preferredModel],
return index === -1 ? 0 : index; );
}, [MODEL_OPTIONS, preferredKey]);
// Handle selection internally (Autonomous Dialog).
const handleSelect = useCallback( const handleSelect = useCallback(
async (selected: string) => { (model: 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) { if (config) {
try { config.setModel(model);
await config.switchModel( const event = new ModelSlashCommandEvent(model);
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); 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(); onClose();
}, },
[authType, config, onClose, settings, uiState, setErrorMessage], [config, onClose],
); );
const hasModels = MODEL_OPTIONS.length > 0;
return ( return (
<Box <Box
borderStyle="round" borderStyle="round"
@@ -305,73 +89,14 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
width="100%" width="100%"
> >
<Text bold>{t('Select Model')}</Text> <Text bold>{t('Select Model')}</Text>
<Box marginTop={1}>
<Box marginTop={1} flexDirection="column"> <DescriptiveRadioButtonSelect
<Text color={theme.text.secondary}> items={MODEL_OPTIONS}
{t('Current (effective) configuration')} onSelect={handleSelect}
</Text> initialIndex={initialIndex}
<Box flexDirection="column" marginTop={1}> showNumbers={true}
<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> </Box>
{!hasModels ? (
<Box marginTop={1} flexDirection="column">
<Text color={theme.status.warning}>
{t(
'No models available for the current authentication type ({{authType}}).',
{
authType: authType ? String(authType) : t('(none)'),
},
)}
</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"> <Box marginTop={1} flexDirection="column">
<Text color={theme.text.secondary}>{t('(Press Esc to close)')}</Text> <Text color={theme.text.secondary}>{t('(Press Esc to close)')}</Text>
</Box> </Box>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,205 +0,0 @@
/**
* @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,12 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { import { AuthType, DEFAULT_QWEN_MODEL } from '@qwen-code/qwen-code-core';
AuthType,
DEFAULT_QWEN_MODEL,
type Config,
type AvailableModel as CoreAvailableModel,
} from '@qwen-code/qwen-code-core';
import { t } from '../../i18n/index.js'; import { t } from '../../i18n/index.js';
export type AvailableModel = { export type AvailableModel = {
@@ -62,78 +57,20 @@ export function getFilteredQwenModels(
*/ */
export function getOpenAIAvailableModelFromEnv(): AvailableModel | null { export function getOpenAIAvailableModelFromEnv(): AvailableModel | null {
const id = process.env['OPENAI_MODEL']?.trim(); const id = process.env['OPENAI_MODEL']?.trim();
return id return id ? { id, label: id } : null;
? {
id,
label: id,
get description() {
return t('Configured via OPENAI_MODEL environment variable');
},
}
: null;
} }
export function getAnthropicAvailableModelFromEnv(): AvailableModel | null { export function getAnthropicAvailableModelFromEnv(): AvailableModel | null {
const id = process.env['ANTHROPIC_MODEL']?.trim(); const id = process.env['ANTHROPIC_MODEL']?.trim();
return id return id ? { id, label: id } : null;
? {
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( export function getAvailableModelsForAuthType(
authType: AuthType, authType: AuthType,
config?: Config,
): AvailableModel[] { ): 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) { switch (authType) {
case AuthType.QWEN_OAUTH:
return AVAILABLE_MODELS_QWEN;
case AuthType.USE_OPENAI: { case AuthType.USE_OPENAI: {
const openAIModel = getOpenAIAvailableModelFromEnv(); const openAIModel = getOpenAIAvailableModelFromEnv();
return openAIModel ? [openAIModel] : []; return openAIModel ? [openAIModel] : [];
@@ -143,10 +80,13 @@ export function getAvailableModelsForAuthType(
return anthropicModel ? [anthropicModel] : []; return anthropicModel ? [anthropicModel] : [];
} }
default: default:
// For other auth types, return empty array for now
// This can be expanded later according to the design doc
return []; return [];
} }
} }
/**
/** /**
* Hard code the default vision model as a string literal, * Hard code the default vision model as a string literal,
* until our coding model supports multimodal. * until our coding model supports multimodal.

View File

@@ -6,11 +6,7 @@
import { vi, type Mock, type MockInstance } from 'vitest'; import { vi, type Mock, type MockInstance } from 'vitest';
import type { Config } from '@qwen-code/qwen-code-core'; import type { Config } from '@qwen-code/qwen-code-core';
import { import { OutputFormat, FatalInputError } from '@qwen-code/qwen-code-core';
OutputFormat,
FatalInputError,
ToolErrorType,
} from '@qwen-code/qwen-code-core';
import { import {
getErrorMessage, getErrorMessage,
handleError, handleError,
@@ -69,7 +65,6 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
describe('errors', () => { describe('errors', () => {
let mockConfig: Config; let mockConfig: Config;
let processExitSpy: MockInstance; let processExitSpy: MockInstance;
let processStderrWriteSpy: MockInstance;
let consoleErrorSpy: MockInstance; let consoleErrorSpy: MockInstance;
beforeEach(() => { beforeEach(() => {
@@ -79,11 +74,6 @@ describe('errors', () => {
// Mock console.error // Mock console.error
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); 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 // Mock process.exit to throw instead of actually exiting
processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new Error(`process.exit called with code: ${code}`); throw new Error(`process.exit called with code: ${code}`);
@@ -94,13 +84,11 @@ describe('errors', () => {
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT), getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'test' }), getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'test' }),
getDebugMode: vi.fn().mockReturnValue(true), getDebugMode: vi.fn().mockReturnValue(true),
isInteractive: vi.fn().mockReturnValue(false),
} as unknown as Config; } as unknown as Config;
}); });
afterEach(() => { afterEach(() => {
consoleErrorSpy.mockRestore(); consoleErrorSpy.mockRestore();
processStderrWriteSpy.mockRestore();
processExitSpy.mockRestore(); processExitSpy.mockRestore();
}); });
@@ -444,87 +432,6 @@ describe('errors', () => {
expect(processExitSpy).not.toHaveBeenCalled(); 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', () => { describe('handleCancellationError', () => {

View File

@@ -11,7 +11,6 @@ import {
parseAndFormatApiError, parseAndFormatApiError,
FatalTurnLimitedError, FatalTurnLimitedError,
FatalCancellationError, FatalCancellationError,
ToolErrorType,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
export function getErrorMessage(error: unknown): string { export function getErrorMessage(error: unknown): string {
@@ -103,24 +102,10 @@ export function handleToolError(
toolName: string, toolName: string,
toolError: Error, toolError: Error,
config: Config, config: Config,
errorCode?: string | number, _errorCode?: string | number,
resultDisplay?: string, resultDisplay?: string,
): void { ): void {
// Check if this is a permission denied error in non-interactive mode // Always just log to stderr; JSON/streaming formatting happens in the tool_result block elsewhere
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()) { if (config.getDebugMode()) {
console.error( console.error(
`Error executing tool ${toolName}: ${resultDisplay || toolError.message}`, `Error executing tool ${toolName}: ${resultDisplay || toolError.message}`,

View File

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

View File

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

View File

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

View File

@@ -5,42 +5,69 @@
*/ */
import type { Config } from '@qwen-code/qwen-code-core'; import type { Config } from '@qwen-code/qwen-code-core';
import { OutputFormat } from '@qwen-code/qwen-code-core'; import { AuthType, OutputFormat } from '@qwen-code/qwen-code-core';
import { USER_SETTINGS_PATH } from './config/settings.js';
import { validateAuthMethod } from './config/auth.js'; import { validateAuthMethod } from './config/auth.js';
import { type LoadedSettings } from './config/settings.js'; import { type LoadedSettings } from './config/settings.js';
import { JsonOutputAdapter } from './nonInteractive/io/JsonOutputAdapter.js'; import { JsonOutputAdapter } from './nonInteractive/io/JsonOutputAdapter.js';
import { StreamJsonOutputAdapter } from './nonInteractive/io/StreamJsonOutputAdapter.js'; import { StreamJsonOutputAdapter } from './nonInteractive/io/StreamJsonOutputAdapter.js';
import { runExitCleanup } from './utils/cleanup.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( export async function validateNonInteractiveAuth(
configuredAuthType: AuthType | undefined,
useExternalAuth: boolean | undefined, useExternalAuth: boolean | undefined,
nonInteractiveConfig: Config, nonInteractiveConfig: Config,
settings: LoadedSettings, settings: LoadedSettings,
): Promise<Config> { ): Promise<Config> {
try { try {
// Get the actual authType from config which has already resolved CLI args, env vars, and settings
const authType = nonInteractiveConfig.modelsConfig.getCurrentAuthType();
if (!authType) {
throw new Error(
'No auth type is selected. Please configure an auth type (e.g. via settings or `--auth-type`) before running in non-interactive mode.',
);
}
const resolvedAuthType: NonNullable<typeof authType> = authType;
const enforcedType = settings.merged.security?.auth?.enforcedType; const enforcedType = settings.merged.security?.auth?.enforcedType;
if (enforcedType && enforcedType !== resolvedAuthType) { if (enforcedType) {
const message = `The configured auth type is ${enforcedType}, but the current auth type is ${resolvedAuthType}. Please re-authenticate with the correct type.`; 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`;
throw new Error(message); throw new Error(message);
} }
const authType: AuthType = effectiveAuthType as AuthType;
if (!useExternalAuth) { if (!useExternalAuth) {
const err = validateAuthMethod(resolvedAuthType, nonInteractiveConfig); const err = validateAuthMethod(String(authType));
if (err != null) { if (err != null) {
throw new Error(err); throw new Error(err);
} }
} }
await nonInteractiveConfig.refreshAuth(resolvedAuthType); await nonInteractiveConfig.refreshAuth(authType);
return nonInteractiveConfig; return nonInteractiveConfig;
} catch (error) { } catch (error) {
const outputFormat = nonInteractiveConfig.getOutputFormat(); const outputFormat = nonInteractiveConfig.getOutputFormat();

View File

@@ -8,8 +8,12 @@ export * from './src/index.js';
export { Storage } from './src/config/storage.js'; export { Storage } from './src/config/storage.js';
export { export {
DEFAULT_QWEN_MODEL, DEFAULT_QWEN_MODEL,
DEFAULT_QWEN_FLASH_MODEL,
DEFAULT_QWEN_EMBEDDING_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'; } from './src/config/models.js';
export { export {
serializeTerminalToObject, serializeTerminalToObject,

View File

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

View File

@@ -15,16 +15,10 @@ import {
DEFAULT_OTLP_ENDPOINT, DEFAULT_OTLP_ENDPOINT,
QwenLogger, QwenLogger,
} from '../telemetry/index.js'; } from '../telemetry/index.js';
import type { import type { ContentGeneratorConfig } from '../core/contentGenerator.js';
ContentGenerator,
ContentGeneratorConfig,
} from '../core/contentGenerator.js';
import { DEFAULT_DASHSCOPE_BASE_URL } from '../core/openaiContentGenerator/constants.js';
import { import {
AuthType, AuthType,
createContentGenerator,
createContentGeneratorConfig, createContentGeneratorConfig,
resolveContentGeneratorConfigWithSources,
} from '../core/contentGenerator.js'; } from '../core/contentGenerator.js';
import { GeminiClient } from '../core/client.js'; import { GeminiClient } from '../core/client.js';
import { GitService } from '../services/gitService.js'; import { GitService } from '../services/gitService.js';
@@ -214,19 +208,6 @@ describe('Server Config (config.ts)', () => {
vi.spyOn(QwenLogger.prototype, 'logStartSessionEvent').mockImplementation( vi.spyOn(QwenLogger.prototype, 'logStartSessionEvent').mockImplementation(
async () => undefined, 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', () => { describe('initialize', () => {
@@ -274,28 +255,31 @@ describe('Server Config (config.ts)', () => {
const mockContentConfig = { const mockContentConfig = {
apiKey: 'test-key', apiKey: 'test-key',
model: 'qwen3-coder-plus', model: 'qwen3-coder-plus',
authType,
}; };
vi.mocked(resolveContentGeneratorConfigWithSources).mockReturnValue({ vi.mocked(createContentGeneratorConfig).mockReturnValue(
config: mockContentConfig as ContentGeneratorConfig, mockContentConfig,
sources: {}, );
});
// Set fallback mode to true to ensure it gets reset
config.setFallbackMode(true);
expect(config.isInFallbackMode()).toBe(true);
await config.refreshAuth(authType); await config.refreshAuth(authType);
expect(resolveContentGeneratorConfigWithSources).toHaveBeenCalledWith( expect(createContentGeneratorConfig).toHaveBeenCalledWith(
config, config,
authType, authType,
expect.objectContaining({ {
model: MODEL, model: MODEL,
}), baseUrl: undefined,
expect.anything(), },
expect.anything(),
); );
// Verify that contentGeneratorConfig is updated // Verify that contentGeneratorConfig is updated
expect(config.getContentGeneratorConfig()).toEqual(mockContentConfig); expect(config.getContentGeneratorConfig()).toEqual(mockContentConfig);
expect(GeminiClient).toHaveBeenCalledWith(config); 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 () => { it('should not strip thoughts when switching from Vertex to GenAI', async () => {
@@ -316,129 +300,6 @@ 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', () => { it('Config constructor should store userMemory correctly', () => {
const config = new Config(baseParams); const config = new Config(baseParams);

View File

@@ -16,8 +16,9 @@ import { ProxyAgent, setGlobalDispatcher } from 'undici';
import type { import type {
ContentGenerator, ContentGenerator,
ContentGeneratorConfig, ContentGeneratorConfig,
AuthType,
} from '../core/contentGenerator.js'; } from '../core/contentGenerator.js';
import type { ContentGeneratorConfigSources } from '../core/contentGenerator.js'; import type { FallbackModelHandler } from '../fallback/types.js';
import type { MCPOAuthConfig } from '../mcp/oauth-provider.js'; import type { MCPOAuthConfig } from '../mcp/oauth-provider.js';
import type { ShellExecutionConfig } from '../services/shellExecutionService.js'; import type { ShellExecutionConfig } from '../services/shellExecutionService.js';
import type { AnyToolInvocation } from '../tools/tools.js'; import type { AnyToolInvocation } from '../tools/tools.js';
@@ -26,9 +27,8 @@ import type { AnyToolInvocation } from '../tools/tools.js';
import { BaseLlmClient } from '../core/baseLlmClient.js'; import { BaseLlmClient } from '../core/baseLlmClient.js';
import { GeminiClient } from '../core/client.js'; import { GeminiClient } from '../core/client.js';
import { import {
AuthType,
createContentGenerator, createContentGenerator,
resolveContentGeneratorConfigWithSources, createContentGeneratorConfig,
} from '../core/contentGenerator.js'; } from '../core/contentGenerator.js';
import { tokenLimit } from '../core/tokenLimits.js'; import { tokenLimit } from '../core/tokenLimits.js';
@@ -94,7 +94,7 @@ import {
DEFAULT_FILE_FILTERING_OPTIONS, DEFAULT_FILE_FILTERING_OPTIONS,
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
} from './constants.js'; } from './constants.js';
import { DEFAULT_QWEN_EMBEDDING_MODEL } from './models.js'; import { DEFAULT_QWEN_EMBEDDING_MODEL, DEFAULT_QWEN_MODEL } from './models.js';
import { Storage } from './storage.js'; import { Storage } from './storage.js';
import { ChatRecordingService } from '../services/chatRecordingService.js'; import { ChatRecordingService } from '../services/chatRecordingService.js';
import { import {
@@ -103,12 +103,6 @@ import {
} from '../services/sessionService.js'; } from '../services/sessionService.js';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import {
ModelsConfig,
type ModelProvidersConfig,
type AvailableModel,
} from '../models/index.js';
// Re-export types // Re-export types
export type { AnyToolInvocation, FileFilteringOptions, MCPOAuthConfig }; export type { AnyToolInvocation, FileFilteringOptions, MCPOAuthConfig };
export { export {
@@ -324,11 +318,6 @@ export interface ConfigParameters {
ideMode?: boolean; ideMode?: boolean;
authType?: AuthType; authType?: AuthType;
generationConfig?: Partial<ContentGeneratorConfig>; 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; cliVersion?: string;
loadMemoryFromIncludeDirectories?: boolean; loadMemoryFromIncludeDirectories?: boolean;
chatRecording?: boolean; chatRecording?: boolean;
@@ -364,8 +353,6 @@ export interface ConfigParameters {
sdkMode?: boolean; sdkMode?: boolean;
sessionSubagents?: SubagentConfig[]; sessionSubagents?: SubagentConfig[];
channel?: string; channel?: string;
/** Model providers configuration grouped by authType */
modelProvidersConfig?: ModelProvidersConfig;
} }
function normalizeConfigOutputFormat( function normalizeConfigOutputFormat(
@@ -407,12 +394,9 @@ export class Config {
private skillManager!: SkillManager; private skillManager!: SkillManager;
private fileSystemService: FileSystemService; private fileSystemService: FileSystemService;
private contentGeneratorConfig!: ContentGeneratorConfig; private contentGeneratorConfig!: ContentGeneratorConfig;
private contentGeneratorConfigSources: ContentGeneratorConfigSources = {};
private contentGenerator!: ContentGenerator; private contentGenerator!: ContentGenerator;
private _generationConfig: Partial<ContentGeneratorConfig>;
private readonly embeddingModel: string; private readonly embeddingModel: string;
private _modelsConfig!: ModelsConfig;
private readonly modelProvidersConfig?: ModelProvidersConfig;
private readonly sandbox: SandboxConfig | undefined; private readonly sandbox: SandboxConfig | undefined;
private readonly targetDir: string; private readonly targetDir: string;
private workspaceContext: WorkspaceContext; private workspaceContext: WorkspaceContext;
@@ -461,6 +445,7 @@ export class Config {
private readonly folderTrust: boolean; private readonly folderTrust: boolean;
private ideMode: boolean; private ideMode: boolean;
private inFallbackMode = false;
private readonly maxSessionTurns: number; private readonly maxSessionTurns: number;
private readonly sessionTokenLimit: number; private readonly sessionTokenLimit: number;
private readonly listExtensions: boolean; private readonly listExtensions: boolean;
@@ -469,6 +454,8 @@ export class Config {
name: string; name: string;
extensionName: string; extensionName: string;
}>; }>;
fallbackModelHandler?: FallbackModelHandler;
private quotaErrorOccurred: boolean = false;
private readonly summarizeToolOutput: private readonly summarizeToolOutput:
| Record<string, SummarizeToolOutputSettings> | Record<string, SummarizeToolOutputSettings>
| undefined; | undefined;
@@ -583,7 +570,13 @@ export class Config {
this.folderTrustFeature = params.folderTrustFeature ?? false; this.folderTrustFeature = params.folderTrustFeature ?? false;
this.folderTrust = params.folderTrust ?? false; this.folderTrust = params.folderTrust ?? false;
this.ideMode = params.ideMode ?? false; this.ideMode = params.ideMode ?? false;
this.modelProvidersConfig = params.modelProvidersConfig; this._generationConfig = {
model: params.model,
...(params.generationConfig || {}),
baseUrl: params.generationConfig?.baseUrl,
};
this.contentGeneratorConfig = this
._generationConfig as ContentGeneratorConfig;
this.cliVersion = params.cliVersion; this.cliVersion = params.cliVersion;
this.chatRecordingEnabled = params.chatRecording ?? true; this.chatRecordingEnabled = params.chatRecording ?? true;
@@ -626,22 +619,6 @@ export class Config {
setGeminiMdFilename(params.contextFileName); 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) { if (this.telemetrySettings.enabled) {
initializeTelemetry(this); initializeTelemetry(this);
} }
@@ -692,61 +669,45 @@ export class Config {
return this.contentGenerator; 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. * Updates the credentials in the generation config.
* Exclusive for `OpenAIKeyPrompt` to update credentials via `/auth` * This is needed when credentials are set after Config construction.
* Delegates to ModelsConfig.
*/ */
updateCredentials(credentials: { updateCredentials(credentials: {
apiKey?: string; apiKey?: string;
baseUrl?: string; baseUrl?: string;
model?: string; model?: string;
}): void { }): void {
this._modelsConfig.updateCredentials(credentials); if (credentials.apiKey) {
this._generationConfig.apiKey = credentials.apiKey;
}
if (credentials.baseUrl) {
this._generationConfig.baseUrl = credentials.baseUrl;
}
if (credentials.model) {
this._generationConfig.model = credentials.model;
}
} }
/**
* Refresh authentication and rebuild ContentGenerator.
*/
async refreshAuth(authMethod: AuthType, isInitialAuth?: boolean) { async refreshAuth(authMethod: AuthType, isInitialAuth?: boolean) {
// Sync modelsConfig state for this auth refresh const newContentGeneratorConfig = createContentGeneratorConfig(
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, this,
authMethod, authMethod,
this._modelsConfig.getGenerationConfig(), this._generationConfig,
this._modelsConfig.getGenerationConfigSources(),
{
strictModelProvider:
this._modelsConfig.isStrictModelProviderSelection(),
},
); );
const newContentGeneratorConfig = config;
this.contentGenerator = await createContentGenerator( this.contentGenerator = await createContentGenerator(
newContentGeneratorConfig, newContentGeneratorConfig,
this, this,
requireCached ? true : isInitialAuth, isInitialAuth,
); );
// Only assign to instance properties after successful initialization // Only assign to instance properties after successful initialization
this.contentGeneratorConfig = newContentGeneratorConfig; this.contentGeneratorConfig = newContentGeneratorConfig;
this.contentGeneratorConfigSources = sources;
// Initialize BaseLlmClient now that the ContentGenerator is available // Initialize BaseLlmClient now that the ContentGenerator is available
this.baseLlmClient = new BaseLlmClient(this.contentGenerator, this); this.baseLlmClient = new BaseLlmClient(this.contentGenerator, this);
// Reset the session flag since we're explicitly changing auth and using default model
this.inFallbackMode = false;
} }
/** /**
@@ -806,125 +767,31 @@ export class Config {
return this.contentGeneratorConfig; return this.contentGeneratorConfig;
} }
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 { getModel(): string {
return this.contentGeneratorConfig?.model || this._modelsConfig.getModel(); return this.contentGeneratorConfig?.model || DEFAULT_QWEN_MODEL;
} }
/**
* Set model programmatically (e.g., VLM auto-switch, fallback).
* Delegates to ModelsConfig.
*/
async setModel( async setModel(
newModel: string, newModel: string,
metadata?: { reason?: string; context?: string }, _metadata?: { reason?: string; context?: string },
): Promise<void> { ): Promise<void> {
await this._modelsConfig.setModel(newModel, metadata);
// Also update contentGeneratorConfig for hot-update compatibility
if (this.contentGeneratorConfig) { if (this.contentGeneratorConfig) {
this.contentGeneratorConfig.model = newModel; this.contentGeneratorConfig.model = newModel;
} }
// TODO: Log _metadata for telemetry if needed
// This _metadata can be used for tracking model switches (reason, context)
} }
/** isInFallbackMode(): boolean {
* Handle model change from ModelsConfig. return this.inFallbackMode;
* 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 {
* Get available models for the current authType. this.inFallbackMode = active;
* Delegates to ModelsConfig.
*/
getAvailableModels(): AvailableModel[] {
return this._modelsConfig.getAvailableModels();
} }
/** setFallbackModelHandler(handler: FallbackModelHandler): void {
* Get available models for a specific authType. this.fallbackModelHandler = handler;
* 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 { getMaxSessionTurns(): number {
@@ -935,6 +802,14 @@ export class Config {
return this.sessionTokenLimit; return this.sessionTokenLimit;
} }
setQuotaErrorOccurred(value: boolean): void {
this.quotaErrorOccurred = value;
}
getQuotaErrorOccurred(): boolean {
return this.quotaErrorOccurred;
}
getEmbeddingModel(): string { getEmbeddingModel(): string {
return this.embeddingModel; return this.embeddingModel;
} }
@@ -1276,7 +1151,7 @@ export class Config {
} }
getAuthType(): AuthType | undefined { getAuthType(): AuthType | undefined {
return this.contentGeneratorConfig?.authType; return this.contentGeneratorConfig.authType;
} }
getCliVersion(): string | undefined { getCliVersion(): string | undefined {

View File

@@ -0,0 +1,99 @@
/**
* @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

@@ -0,0 +1,83 @@
/**
* @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,3 +7,46 @@
export const DEFAULT_QWEN_MODEL = 'coder-model'; export const DEFAULT_QWEN_MODEL = 'coder-model';
export const DEFAULT_QWEN_FLASH_MODEL = 'coder-model'; export const DEFAULT_QWEN_FLASH_MODEL = 'coder-model';
export const DEFAULT_QWEN_EMBEDDING_MODEL = 'text-embedding-v4'; 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, type ChatCompressionInfo,
} from './turn.js'; } from './turn.js';
import { getCoreSystemPrompt } from './prompts.js'; import { getCoreSystemPrompt } from './prompts.js';
import { DEFAULT_QWEN_FLASH_MODEL } from '../config/models.js'; import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { setSimulate429 } from '../utils/testUtils.js'; import { setSimulate429 } from '../utils/testUtils.js';
import { tokenLimit } from './tokenLimits.js'; import { tokenLimit } from './tokenLimits.js';
@@ -302,6 +302,8 @@ describe('Gemini Client (client.ts)', () => {
getFileService: vi.fn().mockReturnValue(fileService), getFileService: vi.fn().mockReturnValue(fileService),
getMaxSessionTurns: vi.fn().mockReturnValue(0), getMaxSessionTurns: vi.fn().mockReturnValue(0),
getSessionTokenLimit: vi.fn().mockReturnValue(32000), getSessionTokenLimit: vi.fn().mockReturnValue(32000),
getQuotaErrorOccurred: vi.fn().mockReturnValue(false),
setQuotaErrorOccurred: vi.fn(),
getNoBrowser: vi.fn().mockReturnValue(false), getNoBrowser: vi.fn().mockReturnValue(false),
getUsageStatisticsEnabled: vi.fn().mockReturnValue(true), getUsageStatisticsEnabled: vi.fn().mockReturnValue(true),
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
@@ -315,6 +317,8 @@ describe('Gemini Client (client.ts)', () => {
getModelRouterService: vi.fn().mockReturnValue({ getModelRouterService: vi.fn().mockReturnValue({
route: vi.fn().mockResolvedValue({ model: 'default-routed-model' }), route: vi.fn().mockResolvedValue({ model: 'default-routed-model' }),
}), }),
isInFallbackMode: vi.fn().mockReturnValue(false),
setFallbackMode: vi.fn(),
getCliVersion: vi.fn().mockReturnValue('1.0.0'), getCliVersion: vi.fn().mockReturnValue('1.0.0'),
getChatCompression: vi.fn().mockReturnValue(undefined), getChatCompression: vi.fn().mockReturnValue(undefined),
getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false), getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false),
@@ -1058,18 +1062,26 @@ describe('Gemini Client (client.ts)', () => {
// Assert // Assert
expect(ideContextStore.get).toHaveBeenCalled(); expect(ideContextStore.get).toHaveBeenCalled();
const expectedContext = `Here is the user's editor context. This is for your information only. const expectedContext = `
Active file: Here is the user's editor context as a JSON object. This is for your information only.
Path: /path/to/active/file.ts \`\`\`json
Cursor: line 5, character 10 ${JSON.stringify(
Selected text: {
activeFile: {
path: '/path/to/active/file.ts',
cursor: {
line: 5,
character: 10,
},
selectedText: 'hello',
},
otherOpenFiles: ['/path/to/recent/file1.ts', '/path/to/recent/file2.ts'],
},
null,
2,
)}
\`\`\` \`\`\`
hello `.trim();
\`\`\`
Other open files:
- /path/to/recent/file1.ts
- /path/to/recent/file2.ts`;
const expectedRequest = [{ text: expectedContext }]; const expectedRequest = [{ text: expectedContext }];
expect(mockChat.addHistory).toHaveBeenCalledWith({ expect(mockChat.addHistory).toHaveBeenCalledWith({
role: 'user', role: 'user',
@@ -1169,14 +1181,25 @@ Other open files:
// Assert // Assert
expect(ideContextStore.get).toHaveBeenCalled(); expect(ideContextStore.get).toHaveBeenCalled();
const expectedContext = `Here is the user's editor context. This is for your information only. const expectedContext = `
Active file: Here is the user's editor context as a JSON object. This is for your information only.
Path: /path/to/active/file.ts \`\`\`json
Cursor: line 5, character 10 ${JSON.stringify(
Selected text: {
activeFile: {
path: '/path/to/active/file.ts',
cursor: {
line: 5,
character: 10,
},
selectedText: 'hello',
},
},
null,
2,
)}
\`\`\` \`\`\`
hello `.trim();
\`\`\``;
const expectedRequest = [{ text: expectedContext }]; const expectedRequest = [{ text: expectedContext }];
expect(mockChat.addHistory).toHaveBeenCalledWith({ expect(mockChat.addHistory).toHaveBeenCalledWith({
role: 'user', role: 'user',
@@ -1235,10 +1258,18 @@ hello
// Assert // Assert
expect(ideContextStore.get).toHaveBeenCalled(); expect(ideContextStore.get).toHaveBeenCalled();
const expectedContext = `Here is the user's editor context. This is for your information only. const expectedContext = `
Other open files: Here is the user's editor context as a JSON object. This is for your information only.
- /path/to/recent/file1.ts \`\`\`json
- /path/to/recent/file2.ts`; ${JSON.stringify(
{
otherOpenFiles: ['/path/to/recent/file1.ts', '/path/to/recent/file2.ts'],
},
null,
2,
)}
\`\`\`
`.trim();
const expectedRequest = [{ text: expectedContext }]; const expectedRequest = [{ text: expectedContext }];
expect(mockChat.addHistory).toHaveBeenCalledWith({ expect(mockChat.addHistory).toHaveBeenCalledWith({
role: 'user', role: 'user',
@@ -1755,9 +1786,11 @@ Other open files:
// Also verify it's the full context, not a delta. // Also verify it's the full context, not a delta.
const call = mockChat.addHistory.mock.calls[0][0]; const call = mockChat.addHistory.mock.calls[0][0];
const contextText = call.parts[0].text; const contextText = call.parts[0].text;
// Verify it contains the active file information in plain text format const contextJson = JSON.parse(
expect(contextText).toContain('Active file:'); contextText.match(/```json\n(.*)\n```/s)![1],
expect(contextText).toContain('Path: /path/to/active/file.ts'); );
expect(contextJson).toHaveProperty('activeFile');
expect(contextJson.activeFile.path).toBe('/path/to/active/file.ts');
}); });
}); });
@@ -1960,7 +1993,7 @@ Other open files:
); );
expect(contextCall).toBeDefined(); expect(contextCall).toBeDefined();
expect(JSON.stringify(contextCall![0])).toContain( expect(JSON.stringify(contextCall![0])).toContain(
"Here is the user's editor context.", "Here is the user's editor context as a JSON object",
); );
// Check that the sent context is the new one (fileB.ts) // Check that the sent context is the new one (fileB.ts)
expect(JSON.stringify(contextCall![0])).toContain('fileB.ts'); expect(JSON.stringify(contextCall![0])).toContain('fileB.ts');
@@ -1996,7 +2029,9 @@ Other open files:
// Assert: Full context for fileA.ts was sent and stored. // Assert: Full context for fileA.ts was sent and stored.
const initialCall = vi.mocked(mockChat.addHistory!).mock.calls[0][0]; const initialCall = vi.mocked(mockChat.addHistory!).mock.calls[0][0];
expect(JSON.stringify(initialCall)).toContain("user's editor context."); expect(JSON.stringify(initialCall)).toContain(
"user's editor context as a JSON object",
);
expect(JSON.stringify(initialCall)).toContain('fileA.ts'); expect(JSON.stringify(initialCall)).toContain('fileA.ts');
// This implicitly tests that `lastSentIdeContext` is now set internally by the client. // This implicitly tests that `lastSentIdeContext` is now set internally by the client.
vi.mocked(mockChat.addHistory!).mockClear(); vi.mocked(mockChat.addHistory!).mockClear();
@@ -2094,9 +2129,9 @@ Other open files:
const finalCall = vi.mocked(mockChat.addHistory!).mock.calls[0][0]; const finalCall = vi.mocked(mockChat.addHistory!).mock.calls[0][0];
expect(JSON.stringify(finalCall)).toContain('summary of changes'); expect(JSON.stringify(finalCall)).toContain('summary of changes');
// The delta should reflect fileA being closed and fileC being opened. // The delta should reflect fileA being closed and fileC being opened.
expect(JSON.stringify(finalCall)).toContain('Files closed'); expect(JSON.stringify(finalCall)).toContain('filesClosed');
expect(JSON.stringify(finalCall)).toContain('fileA.ts'); expect(JSON.stringify(finalCall)).toContain('fileA.ts');
expect(JSON.stringify(finalCall)).toContain('Active file changed'); expect(JSON.stringify(finalCall)).toContain('activeFileChanged');
expect(JSON.stringify(finalCall)).toContain('fileC.ts'); expect(JSON.stringify(finalCall)).toContain('fileC.ts');
}); });
}); });
@@ -2227,12 +2262,12 @@ Other open files:
contents, contents,
generationConfig, generationConfig,
abortSignal, abortSignal,
DEFAULT_QWEN_FLASH_MODEL, DEFAULT_GEMINI_FLASH_MODEL,
); );
expect(mockContentGenerator.generateContent).toHaveBeenCalledWith( expect(mockContentGenerator.generateContent).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
model: DEFAULT_QWEN_FLASH_MODEL, model: DEFAULT_GEMINI_FLASH_MODEL,
config: expect.objectContaining({ config: expect.objectContaining({
abortSignal, abortSignal,
systemInstruction: getCoreSystemPrompt(''), systemInstruction: getCoreSystemPrompt(''),
@@ -2255,7 +2290,7 @@ Other open files:
contents, contents,
{}, {},
new AbortController().signal, new AbortController().signal,
DEFAULT_QWEN_FLASH_MODEL, DEFAULT_GEMINI_FLASH_MODEL,
); );
expect(mockContentGenerator.generateContent).not.toHaveBeenCalledWith({ expect(mockContentGenerator.generateContent).not.toHaveBeenCalledWith({
@@ -2265,7 +2300,7 @@ Other open files:
}); });
expect(mockContentGenerator.generateContent).toHaveBeenCalledWith( expect(mockContentGenerator.generateContent).toHaveBeenCalledWith(
{ {
model: DEFAULT_QWEN_FLASH_MODEL, model: DEFAULT_GEMINI_FLASH_MODEL,
config: expect.any(Object), config: expect.any(Object),
contents, contents,
}, },
@@ -2273,7 +2308,28 @@ Other open files:
); );
}); });
// Note: there is currently no "fallback mode" model routing; the model used it('should use the Flash model when fallback mode is active', async () => {
// is always the one explicitly requested by the caller. 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',
);
});
}); });
}); });

View File

@@ -15,6 +15,7 @@ import type {
// Config // Config
import { ApprovalMode, type Config } from '../config/config.js'; import { ApprovalMode, type Config } from '../config/config.js';
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
// Core modules // Core modules
import type { ContentGenerator } from './contentGenerator.js'; import type { ContentGenerator } from './contentGenerator.js';
@@ -218,48 +219,42 @@ export class GeminiClient {
} }
if (forceFullContext || !this.lastSentIdeContext) { if (forceFullContext || !this.lastSentIdeContext) {
// Send full context as plain text // Send full context as JSON
const openFiles = currentIdeContext.workspaceState?.openFiles || []; const openFiles = currentIdeContext.workspaceState?.openFiles || [];
const activeFile = openFiles.find((f) => f.isActive); const activeFile = openFiles.find((f) => f.isActive);
const otherOpenFiles = openFiles const otherOpenFiles = openFiles
.filter((f) => !f.isActive) .filter((f) => !f.isActive)
.map((f) => f.path); .map((f) => f.path);
const contextLines: string[] = []; const contextData: Record<string, unknown> = {};
if (activeFile) { if (activeFile) {
contextLines.push('Active file:'); contextData['activeFile'] = {
contextLines.push(` Path: ${activeFile.path}`); path: activeFile.path,
if (activeFile.cursor) { cursor: activeFile.cursor
contextLines.push( ? {
` Cursor: line ${activeFile.cursor.line}, character ${activeFile.cursor.character}`, line: activeFile.cursor.line,
); character: activeFile.cursor.character,
} }
if (activeFile.selectedText) { : undefined,
contextLines.push(' Selected text:'); selectedText: activeFile.selectedText || undefined,
contextLines.push('```'); };
contextLines.push(activeFile.selectedText);
contextLines.push('```');
}
} }
if (otherOpenFiles.length > 0) { if (otherOpenFiles.length > 0) {
if (contextLines.length > 0) { contextData['otherOpenFiles'] = otherOpenFiles;
contextLines.push('');
}
contextLines.push('Other open files:');
for (const filePath of otherOpenFiles) {
contextLines.push(` - ${filePath}`);
}
} }
if (contextLines.length === 0) { if (Object.keys(contextData).length === 0) {
return { contextParts: [], newIdeContext: currentIdeContext }; return { contextParts: [], newIdeContext: currentIdeContext };
} }
const jsonString = JSON.stringify(contextData, null, 2);
const contextParts = [ const contextParts = [
"Here is the user's editor context. This is for your information only.", "Here is the user's editor context as a JSON object. This is for your information only.",
contextLines.join('\n'), '```json',
jsonString,
'```',
]; ];
if (this.config.getDebugMode()) { if (this.config.getDebugMode()) {
@@ -270,8 +265,9 @@ export class GeminiClient {
newIdeContext: currentIdeContext, newIdeContext: currentIdeContext,
}; };
} else { } else {
// Calculate and send delta as plain text // Calculate and send delta as JSON
const changeLines: string[] = []; const delta: Record<string, unknown> = {};
const changes: Record<string, unknown> = {};
const lastFiles = new Map( const lastFiles = new Map(
(this.lastSentIdeContext.workspaceState?.openFiles || []).map( (this.lastSentIdeContext.workspaceState?.openFiles || []).map(
@@ -292,10 +288,7 @@ export class GeminiClient {
} }
} }
if (openedFiles.length > 0) { if (openedFiles.length > 0) {
changeLines.push('Files opened:'); changes['filesOpened'] = openedFiles;
for (const filePath of openedFiles) {
changeLines.push(` - ${filePath}`);
}
} }
const closedFiles: string[] = []; const closedFiles: string[] = [];
@@ -305,13 +298,7 @@ export class GeminiClient {
} }
} }
if (closedFiles.length > 0) { if (closedFiles.length > 0) {
if (changeLines.length > 0) { changes['filesClosed'] = closedFiles;
changeLines.push('');
}
changeLines.push('Files closed:');
for (const filePath of closedFiles) {
changeLines.push(` - ${filePath}`);
}
} }
const lastActiveFile = ( const lastActiveFile = (
@@ -323,22 +310,16 @@ export class GeminiClient {
if (currentActiveFile) { if (currentActiveFile) {
if (!lastActiveFile || lastActiveFile.path !== currentActiveFile.path) { if (!lastActiveFile || lastActiveFile.path !== currentActiveFile.path) {
if (changeLines.length > 0) { changes['activeFileChanged'] = {
changeLines.push(''); path: currentActiveFile.path,
} cursor: currentActiveFile.cursor
changeLines.push('Active file changed:'); ? {
changeLines.push(` Path: ${currentActiveFile.path}`); line: currentActiveFile.cursor.line,
if (currentActiveFile.cursor) { character: currentActiveFile.cursor.character,
changeLines.push( }
` Cursor: line ${currentActiveFile.cursor.line}, character ${currentActiveFile.cursor.character}`, : undefined,
); selectedText: currentActiveFile.selectedText || undefined,
} };
if (currentActiveFile.selectedText) {
changeLines.push(' Selected text:');
changeLines.push('```');
changeLines.push(currentActiveFile.selectedText);
changeLines.push('```');
}
} else { } else {
const lastCursor = lastActiveFile.cursor; const lastCursor = lastActiveFile.cursor;
const currentCursor = currentActiveFile.cursor; const currentCursor = currentActiveFile.cursor;
@@ -348,50 +329,42 @@ export class GeminiClient {
lastCursor.line !== currentCursor.line || lastCursor.line !== currentCursor.line ||
lastCursor.character !== currentCursor.character) lastCursor.character !== currentCursor.character)
) { ) {
if (changeLines.length > 0) { changes['cursorMoved'] = {
changeLines.push(''); path: currentActiveFile.path,
} cursor: {
changeLines.push('Cursor moved:'); line: currentCursor.line,
changeLines.push(` Path: ${currentActiveFile.path}`); character: currentCursor.character,
changeLines.push( },
` New position: line ${currentCursor.line}, character ${currentCursor.character}`, };
);
} }
const lastSelectedText = lastActiveFile.selectedText || ''; const lastSelectedText = lastActiveFile.selectedText || '';
const currentSelectedText = currentActiveFile.selectedText || ''; const currentSelectedText = currentActiveFile.selectedText || '';
if (lastSelectedText !== currentSelectedText) { if (lastSelectedText !== currentSelectedText) {
if (changeLines.length > 0) { changes['selectionChanged'] = {
changeLines.push(''); path: currentActiveFile.path,
} selectedText: currentSelectedText,
changeLines.push('Selection changed:'); };
changeLines.push(` Path: ${currentActiveFile.path}`);
if (currentSelectedText) {
changeLines.push(' Selected text:');
changeLines.push('```');
changeLines.push(currentSelectedText);
changeLines.push('```');
} else {
changeLines.push(' Selected text: (none)');
}
} }
} }
} else if (lastActiveFile) { } else if (lastActiveFile) {
if (changeLines.length > 0) { changes['activeFileChanged'] = {
changeLines.push(''); path: null,
} previousPath: lastActiveFile.path,
changeLines.push('Active file changed:'); };
changeLines.push(' No active file');
changeLines.push(` Previous path: ${lastActiveFile.path}`);
} }
if (changeLines.length === 0) { if (Object.keys(changes).length === 0) {
return { contextParts: [], newIdeContext: currentIdeContext }; return { contextParts: [], newIdeContext: currentIdeContext };
} }
delta['changes'] = changes;
const jsonString = JSON.stringify(delta, null, 2);
const contextParts = [ const contextParts = [
"Here is a summary of changes in the user's editor context. This is for your information only.", "Here is a summary of changes in the user's editor context, in JSON format. This is for your information only.",
changeLines.join('\n'), '```json',
jsonString,
'```',
]; ];
if (this.config.getDebugMode()) { if (this.config.getDebugMode()) {
@@ -569,6 +542,11 @@ export class GeminiClient {
} }
} }
if (!turn.pendingToolCalls.length && signal && !signal.aborted) { if (!turn.pendingToolCalls.length && signal && !signal.aborted) {
// Check if next speaker check is needed
if (this.config.getQuotaErrorOccurred()) {
return turn;
}
if (this.config.getSkipNextSpeakerCheck()) { if (this.config.getSkipNextSpeakerCheck()) {
return turn; return turn;
} }
@@ -624,11 +602,14 @@ export class GeminiClient {
}; };
const apiCall = () => { const apiCall = () => {
currentAttemptModel = model; const modelToUse = this.config.isInFallbackMode()
? DEFAULT_GEMINI_FLASH_MODEL
: model;
currentAttemptModel = modelToUse;
return this.getContentGeneratorOrFail().generateContent( return this.getContentGeneratorOrFail().generateContent(
{ {
model, model: modelToUse,
config: requestConfig, config: requestConfig,
contents, contents,
}, },

View File

@@ -5,11 +5,7 @@
*/ */
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { import { createContentGenerator, AuthType } from './contentGenerator.js';
createContentGenerator,
createContentGeneratorConfig,
AuthType,
} from './contentGenerator.js';
import { GoogleGenAI } from '@google/genai'; import { GoogleGenAI } from '@google/genai';
import type { Config } from '../config/config.js'; import type { Config } from '../config/config.js';
import { LoggingContentGenerator } from './loggingContentGenerator/index.js'; import { LoggingContentGenerator } from './loggingContentGenerator/index.js';
@@ -82,32 +78,3 @@ describe('createContentGenerator', () => {
expect(generator).toBeInstanceOf(LoggingContentGenerator); 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,24 +12,9 @@ import type {
GenerateContentParameters, GenerateContentParameters,
GenerateContentResponse, GenerateContentResponse,
} from '@google/genai'; } from '@google/genai';
import { DEFAULT_QWEN_MODEL } from '../config/models.js';
import type { Config } from '../config/config.js'; import type { Config } from '../config/config.js';
import { LoggingContentGenerator } from './loggingContentGenerator/index.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. * Interface abstracting the core functionalities for generating content and counting tokens.
@@ -63,7 +48,6 @@ export enum AuthType {
export type ContentGeneratorConfig = { export type ContentGeneratorConfig = {
model: string; model: string;
apiKey?: string; apiKey?: string;
apiKeyEnvKey?: string;
baseUrl?: string; baseUrl?: string;
vertexai?: boolean; vertexai?: boolean;
authType?: AuthType | undefined; authType?: AuthType | undefined;
@@ -93,178 +77,102 @@ export type ContentGeneratorConfig = {
schemaCompliance?: 'auto' | 'openapi_30'; schemaCompliance?: 'auto' | 'openapi_30';
}; };
// 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>,
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(),
};
// 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()',
});
}
// Preserve seed sources for fields that were passed in
const seedOrUnknown = (path: string): ContentGeneratorConfigSource =>
getSeedSource(seedSources, path) ?? { kind: 'unknown' };
for (const field of PROVIDER_SOURCED_FIELDS) {
if (generationConfig && field in generationConfig && !sources[field]) {
setSource(sources, field, seedOrUnknown(field));
}
}
// 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'));
}
return {
config: newContentGeneratorConfig as ContentGeneratorConfig,
sources,
};
}
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,
}),
);
}
}
// 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 }));
}
}
// 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( export function createContentGeneratorConfig(
config: Config, config: Config,
authType: AuthType | undefined, authType: AuthType | undefined,
generationConfig?: Partial<ContentGeneratorConfig>, generationConfig?: Partial<ContentGeneratorConfig>,
): ContentGeneratorConfig { ): ContentGeneratorConfig {
return resolveContentGeneratorConfigWithSources( let newContentGeneratorConfig: Partial<ContentGeneratorConfig> = {
config, ...(generationConfig || {}),
authType, authType,
generationConfig, proxy: config?.getProxy(),
).config; };
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;
}
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'],
};
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.');
}
}
if (authType === AuthType.USE_GEMINI) {
newContentGeneratorConfig = {
...newContentGeneratorConfig,
apiKey: newContentGeneratorConfig.apiKey || process.env['GEMINI_API_KEY'],
model: newContentGeneratorConfig.model || process.env['GEMINI_MODEL'],
};
if (!newContentGeneratorConfig.apiKey) {
throw new Error('GEMINI_API_KEY environment variable not found.');
}
if (!newContentGeneratorConfig.model) {
throw new Error('GEMINI_MODEL environment variable not found.');
}
}
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.');
}
}
return newContentGeneratorConfig as ContentGeneratorConfig;
} }
export async function createContentGenerator( export async function createContentGenerator(
@@ -272,12 +180,11 @@ export async function createContentGenerator(
gcConfig: Config, gcConfig: Config,
isInitialAuth?: boolean, isInitialAuth?: boolean,
): Promise<ContentGenerator> { ): Promise<ContentGenerator> {
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) { if (config.authType === AuthType.USE_OPENAI) {
if (!config.apiKey) {
throw new Error('OPENAI_API_KEY environment variable not found.');
}
// Import OpenAIContentGenerator dynamically to avoid circular dependencies // Import OpenAIContentGenerator dynamically to avoid circular dependencies
const { createOpenAIContentGenerator } = await import( const { createOpenAIContentGenerator } = await import(
'./openaiContentGenerator/index.js' './openaiContentGenerator/index.js'
@@ -316,6 +223,10 @@ export async function createContentGenerator(
} }
if (config.authType === AuthType.USE_ANTHROPIC) { if (config.authType === AuthType.USE_ANTHROPIC) {
if (!config.apiKey) {
throw new Error('ANTHROPIC_API_KEY environment variable not found.');
}
const { createAnthropicContentGenerator } = await import( const { createAnthropicContentGenerator } = await import(
'./anthropicContentGenerator/index.js' './anthropicContentGenerator/index.js'
); );

View File

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

View File

@@ -824,6 +824,7 @@ export class CoreToolScheduler {
*/ */
const shouldAutoDeny = const shouldAutoDeny =
!this.config.isInteractive() && !this.config.isInteractive() &&
!this.config.getIdeMode() &&
!this.config.getExperimentalZedIntegration() && !this.config.getExperimentalZedIntegration() &&
this.config.getInputFormat() !== InputFormat.STREAM_JSON; this.config.getInputFormat() !== InputFormat.STREAM_JSON;

View File

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

View File

@@ -19,6 +19,10 @@ import type {
import { ApiError, createUserContent } from '@google/genai'; import { ApiError, createUserContent } from '@google/genai';
import { retryWithBackoff } from '../utils/retry.js'; import { retryWithBackoff } from '../utils/retry.js';
import type { Config } from '../config/config.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 { hasCycleInSchema } from '../tools/tools.js';
import type { StructuredError } from './turn.js'; import type { StructuredError } from './turn.js';
import { import {
@@ -348,15 +352,31 @@ export class GeminiChat {
params: SendMessageParameters, params: SendMessageParameters,
prompt_id: string, prompt_id: string,
): Promise<AsyncGenerator<GenerateContentResponse>> { ): Promise<AsyncGenerator<GenerateContentResponse>> {
const apiCall = () => const apiCall = () => {
this.config.getContentGenerator().generateContentStream( 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(
{ {
model, model: modelToUse,
contents: requestContents, contents: requestContents,
config: { ...this.generationConfig, ...params.config }, config: { ...this.generationConfig, ...params.config },
}, },
prompt_id, prompt_id,
); );
};
const onPersistent429Callback = async ( const onPersistent429Callback = async (
authType?: string, authType?: string,
error?: unknown, error?: unknown,

View File

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

View File

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

View File

@@ -46,7 +46,6 @@ describe('ContentGenerationPipeline', () => {
// Mock converter // Mock converter
mockConverter = { mockConverter = {
setModel: vi.fn(),
convertGeminiRequestToOpenAI: vi.fn(), convertGeminiRequestToOpenAI: vi.fn(),
convertOpenAIResponseToGemini: vi.fn(), convertOpenAIResponseToGemini: vi.fn(),
convertOpenAIChunkToGemini: vi.fn(), convertOpenAIChunkToGemini: vi.fn(),
@@ -100,7 +99,6 @@ describe('ContentGenerationPipeline', () => {
describe('constructor', () => { describe('constructor', () => {
it('should initialize with correct configuration', () => { it('should initialize with correct configuration', () => {
expect(mockProvider.buildClient).toHaveBeenCalled(); expect(mockProvider.buildClient).toHaveBeenCalled();
// Converter is constructed once and the model is updated per-request via setModel().
expect(OpenAIContentConverter).toHaveBeenCalledWith( expect(OpenAIContentConverter).toHaveBeenCalledWith(
'test-model', 'test-model',
undefined, undefined,
@@ -146,9 +144,6 @@ describe('ContentGenerationPipeline', () => {
// Assert // Assert
expect(result).toBe(mockGeminiResponse); expect(result).toBe(mockGeminiResponse);
expect(
(mockConverter as unknown as { setModel: Mock }).setModel,
).toHaveBeenCalledWith('test-model');
expect(mockConverter.convertGeminiRequestToOpenAI).toHaveBeenCalledWith( expect(mockConverter.convertGeminiRequestToOpenAI).toHaveBeenCalledWith(
request, request,
); );
@@ -169,53 +164,6 @@ 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 () => { it('should handle tools in request', async () => {
// Arrange // Arrange
const request: GenerateContentParameters = { const request: GenerateContentParameters = {
@@ -269,9 +217,6 @@ describe('ContentGenerationPipeline', () => {
// Assert // Assert
expect(result).toBe(mockGeminiResponse); expect(result).toBe(mockGeminiResponse);
expect(
(mockConverter as unknown as { setModel: Mock }).setModel,
).toHaveBeenCalledWith('test-model');
expect(mockConverter.convertGeminiToolsToOpenAI).toHaveBeenCalledWith( expect(mockConverter.convertGeminiToolsToOpenAI).toHaveBeenCalledWith(
request.config!.tools, request.config!.tools,
); );

View File

@@ -40,16 +40,10 @@ export class ContentGenerationPipeline {
request: GenerateContentParameters, request: GenerateContentParameters,
userPromptId: string, userPromptId: string,
): Promise<GenerateContentResponse> { ): 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( return this.executeWithErrorHandling(
request, request,
userPromptId, userPromptId,
false, false,
effectiveModel,
async (openaiRequest) => { async (openaiRequest) => {
const openaiResponse = (await this.client.chat.completions.create( const openaiResponse = (await this.client.chat.completions.create(
openaiRequest, openaiRequest,
@@ -70,13 +64,10 @@ export class ContentGenerationPipeline {
request: GenerateContentParameters, request: GenerateContentParameters,
userPromptId: string, userPromptId: string,
): Promise<AsyncGenerator<GenerateContentResponse>> { ): Promise<AsyncGenerator<GenerateContentResponse>> {
const effectiveModel = this.contentGeneratorConfig.model;
this.converter.setModel(effectiveModel);
return this.executeWithErrorHandling( return this.executeWithErrorHandling(
request, request,
userPromptId, userPromptId,
true, true,
effectiveModel,
async (openaiRequest, context) => { async (openaiRequest, context) => {
// Stage 1: Create OpenAI stream // Stage 1: Create OpenAI stream
const stream = (await this.client.chat.completions.create( const stream = (await this.client.chat.completions.create(
@@ -233,13 +224,12 @@ export class ContentGenerationPipeline {
request: GenerateContentParameters, request: GenerateContentParameters,
userPromptId: string, userPromptId: string,
streaming: boolean = false, streaming: boolean = false,
effectiveModel: string,
): Promise<OpenAI.Chat.ChatCompletionCreateParams> { ): Promise<OpenAI.Chat.ChatCompletionCreateParams> {
const messages = this.converter.convertGeminiRequestToOpenAI(request); const messages = this.converter.convertGeminiRequestToOpenAI(request);
// Apply provider-specific enhancements // Apply provider-specific enhancements
const baseRequest: OpenAI.Chat.ChatCompletionCreateParams = { const baseRequest: OpenAI.Chat.ChatCompletionCreateParams = {
model: effectiveModel, model: this.contentGeneratorConfig.model,
messages, messages,
...this.buildGenerateContentConfig(request), ...this.buildGenerateContentConfig(request),
}; };
@@ -327,22 +317,15 @@ export class ContentGenerationPipeline {
} }
private buildReasoningConfig(): Record<string, unknown> { private buildReasoningConfig(): Record<string, unknown> {
// Reasoning configuration for OpenAI-compatible endpoints is highly fragmented. const reasoning = this.contentGeneratorConfig.reasoning;
// 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.
// We plan to introduce provider- and model-specific settings to enable more if (reasoning === false) {
// fine-grained control over reasoning configuration. return {};
}
return {}; return {
reasoning_effort: reasoning?.effort ?? 'medium',
};
} }
/** /**
@@ -352,24 +335,18 @@ export class ContentGenerationPipeline {
request: GenerateContentParameters, request: GenerateContentParameters,
userPromptId: string, userPromptId: string,
isStreaming: boolean, isStreaming: boolean,
effectiveModel: string,
executor: ( executor: (
openaiRequest: OpenAI.Chat.ChatCompletionCreateParams, openaiRequest: OpenAI.Chat.ChatCompletionCreateParams,
context: RequestContext, context: RequestContext,
) => Promise<T>, ) => Promise<T>,
): Promise<T> { ): Promise<T> {
const context = this.createRequestContext( const context = this.createRequestContext(userPromptId, isStreaming);
userPromptId,
isStreaming,
effectiveModel,
);
try { try {
const openaiRequest = await this.buildRequest( const openaiRequest = await this.buildRequest(
request, request,
userPromptId, userPromptId,
isStreaming, isStreaming,
effectiveModel,
); );
const result = await executor(openaiRequest, context); const result = await executor(openaiRequest, context);
@@ -401,11 +378,10 @@ export class ContentGenerationPipeline {
private createRequestContext( private createRequestContext(
userPromptId: string, userPromptId: string,
isStreaming: boolean, isStreaming: boolean,
effectiveModel: string,
): RequestContext { ): RequestContext {
return { return {
userPromptId, userPromptId,
model: effectiveModel, model: this.contentGeneratorConfig.model,
authType: this.contentGeneratorConfig.authType || 'unknown', authType: this.contentGeneratorConfig.authType || 'unknown',
startTime: Date.now(), startTime: Date.now(),
duration: 0, duration: 0,

View File

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

View File

@@ -0,0 +1,23 @@
/**
* @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,30 +9,6 @@ export * from './config/config.js';
export * from './output/types.js'; export * from './output/types.js';
export * from './output/json-formatter.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 Core Logic
export * from './core/client.js'; export * from './core/client.js';
export * from './core/contentGenerator.js'; export * from './core/contentGenerator.js';
@@ -45,6 +21,8 @@ export * from './core/geminiRequest.js';
export * from './core/coreToolScheduler.js'; export * from './core/coreToolScheduler.js';
export * from './core/nonInteractiveToolExecutor.js'; export * from './core/nonInteractiveToolExecutor.js';
export * from './fallback/types.js';
export * from './qwen/qwenOAuth2.js'; export * from './qwen/qwenOAuth2.js';
// Export utilities // Export utilities
@@ -60,7 +38,6 @@ export * from './utils/quotaErrorDetection.js';
export * from './utils/fileUtils.js'; export * from './utils/fileUtils.js';
export * from './utils/retry.js'; export * from './utils/retry.js';
export * from './utils/shell-utils.js'; export * from './utils/shell-utils.js';
export * from './utils/tool-utils.js';
export * from './utils/terminalSerializer.js'; export * from './utils/terminalSerializer.js';
export * from './utils/systemEncoding.js'; export * from './utils/systemEncoding.js';
export * from './utils/textUtils.js'; export * from './utils/textUtils.js';
@@ -77,9 +54,6 @@ export * from './utils/projectSummary.js';
export * from './utils/promptIdContext.js'; export * from './utils/promptIdContext.js';
export * from './utils/thoughtUtils.js'; export * from './utils/thoughtUtils.js';
// Config resolution utilities
export * from './utils/configResolver.js';
// Export services // Export services
export * from './services/fileDiscoveryService.js'; export * from './services/fileDiscoveryService.js';
export * from './services/gitService.js'; export * from './services/gitService.js';

View File

@@ -1,134 +0,0 @@
/**
* @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

@@ -1,44 +0,0 @@
/**
* @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

@@ -1,125 +0,0 @@
/**
* @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

@@ -1,355 +0,0 @@
/**
* @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

@@ -1,364 +0,0 @@
/**
* @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.
// If authType is not provided, do not read any auth env vars.
const envMapping = authType
? AUTH_ENV_MAPPINGS[authType]
: { model: [], apiKey: [], baseUrl: [] };
// Build layers for each field in priority order
// Priority: modelProvider > cli > env > settings > default
// ---- Model ----
const modelLayers: Array<ConfigLayer<string>> = [];
if (authType && 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 = authType ? 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 (authType && 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 (authType && 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 (authType && 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 | undefined,
modelId: string | undefined,
sources: ConfigSources,
): Partial<ContentGeneratorConfig> {
const result: Partial<ContentGeneratorConfig> = {};
for (const field of MODEL_GENERATION_CONFIG_FIELDS) {
// ModelProvider config takes priority
if (authType && 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

@@ -1,388 +0,0 @@
/**
* @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

@@ -1,180 +0,0 @@
/**
* @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

@@ -1,599 +0,0 @@
/**
* @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 apply Qwen OAuth apiKey placeholder during syncAfterAuthRefresh for fresh users', () => {
// Fresh user: authType not selected yet (currentAuthType undefined).
const modelsConfig = new ModelsConfig();
// Config.refreshAuth passes modelId from modelsConfig.getModel(), which falls back to DEFAULT_QWEN_MODEL.
modelsConfig.syncAfterAuthRefresh(
AuthType.QWEN_OAUTH,
modelsConfig.getModel(),
);
const gc = currentGenerationConfig(modelsConfig);
expect(gc.model).toBe('coder-model');
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

@@ -1,634 +0,0 @@
/**
* @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 | undefined;
// 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 | undefined;
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;
}
/**
* Get current model ID
*/
getModel(): string {
return this._generationConfig.model || DEFAULT_QWEN_MODEL;
}
/**
* Get current authType
*/
getCurrentAuthType(): AuthType | undefined {
return this.currentAuthType;
}
/**
* Check if authType was explicitly provided (via CLI or settings).
* If false, no authType was provided yet (fresh user).
*/
wasAuthTypeExplicitlyProvided(): boolean {
return this.authTypeWasExplicitlyProvided;
}
/**
* Get available models for current authType
*/
getAvailableModels(): AvailableModel[] {
return this.currentAuthType
? 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.currentAuthType &&
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 {
// Defensive: this method is only called after switchModel() sets currentAuthType,
// but keep type safety for any future callsites.
const authType = this.currentAuthType;
if (!authType) {
return true;
}
// For Qwen OAuth, model switches within the same authType can always be hot-updated
// (coder-model <-> vision-model don't require ContentGenerator recreation)
if (authType === AuthType.QWEN_OAUTH) {
return false;
}
// Get previous and current model configs
const previousModel = this.modelRegistry.getModel(
authType,
previousModelId,
);
const currentModel = this.modelRegistry.getModel(
authType,
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) {
// Ensure applyResolvedModelDefaults can correctly apply authType-specific
// behavior (e.g., Qwen OAuth placeholder token) by setting currentAuthType
// before applying defaults.
this.currentAuthType = authType;
this.applyResolvedModelDefaults(resolved);
}
} else {
this.currentAuthType = authType;
}
}
/**
* Update callback for model changes
*/
setOnModelChange(callback: OnModelChangeCallback): void {
this.onModelChange = callback;
}
}

View File

@@ -1,101 +0,0 @@
/**
* @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

@@ -601,17 +601,8 @@ async function authWithQwenDeviceFlow(
console.log('Waiting for authorization to complete...\n'); console.log('Waiting for authorization to complete...\n');
}; };
// Always show the fallback message in non-interactive environments to ensure // If browser launch is not suppressed, try to open the URL
// users can see the authorization URL even if browser launching is attempted. if (!config.isBrowserLaunchSuppressed()) {
// This is critical for headless/remote environments where browser launching
// may silently fail without throwing an error.
if (config.isBrowserLaunchSuppressed()) {
// Browser launch is suppressed, show fallback message
showFallbackMessage();
} else {
// Try to open the URL in browser, but always show the URL as fallback
// to handle cases where browser launch silently fails (e.g., headless servers)
showFallbackMessage();
try { try {
const childProcess = await open(deviceAuth.verification_uri_complete); const childProcess = await open(deviceAuth.verification_uri_complete);
@@ -620,19 +611,19 @@ async function authWithQwenDeviceFlow(
// in a minimal Docker container), it will emit an unhandled 'error' event, // in a minimal Docker container), it will emit an unhandled 'error' event,
// causing the entire Node.js process to crash. // causing the entire Node.js process to crash.
if (childProcess) { if (childProcess) {
childProcess.on('error', (err) => { childProcess.on('error', () => {
console.debug( console.debug(
'Browser launch failed:', 'Failed to open browser. Visit this URL to authorize:',
err.message || 'Unknown error',
); );
showFallbackMessage();
}); });
} }
} catch (err) { } catch (_err) {
console.debug( showFallbackMessage();
'Failed to open browser:',
err instanceof Error ? err.message : 'Unknown error',
);
} }
} else {
// Browser launch is suppressed, show fallback message
showFallbackMessage();
} }
// Emit auth progress event // Emit auth progress event

View File

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

View File

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

View File

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

View File

@@ -248,7 +248,7 @@ describe('ShellTool', () => {
wrappedCommand, wrappedCommand,
'/test/dir', '/test/dir',
expect.any(Function), expect.any(Function),
expect.any(AbortSignal), mockAbortSignal,
false, false,
{}, {},
); );
@@ -275,7 +275,7 @@ describe('ShellTool', () => {
wrappedCommand, wrappedCommand,
expect.any(String), expect.any(String),
expect.any(Function), expect.any(Function),
expect.any(AbortSignal), mockAbortSignal,
false, false,
{}, {},
); );
@@ -300,7 +300,7 @@ describe('ShellTool', () => {
wrappedCommand, wrappedCommand,
expect.any(String), expect.any(String),
expect.any(Function), expect.any(Function),
expect.any(AbortSignal), mockAbortSignal,
false, false,
{}, {},
); );
@@ -325,7 +325,7 @@ describe('ShellTool', () => {
wrappedCommand, wrappedCommand,
expect.any(String), expect.any(String),
expect.any(Function), expect.any(Function),
expect.any(AbortSignal), mockAbortSignal,
false, false,
{}, {},
); );
@@ -350,7 +350,7 @@ describe('ShellTool', () => {
wrappedCommand, wrappedCommand,
'/test/dir/subdir', '/test/dir/subdir',
expect.any(Function), expect.any(Function),
expect.any(AbortSignal), mockAbortSignal,
false, false,
{}, {},
); );
@@ -378,7 +378,7 @@ describe('ShellTool', () => {
'dir', 'dir',
'/test/dir', '/test/dir',
expect.any(Function), expect.any(Function),
expect.any(AbortSignal), mockAbortSignal,
false, false,
{}, {},
); );
@@ -471,7 +471,7 @@ describe('ShellTool', () => {
expect(summarizer.summarizeToolOutput).toHaveBeenCalledWith( expect(summarizer.summarizeToolOutput).toHaveBeenCalledWith(
expect.any(String), expect.any(String),
mockConfig.getGeminiClient(), mockConfig.getGeminiClient(),
expect.any(AbortSignal), mockAbortSignal,
1000, 1000,
); );
expect(result.llmContent).toBe('summarized output'); expect(result.llmContent).toBe('summarized output');
@@ -580,7 +580,7 @@ describe('ShellTool', () => {
), ),
expect.any(String), expect.any(String),
expect.any(Function), expect.any(Function),
expect.any(AbortSignal), mockAbortSignal,
false, false,
{}, {},
); );
@@ -610,7 +610,7 @@ describe('ShellTool', () => {
), ),
expect.any(String), expect.any(String),
expect.any(Function), expect.any(Function),
expect.any(AbortSignal), mockAbortSignal,
false, false,
{}, {},
); );
@@ -640,7 +640,7 @@ describe('ShellTool', () => {
), ),
expect.any(String), expect.any(String),
expect.any(Function), expect.any(Function),
expect.any(AbortSignal), mockAbortSignal,
false, false,
{}, {},
); );
@@ -699,7 +699,7 @@ describe('ShellTool', () => {
expect.stringContaining('npm install'), expect.stringContaining('npm install'),
expect.any(String), expect.any(String),
expect.any(Function), expect.any(Function),
expect.any(AbortSignal), mockAbortSignal,
false, false,
{}, {},
); );
@@ -728,7 +728,7 @@ describe('ShellTool', () => {
expect.stringContaining('git commit'), expect.stringContaining('git commit'),
expect.any(String), expect.any(String),
expect.any(Function), expect.any(Function),
expect.any(AbortSignal), mockAbortSignal,
false, false,
{}, {},
); );
@@ -758,7 +758,7 @@ describe('ShellTool', () => {
), ),
expect.any(String), expect.any(String),
expect.any(Function), expect.any(Function),
expect.any(AbortSignal), mockAbortSignal,
false, false,
{}, {},
); );
@@ -794,7 +794,7 @@ describe('ShellTool', () => {
expect.stringContaining('git commit -m "Initial commit"'), expect.stringContaining('git commit -m "Initial commit"'),
expect.any(String), expect.any(String),
expect.any(Function), expect.any(Function),
expect.any(AbortSignal), mockAbortSignal,
false, false,
{}, {},
); );
@@ -831,7 +831,7 @@ describe('ShellTool', () => {
), ),
expect.any(String), expect.any(String),
expect.any(Function), expect.any(Function),
expect.any(AbortSignal), mockAbortSignal,
false, false,
{}, {},
); );
@@ -962,41 +962,4 @@ spanning multiple lines"`;
expect(shellTool.description).toMatchSnapshot(); 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,
{},
);
});
});
}); });

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