Compare commits

..

27 Commits

Author SHA1 Message Date
github-actions[bot]
7a0bdceb5d chore(release): v0.0.7-nightly.0 2025-08-12 13:21:47 +00:00
tanzhenxin
7856f52afb Merge pull request #298 from QwenLM/chore/pkg_version
Chore/pkg version
2025-08-12 21:12:07 +08:00
tanzhenxin
e986476fe0 chore: pump version to 0.0.6 2025-08-12 21:03:25 +08:00
tanzhenxin
cfc1aebee6 chore: use correct CLI_VERSION for logging 2025-08-12 21:00:17 +08:00
pomelo
ef1c8a4bfe Merge pull request #293 from Clarence-pan/main
fix: 🐛 fix EPERM error when run `qwen --sandbox` in macOS
2025-08-12 19:53:59 +08:00
tanzhenxin
484292b2ac Merge pull request #297 from QwenLM/chore/pr-review-action
chore: adjust workflow to run PR review
2025-08-12 19:35:07 +08:00
mingholy.lmh
f9659184d4 chore: adjust workflow to run PR review 2025-08-12 18:05:52 +08:00
tanzhenxin
6d5bb1b57c Merge pull request #284 from QwenLM/feat/usage_stats_logging
feat: add usage statistics logging for Qwen integration
2025-08-12 17:56:34 +08:00
tanzhenxin
fb9f2d292c Merge pull request #274 from QwenLM/feat/memory_tool_docs
Make `/init` respect configured context filename and align docs with QWEN.md
2025-08-12 17:56:16 +08:00
Clarence-pan
16ea8560b7 fix: 🐛 fix EPERM error when run qwen --sandbox in macOS 2025-08-12 15:04:01 +08:00
tanzhenxin
2655af079a chore: npm run format & lint 2025-08-12 12:21:39 +08:00
tanzhenxin
807844fb57 feat: implement usage stats logging with telemetry refactoring 2025-08-12 12:16:38 +08:00
mingholy.lmh
2202d26ac7 fix: add comment 2025-08-12 11:58:58 +08:00
mingholy.lmh
58f66ccfc6 fix: openaiContentGenerator
- remove `metadata` when using unspported models/providers
- use `qwen3-code-plus` as default, fix picking wrong model when refresh auth
2025-08-12 11:58:58 +08:00
mingholy.lmh
65c622c0ac test: tweak test cases 2025-08-12 11:58:41 +08:00
mingholy.lmh
a3ec2f52c9 fix: terminal flicker when waiting for login 2025-08-12 11:58:41 +08:00
tanzhenxin
c96852dc56 feat: add usage statistics logging for Qwen integration 2025-08-11 22:13:56 +08:00
tanzhenxin
028a82ebeb chore: format&lint 2025-08-11 12:01:43 +08:00
tanzhenxin
6b67cd1b57 make /init respect configured context filename and align docs with QWEN.md 2025-08-11 11:56:12 +08:00
tanzhenxin
96a9b683b2 Merge pull request #266 from mahone3297/fix-readme-status-command
Fix README.md: Replace /status command with /stats command in documen…
2025-08-11 09:54:03 +08:00
tanzhenxin
dcc86699cf Merge pull request #235 from AstroAir/main
rename GEMINI.md to QWEN.md across the codebase
2025-08-11 09:51:01 +08:00
mahone3297
964509f587 Fix README.md: Replace /status command with /stats command in documentation
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-08-10 21:54:26 +08:00
AstroAir
e3a5806ae2 fix: simplify mock return values for QWEN.md in App tests 2025-08-10 10:19:47 +08:00
Max Qian
a45adbdc76 Merge branch 'QwenLM:main' into main 2025-08-10 08:36:39 +08:00
tanzhenxin
41500814b0 Merge pull request #242 from nipeharefa/rename-make-npx
fix: rename make run-npx from gemini to qwen
2025-08-09 22:26:14 +08:00
Nipe Setiawan Harefa
786832913b fix: rename make run-npx from gemini to qwen 2025-08-09 16:58:09 +07:00
AstroAir
4807434d9f refactor: rename GEMINI.md to QWEN.md across the codebase 2025-08-09 10:33:02 +08:00
54 changed files with 1756 additions and 331 deletions

View File

@@ -24,7 +24,7 @@ jobs:
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
with:
version: 0.0.4
version: 0.0.6
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
settings_json: |
{

View File

@@ -42,7 +42,7 @@ jobs:
ISSUES_TO_TRIAGE: ${{ steps.find_issues.outputs.issues_to_triage }}
REPOSITORY: ${{ github.repository }}
with:
version: 0.0.4
version: 0.0.6
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
OPENAI_MODEL: ${{ secrets.OPENAI_MODEL }}

View File

@@ -1,7 +1,7 @@
name: 🧐 Qwen Pull Request Review
on:
pull_request:
pull_request_target:
types: [opened]
pull_request_review_comment:
types: [created]

View File

@@ -53,7 +53,7 @@ debug:
run-npx:
npx https://github.com/google-gemini/gemini-cli
npx https://github.com/QwenLM/qwen-code
create-alias:
scripts/create_alias.sh

View File

@@ -101,7 +101,7 @@ Create or edit `.qwen/settings.json` in your home directory:
- **`/compress`** - Compress conversation history to continue within token limits
- **`/clear`** - Clear all conversation history and start fresh
- **`/status`** - Check current token usage and limits
- **`/stats`** - Check current token usage and limits
> 📝 **Note**: Session token limit applies to a single conversation, not cumulative API calls.
@@ -310,7 +310,7 @@ qwen
- `/help` - Display available commands
- `/clear` - Clear conversation history
- `/compress` - Compress history to save tokens
- `/status` - Show current session information
- `/stats` - Show current session information
- `/exit` or `/quit` - Exit Qwen Code
### Keyboard Shortcuts

View File

@@ -46,7 +46,7 @@ Slash commands provide meta-level control over the CLI itself.
- **Usage:** `/directory add <path1>,<path2>`
- **Note:** Disabled in restrictive sandbox profiles. If you're using that, use `--include-directories` when starting the session instead.
- **`show`**:
- **Description:** Display all directories added by `/direcotry add` and `--include-directories`.
- **Description:** Display all directories added by `/directory add` and `--include-directories`.
- **Usage:** `/directory show`
- **`/editor`**
@@ -70,15 +70,15 @@ Slash commands provide meta-level control over the CLI itself.
- **Keyboard Shortcut:** Press **Ctrl+T** at any time to toggle between showing and hiding tool descriptions.
- **`/memory`**
- **Description:** Manage the AI's instructional context (hierarchical memory loaded from `GEMINI.md` files).
- **Description:** Manage the AI's instructional context (hierarchical memory loaded from `QWEN.md` files by default; configurable via `contextFileName`).
- **Sub-commands:**
- **`add`**:
- **Description:** Adds the following text to the AI's memory. Usage: `/memory add <text to remember>`
- **`show`**:
- **Description:** Display the full, concatenated content of the current hierarchical memory that has been loaded from all `GEMINI.md` files. This lets you inspect the instructional context being provided to the Gemini model.
- **Description:** Display the full, concatenated content of the current hierarchical memory that has been loaded from all context files (e.g., `QWEN.md`). This lets you inspect the instructional context being provided to the model.
- **`refresh`**:
- **Description:** Reload the hierarchical instructional memory from all `GEMINI.md` files found in the configured locations (global, project/ancestors, and sub-directories). This command updates the model with the latest `GEMINI.md` content.
- **Note:** For more details on how `GEMINI.md` files contribute to hierarchical memory, see the [CLI Configuration documentation](./configuration.md#4-geminimd-files-hierarchical-instructional-context).
- **Description:** Reload the hierarchical instructional memory from all context files (default: `QWEN.md`) found in the configured locations (global, project/ancestors, and sub-directories). This updates the model with the latest context content.
- **Note:** For more details on how context files contribute to hierarchical memory, see the [CLI Configuration documentation](./configuration.md#context-files-hierarchical-instructional-context).
- **`/restore`**
- **Description:** Restores the project files to the state they were in just before a tool was executed. This is particularly useful for undoing file edits made by a tool. If run without a tool call ID, it will list available checkpoints to restore from.
@@ -123,7 +123,7 @@ Slash commands provide meta-level control over the CLI itself.
- **Status indicator:** When enabled, shows `[NORMAL]` or `[INSERT]` in the footer
- **`/init`**
- **Description:** To help users easily create a `GEMINI.md` file, this command analyzes the current directory and generates a tailored context file, making it simpler for them to provide project-specific instructions to the Gemini agent.
- **Description:** Analyzes the current directory and creates a `QWEN.md` context file by default (or the filename specified by `contextFileName`). If a non-empty file already exists, no changes are made. The command seeds an empty file and prompts the model to populate it with project-specific instructions.
### Custom Commands

View File

@@ -38,8 +38,8 @@ In addition to a project settings file, a project's `.gemini` directory can cont
### Available settings in `settings.json`:
- **`contextFileName`** (string or array of strings):
- **Description:** Specifies the filename for context files (e.g., `GEMINI.md`, `AGENTS.md`). Can be a single filename or a list of accepted filenames.
- **Default:** `GEMINI.md`
- **Description:** Specifies the filename for context files (e.g., `QWEN.md`, `AGENTS.md`). Can be a single filename or a list of accepted filenames.
- **Default:** `QWEN.md`
- **Example:** `"contextFileName": "AGENTS.md"`
- **`bugCommand`** (object):
@@ -415,7 +415,7 @@ While not strictly configuration for the CLI's _behavior_, context files (defaul
- **Purpose:** These Markdown files contain instructions, guidelines, or context that you want the Gemini model to be aware of during your interactions. The system is designed to manage this instructional context hierarchically.
### Example Context File Content (e.g., `GEMINI.md`)
### Example Context File Content (e.g., `QWEN.md`)
Here's a conceptual example of what a context file at the root of a TypeScript project might contain:
@@ -450,9 +450,9 @@ Here's a conceptual example of what a context file at the root of a TypeScript p
This example demonstrates how you can provide general project context, specific coding conventions, and even notes about particular files or components. The more relevant and precise your context files are, the better the AI can assist you. Project-specific context files are highly encouraged to establish conventions and context.
- **Hierarchical Loading and Precedence:** The CLI implements a sophisticated hierarchical memory system by loading context files (e.g., `GEMINI.md`) from several locations. Content from files lower in this list (more specific) typically overrides or supplements content from files higher up (more general). The exact concatenation order and final context can be inspected using the `/memory show` command. The typical loading order is:
- **Hierarchical Loading and Precedence:** The CLI implements a sophisticated hierarchical memory system by loading context files (e.g., `QWEN.md`) from several locations. Content from files lower in this list (more specific) typically overrides or supplements content from files higher up (more general). The exact concatenation order and final context can be inspected using the `/memory show` command. The typical loading order is:
1. **Global Context File:**
- Location: `~/.gemini/<contextFileName>` (e.g., `~/.gemini/GEMINI.md` in your user home directory).
- Location: `~/.qwen/<contextFileName>` (e.g., `~/.qwen/QWEN.md` in your user home directory).
- Scope: Provides default instructions for all your projects.
2. **Project Root & Ancestors Context Files:**
- Location: The CLI searches for the configured context file in the current working directory and then in each parent directory up to either the project root (identified by a `.git` folder) or your home directory.
@@ -523,3 +523,5 @@ You can opt out of usage statistics collection at any time by setting the `usage
"usageStatisticsEnabled": false
}
```
Note: When usage statistics are enabled, events are sent to an Alibaba Cloud RUM collection endpoint.

View File

@@ -5,14 +5,14 @@ Gemini CLI's core package (`packages/core`) is the backend portion of Gemini CLI
## Navigating this section
- **[Core tools API](./tools-api.md):** Information on how tools are defined, registered, and used by the core.
- **[Memory Import Processor](./memport.md):** Documentation for the modular GEMINI.md import feature using @file.md syntax.
- **[Memory Import Processor](./memport.md):** Documentation for the modular QWEN.md import feature using @file.md syntax.
## Role of the core
While the `packages/cli` portion of Gemini CLI provides the user interface, `packages/core` is responsible for:
- **Gemini API interaction:** Securely communicating with the Google Gemini API, sending user prompts, and receiving model responses.
- **Prompt engineering:** Constructing effective prompts for the Gemini model, potentially incorporating conversation history, tool definitions, and instructional context from `GEMINI.md` files.
- **Prompt engineering:** Constructing effective prompts for the model, potentially incorporating conversation history, tool definitions, and instructional context from context files (e.g., `QWEN.md`).
- **Tool management & orchestration:**
- Registering available tools (e.g., file system tools, shell command execution).
- Interpreting tool use requests from the Gemini model.
@@ -48,8 +48,8 @@ The file discovery service is responsible for finding files in the project that
## Memory discovery service
The memory discovery service is responsible for finding and loading the `GEMINI.md` files that provide context to the model. It searches for these files in a hierarchical manner, starting from the current working directory and moving up to the project root and the user's home directory. It also searches in subdirectories.
The memory discovery service is responsible for finding and loading the context files (default: `QWEN.md`) that provide context to the model. It searches for these files in a hierarchical manner, starting from the current working directory and moving up to the project root and the user's home directory. It also searches in subdirectories.
This allows you to have global, project-level, and component-level context files, which are all combined to provide the model with the most relevant information.
You can use the [`/memory` command](../cli/commands.md) to `show`, `add`, and `refresh` the content of loaded `GEMINI.md` files.
You can use the [`/memory` command](../cli/commands.md) to `show`, `add`, and `refresh` the content of loaded context files.

View File

@@ -1,17 +1,17 @@
# Memory Import Processor
The Memory Import Processor is a feature that allows you to modularize your GEMINI.md files by importing content from other files using the `@file.md` syntax.
The Memory Import Processor is a feature that allows you to modularize your context files (e.g., `QWEN.md`) by importing content from other files using the `@file.md` syntax.
## Overview
This feature enables you to break down large GEMINI.md files into smaller, more manageable components that can be reused across different contexts. The import processor supports both relative and absolute paths, with built-in safety features to prevent circular imports and ensure file access security.
This feature enables you to break down large context files (e.g., `QWEN.md`) into smaller, more manageable components that can be reused across different contexts. The import processor supports both relative and absolute paths, with built-in safety features to prevent circular imports and ensure file access security.
## Syntax
Use the `@` symbol followed by the path to the file you want to import:
```markdown
# Main GEMINI.md file
# Main QWEN.md file
This is the main content.
@@ -39,7 +39,7 @@ More content here.
### Basic Import
```markdown
# My GEMINI.md
# My QWEN.md
Welcome to my project!
@@ -110,13 +110,13 @@ The import processor uses the `marked` library to detect code blocks and inline
## Import Tree Structure
The processor returns an import tree that shows the hierarchy of imported files, similar to Claude's `/memory` feature. This helps users debug problems with their GEMINI.md files by showing which files were read and their import relationships.
The processor returns an import tree that shows the hierarchy of imported files. This helps users debug problems with their context files by showing which files were read and their import relationships.
Example tree structure:
```
Memory Files
L project: GEMINI.md
Memory Files
L project: QWEN.md
L a.md
L b.md
L c.md
@@ -138,7 +138,7 @@ Note: The import tree is mainly for clarity during development and has limited r
### `processImports(content, basePath, debugMode?, importState?)`
Processes import statements in GEMINI.md content.
Processes import statements in context file content.
**Parameters:**

View File

@@ -28,7 +28,7 @@ The `gemini-extension.json` file contains the configuration for the extension. T
"command": "node my-server.js"
}
},
"contextFileName": "GEMINI.md",
"contextFileName": "QWEN.md",
"excludeTools": ["run_shell_command"]
}
```
@@ -36,7 +36,7 @@ The `gemini-extension.json` file contains the configuration for the extension. T
- `name`: The name of the extension. This is used to uniquely identify the extension and for conflict resolution when extension commands have the same name as user or project commands.
- `version`: The version of the extension.
- `mcpServers`: A map of MCP servers to configure. The key is the name of the server, and the value is the server configuration. These servers will be loaded on startup just like MCP servers configured in a [`settings.json` file](./cli/configuration.md). If both an extension and a `settings.json` file configure an MCP server with the same name, the server defined in the `settings.json` file takes precedence.
- `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the workspace. If this property is not used but a `GEMINI.md` file is present in your extension directory, then that file will be loaded.
- `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the workspace. If this property is not used but a `QWEN.md` file is present in your extension directory, then that file will be loaded.
- `excludeTools`: An array of tool names to exclude from the model. You can also specify command-specific restrictions for tools that support it, like the `run_shell_command` tool. For example, `"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf` command.
When Gemini CLI starts, it loads all the extensions and merges their configurations. If there are any conflicts, the workspace configuration takes precedence.

View File

@@ -4,7 +4,7 @@ This document describes the `save_memory` tool for the Gemini CLI.
## Description
Use `save_memory` to save and recall information across your Gemini CLI sessions. With `save_memory`, you can direct the CLI to remember key details across sessions, providing personalized and directed assistance.
Use `save_memory` to save and recall information across your Qwen Code sessions. With `save_memory`, you can direct the CLI to remember key details across sessions, providing personalized and directed assistance.
### Arguments
@@ -14,9 +14,9 @@ Use `save_memory` to save and recall information across your Gemini CLI sessions
## How to use `save_memory` with the Gemini CLI
The tool appends the provided `fact` to a special `GEMINI.md` file located in the user's home directory (`~/.gemini/GEMINI.md`). This file can be configured to have a different name.
The tool appends the provided `fact` to your context file in the user's home directory (`~/.qwen/QWEN.md` by default). This filename can be configured via `contextFileName`.
Once added, the facts are stored under a `## Gemini Added Memories` section. This file is loaded as context in subsequent sessions, allowing the CLI to recall the saved information.
Once added, the facts are stored under a `## Qwen Added Memories` section. This file is loaded as context in subsequent sessions, allowing the CLI to recall the saved information.
Usage:

10
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.0.5",
"version": "0.0.7-nightly.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
"version": "0.0.5",
"version": "0.0.7-nightly.0",
"workspaces": [
"packages/*"
],
@@ -11817,7 +11817,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
"version": "0.0.5",
"version": "0.0.7-nightly.0",
"dependencies": {
"@google/genai": "1.9.0",
"@iarna/toml": "^2.2.5",
@@ -11894,7 +11894,7 @@
},
"packages/core": {
"name": "@qwen-code/qwen-code-core",
"version": "0.0.5",
"version": "0.0.7-nightly.0",
"dependencies": {
"@google/genai": "1.9.0",
"@modelcontextprotocol/sdk": "^1.11.0",
@@ -11962,7 +11962,7 @@
},
"packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion",
"version": "0.0.4",
"version": "0.0.6",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.15.1",

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.0.5",
"version": "0.0.7-nightly.0",
"description": "Qwen Code",
"repository": {
"type": "git",
@@ -25,7 +25,7 @@
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.5"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.7-nightly.0"
},
"dependencies": {
"@google/genai": "1.9.0",

View File

@@ -412,6 +412,7 @@ export async function loadCliConfig(
}
const sandboxConfig = await loadSandboxConfig(settings, argv);
const cliVersion = await getCliVersion();
return new Config({
sessionId,
@@ -497,6 +498,7 @@ export async function loadCliConfig(
},
],
contentGenerator: settings.contentGenerator,
cliVersion,
});
}

View File

@@ -148,7 +148,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
getUserTier: vi.fn(),
})),
getCheckpointingEnabled: vi.fn(() => opts.checkpointing ?? true),
getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']),
getAllGeminiMdFilenames: vi.fn(() => ['QWEN.md']),
setFlashFallbackHandler: vi.fn(),
getSessionId: vi.fn(() => 'test-session-id'),
getUserTier: vi.fn().mockResolvedValue(undefined),
@@ -169,7 +169,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
...actualCore,
Config: ConfigClassMock,
MCPServerConfig: actualCore.MCPServerConfig,
getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']),
getAllGeminiMdFilenames: vi.fn(() => ['QWEN.md']),
ideContext: ideContextMock,
isGitRepository: vi.fn(),
};
@@ -577,7 +577,7 @@ describe('App UI', () => {
},
});
mockConfig.getGeminiMdFileCount.mockReturnValue(1);
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['GEMINI.md']);
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md']);
const { lastFrame, unmount } = render(
<App
@@ -589,13 +589,13 @@ describe('App UI', () => {
currentUnmount = unmount;
await Promise.resolve();
expect(lastFrame()).toContain(
'Using: 1 open file (ctrl+e to view) | 1 GEMINI.md file',
'Using: 1 open file (ctrl+e to view) | 1 QWEN.md file',
);
});
it('should display default "GEMINI.md" in footer when contextFileName is not set and count is 1', async () => {
it('should display default "QWEN.md" in footer when contextFileName is not set and count is 1', async () => {
mockConfig.getGeminiMdFileCount.mockReturnValue(1);
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['GEMINI.md']);
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md']);
// For this test, ensure showMemoryUsage is false or debugMode is false if it relies on that
mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false);
@@ -609,15 +609,12 @@ describe('App UI', () => {
);
currentUnmount = unmount;
await Promise.resolve(); // Wait for any async updates
expect(lastFrame()).toContain('Using: 1 GEMINI.md file');
expect(lastFrame()).toContain('Using: 1 QWEN.md file');
});
it('should display default "GEMINI.md" with plural when contextFileName is not set and count is > 1', async () => {
it('should display default "QWEN.md" with plural when contextFileName is not set and count is > 1', async () => {
mockConfig.getGeminiMdFileCount.mockReturnValue(2);
mockConfig.getAllGeminiMdFilenames.mockReturnValue([
'GEMINI.md',
'GEMINI.md',
]);
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md', 'QWEN.md']);
mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false);
@@ -630,7 +627,7 @@ describe('App UI', () => {
);
currentUnmount = unmount;
await Promise.resolve();
expect(lastFrame()).toContain('Using: 2 GEMINI.md files');
expect(lastFrame()).toContain('Using: 2 QWEN.md files');
});
it('should display custom contextFileName in footer when set and count is 1', async () => {
@@ -727,12 +724,9 @@ describe('App UI', () => {
expect(lastFrame()).not.toContain('ANY_FILE.MD');
});
it('should display GEMINI.md and MCP server count when both are present', async () => {
it('should display QWEN.md and MCP server count when both are present', async () => {
mockConfig.getGeminiMdFileCount.mockReturnValue(2);
mockConfig.getAllGeminiMdFilenames.mockReturnValue([
'GEMINI.md',
'GEMINI.md',
]);
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md', 'QWEN.md']);
mockConfig.getMcpServers.mockReturnValue({
server1: {} as MCPServerConfig,
});
@@ -751,7 +745,7 @@ describe('App UI', () => {
expect(lastFrame()).toContain('1 MCP server');
});
it('should display only MCP server count when GEMINI.md count is 0', async () => {
it('should display only MCP server count when QWEN.md count is 0', async () => {
mockConfig.getGeminiMdFileCount.mockReturnValue(0);
mockConfig.getAllGeminiMdFilenames.mockReturnValue([]);
mockConfig.getMcpServers.mockReturnValue({

View File

@@ -11,16 +11,31 @@ import { initCommand } from './initCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { type CommandContext } from './types.js';
// Mock the 'fs' module
vi.mock('fs', () => ({
existsSync: vi.fn(),
writeFileSync: vi.fn(),
}));
// Mock the 'fs' module with both named and default exports to avoid breaking default import sites
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
const existsSync = vi.fn();
const writeFileSync = vi.fn();
const readFileSync = vi.fn();
return {
...actual,
existsSync,
writeFileSync,
readFileSync,
default: {
...(actual as unknown as Record<string, unknown>),
existsSync,
writeFileSync,
readFileSync,
},
} as unknown as typeof import('fs');
});
describe('initCommand', () => {
let mockContext: CommandContext;
const targetDir = '/test/dir';
const geminiMdPath = path.join(targetDir, 'GEMINI.md');
const DEFAULT_CONTEXT_FILENAME = 'QWEN.md';
const geminiMdPath = path.join(targetDir, DEFAULT_CONTEXT_FILENAME);
beforeEach(() => {
// Create a fresh mock context for each test
@@ -38,9 +53,10 @@ describe('initCommand', () => {
vi.clearAllMocks();
});
it('should inform the user if GEMINI.md already exists', async () => {
it(`should inform the user if ${DEFAULT_CONTEXT_FILENAME} already exists and is non-empty`, async () => {
// Arrange: Simulate that the file exists
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.spyOn(fs, 'readFileSync').mockReturnValue('# Existing content');
// Act: Run the command's action
const result = await initCommand.action!(mockContext, '');
@@ -49,14 +65,13 @@ describe('initCommand', () => {
expect(result).toEqual({
type: 'message',
messageType: 'info',
content:
'A GEMINI.md file already exists in this directory. No changes were made.',
content: `A ${DEFAULT_CONTEXT_FILENAME} file already exists in this directory. No changes were made.`,
});
// Assert: Ensure no file was written
expect(fs.writeFileSync).not.toHaveBeenCalled();
});
it('should create GEMINI.md and submit a prompt if it does not exist', async () => {
it(`should create ${DEFAULT_CONTEXT_FILENAME} and submit a prompt if it does not exist`, async () => {
// Arrange: Simulate that the file does not exist
vi.mocked(fs.existsSync).mockReturnValue(false);
@@ -70,7 +85,7 @@ describe('initCommand', () => {
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: 'info',
text: 'Empty GEMINI.md created. Now analyzing the project to populate it.',
text: `Empty ${DEFAULT_CONTEXT_FILENAME} created. Now analyzing the project to populate it.`,
},
expect.any(Number),
);
@@ -78,10 +93,20 @@ describe('initCommand', () => {
// Assert: Check that the correct prompt is submitted
expect(result.type).toBe('submit_prompt');
expect(result.content).toContain(
'You are an AI agent that brings the power of Gemini',
'You are Qwen Code, an interactive CLI agent',
);
});
it(`should proceed to initialize when ${DEFAULT_CONTEXT_FILENAME} exists but is empty`, async () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.spyOn(fs, 'readFileSync').mockReturnValue(' \n ');
const result = await initCommand.action!(mockContext, '');
expect(fs.writeFileSync).toHaveBeenCalledWith(geminiMdPath, '', 'utf8');
expect(result.type).toBe('submit_prompt');
});
it('should return an error if config is not available', async () => {
// Arrange: Create a context without config
const noConfigContext = createMockCommandContext();

View File

@@ -12,10 +12,11 @@ import {
SlashCommandActionReturn,
CommandKind,
} from './types.js';
import { getCurrentGeminiMdFilename } from '@qwen-code/qwen-code-core';
export const initCommand: SlashCommand = {
name: 'init',
description: 'Analyzes the project and creates a tailored GEMINI.md file.',
description: 'Analyzes the project and creates a tailored QWEN.md file.',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
@@ -29,32 +30,55 @@ export const initCommand: SlashCommand = {
};
}
const targetDir = context.services.config.getTargetDir();
const geminiMdPath = path.join(targetDir, 'GEMINI.md');
const contextFileName = getCurrentGeminiMdFilename();
const contextFilePath = path.join(targetDir, contextFileName);
if (fs.existsSync(geminiMdPath)) {
try {
if (fs.existsSync(contextFilePath)) {
// If file exists but is empty (or whitespace), continue to initialize; otherwise, bail out
try {
const existing = fs.readFileSync(contextFilePath, 'utf8');
if (existing && existing.trim().length > 0) {
return {
type: 'message',
messageType: 'info',
content: `A ${contextFileName} file already exists in this directory. No changes were made.`,
};
}
} catch {
// If we fail to read, conservatively proceed to (re)create the file
}
}
// Ensure an empty context file exists before prompting the model to populate it
try {
fs.writeFileSync(contextFilePath, '', 'utf8');
context.ui.addItem(
{
type: 'info',
text: `Empty ${contextFileName} created. Now analyzing the project to populate it.`,
},
Date.now(),
);
} catch (err) {
return {
type: 'message',
messageType: 'error',
content: `Failed to create ${contextFileName}: ${err instanceof Error ? err.message : String(err)}`,
};
}
} catch (error) {
return {
type: 'message',
messageType: 'info',
content:
'A GEMINI.md file already exists in this directory. No changes were made.',
messageType: 'error',
content: `Unexpected error preparing ${contextFileName}: ${error instanceof Error ? error.message : String(error)}`,
};
}
// Create an empty GEMINI.md file
fs.writeFileSync(geminiMdPath, '', 'utf8');
context.ui.addItem(
{
type: 'info',
text: 'Empty GEMINI.md created. Now analyzing the project to populate it.',
},
Date.now(),
);
return {
type: 'submit_prompt',
content: `
You are an AI agent that brings the power of Gemini directly into the terminal. Your task is to analyze the current directory and generate a comprehensive GEMINI.md file to be used as instructional context for future interactions.
You are Qwen Code, an interactive CLI agent. Analyze the current directory and generate a comprehensive ${contextFileName} file to be used as instructional context for future interactions.
**Analysis Process:**
@@ -70,7 +94,7 @@ You are an AI agent that brings the power of Gemini directly into the terminal.
* **Code Project:** Look for clues like \`package.json\`, \`requirements.txt\`, \`pom.xml\`, \`go.mod\`, \`Cargo.toml\`, \`build.gradle\`, or a \`src\` directory. If you find them, this is likely a software project.
* **Non-Code Project:** If you don't find code-related files, this might be a directory for documentation, research papers, notes, or something else.
**GEMINI.md Content Generation:**
**${contextFileName} Content Generation:**
**For a Code Project:**
@@ -86,7 +110,7 @@ You are an AI agent that brings the power of Gemini directly into the terminal.
**Final Output:**
Write the complete content to the \`GEMINI.md\` file. The output must be well-formatted Markdown.
Write the complete content to the \`${contextFileName}\` file. The output must be well-formatted Markdown.
`,
};
},

View File

@@ -22,6 +22,12 @@ vi.mock('ink-spinner', () => ({
default: ({ type }: { type: string }) => `MockSpinner(${type})`,
}));
// Mock ink-link
vi.mock('ink-link', () => ({
default: ({ children }: { children: React.ReactNode; url: string }) =>
children,
}));
describe('QwenOAuthProgress', () => {
const mockOnTimeout = vi.fn();
const mockOnCancel = vi.fn();
@@ -39,7 +45,17 @@ describe('QwenOAuthProgress', () => {
const mockDeviceAuth = createMockDeviceAuth();
const renderComponent = (
props: Partial<{ deviceAuth: DeviceAuthorizationInfo }> = {},
props: Partial<{
deviceAuth: DeviceAuthorizationInfo;
authStatus:
| 'idle'
| 'polling'
| 'success'
| 'error'
| 'timeout'
| 'rate_limit';
authMessage: string | null;
}> = {},
) =>
render(
<QwenOAuthProgress
@@ -85,20 +101,43 @@ describe('QwenOAuthProgress', () => {
const { lastFrame } = renderComponent({ deviceAuth: mockDeviceAuth });
const output = lastFrame();
expect(output).toContain('Qwen OAuth Authentication');
expect(output).toContain('Please visit this URL to authorize:');
expect(output).toContain(mockDeviceAuth.verification_uri_complete);
// Initially no QR code shown until it's generated, but the status area should be visible
expect(output).toContain('MockSpinner(dots)');
expect(output).toContain('Waiting for authorization');
expect(output).toContain('Time remaining: 5:00');
expect(output).toContain('(Press ESC to cancel)');
});
it('should display correct URL in bordered box', () => {
it('should display correct URL in Static component when QR code is generated', async () => {
const qrcode = await import('qrcode-terminal');
const mockGenerate = vi.mocked(qrcode.default.generate);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let qrCallback: any = null;
mockGenerate.mockImplementation((url, options, callback) => {
qrCallback = callback;
});
const customAuth = createMockDeviceAuth({
verification_uri_complete: 'https://custom.com/auth?code=XYZ789',
});
const { lastFrame } = renderComponent({ deviceAuth: customAuth });
const { lastFrame, rerender } = renderComponent({
deviceAuth: customAuth,
});
// Manually trigger the QR code callback
if (qrCallback && typeof qrCallback === 'function') {
qrCallback('Mock QR Code Data');
}
rerender(
<QwenOAuthProgress
onTimeout={mockOnTimeout}
onCancel={mockOnCancel}
deviceAuth={customAuth}
/>,
);
expect(lastFrame()).toContain('https://custom.com/auth?code=XYZ789');
});
@@ -204,14 +243,16 @@ describe('QwenOAuthProgress', () => {
expect(lastFrame()).toContain('Time remaining: 4:59');
});
it('should not start timer when deviceAuth is null', () => {
render(
it('should use default 300 second timeout when deviceAuth is null', () => {
const { lastFrame } = render(
<QwenOAuthProgress onTimeout={mockOnTimeout} onCancel={mockOnCancel} />,
);
// Advance timer and ensure onTimeout is not called
vi.advanceTimersByTime(5000);
expect(mockOnTimeout).not.toHaveBeenCalled();
// Should show default 5:00 (300 seconds) timeout
expect(lastFrame()).toContain('Time remaining: 5:00');
// The timer functionality is already tested in other tests,
// this test mainly verifies the default timeout value is used
});
});
@@ -298,8 +339,41 @@ describe('QwenOAuthProgress', () => {
);
});
// Note: QR code display test skipped due to timing complexities with async state updates
// The QR code generation is already tested in 'should generate QR code when deviceAuth is provided'
it('should display QR code in Static component when available', async () => {
const qrcode = await import('qrcode-terminal');
const mockGenerate = vi.mocked(qrcode.default.generate);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let qrCallback: any = null;
mockGenerate.mockImplementation((url, options, callback) => {
qrCallback = callback;
});
const { lastFrame, rerender } = render(
<QwenOAuthProgress
onTimeout={mockOnTimeout}
onCancel={mockOnCancel}
deviceAuth={mockDeviceAuth}
/>,
);
// Manually trigger the QR code callback
if (qrCallback && typeof qrCallback === 'function') {
qrCallback('Mock QR Code Data');
}
rerender(
<QwenOAuthProgress
onTimeout={mockOnTimeout}
onCancel={mockOnCancel}
deviceAuth={mockDeviceAuth}
/>,
);
const output = lastFrame();
expect(output).toContain('Or scan the QR code below:');
expect(output).toContain('Mock QR Code Data');
});
it('should handle QR code generation errors gracefully', async () => {
const qrcode = await import('qrcode-terminal');
@@ -320,7 +394,7 @@ describe('QwenOAuthProgress', () => {
/>,
);
// Should not crash and should not show QR code section
// Should not crash and should not show QR code section since QR generation failed
const output = lastFrame();
expect(output).not.toContain('Or scan the QR code below:');
expect(consoleErrorSpy).toHaveBeenCalledWith(
@@ -415,14 +489,58 @@ describe('QwenOAuthProgress', () => {
/>,
);
expect(lastFrame()).toContain('Qwen OAuth Authentication');
// Initially shows waiting for authorization
expect(lastFrame()).toContain('Waiting for authorization');
rerender(
<QwenOAuthProgress onTimeout={mockOnTimeout} onCancel={mockOnCancel} />,
);
expect(lastFrame()).toContain('Waiting for Qwen OAuth authentication...');
expect(lastFrame()).not.toContain('Qwen OAuth Authentication');
expect(lastFrame()).not.toContain('Waiting for authorization');
});
});
describe('Timeout state', () => {
it('should render timeout state when authStatus is timeout', () => {
const { lastFrame } = renderComponent({
authStatus: 'timeout',
authMessage: 'Custom timeout message',
});
const output = lastFrame();
expect(output).toContain('Qwen OAuth Authentication Timeout');
expect(output).toContain('Custom timeout message');
expect(output).toContain(
'Press any key to return to authentication type selection.',
);
});
it('should render default timeout message when no authMessage provided', () => {
const { lastFrame } = renderComponent({
authStatus: 'timeout',
});
const output = lastFrame();
expect(output).toContain('Qwen OAuth Authentication Timeout');
expect(output).toContain(
'OAuth token expired (over 300 seconds). Please select authentication method again.',
);
});
it('should call onCancel for any key press in timeout state', () => {
const { stdin } = renderComponent({
authStatus: 'timeout',
});
// Simulate any key press
stdin.write('a');
expect(mockOnCancel).toHaveBeenCalledTimes(1);
// Reset mock and try enter key
mockOnCancel.mockClear();
stdin.write('\r');
expect(mockOnCancel).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -5,7 +5,7 @@
*/
import React, { useState, useEffect } from 'react';
import { Box, Text, useInput } from 'ink';
import { Box, Text, useInput, Static } from 'ink';
import Spinner from 'ink-spinner';
import Link from 'ink-link';
import qrcode from 'qrcode-terminal';
@@ -26,6 +26,19 @@ interface QwenOAuthProgressProps {
authMessage?: string | null;
}
interface StaticItem {
key: string;
type:
| 'title'
| 'instructions'
| 'url'
| 'qr-instructions'
| 'qr-code'
| 'auth-content';
url?: string;
qrCode?: string;
}
export function QwenOAuthProgress({
onTimeout,
onCancel,
@@ -54,24 +67,26 @@ export function QwenOAuthProgress({
return;
}
// Generate QR code string
const generateQR = () => {
try {
qrcode.generate(
deviceAuth.verification_uri_complete,
{ small: true },
(qrcode: string) => {
setQrCodeData(qrcode);
},
);
} catch (error) {
console.error('Failed to generate QR code:', error);
setQrCodeData(null);
}
};
// Only generate QR code if we don't have one yet for this URL
if (qrCodeData === null) {
const generateQR = () => {
try {
qrcode.generate(
deviceAuth.verification_uri_complete,
{ small: true },
(qrcode: string) => {
setQrCodeData(qrcode);
},
);
} catch (error) {
console.error('Failed to generate QR code:', error);
setQrCodeData(null);
}
};
generateQR();
}, [deviceAuth]);
generateQR();
}
}, [deviceAuth, qrCodeData]);
// Countdown timer
useEffect(() => {
@@ -161,48 +176,77 @@ export function QwenOAuthProgress({
}
return (
<Box
borderStyle="round"
borderColor={Colors.AccentBlue}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold color={Colors.AccentBlue}>
Qwen OAuth Authentication
</Text>
<Box marginTop={1}>
<Text>Please visit this URL to authorize:</Text>
</Box>
<Link url={deviceAuth.verification_uri_complete} fallback={false}>
<Text color={Colors.AccentGreen} bold>
{deviceAuth.verification_uri_complete}
</Text>
</Link>
<>
{qrCodeData && (
<>
<Box marginTop={1}>
<Text>Or scan the QR code below:</Text>
</Box>
<Box marginTop={1}>
<Text>{qrCodeData}</Text>
</Box>
</>
<Static
items={
[
{
key: 'auth-content',
type: 'auth-content' as const,
url: deviceAuth.verification_uri_complete,
qrCode: qrCodeData,
},
] as StaticItem[]
}
style={{
width: '100%',
}}
>
{(item: StaticItem) => (
<Box
borderStyle="round"
borderColor={Colors.AccentBlue}
flexDirection="column"
padding={1}
width="100%"
key={item.key}
>
<Text bold color={Colors.AccentBlue}>
Qwen OAuth Authentication
</Text>
<Box marginTop={1}>
<Text>Please visit this URL to authorize:</Text>
</Box>
<Link url={item.url || ''} fallback={false}>
<Text color={Colors.AccentGreen} bold>
{item.url || ''}
</Text>
</Link>
<Box marginTop={1}>
<Text>Or scan the QR code below:</Text>
</Box>
<Box marginTop={1}>
<Text>{item.qrCode || ''}</Text>
</Box>
</Box>
)}
</Static>
)}
<Box
borderStyle="round"
borderColor={Colors.AccentBlue}
flexDirection="column"
padding={1}
width="100%"
>
<Box marginTop={1}>
<Text>
<Spinner type="dots" /> Waiting for authorization{dots}
</Text>
</Box>
<Box marginTop={1}>
<Text>
<Spinner type="dots" /> Waiting for authorization{dots}
</Text>
<Box marginTop={1} justifyContent="space-between">
<Text color={Colors.Gray}>
Time remaining: {formatTime(timeRemaining)}
</Text>
<Text color={Colors.AccentPurple}>(Press ESC to cancel)</Text>
</Box>
</Box>
<Box marginTop={1} justifyContent="space-between">
<Text color={Colors.Gray}>
Time remaining: {formatTime(timeRemaining)}
</Text>
<Text color={Colors.AccentPurple}>(Press ESC to cancel)</Text>
</Box>
</Box>
</>
);
}

View File

@@ -66,8 +66,8 @@ export function createShowMemoryAction(
type: MessageType.INFO,
content:
fileCount > 0
? 'Hierarchical memory (GEMINI.md or other context files) is loaded but content is empty.'
: 'No hierarchical memory (GEMINI.md or other context files) is currently loaded.',
? 'Hierarchical memory (QWEN.md or other context files) is loaded but content is empty.'
: 'No hierarchical memory (QWEN.md or other context files) is currently loaded.',
timestamp: new Date(),
});
}

View File

@@ -9,7 +9,7 @@
(subpath (param "TARGET_DIR"))
(subpath (param "TMP_DIR"))
(subpath (param "CACHE_DIR"))
(subpath (string-append (param "HOME_DIR") "/.gemini"))
(subpath (string-append (param "HOME_DIR") "/.qwen"))
(subpath (string-append (param "HOME_DIR") "/.npm"))
(subpath (string-append (param "HOME_DIR") "/.cache"))
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))

View File

@@ -9,7 +9,7 @@
(subpath (param "TARGET_DIR"))
(subpath (param "TMP_DIR"))
(subpath (param "CACHE_DIR"))
(subpath (string-append (param "HOME_DIR") "/.gemini"))
(subpath (string-append (param "HOME_DIR") "/.qwen"))
(subpath (string-append (param "HOME_DIR") "/.npm"))
(subpath (string-append (param "HOME_DIR") "/.cache"))
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))

View File

@@ -9,7 +9,7 @@
(subpath (param "TARGET_DIR"))
(subpath (param "TMP_DIR"))
(subpath (param "CACHE_DIR"))
(subpath (string-append (param "HOME_DIR") "/.gemini"))
(subpath (string-append (param "HOME_DIR") "/.qwen"))
(subpath (string-append (param "HOME_DIR") "/.npm"))
(subpath (string-append (param "HOME_DIR") "/.cache"))
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))

View File

@@ -67,7 +67,7 @@
(subpath (param "TARGET_DIR"))
(subpath (param "TMP_DIR"))
(subpath (param "CACHE_DIR"))
(subpath (string-append (param "HOME_DIR") "/.gemini"))
(subpath (string-append (param "HOME_DIR") "/.qwen"))
(subpath (string-append (param "HOME_DIR") "/.npm"))
(subpath (string-append (param "HOME_DIR") "/.cache"))
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))

View File

@@ -67,7 +67,7 @@
(subpath (param "TARGET_DIR"))
(subpath (param "TMP_DIR"))
(subpath (param "CACHE_DIR"))
(subpath (string-append (param "HOME_DIR") "/.gemini"))
(subpath (string-append (param "HOME_DIR") "/.qwen"))
(subpath (string-append (param "HOME_DIR") "/.npm"))
(subpath (string-append (param "HOME_DIR") "/.cache"))
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))

View File

@@ -67,7 +67,7 @@
(subpath (param "TARGET_DIR"))
(subpath (param "TMP_DIR"))
(subpath (param "CACHE_DIR"))
(subpath (string-append (param "HOME_DIR") "/.gemini"))
(subpath (string-append (param "HOME_DIR") "/.qwen"))
(subpath (string-append (param "HOME_DIR") "/.npm"))
(subpath (string-append (param "HOME_DIR") "/.cache"))
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-core",
"version": "0.0.5",
"version": "0.0.7-nightly.0",
"description": "Qwen Code Core",
"repository": {
"type": "git",

View File

@@ -60,8 +60,8 @@ vi.mock('../tools/read-many-files');
vi.mock('../tools/memoryTool', () => ({
MemoryTool: vi.fn(),
setGeminiMdFilename: vi.fn(),
getCurrentGeminiMdFilename: vi.fn(() => 'GEMINI.md'), // Mock the original filename
DEFAULT_CONTEXT_FILENAME: 'GEMINI.md',
getCurrentGeminiMdFilename: vi.fn(() => 'QWEN.md'), // Mock the original filename
DEFAULT_CONTEXT_FILENAME: 'QWEN.md',
GEMINI_CONFIG_DIR: '.gemini',
}));

View File

@@ -43,7 +43,7 @@ import {
DEFAULT_GEMINI_EMBEDDING_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
} from './models.js';
import { ClearcutLogger } from '../telemetry/clearcut-logger/clearcut-logger.js';
import { QwenLogger } from '../telemetry/qwen-logger/qwen-logger.js';
import { shouldAttemptBrowserLaunch } from '../utils/browser.js';
import { MCPOAuthConfig } from '../mcp/oauth-provider.js';
import { IdeClient } from '../ide/ide-client.js';
@@ -208,6 +208,7 @@ export interface ConfigParameters {
timeout?: number;
maxRetries?: number;
};
cliVersion?: string;
}
export class Config {
@@ -281,6 +282,7 @@ export class Config {
timeout?: number;
maxRetries?: number;
};
private readonly cliVersion?: string;
constructor(params: ConfigParameters) {
this.sessionId = params.sessionId;
this.embeddingModel =
@@ -350,6 +352,7 @@ export class Config {
this.enableOpenAILogging = params.enableOpenAILogging ?? false;
this.sampling_params = params.sampling_params;
this.contentGenerator = params.contentGenerator;
this.cliVersion = params.cliVersion;
if (params.contextFileName) {
setGeminiMdFilename(params.contextFileName);
@@ -360,7 +363,7 @@ export class Config {
}
if (this.getUsageStatisticsEnabled()) {
ClearcutLogger.getInstance(this)?.logStartSessionEvent(
QwenLogger.getInstance(this)?.logStartSessionEvent(
new StartSessionEvent(this),
);
} else {
@@ -719,6 +722,10 @@ export class Config {
return this.contentGenerator?.maxRetries;
}
getCliVersion(): string | undefined {
return this.cliVersion;
}
getSystemPromptMappings():
| Array<{
baseUrls?: string[];

View File

@@ -4,6 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
export const DEFAULT_QWEN_MODEL = 'qwen3-coder-plus';
// We do not have a fallback model for now, but note it here anyway.
export const DEFAULT_QWEN_FLASH_MODEL = 'qwen3-coder-flash';
export const DEFAULT_GEMINI_MODEL = 'qwen3-coder-plus';
export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash';
export const DEFAULT_GEMINI_FLASH_LITE_MODEL = 'gemini-2.5-flash-lite';

View File

@@ -15,6 +15,7 @@ vi.mock('openai');
// Mock logger modules
vi.mock('../../telemetry/loggers.js', () => ({
logApiResponse: vi.fn(),
logApiError: vi.fn(),
}));
vi.mock('../../utils/openaiLogger.js', () => ({
@@ -44,6 +45,7 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
timeout: 120000,
maxRetries: 3,
}),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
// Mock OpenAI client
@@ -255,6 +257,7 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
timeout: 300000, // 5 minutes
maxRetries: 5,
}),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
new OpenAIContentGenerator('test-key', 'gpt-4', customConfig);
@@ -273,6 +276,7 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
it('should handle missing timeout config gracefully', () => {
const noTimeoutConfig = {
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
new OpenAIContentGenerator('test-key', 'gpt-4', noTimeoutConfig);
@@ -290,28 +294,18 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
});
describe('token estimation on timeout', () => {
it('should estimate tokens even when request times out', async () => {
it('should surface a clear timeout error when request times out', async () => {
const timeoutError = new Error('Request timeout');
mockOpenAIClient.chat.completions.create.mockRejectedValue(timeoutError);
// Mock countTokens to return a value
const mockCountTokens = vi.spyOn(generator, 'countTokens');
mockCountTokens.mockResolvedValue({ totalTokens: 100 });
const request = {
contents: [{ role: 'user' as const, parts: [{ text: 'Hello world' }] }],
model: 'gpt-4',
};
try {
await generator.generateContent(request, 'test-prompt-id');
} catch (_error) {
// Verify that countTokens was called for estimation
expect(mockCountTokens).toHaveBeenCalledWith({
contents: request.contents,
model: 'gpt-4',
});
}
await expect(
generator.generateContent(request, 'test-prompt-id'),
).rejects.toThrow(/Request timeout after \d+s/);
});
it('should fall back to character-based estimation if countTokens fails', async () => {

View File

@@ -228,6 +228,7 @@ describe('Gemini Client (client.ts)', () => {
getGeminiClient: vi.fn(),
setFallbackMode: vi.fn(),
getDebugMode: vi.fn().mockReturnValue(false),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
};
const MockedConfig = vi.mocked(Config, true);
MockedConfig.mockImplementation(

View File

@@ -17,7 +17,9 @@ import { Config } from '../config/config.js';
vi.mock('../code_assist/codeAssist.js');
vi.mock('@google/genai');
const mockConfig = {} as unknown as Config;
const mockConfig = {
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
describe('createContentGenerator', () => {
it('should create a CodeAssistContentGenerator', async () => {
@@ -73,6 +75,7 @@ describe('createContentGeneratorConfig', () => {
getSamplingParams: vi.fn().mockReturnValue(undefined),
getContentGeneratorTimeout: vi.fn().mockReturnValue(undefined),
getContentGeneratorMaxRetries: vi.fn().mockReturnValue(undefined),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
beforeEach(() => {

View File

@@ -14,7 +14,7 @@ import {
GoogleGenAI,
} from '@google/genai';
import { createCodeAssistContentGenerator } from '../code_assist/codeAssist.js';
import { DEFAULT_GEMINI_MODEL } from '../config/models.js';
import { DEFAULT_GEMINI_MODEL, DEFAULT_QWEN_MODEL } from '../config/models.js';
import { Config } from '../config/config.js';
import { getEffectiveModel } from './modelCheck.js';
import { UserTierId } from '../code_assist/types.js';
@@ -136,7 +136,9 @@ export function createContentGeneratorConfig(
// For Qwen OAuth, we'll handle the API key dynamically in createContentGenerator
// Set a special marker to indicate this is Qwen OAuth
contentGeneratorConfig.apiKey = 'QWEN_OAUTH_DYNAMIC_TOKEN';
contentGeneratorConfig.model = config.getModel() || DEFAULT_GEMINI_MODEL;
// Prefer to use qwen3-coder-plus as the default Qwen model if QWEN_MODEL is not set.
contentGeneratorConfig.model = process.env.QWEN_MODEL || DEFAULT_QWEN_MODEL;
return contentGeneratorConfig;
}
@@ -149,7 +151,7 @@ export async function createContentGenerator(
gcConfig: Config,
sessionId?: string,
): Promise<ContentGenerator> {
const version = process.env.CLI_VERSION || process.version;
const version = gcConfig.getCliVersion() || 'unknown';
const httpOptions = {
headers: {
'User-Agent': `GeminiCLI/${version} (${process.platform}; ${process.arch})`,

View File

@@ -158,14 +158,23 @@ export class GeminiChat {
prompt_id: string,
usageMetadata?: GenerateContentResponseUsageMetadata,
responseText?: string,
responseId?: string,
): Promise<void> {
const authType = this.config.getContentGeneratorConfig()?.authType;
// Don't log API responses for openaiContentGenerator
if (authType === AuthType.QWEN_OAUTH || authType === AuthType.USE_OPENAI) {
return;
}
logApiResponse(
this.config,
new ApiResponseEvent(
responseId || `gemini-${Date.now()}`,
this.config.getModel(),
durationMs,
prompt_id,
this.config.getContentGeneratorConfig()?.authType,
authType,
usageMetadata,
responseText,
),
@@ -176,18 +185,27 @@ export class GeminiChat {
durationMs: number,
error: unknown,
prompt_id: string,
responseId?: string,
): void {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorType = error instanceof Error ? error.name : 'unknown';
const authType = this.config.getContentGeneratorConfig()?.authType;
// Don't log API errors for openaiContentGenerator
if (authType === AuthType.QWEN_OAUTH || authType === AuthType.USE_OPENAI) {
return;
}
logApiError(
this.config,
new ApiErrorEvent(
responseId,
this.config.getModel(),
errorMessage,
durationMs,
prompt_id,
this.config.getContentGeneratorConfig()?.authType,
authType,
errorType,
),
);
@@ -320,6 +338,7 @@ export class GeminiChat {
prompt_id,
response.usageMetadata,
JSON.stringify(response),
response.responseId,
);
this.sendPromise = (async () => {
@@ -563,6 +582,7 @@ export class GeminiChat {
prompt_id,
this.getFinalUsageMetadata(chunks),
JSON.stringify(chunks),
chunks[chunks.length - 1]?.responseId,
);
}
this.recordHistory(inputContent, outputContent);

View File

@@ -23,6 +23,7 @@ vi.mock('openai');
// Mock logger modules
vi.mock('../telemetry/loggers.js', () => ({
logApiResponse: vi.fn(),
logApiError: vi.fn(),
}));
vi.mock('../utils/openaiLogger.js', () => ({
@@ -65,6 +66,7 @@ describe('OpenAIContentGenerator', () => {
top_p: 0.9,
},
}),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
// Mock OpenAI client
@@ -142,6 +144,7 @@ describe('OpenAIContentGenerator', () => {
timeout: 300000,
maxRetries: 5,
}),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
new OpenAIContentGenerator('test-key', 'gpt-4', customConfig);
@@ -900,6 +903,7 @@ describe('OpenAIContentGenerator', () => {
getContentGeneratorConfig: vi.fn().mockReturnValue({
enableOpenAILogging: true,
}),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
const loggingGenerator = new OpenAIContentGenerator(
@@ -1022,6 +1026,7 @@ describe('OpenAIContentGenerator', () => {
getContentGeneratorConfig: vi.fn().mockReturnValue({
enableOpenAILogging: true,
}),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
const loggingGenerator = new OpenAIContentGenerator(
@@ -1816,6 +1821,7 @@ describe('OpenAIContentGenerator', () => {
max_tokens: 500,
},
}),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
const loggingGenerator = new OpenAIContentGenerator(
@@ -2000,6 +2006,7 @@ describe('OpenAIContentGenerator', () => {
getContentGeneratorConfig: vi.fn().mockReturnValue({
enableOpenAILogging: true,
}),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
const loggingGenerator = new OpenAIContentGenerator(
@@ -2256,6 +2263,7 @@ describe('OpenAIContentGenerator', () => {
top_p: undefined,
},
}),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
const testGenerator = new OpenAIContentGenerator(
@@ -2313,6 +2321,7 @@ describe('OpenAIContentGenerator', () => {
frequency_penalty: 0.3,
},
}),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
const testGenerator = new OpenAIContentGenerator(
@@ -2384,4 +2393,616 @@ describe('OpenAIContentGenerator', () => {
consoleSpy.mockRestore();
});
});
describe('metadata control', () => {
it('should include metadata when authType is QWEN_OAUTH', async () => {
const qwenConfig = {
getContentGeneratorConfig: vi.fn().mockReturnValue({
authType: 'qwen-oauth',
enableOpenAILogging: false,
}),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
const qwenGenerator = new OpenAIContentGenerator(
'test-key',
'qwen-turbo',
qwenConfig,
);
const mockResponse = {
id: 'chatcmpl-123',
choices: [
{
index: 0,
message: { role: 'assistant', content: 'Response' },
finish_reason: 'stop',
},
],
created: 1677652288,
model: 'qwen-turbo',
};
mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse);
const request: GenerateContentParameters = {
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
model: 'qwen-turbo',
};
await qwenGenerator.generateContent(request, 'test-prompt-id');
expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
metadata: {
sessionId: 'test-session-id',
promptId: 'test-prompt-id',
},
}),
);
});
it('should include metadata when baseURL is dashscope openai compatible mode', async () => {
// Mock environment to set dashscope base URL BEFORE creating the generator
vi.stubEnv(
'OPENAI_BASE_URL',
'https://dashscope.aliyuncs.com/compatible-mode/v1',
);
const dashscopeConfig = {
getContentGeneratorConfig: vi.fn().mockReturnValue({
authType: 'openai', // Not QWEN_OAUTH
enableOpenAILogging: false,
}),
getSessionId: vi.fn().mockReturnValue('dashscope-session-id'),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
const dashscopeGenerator = new OpenAIContentGenerator(
'test-key',
'qwen-turbo',
dashscopeConfig,
);
// Debug: Check if the client was created with the correct baseURL
expect(vi.mocked(OpenAI)).toHaveBeenCalledWith(
expect.objectContaining({
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
}),
);
// Mock the client's baseURL property to return the expected value
Object.defineProperty(dashscopeGenerator['client'], 'baseURL', {
value: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
writable: true,
});
const mockResponse = {
id: 'chatcmpl-123',
choices: [
{
index: 0,
message: { role: 'assistant', content: 'Response' },
finish_reason: 'stop',
},
],
created: 1677652288,
model: 'qwen-turbo',
};
mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse);
const request: GenerateContentParameters = {
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
model: 'qwen-turbo',
};
await dashscopeGenerator.generateContent(request, 'dashscope-prompt-id');
expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
metadata: {
sessionId: 'dashscope-session-id',
promptId: 'dashscope-prompt-id',
},
}),
);
});
it('should NOT include metadata for regular OpenAI providers', async () => {
const regularConfig = {
getContentGeneratorConfig: vi.fn().mockReturnValue({
authType: 'openai',
enableOpenAILogging: false,
}),
getSessionId: vi.fn().mockReturnValue('regular-session-id'),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
const regularGenerator = new OpenAIContentGenerator(
'test-key',
'gpt-4',
regularConfig,
);
const mockResponse = {
id: 'chatcmpl-123',
choices: [
{
index: 0,
message: { role: 'assistant', content: 'Response' },
finish_reason: 'stop',
},
],
created: 1677652288,
model: 'gpt-4',
};
mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse);
const request: GenerateContentParameters = {
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
model: 'gpt-4',
};
await regularGenerator.generateContent(request, 'regular-prompt-id');
// Should NOT include metadata
expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith(
expect.not.objectContaining({
metadata: expect.any(Object),
}),
);
});
it('should NOT include metadata for other auth types', async () => {
const otherAuthConfig = {
getContentGeneratorConfig: vi.fn().mockReturnValue({
authType: 'gemini-api-key',
enableOpenAILogging: false,
}),
getSessionId: vi.fn().mockReturnValue('other-session-id'),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
const otherGenerator = new OpenAIContentGenerator(
'test-key',
'gpt-4',
otherAuthConfig,
);
const mockResponse = {
id: 'chatcmpl-123',
choices: [
{
index: 0,
message: { role: 'assistant', content: 'Response' },
finish_reason: 'stop',
},
],
created: 1677652288,
model: 'gpt-4',
};
mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse);
const request: GenerateContentParameters = {
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
model: 'gpt-4',
};
await otherGenerator.generateContent(request, 'other-prompt-id');
// Should NOT include metadata
expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith(
expect.not.objectContaining({
metadata: expect.any(Object),
}),
);
});
it('should NOT include metadata for other base URLs', async () => {
// Mock environment to set a different base URL
vi.stubEnv('OPENAI_BASE_URL', 'https://api.openai.com/v1');
const otherBaseUrlConfig = {
getContentGeneratorConfig: vi.fn().mockReturnValue({
authType: 'openai',
enableOpenAILogging: false,
}),
getSessionId: vi.fn().mockReturnValue('other-base-url-session-id'),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
const otherBaseUrlGenerator = new OpenAIContentGenerator(
'test-key',
'gpt-4',
otherBaseUrlConfig,
);
const mockResponse = {
id: 'chatcmpl-123',
choices: [
{
index: 0,
message: { role: 'assistant', content: 'Response' },
finish_reason: 'stop',
},
],
created: 1677652288,
model: 'gpt-4',
};
mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse);
const request: GenerateContentParameters = {
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
model: 'gpt-4',
};
await otherBaseUrlGenerator.generateContent(
request,
'other-base-url-prompt-id',
);
// Should NOT include metadata
expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith(
expect.not.objectContaining({
metadata: expect.any(Object),
}),
);
});
it('should include metadata in streaming requests when conditions are met', async () => {
const qwenConfig = {
getContentGeneratorConfig: vi.fn().mockReturnValue({
authType: 'qwen-oauth',
enableOpenAILogging: false,
}),
getSessionId: vi.fn().mockReturnValue('streaming-session-id'),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
const qwenGenerator = new OpenAIContentGenerator(
'test-key',
'qwen-turbo',
qwenConfig,
);
const mockStream = [
{
id: 'chatcmpl-123',
choices: [
{
index: 0,
delta: { content: 'Hello' },
finish_reason: null,
},
],
created: 1677652288,
},
{
id: 'chatcmpl-123',
choices: [
{
index: 0,
delta: { content: ' there!' },
finish_reason: 'stop',
},
],
created: 1677652288,
},
];
mockOpenAIClient.chat.completions.create.mockResolvedValue({
async *[Symbol.asyncIterator]() {
for (const chunk of mockStream) {
yield chunk;
}
},
});
const request: GenerateContentParameters = {
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
model: 'qwen-turbo',
};
const stream = await qwenGenerator.generateContentStream(
request,
'streaming-prompt-id',
);
// Verify metadata was included in the streaming request
expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
metadata: {
sessionId: 'streaming-session-id',
promptId: 'streaming-prompt-id',
},
stream: true,
stream_options: { include_usage: true },
}),
);
// Consume the stream to complete the test
const responses = [];
for await (const response of stream) {
responses.push(response);
}
expect(responses).toHaveLength(2);
});
it('should NOT include metadata in streaming requests when conditions are not met', async () => {
const regularConfig = {
getContentGeneratorConfig: vi.fn().mockReturnValue({
authType: 'openai',
enableOpenAILogging: false,
}),
getSessionId: vi.fn().mockReturnValue('regular-streaming-session-id'),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
const regularGenerator = new OpenAIContentGenerator(
'test-key',
'gpt-4',
regularConfig,
);
const mockStream = [
{
id: 'chatcmpl-123',
choices: [
{
index: 0,
delta: { content: 'Hello' },
finish_reason: null,
},
],
created: 1677652288,
},
{
id: 'chatcmpl-123',
choices: [
{
index: 0,
delta: { content: ' there!' },
finish_reason: 'stop',
},
],
created: 1677652288,
},
];
mockOpenAIClient.chat.completions.create.mockResolvedValue({
async *[Symbol.asyncIterator]() {
for (const chunk of mockStream) {
yield chunk;
}
},
});
const request: GenerateContentParameters = {
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
model: 'gpt-4',
};
const stream = await regularGenerator.generateContentStream(
request,
'regular-streaming-prompt-id',
);
// Verify metadata was NOT included in the streaming request
expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith(
expect.not.objectContaining({
metadata: expect.any(Object),
}),
);
// Consume the stream to complete the test
const responses = [];
for await (const response of stream) {
responses.push(response);
}
expect(responses).toHaveLength(2);
});
it('should handle undefined sessionId gracefully', async () => {
const qwenConfig = {
getContentGeneratorConfig: vi.fn().mockReturnValue({
authType: 'qwen-oauth',
enableOpenAILogging: false,
}),
getSessionId: vi.fn().mockReturnValue(undefined), // Undefined session ID
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
const qwenGenerator = new OpenAIContentGenerator(
'test-key',
'qwen-turbo',
qwenConfig,
);
const mockResponse = {
id: 'chatcmpl-123',
choices: [
{
index: 0,
message: { role: 'assistant', content: 'Response' },
finish_reason: 'stop',
},
],
created: 1677652288,
model: 'qwen-turbo',
};
mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse);
const request: GenerateContentParameters = {
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
model: 'qwen-turbo',
};
await qwenGenerator.generateContent(
request,
'undefined-session-prompt-id',
);
expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
metadata: {
sessionId: undefined,
promptId: 'undefined-session-prompt-id',
},
}),
);
});
it('should handle undefined baseURL gracefully', async () => {
// Ensure no base URL is set
vi.stubEnv('OPENAI_BASE_URL', '');
const noBaseUrlConfig = {
getContentGeneratorConfig: vi.fn().mockReturnValue({
authType: 'openai',
enableOpenAILogging: false,
}),
getSessionId: vi.fn().mockReturnValue('no-base-url-session-id'),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
const noBaseUrlGenerator = new OpenAIContentGenerator(
'test-key',
'gpt-4',
noBaseUrlConfig,
);
const mockResponse = {
id: 'chatcmpl-123',
choices: [
{
index: 0,
message: { role: 'assistant', content: 'Response' },
finish_reason: 'stop',
},
],
created: 1677652288,
model: 'gpt-4',
};
mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse);
const request: GenerateContentParameters = {
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
model: 'gpt-4',
};
await noBaseUrlGenerator.generateContent(
request,
'no-base-url-prompt-id',
);
// Should NOT include metadata when baseURL is empty
expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith(
expect.not.objectContaining({
metadata: expect.any(Object),
}),
);
});
it('should handle undefined authType gracefully', async () => {
const undefinedAuthConfig = {
getContentGeneratorConfig: vi.fn().mockReturnValue({
authType: undefined, // Undefined auth type
enableOpenAILogging: false,
}),
getSessionId: vi.fn().mockReturnValue('undefined-auth-session-id'),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
const undefinedAuthGenerator = new OpenAIContentGenerator(
'test-key',
'gpt-4',
undefinedAuthConfig,
);
const mockResponse = {
id: 'chatcmpl-123',
choices: [
{
index: 0,
message: { role: 'assistant', content: 'Response' },
finish_reason: 'stop',
},
],
created: 1677652288,
model: 'gpt-4',
};
mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse);
const request: GenerateContentParameters = {
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
model: 'gpt-4',
};
await undefinedAuthGenerator.generateContent(
request,
'undefined-auth-prompt-id',
);
// Should NOT include metadata when authType is undefined
expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith(
expect.not.objectContaining({
metadata: expect.any(Object),
}),
);
});
it('should handle undefined config gracefully', async () => {
const undefinedConfig = {
getContentGeneratorConfig: vi.fn().mockReturnValue(undefined), // Undefined config
getSessionId: vi.fn().mockReturnValue('undefined-config-session-id'),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
const undefinedConfigGenerator = new OpenAIContentGenerator(
'test-key',
'gpt-4',
undefinedConfig,
);
const mockResponse = {
id: 'chatcmpl-123',
choices: [
{
index: 0,
message: { role: 'assistant', content: 'Response' },
finish_reason: 'stop',
},
],
created: 1677652288,
model: 'gpt-4',
};
mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse);
const request: GenerateContentParameters = {
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
model: 'gpt-4',
};
await undefinedConfigGenerator.generateContent(
request,
'undefined-config-prompt-id',
);
// Should NOT include metadata when config is undefined
expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith(
expect.not.objectContaining({
metadata: expect.any(Object),
}),
);
});
});
});

View File

@@ -20,10 +20,10 @@ import {
FunctionCall,
FunctionResponse,
} from '@google/genai';
import { ContentGenerator } from './contentGenerator.js';
import { AuthType, ContentGenerator } from './contentGenerator.js';
import OpenAI from 'openai';
import { logApiResponse } from '../telemetry/loggers.js';
import { ApiResponseEvent } from '../telemetry/types.js';
import { logApiError, logApiResponse } from '../telemetry/loggers.js';
import { ApiErrorEvent, ApiResponseEvent } from '../telemetry/types.js';
import { Config } from '../config/config.js';
import { openaiLogger } from '../utils/openaiLogger.js';
@@ -114,8 +114,7 @@ export class OpenAIContentGenerator implements ContentGenerator {
timeoutConfig.maxRetries = contentGeneratorConfig.maxRetries;
}
// Set up User-Agent header (same format as contentGenerator.ts)
const version = process.env.CLI_VERSION || process.version;
const version = config.getCliVersion() || 'unknown';
const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`;
// Check if using OpenRouter and add required headers
@@ -185,6 +184,46 @@ export class OpenAIContentGenerator implements ContentGenerator {
);
}
/**
* Determine if metadata should be included in the request.
* Only include the `metadata` field if the provider is QWEN_OAUTH
* or the baseUrl is 'https://dashscope.aliyuncs.com/compatible-mode/v1'.
* This is because some models/providers do not support metadata or need extra configuration.
*
* @returns true if metadata should be included, false otherwise
*/
private shouldIncludeMetadata(): boolean {
const authType = this.config.getContentGeneratorConfig?.()?.authType;
// baseUrl may be undefined; default to empty string if so
const baseUrl = this.client?.baseURL || '';
return (
authType === AuthType.QWEN_OAUTH ||
baseUrl === 'https://dashscope.aliyuncs.com/compatible-mode/v1'
);
}
/**
* Build metadata object for OpenAI API requests.
*
* @param userPromptId The user prompt ID to include in metadata
* @returns metadata object if shouldIncludeMetadata() returns true, undefined otherwise
*/
private buildMetadata(
userPromptId: string,
): { metadata: { sessionId?: string; promptId: string } } | undefined {
if (!this.shouldIncludeMetadata()) {
return undefined;
}
return {
metadata: {
sessionId: this.config.getSessionId?.(),
promptId: userPromptId,
},
};
}
async generateContent(
request: GenerateContentParameters,
userPromptId: string,
@@ -205,10 +244,7 @@ export class OpenAIContentGenerator implements ContentGenerator {
model: this.model,
messages,
...samplingParams,
metadata: {
sessionId: this.config.getSessionId?.(),
promptId: userPromptId,
},
...(this.buildMetadata(userPromptId) || {}),
};
if (request.config?.tools) {
@@ -226,6 +262,7 @@ export class OpenAIContentGenerator implements ContentGenerator {
// Log API response event for UI telemetry
const responseEvent = new ApiResponseEvent(
response.responseId || 'unknown',
this.model,
durationMs,
userPromptId,
@@ -254,41 +291,21 @@ export class OpenAIContentGenerator implements ContentGenerator {
? error.message
: String(error);
// Estimate token usage even when there's an error
// This helps track costs and usage even for failed requests
let estimatedUsage;
try {
const tokenCountResult = await this.countTokens({
contents: request.contents,
model: this.model,
});
estimatedUsage = {
promptTokenCount: tokenCountResult.totalTokens,
candidatesTokenCount: 0, // No completion tokens since request failed
totalTokenCount: tokenCountResult.totalTokens,
};
} catch {
// If token counting also fails, provide a minimal estimate
const contentStr = JSON.stringify(request.contents);
const estimatedTokens = Math.ceil(contentStr.length / 4);
estimatedUsage = {
promptTokenCount: estimatedTokens,
candidatesTokenCount: 0,
totalTokenCount: estimatedTokens,
};
}
// Log API error event for UI telemetry with estimated usage
const errorEvent = new ApiResponseEvent(
// Log API error event for UI telemetry
const errorEvent = new ApiErrorEvent(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(error as any).requestID || 'unknown',
this.model,
errorMessage,
durationMs,
userPromptId,
this.config.getContentGeneratorConfig()?.authType,
estimatedUsage,
undefined,
errorMessage,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(error as any).type,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(error as any).code,
);
logApiResponse(this.config, errorEvent);
logApiError(this.config, errorEvent);
// Log error interaction if enabled
if (this.config.getContentGeneratorConfig()?.enableOpenAILogging) {
@@ -339,10 +356,7 @@ export class OpenAIContentGenerator implements ContentGenerator {
...samplingParams,
stream: true,
stream_options: { include_usage: true },
metadata: {
sessionId: this.config.getSessionId?.(),
promptId: userPromptId,
},
...(this.buildMetadata(userPromptId) || {}),
};
if (request.config?.tools) {
@@ -380,6 +394,7 @@ export class OpenAIContentGenerator implements ContentGenerator {
// Log API response event for UI telemetry
const responseEvent = new ApiResponseEvent(
responses[responses.length - 1]?.responseId || 'unknown',
this.model,
durationMs,
userPromptId,
@@ -411,40 +426,21 @@ export class OpenAIContentGenerator implements ContentGenerator {
? error.message
: String(error);
// Estimate token usage even when there's an error in streaming
let estimatedUsage;
try {
const tokenCountResult = await this.countTokens({
contents: request.contents,
model: this.model,
});
estimatedUsage = {
promptTokenCount: tokenCountResult.totalTokens,
candidatesTokenCount: 0, // No completion tokens since request failed
totalTokenCount: tokenCountResult.totalTokens,
};
} catch {
// If token counting also fails, provide a minimal estimate
const contentStr = JSON.stringify(request.contents);
const estimatedTokens = Math.ceil(contentStr.length / 4);
estimatedUsage = {
promptTokenCount: estimatedTokens,
candidatesTokenCount: 0,
totalTokenCount: estimatedTokens,
};
}
// Log API error event for UI telemetry with estimated usage
const errorEvent = new ApiResponseEvent(
// Log API error event for UI telemetry
const errorEvent = new ApiErrorEvent(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(error as any).requestID || 'unknown',
this.model,
errorMessage,
durationMs,
userPromptId,
this.config.getContentGeneratorConfig()?.authType,
estimatedUsage,
undefined,
errorMessage,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(error as any).type,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(error as any).code,
);
logApiResponse(this.config, errorEvent);
logApiError(this.config, errorEvent);
// Log error interaction if enabled
if (this.config.getContentGeneratorConfig()?.enableOpenAILogging) {
@@ -484,40 +480,21 @@ export class OpenAIContentGenerator implements ContentGenerator {
? error.message
: String(error);
// Estimate token usage even when there's an error in streaming setup
let estimatedUsage;
try {
const tokenCountResult = await this.countTokens({
contents: request.contents,
model: this.model,
});
estimatedUsage = {
promptTokenCount: tokenCountResult.totalTokens,
candidatesTokenCount: 0, // No completion tokens since request failed
totalTokenCount: tokenCountResult.totalTokens,
};
} catch {
// If token counting also fails, provide a minimal estimate
const contentStr = JSON.stringify(request.contents);
const estimatedTokens = Math.ceil(contentStr.length / 4);
estimatedUsage = {
promptTokenCount: estimatedTokens,
candidatesTokenCount: 0,
totalTokenCount: estimatedTokens,
};
}
// Log API error event for UI telemetry with estimated usage
const errorEvent = new ApiResponseEvent(
// Log API error event for UI telemetry
const errorEvent = new ApiErrorEvent(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(error as any).requestID || 'unknown',
this.model,
errorMessage,
durationMs,
userPromptId,
this.config.getContentGeneratorConfig()?.authType,
estimatedUsage,
undefined,
errorMessage,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(error as any).type,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(error as any).code,
);
logApiResponse(this.config, errorEvent);
logApiError(this.config, errorEvent);
// Allow subclasses to suppress error logging for specific scenarios
if (!this.shouldSuppressErrorLogging(error, request)) {

View File

@@ -19,6 +19,8 @@ import { LoopDetectionService } from './loopDetectionService.js';
vi.mock('../telemetry/loggers.js', () => ({
logLoopDetected: vi.fn(),
logApiError: vi.fn(),
logApiResponse: vi.fn(),
}));
const TOOL_CALL_LOOP_THRESHOLD = 5;

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
// Defines valid event metadata keys for Clearcut logging.
// Defines valid event metadata keys for Qwen logging.
export enum EventMetadataKey {
GEMINI_CLI_KEY_UNKNOWN = 0,

View File

@@ -9,11 +9,12 @@
*/
import { describe, it, expect } from 'vitest';
import { ClearcutLogger } from './clearcut-logger/clearcut-logger.js';
import { QwenLogger } from './qwen-logger/qwen-logger.js';
import { RumEvent } from './qwen-logger/event-types.js';
import { Config } from '../config/config.js';
describe('Circular Reference Integration Test', () => {
it('should handle HttpsProxyAgent-like circular references in clearcut logging', () => {
it('should handle HttpsProxyAgent-like circular references in qwen logging', () => {
// Create a mock config with proxy
const mockConfig = {
getTelemetryEnabled: () => true,
@@ -44,16 +45,20 @@ describe('Circular Reference Integration Test', () => {
proxyAgentLike.sockets['cloudcode-pa.googleapis.com:443'] = [socketLike];
// Create an event that would contain this circular structure
const problematicEvent = {
const problematicEvent: RumEvent = {
timestamp: Date.now(),
event_type: 'exception',
type: 'error',
name: 'api_error',
error: new Error('Network error'),
function_args: {
filePath: '/test/file.txt',
httpAgent: proxyAgentLike, // This would cause the circular reference
},
};
} as RumEvent;
// Test that ClearcutLogger can handle this
const logger = ClearcutLogger.getInstance(mockConfig);
// Test that QwenLogger can handle this
const logger = QwenLogger.getInstance(mockConfig);
expect(() => {
logger?.enqueueLogEvent(problematicEvent);

View File

@@ -212,6 +212,7 @@ describe('loggers', () => {
toolUsePromptTokenCount: 2,
};
const event = new ApiResponseEvent(
'test-response-id',
'test-model',
100,
'prompt-id-1',
@@ -229,6 +230,7 @@ describe('loggers', () => {
'event.name': EVENT_API_RESPONSE,
'event.timestamp': '2025-01-01T00:00:00.000Z',
[SemanticAttributes.HTTP_STATUS_CODE]: 200,
response_id: 'test-response-id',
model: 'test-model',
status_code: 200,
duration_ms: 100,
@@ -275,6 +277,7 @@ describe('loggers', () => {
toolUsePromptTokenCount: 2,
};
const event = new ApiResponseEvent(
'test-response-id-2',
'test-model',
100,
'prompt-id-1',

View File

@@ -39,7 +39,7 @@ import {
} from './metrics.js';
import { isTelemetrySdkInitialized } from './sdk.js';
import { uiTelemetryService, UiEvent } from './uiTelemetry.js';
import { ClearcutLogger } from './clearcut-logger/clearcut-logger.js';
import { QwenLogger } from './qwen-logger/qwen-logger.js';
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
const shouldLogUserPrompts = (config: Config): boolean =>
@@ -55,7 +55,7 @@ export function logCliConfiguration(
config: Config,
event: StartSessionEvent,
): void {
ClearcutLogger.getInstance(config)?.logStartSessionEvent(event);
QwenLogger.getInstance(config)?.logStartSessionEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
@@ -84,7 +84,7 @@ export function logCliConfiguration(
}
export function logUserPrompt(config: Config, event: UserPromptEvent): void {
ClearcutLogger.getInstance(config)?.logNewPromptEvent(event);
QwenLogger.getInstance(config)?.logNewPromptEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
@@ -113,7 +113,7 @@ export function logToolCall(config: Config, event: ToolCallEvent): void {
'event.timestamp': new Date().toISOString(),
} as UiEvent;
uiTelemetryService.addEvent(uiEvent);
ClearcutLogger.getInstance(config)?.logToolCallEvent(event);
QwenLogger.getInstance(config)?.logToolCallEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
@@ -146,7 +146,7 @@ export function logToolCall(config: Config, event: ToolCallEvent): void {
}
export function logApiRequest(config: Config, event: ApiRequestEvent): void {
ClearcutLogger.getInstance(config)?.logApiRequestEvent(event);
QwenLogger.getInstance(config)?.logApiRequestEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
@@ -168,7 +168,7 @@ export function logFlashFallback(
config: Config,
event: FlashFallbackEvent,
): void {
ClearcutLogger.getInstance(config)?.logFlashFallbackEvent(event);
QwenLogger.getInstance(config)?.logFlashFallbackEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
@@ -193,7 +193,7 @@ export function logApiError(config: Config, event: ApiErrorEvent): void {
'event.timestamp': new Date().toISOString(),
} as UiEvent;
uiTelemetryService.addEvent(uiEvent);
ClearcutLogger.getInstance(config)?.logApiErrorEvent(event);
QwenLogger.getInstance(config)?.logApiErrorEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
@@ -235,7 +235,7 @@ export function logApiResponse(config: Config, event: ApiResponseEvent): void {
'event.timestamp': new Date().toISOString(),
} as UiEvent;
uiTelemetryService.addEvent(uiEvent);
ClearcutLogger.getInstance(config)?.logApiResponseEvent(event);
QwenLogger.getInstance(config)?.logApiResponseEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
...getCommonAttributes(config),
@@ -298,7 +298,7 @@ export function logLoopDetected(
config: Config,
event: LoopDetectedEvent,
): void {
ClearcutLogger.getInstance(config)?.logLoopDetectedEvent(event);
QwenLogger.getInstance(config)?.logLoopDetectedEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
@@ -318,7 +318,7 @@ export function logNextSpeakerCheck(
config: Config,
event: NextSpeakerCheckEvent,
): void {
ClearcutLogger.getInstance(config)?.logNextSpeakerCheck(event);
QwenLogger.getInstance(config)?.logNextSpeakerCheck(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
@@ -339,7 +339,7 @@ export function logSlashCommand(
config: Config,
event: SlashCommandEvent,
): void {
ClearcutLogger.getInstance(config)?.logSlashCommandEvent(event);
QwenLogger.getInstance(config)?.logSlashCommandEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {

View File

@@ -0,0 +1,83 @@
// RUM Protocol Data Structures
export interface RumApp {
id: string;
env: string;
version: string;
type: 'cli' | 'extension';
}
export interface RumUser {
id: string;
}
export interface RumSession {
id: string;
}
export interface RumView {
id: string;
name: string;
}
export interface RumEvent {
timestamp?: number;
event_type?: 'view' | 'action' | 'exception' | 'resource';
type: string; // Event type
name: string; // Event name
snapshots?: string; // JSON string of event snapshots
properties?: Record<string, unknown>;
// [key: string]: unknown;
}
export interface RumViewEvent extends RumEvent {
view_type?: string; // View rendering type
time_spent?: number; // Time spent on current view in ms
}
export interface RumActionEvent extends RumEvent {
target_name?: string; // Element user interacted with (for auto-collected actions only)
duration?: number; // Action duration in ms
method_info?: string; // Action callback, e.g.: onClick()
}
export interface RumExceptionEvent extends RumEvent {
source?: string; // Error source, e.g.: console, event
file?: string; // Error file
subtype?: string; // Secondary classification of error type
message?: string; // Concise, readable message explaining the event
stack?: string; // Stack trace or supplemental information about the error
caused_by?: string; // Exception cause
line?: number; // Line number where exception occurred
column?: number; // Column number where exception occurred
thread_id?: string; // Thread ID
binary_images?: string; // Error source
}
export interface RumResourceEvent extends RumEvent {
method?: string; // HTTP request method: POST, GET, etc.
status_code?: string; // Resource status code
message?: string; // Error message content, corresponds to resource.error_msg
url?: string; // Resource URL
provider_type?: string; // Resource provider type: first-party, cdn, ad, analytics
trace_id?: string; // Resource request TraceID
success?: number; // Resource loading success: 1 (default) success, 0 failure
duration?: number; // Total time spent loading resource in ms (responseEnd - redirectStart)
size?: number; // Resource size in bytes, corresponds to decodedBodySize
connect_duration?: number; // Time spent establishing connection to server in ms (connectEnd - connectStart)
ssl_duration?: number; // Time spent on TLS handshake in ms (connectEnd - secureConnectionStart), 0 if no SSL
dns_duration?: number; // Time spent resolving DNS name in ms (domainLookupEnd - domainLookupStart)
redirect_duration?: number; // Time spent on HTTP redirects in ms (redirectEnd - redirectStart)
first_byte_duration?: number; // Time waiting for first byte of response in ms (responseStart - requestStart)
download_duration?: number; // Time spent downloading response in ms (responseEnd - responseStart)
timing_data?: string; // JSON string of PerformanceResourceTiming
trace_data?: string; // Trace information snapshot JSON string
}
export interface RumPayload {
app: RumApp;
user: RumUser;
session: RumSession;
view: RumView;
events: RumEvent[];
_v: string;
}

View File

@@ -0,0 +1,484 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Buffer } from 'buffer';
import * as https from 'https';
import { HttpsProxyAgent } from 'https-proxy-agent';
import { randomUUID } from 'crypto';
import {
StartSessionEvent,
EndSessionEvent,
UserPromptEvent,
ToolCallEvent,
ApiRequestEvent,
ApiResponseEvent,
ApiErrorEvent,
FlashFallbackEvent,
LoopDetectedEvent,
NextSpeakerCheckEvent,
SlashCommandEvent,
MalformedJsonResponseEvent,
} from '../types.js';
import {
RumEvent,
RumViewEvent,
RumActionEvent,
RumResourceEvent,
RumExceptionEvent,
RumPayload,
} from './event-types.js';
// Removed unused EventMetadataKey import
import { Config } from '../../config/config.js';
import { safeJsonStringify } from '../../utils/safeJsonStringify.js';
// Removed unused import
import { HttpError, retryWithBackoff } from '../../utils/retry.js';
import { getInstallationId } from '../../utils/user_id.js';
// Usage statistics collection endpoint
const USAGE_STATS_HOSTNAME = 'gb4w8c3ygj-default-sea.rum.aliyuncs.com';
const USAGE_STATS_PATH = '/';
const RUN_APP_ID = 'gb4w8c3ygj@851d5d500f08f92';
export interface LogResponse {
nextRequestWaitMs?: number;
}
// Singleton class for batch posting log events to RUM. When a new event comes in, the elapsed time
// is checked and events are flushed to RUM if at least a minute has passed since the last flush.
export class QwenLogger {
private static instance: QwenLogger;
private config?: Config;
private readonly events: RumEvent[] = [];
private last_flush_time: number = Date.now();
private flush_interval_ms: number = 1000 * 60; // Wait at least a minute before flushing events.
private userId: string;
private sessionId: string;
private viewId: string;
private isFlushInProgress: boolean = false;
private isShutdown: boolean = false;
private constructor(config?: Config) {
this.config = config;
this.userId = this.generateUserId();
this.sessionId =
typeof this.config?.getSessionId === 'function'
? this.config.getSessionId()
: '';
this.viewId = randomUUID();
}
private generateUserId(): string {
// Use installation ID as user ID for consistency
return `user-${getInstallationId()}`;
}
static getInstance(config?: Config): QwenLogger | undefined {
if (config === undefined || !config?.getUsageStatisticsEnabled())
return undefined;
if (!QwenLogger.instance) {
QwenLogger.instance = new QwenLogger(config);
}
process.on('exit', QwenLogger.instance.shutdown.bind(QwenLogger.instance));
return QwenLogger.instance;
}
enqueueLogEvent(event: RumEvent): void {
this.events.push(event);
}
createRumEvent(
eventType: 'view' | 'action' | 'exception' | 'resource',
type: string,
name: string,
properties: Partial<RumEvent>,
): RumEvent {
return {
timestamp: Date.now(),
event_type: eventType,
type,
name,
...(properties || {}),
};
}
createViewEvent(
type: string,
name: string,
properties: Partial<RumViewEvent>,
): RumEvent {
return this.createRumEvent('view', type, name, properties);
}
createActionEvent(
type: string,
name: string,
properties: Partial<RumActionEvent>,
): RumEvent {
return this.createRumEvent('action', type, name, properties);
}
createResourceEvent(
type: string,
name: string,
properties: Partial<RumResourceEvent>,
): RumEvent {
return this.createRumEvent('resource', type, name, properties);
}
createExceptionEvent(
type: string,
name: string,
properties: Partial<RumExceptionEvent>,
): RumEvent {
return this.createRumEvent('exception', type, name, properties);
}
async createRumPayload(): Promise<RumPayload> {
const version = this.config?.getCliVersion() || 'unknown';
return {
app: {
id: RUN_APP_ID,
env: process.env.DEBUG ? 'dev' : 'prod',
version: version || 'unknown',
type: 'cli',
},
user: {
id: this.userId,
},
session: {
id: this.sessionId,
},
view: {
id: this.viewId,
name: 'qwen-code-cli',
},
events: [...this.events],
_v: `qwen-code@${version}`,
};
}
flushIfNeeded(): void {
if (Date.now() - this.last_flush_time < this.flush_interval_ms) {
return;
}
// Prevent concurrent flush operations
if (this.isFlushInProgress) {
return;
}
this.flushToRum().catch((error) => {
console.debug('Error flushing to RUM:', error);
});
}
async flushToRum(): Promise<LogResponse> {
if (this.config?.getDebugMode()) {
console.log('Flushing log events to RUM.');
}
if (this.events.length === 0) {
return {};
}
this.isFlushInProgress = true;
const rumPayload = await this.createRumPayload();
const flushFn = () =>
new Promise<Buffer>((resolve, reject) => {
const body = safeJsonStringify(rumPayload);
const options = {
hostname: USAGE_STATS_HOSTNAME,
path: USAGE_STATS_PATH,
method: 'POST',
headers: {
'Content-Length': Buffer.byteLength(body),
'Content-Type': 'text/plain;charset=UTF-8',
},
};
const bufs: Buffer[] = [];
const req = https.request(
{
...options,
agent: this.getProxyAgent(),
},
(res) => {
if (
res.statusCode &&
(res.statusCode < 200 || res.statusCode >= 300)
) {
const err: HttpError = new Error(
`Request failed with status ${res.statusCode}`,
);
err.status = res.statusCode;
res.resume();
return reject(err);
}
res.on('data', (buf) => bufs.push(buf));
res.on('end', () => resolve(Buffer.concat(bufs)));
},
);
req.on('error', reject);
req.end(body);
});
try {
await retryWithBackoff(flushFn, {
maxAttempts: 3,
initialDelayMs: 200,
shouldRetry: (err: unknown) => {
if (!(err instanceof Error)) return false;
const status = (err as HttpError).status as number | undefined;
// If status is not available, it's likely a network error
if (status === undefined) return true;
// Retry on 429 (Too many Requests) and 5xx server errors.
return status === 429 || (status >= 500 && status < 600);
},
});
this.events.splice(0, this.events.length);
this.last_flush_time = Date.now();
return {};
} catch (error) {
if (this.config?.getDebugMode()) {
console.error('RUM flush failed after multiple retries.', error);
}
return {};
} finally {
this.isFlushInProgress = false;
}
}
logStartSessionEvent(event: StartSessionEvent): void {
const applicationEvent = this.createViewEvent('session', 'session_start', {
properties: {
model: event.model,
},
snapshots: JSON.stringify({
embedding_model: event.embedding_model,
sandbox_enabled: event.sandbox_enabled,
core_tools_enabled: event.core_tools_enabled,
approval_mode: event.approval_mode,
api_key_enabled: event.api_key_enabled,
vertex_ai_enabled: event.vertex_ai_enabled,
debug_enabled: event.debug_enabled,
mcp_servers: event.mcp_servers,
telemetry_enabled: event.telemetry_enabled,
telemetry_log_user_prompts_enabled:
event.telemetry_log_user_prompts_enabled,
}),
});
// Flush start event immediately
this.enqueueLogEvent(applicationEvent);
this.flushToRum().catch((error: unknown) => {
console.debug('Error flushing to RUM:', error);
});
}
logNewPromptEvent(event: UserPromptEvent): void {
const rumEvent = this.createActionEvent('user_prompt', 'user_prompt', {
properties: {
auth_type: event.auth_type,
prompt_id: event.prompt_id,
},
snapshots: JSON.stringify({
prompt_length: event.prompt_length,
}),
});
this.enqueueLogEvent(rumEvent);
this.flushIfNeeded();
}
logToolCallEvent(event: ToolCallEvent): void {
const rumEvent = this.createActionEvent(
'tool_call',
`tool_call#${event.function_name}`,
{
properties: {
prompt_id: event.prompt_id,
},
snapshots: JSON.stringify({
function_name: event.function_name,
decision: event.decision,
success: event.success,
duration_ms: event.duration_ms,
error: event.error,
error_type: event.error_type,
}),
},
);
this.enqueueLogEvent(rumEvent);
this.flushIfNeeded();
}
logApiRequestEvent(_event: ApiRequestEvent): void {
// ignore for now
return;
// const rumEvent = this.createResourceEvent('api', 'api_request', {
// properties: {
// model: event.model,
// prompt_id: event.prompt_id,
// },
// });
// this.enqueueLogEvent(rumEvent);
// this.flushIfNeeded();
}
logApiResponseEvent(event: ApiResponseEvent): void {
const rumEvent = this.createResourceEvent('api', 'api_response', {
status_code: event.status_code?.toString() ?? '',
duration: event.duration_ms,
success: 1,
message: event.error,
trace_id: event.response_id,
properties: {
auth_type: event.auth_type,
model: event.model,
prompt_id: event.prompt_id,
},
snapshots: JSON.stringify({
input_token_count: event.input_token_count,
output_token_count: event.output_token_count,
cached_content_token_count: event.cached_content_token_count,
thoughts_token_count: event.thoughts_token_count,
tool_token_count: event.tool_token_count,
}),
});
this.enqueueLogEvent(rumEvent);
this.flushIfNeeded();
}
logApiErrorEvent(event: ApiErrorEvent): void {
const rumEvent = this.createResourceEvent('api', 'api_error', {
status_code: event.status_code?.toString() ?? '',
duration: event.duration_ms,
success: 0,
message: event.error,
trace_id: event.response_id,
properties: {
auth_type: event.auth_type,
model: event.model,
prompt_id: event.prompt_id,
},
snapshots: JSON.stringify({
error_type: event.error_type,
}),
});
this.enqueueLogEvent(rumEvent);
this.flushIfNeeded();
}
logFlashFallbackEvent(event: FlashFallbackEvent): void {
const rumEvent = this.createActionEvent('fallback', 'flash_fallback', {
properties: {
auth_type: event.auth_type,
},
});
this.enqueueLogEvent(rumEvent);
this.flushIfNeeded();
}
logLoopDetectedEvent(event: LoopDetectedEvent): void {
const rumEvent = this.createExceptionEvent('error', 'loop_detected', {
subtype: 'loop_detected',
properties: {
prompt_id: event.prompt_id,
},
snapshots: JSON.stringify({
loop_type: event.loop_type,
}),
});
this.enqueueLogEvent(rumEvent);
this.flushIfNeeded();
}
logNextSpeakerCheck(event: NextSpeakerCheckEvent): void {
const rumEvent = this.createActionEvent('check', 'next_speaker_check', {
properties: {
prompt_id: event.prompt_id,
},
snapshots: JSON.stringify({
finish_reason: event.finish_reason,
result: event.result,
}),
});
this.enqueueLogEvent(rumEvent);
this.flushIfNeeded();
}
logSlashCommandEvent(event: SlashCommandEvent): void {
const rumEvent = this.createActionEvent('command', 'slash_command', {
snapshots: JSON.stringify({
command: event.command,
subcommand: event.subcommand,
}),
});
this.enqueueLogEvent(rumEvent);
this.flushIfNeeded();
}
logMalformedJsonResponseEvent(event: MalformedJsonResponseEvent): void {
const rumEvent = this.createExceptionEvent(
'error',
'malformed_json_response',
{
subtype: 'malformed_json_response',
properties: {
model: event.model,
},
},
);
this.enqueueLogEvent(rumEvent);
this.flushIfNeeded();
}
logEndSessionEvent(_event: EndSessionEvent): void {
const applicationEvent = this.createViewEvent('session', 'session_end', {});
// Flush immediately on session end.
this.enqueueLogEvent(applicationEvent);
this.flushToRum().catch((error: unknown) => {
console.debug('Error flushing to RUM:', error);
});
}
getProxyAgent() {
const proxyUrl = this.config?.getProxy();
if (!proxyUrl) return undefined;
// undici which is widely used in the repo can only support http & https proxy protocol,
// https://github.com/nodejs/undici/issues/2224
if (proxyUrl.startsWith('http')) {
return new HttpsProxyAgent(proxyUrl);
} else {
throw new Error('Unsupported proxy type');
}
}
shutdown() {
if (this.isShutdown) return;
this.isShutdown = true;
const event = new EndSessionEvent(this.config);
this.logEndSessionEvent(event);
}
}

View File

@@ -28,7 +28,6 @@ import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
import { Config } from '../config/config.js';
import { SERVICE_NAME } from './constants.js';
import { initializeMetrics } from './metrics.js';
import { ClearcutLogger } from './clearcut-logger/clearcut-logger.js';
import {
FileLogExporter,
FileMetricExporter,
@@ -141,7 +140,6 @@ export async function shutdownTelemetry(): Promise<void> {
return;
}
try {
ClearcutLogger.getInstance()?.shutdown();
await sdk.shutdown();
console.log('OpenTelemetry SDK shut down successfully.');
} catch (error) {

View File

@@ -161,6 +161,7 @@ export class ApiRequestEvent {
export class ApiErrorEvent {
'event.name': 'api_error';
'event.timestamp': string; // ISO 8601
response_id?: string;
model: string;
error: string;
error_type?: string;
@@ -170,6 +171,7 @@ export class ApiErrorEvent {
auth_type?: string;
constructor(
response_id: string | undefined,
model: string,
error: string,
duration_ms: number,
@@ -180,6 +182,7 @@ export class ApiErrorEvent {
) {
this['event.name'] = 'api_error';
this['event.timestamp'] = new Date().toISOString();
this.response_id = response_id;
this.model = model;
this.error = error;
this.error_type = error_type;
@@ -193,6 +196,7 @@ export class ApiErrorEvent {
export class ApiResponseEvent {
'event.name': 'api_response';
'event.timestamp': string; // ISO 8601
response_id: string;
model: string;
status_code?: number | string;
duration_ms: number;
@@ -208,6 +212,7 @@ export class ApiResponseEvent {
auth_type?: string;
constructor(
response_id: string,
model: string,
duration_ms: number,
prompt_id: string,
@@ -218,6 +223,7 @@ export class ApiResponseEvent {
) {
this['event.name'] = 'api_response';
this['event.timestamp'] = new Date().toISOString();
this.response_id = response_id;
this.model = model;
this.duration_ms = duration_ms;
this.status_code = 200;

View File

@@ -60,7 +60,7 @@ export const GEMINI_CONFIG_DIR = '.qwen';
export const DEFAULT_CONTEXT_FILENAME = 'QWEN.md';
export const MEMORY_SECTION_HEADER = '## Qwen Added Memories';
// This variable will hold the currently configured filename for GEMINI.md context files.
// This variable will hold the currently configured filename for QWEN.md context files.
// It defaults to DEFAULT_CONTEXT_FILENAME but can be overridden by setGeminiMdFilename.
let currentGeminiMdFilename: string | string[] = DEFAULT_CONTEXT_FILENAME;

View File

@@ -202,8 +202,8 @@ describe('bfsFileSearch', () => {
await createEmptyDir(`dir${i}`, 'subdir1', 'deep');
if (i < 10) {
// Add target files in some directories
await createTestFile('content', `dir${i}`, 'GEMINI.md');
await createTestFile('content', `dir${i}`, 'subdir1', 'GEMINI.md');
await createTestFile('content', `dir${i}`, 'QWEN.md');
await createTestFile('content', `dir${i}`, 'subdir1', 'QWEN.md');
}
}
@@ -216,7 +216,7 @@ describe('bfsFileSearch', () => {
for (let i = 0; i < iterations; i++) {
const searchStartTime = performance.now();
const result = await bfsFileSearch(testRootDir, {
fileName: 'GEMINI.md',
fileName: 'QWEN.md',
maxDirs: 200,
debug: false,
});
@@ -242,7 +242,7 @@ describe('bfsFileSearch', () => {
console.log(
`📊 Min/Max Duration: ${minDuration.toFixed(2)}ms / ${maxDuration.toFixed(2)}ms`,
);
console.log(`📁 Found ${foundFiles} GEMINI.md files`);
console.log(`📁 Found ${foundFiles} QWEN.md files`);
console.log(
`🏎️ Processing ~${Math.round(200 / (avgDuration / 1000))} dirs/second`,
);

View File

@@ -365,7 +365,7 @@ My code memory
it('should load extension context file paths', async () => {
const extensionFilePath = await createTestFile(
path.join(testRootDir, 'extensions/ext1/GEMINI.md'),
path.join(testRootDir, 'extensions/ext1/QWEN.md'),
'Extension memory content',
);

View File

@@ -252,7 +252,7 @@ function concatenateInstructions(
}
/**
* Loads hierarchical GEMINI.md files and concatenates their content.
* Loads hierarchical QWEN.md files and concatenates their content.
* This function is intended for use by the server.
*/
export async function loadServerHierarchicalMemory(
@@ -282,7 +282,7 @@ export async function loadServerHierarchicalMemory(
maxDirs,
);
if (filePaths.length === 0) {
if (debugMode) logger.debug('No GEMINI.md files found in hierarchy.');
if (debugMode) logger.debug('No QWEN.md files found in hierarchy.');
return { memoryContent: '', fileCount: 0 };
}
const contentsWithPaths = await readGeminiMdFiles(

View File

@@ -191,7 +191,7 @@ function findCodeRegions(content: string): Array<[number, number]> {
}
/**
* Processes import statements in GEMINI.md content
* Processes import statements in QWEN.md content
* Supports @path/to/file syntax for importing content from other files
* @param content - The content to process for imports
* @param basePath - The directory path where the current file is located

View File

@@ -8,3 +8,9 @@ import { setSimulate429 } from './src/utils/testUtils.js';
// Disable 429 simulation globally for all tests
setSimulate429(false);
// Some dependencies (e.g., undici) expect a global File constructor in Node.
// Provide a minimal shim for test environment if missing.
if (typeof (globalThis as unknown as { File?: unknown }).File === 'undefined') {
(globalThis as unknown as { File: unknown }).File = class {} as unknown;
}

View File

@@ -2,7 +2,7 @@
"name": "qwen-code-vscode-ide-companion",
"displayName": "Qwen Code Companion",
"description": "Enable Qwen Code with direct access to your VS Code workspace.",
"version": "0.0.4",
"version": "0.0.6",
"publisher": "qwenlm",
"icon": "assets/icon.png",
"repository": {