Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
b8d61a77e8 chore(release): v0.0.5 2025-08-08 13:45:55 +00:00
54 changed files with 333 additions and 1758 deletions

View File

@@ -24,7 +24,7 @@ jobs:
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
with:
version: 0.0.5
version: 0.0.4
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.5
version: 0.0.4
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_target:
pull_request:
types: [opened]
pull_request_review_comment:
types: [created]

View File

@@ -53,7 +53,7 @@ debug:
run-npx:
npx https://github.com/QwenLM/qwen-code
npx https://github.com/google-gemini/gemini-cli
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
- **`/stats`** - Check current token usage and limits
- **`/status`** - 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
- `/stats` - Show current session information
- `/status` - 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 `/directory add` and `--include-directories`.
- **Description:** Display all directories added by `/direcotry 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 `QWEN.md` files by default; configurable via `contextFileName`).
- **Description:** Manage the AI's instructional context (hierarchical memory loaded from `GEMINI.md` files).
- **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 context files (e.g., `QWEN.md`). This lets you inspect the instructional context being provided to the model.
- **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.
- **`refresh`**:
- **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).
- **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).
- **`/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:** 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.
- **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.
### 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., `QWEN.md`, `AGENTS.md`). Can be a single filename or a list of accepted filenames.
- **Default:** `QWEN.md`
- **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`
- **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., `QWEN.md`)
### Example Context File Content (e.g., `GEMINI.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., `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:
- **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:
1. **Global Context File:**
- Location: `~/.qwen/<contextFileName>` (e.g., `~/.qwen/QWEN.md` in your user home directory).
- Location: `~/.gemini/<contextFileName>` (e.g., `~/.gemini/GEMINI.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,5 +523,3 @@ 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 QWEN.md import feature using @file.md syntax.
- **[Memory Import Processor](./memport.md):** Documentation for the modular GEMINI.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 model, potentially incorporating conversation history, tool definitions, and instructional context from context files (e.g., `QWEN.md`).
- **Prompt engineering:** Constructing effective prompts for the Gemini model, potentially incorporating conversation history, tool definitions, and instructional context from `GEMINI.md` files.
- **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 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.
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.
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 context files.
You can use the [`/memory` command](../cli/commands.md) to `show`, `add`, and `refresh` the content of loaded `GEMINI.md` files.

View File

@@ -1,17 +1,17 @@
# Memory Import Processor
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.
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.
## Overview
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.
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.
## Syntax
Use the `@` symbol followed by the path to the file you want to import:
```markdown
# Main QWEN.md file
# Main GEMINI.md file
This is the main content.
@@ -39,7 +39,7 @@ More content here.
### Basic Import
```markdown
# My QWEN.md
# My GEMINI.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. This helps users debug problems with their context files by showing which files were read and their import relationships.
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.
Example tree structure:
```
Memory Files
L project: QWEN.md
Memory Files
L project: GEMINI.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 context file content.
Processes import statements in GEMINI.md 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": "QWEN.md",
"contextFileName": "GEMINI.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 `QWEN.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 `GEMINI.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 Qwen Code 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 Gemini CLI 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 Qwen Code sessions.
## How to use `save_memory` with the Gemini CLI
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`.
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.
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.
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.
Usage:

10
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.0.6",
"version": "0.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
"version": "0.0.6",
"version": "0.0.5",
"workspaces": [
"packages/*"
],
@@ -11817,7 +11817,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
"version": "0.0.6",
"version": "0.0.5",
"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.6",
"version": "0.0.5",
"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.5",
"version": "0.0.4",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.15.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.0.6",
"version": "0.0.5",
"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.6"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.5"
},
"scripts": {
"start": "node scripts/start.js",

View File

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

View File

@@ -412,7 +412,6 @@ export async function loadCliConfig(
}
const sandboxConfig = await loadSandboxConfig(settings, argv);
const cliVersion = await getCliVersion();
return new Config({
sessionId,
@@ -498,7 +497,6 @@ 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(() => ['QWEN.md']),
getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.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(() => ['QWEN.md']),
getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']),
ideContext: ideContextMock,
isGitRepository: vi.fn(),
};
@@ -577,7 +577,7 @@ describe('App UI', () => {
},
});
mockConfig.getGeminiMdFileCount.mockReturnValue(1);
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md']);
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['GEMINI.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 QWEN.md file',
'Using: 1 open file (ctrl+e to view) | 1 GEMINI.md file',
);
});
it('should display default "QWEN.md" in footer when contextFileName is not set and count is 1', async () => {
it('should display default "GEMINI.md" in footer when contextFileName is not set and count is 1', async () => {
mockConfig.getGeminiMdFileCount.mockReturnValue(1);
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md']);
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['GEMINI.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,12 +609,15 @@ describe('App UI', () => {
);
currentUnmount = unmount;
await Promise.resolve(); // Wait for any async updates
expect(lastFrame()).toContain('Using: 1 QWEN.md file');
expect(lastFrame()).toContain('Using: 1 GEMINI.md file');
});
it('should display default "QWEN.md" with plural when contextFileName is not set and count is > 1', async () => {
it('should display default "GEMINI.md" with plural when contextFileName is not set and count is > 1', async () => {
mockConfig.getGeminiMdFileCount.mockReturnValue(2);
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md', 'QWEN.md']);
mockConfig.getAllGeminiMdFilenames.mockReturnValue([
'GEMINI.md',
'GEMINI.md',
]);
mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false);
@@ -627,7 +630,7 @@ describe('App UI', () => {
);
currentUnmount = unmount;
await Promise.resolve();
expect(lastFrame()).toContain('Using: 2 QWEN.md files');
expect(lastFrame()).toContain('Using: 2 GEMINI.md files');
});
it('should display custom contextFileName in footer when set and count is 1', async () => {
@@ -724,9 +727,12 @@ describe('App UI', () => {
expect(lastFrame()).not.toContain('ANY_FILE.MD');
});
it('should display QWEN.md and MCP server count when both are present', async () => {
it('should display GEMINI.md and MCP server count when both are present', async () => {
mockConfig.getGeminiMdFileCount.mockReturnValue(2);
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md', 'QWEN.md']);
mockConfig.getAllGeminiMdFilenames.mockReturnValue([
'GEMINI.md',
'GEMINI.md',
]);
mockConfig.getMcpServers.mockReturnValue({
server1: {} as MCPServerConfig,
});
@@ -745,7 +751,7 @@ describe('App UI', () => {
expect(lastFrame()).toContain('1 MCP server');
});
it('should display only MCP server count when QWEN.md count is 0', async () => {
it('should display only MCP server count when GEMINI.md count is 0', async () => {
mockConfig.getGeminiMdFileCount.mockReturnValue(0);
mockConfig.getAllGeminiMdFilenames.mockReturnValue([]);
mockConfig.getMcpServers.mockReturnValue({

View File

@@ -11,31 +11,16 @@ import { initCommand } from './initCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { type CommandContext } from './types.js';
// 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');
});
// Mock the 'fs' module
vi.mock('fs', () => ({
existsSync: vi.fn(),
writeFileSync: vi.fn(),
}));
describe('initCommand', () => {
let mockContext: CommandContext;
const targetDir = '/test/dir';
const DEFAULT_CONTEXT_FILENAME = 'QWEN.md';
const geminiMdPath = path.join(targetDir, DEFAULT_CONTEXT_FILENAME);
const geminiMdPath = path.join(targetDir, 'GEMINI.md');
beforeEach(() => {
// Create a fresh mock context for each test
@@ -53,10 +38,9 @@ describe('initCommand', () => {
vi.clearAllMocks();
});
it(`should inform the user if ${DEFAULT_CONTEXT_FILENAME} already exists and is non-empty`, async () => {
it('should inform the user if GEMINI.md already exists', 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, '');
@@ -65,13 +49,14 @@ describe('initCommand', () => {
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `A ${DEFAULT_CONTEXT_FILENAME} file already exists in this directory. No changes were made.`,
content:
'A GEMINI.md file already exists in this directory. No changes were made.',
});
// Assert: Ensure no file was written
expect(fs.writeFileSync).not.toHaveBeenCalled();
});
it(`should create ${DEFAULT_CONTEXT_FILENAME} and submit a prompt if it does not exist`, async () => {
it('should create GEMINI.md and submit a prompt if it does not exist', async () => {
// Arrange: Simulate that the file does not exist
vi.mocked(fs.existsSync).mockReturnValue(false);
@@ -85,7 +70,7 @@ describe('initCommand', () => {
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: 'info',
text: `Empty ${DEFAULT_CONTEXT_FILENAME} created. Now analyzing the project to populate it.`,
text: 'Empty GEMINI.md created. Now analyzing the project to populate it.',
},
expect.any(Number),
);
@@ -93,20 +78,10 @@ describe('initCommand', () => {
// Assert: Check that the correct prompt is submitted
expect(result.type).toBe('submit_prompt');
expect(result.content).toContain(
'You are Qwen Code, an interactive CLI agent',
'You are an AI agent that brings the power of Gemini',
);
});
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,11 +12,10 @@ 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 QWEN.md file.',
description: 'Analyzes the project and creates a tailored GEMINI.md file.',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
@@ -30,55 +29,32 @@ export const initCommand: SlashCommand = {
};
}
const targetDir = context.services.config.getTargetDir();
const contextFileName = getCurrentGeminiMdFilename();
const contextFilePath = path.join(targetDir, contextFileName);
const geminiMdPath = path.join(targetDir, 'GEMINI.md');
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) {
if (fs.existsSync(geminiMdPath)) {
return {
type: 'message',
messageType: 'error',
content: `Unexpected error preparing ${contextFileName}: ${error instanceof Error ? error.message : String(error)}`,
messageType: 'info',
content:
'A GEMINI.md file already exists in this directory. No changes were made.',
};
}
// 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 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.
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.
**Analysis Process:**
@@ -94,7 +70,7 @@ You are Qwen Code, an interactive CLI agent. Analyze the current directory and g
* **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.
**${contextFileName} Content Generation:**
**GEMINI.md Content Generation:**
**For a Code Project:**
@@ -110,7 +86,7 @@ You are Qwen Code, an interactive CLI agent. Analyze the current directory and g
**Final Output:**
Write the complete content to the \`${contextFileName}\` file. The output must be well-formatted Markdown.
Write the complete content to the \`GEMINI.md\` file. The output must be well-formatted Markdown.
`,
};
},

View File

@@ -22,12 +22,6 @@ 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();
@@ -45,17 +39,7 @@ describe('QwenOAuthProgress', () => {
const mockDeviceAuth = createMockDeviceAuth();
const renderComponent = (
props: Partial<{
deviceAuth: DeviceAuthorizationInfo;
authStatus:
| 'idle'
| 'polling'
| 'success'
| 'error'
| 'timeout'
| 'rate_limit';
authMessage: string | null;
}> = {},
props: Partial<{ deviceAuth: DeviceAuthorizationInfo }> = {},
) =>
render(
<QwenOAuthProgress
@@ -101,43 +85,20 @@ describe('QwenOAuthProgress', () => {
const { lastFrame } = renderComponent({ deviceAuth: mockDeviceAuth });
const output = lastFrame();
// Initially no QR code shown until it's generated, but the status area should be visible
expect(output).toContain('Qwen OAuth Authentication');
expect(output).toContain('Please visit this URL to authorize:');
expect(output).toContain(mockDeviceAuth.verification_uri_complete);
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 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;
});
it('should display correct URL in bordered box', () => {
const customAuth = createMockDeviceAuth({
verification_uri_complete: 'https://custom.com/auth?code=XYZ789',
});
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}
/>,
);
const { lastFrame } = renderComponent({ deviceAuth: customAuth });
expect(lastFrame()).toContain('https://custom.com/auth?code=XYZ789');
});
@@ -243,16 +204,14 @@ describe('QwenOAuthProgress', () => {
expect(lastFrame()).toContain('Time remaining: 4:59');
});
it('should use default 300 second timeout when deviceAuth is null', () => {
const { lastFrame } = render(
it('should not start timer when deviceAuth is null', () => {
render(
<QwenOAuthProgress onTimeout={mockOnTimeout} onCancel={mockOnCancel} />,
);
// 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
// Advance timer and ensure onTimeout is not called
vi.advanceTimersByTime(5000);
expect(mockOnTimeout).not.toHaveBeenCalled();
});
});
@@ -339,41 +298,8 @@ describe('QwenOAuthProgress', () => {
);
});
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');
});
// 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 handle QR code generation errors gracefully', async () => {
const qrcode = await import('qrcode-terminal');
@@ -394,7 +320,7 @@ describe('QwenOAuthProgress', () => {
/>,
);
// Should not crash and should not show QR code section since QR generation failed
// Should not crash and should not show QR code section
const output = lastFrame();
expect(output).not.toContain('Or scan the QR code below:');
expect(consoleErrorSpy).toHaveBeenCalledWith(
@@ -489,58 +415,14 @@ describe('QwenOAuthProgress', () => {
/>,
);
// Initially shows waiting for authorization
expect(lastFrame()).toContain('Waiting for authorization');
expect(lastFrame()).toContain('Qwen OAuth Authentication');
rerender(
<QwenOAuthProgress onTimeout={mockOnTimeout} onCancel={mockOnCancel} />,
);
expect(lastFrame()).toContain('Waiting for 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);
expect(lastFrame()).not.toContain('Qwen OAuth Authentication');
});
});
});

View File

@@ -5,7 +5,7 @@
*/
import React, { useState, useEffect } from 'react';
import { Box, Text, useInput, Static } from 'ink';
import { Box, Text, useInput } from 'ink';
import Spinner from 'ink-spinner';
import Link from 'ink-link';
import qrcode from 'qrcode-terminal';
@@ -26,19 +26,6 @@ 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,
@@ -67,26 +54,24 @@ export function QwenOAuthProgress({
return;
}
// 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);
}
};
// 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);
}
};
generateQR();
}
}, [deviceAuth, qrCodeData]);
generateQR();
}, [deviceAuth]);
// Countdown timer
useEffect(() => {
@@ -176,77 +161,48 @@ export function QwenOAuthProgress({
}
return (
<>
{qrCodeData && (
<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
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={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} justifyContent="space-between">
<Text color={Colors.Gray}>
Time remaining: {formatTime(timeRemaining)}
</Text>
<Text color={Colors.AccentPurple}>(Press ESC to cancel)</Text>
</Box>
<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>
</>
)}
<Box marginTop={1}>
<Text>
<Spinner type="dots" /> Waiting for authorization{dots}
</Text>
</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 (QWEN.md or other context files) is loaded but content is empty.'
: 'No hierarchical memory (QWEN.md or other context files) is currently loaded.',
? '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.',
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") "/.qwen"))
(subpath (string-append (param "HOME_DIR") "/.gemini"))
(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") "/.qwen"))
(subpath (string-append (param "HOME_DIR") "/.gemini"))
(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") "/.qwen"))
(subpath (string-append (param "HOME_DIR") "/.gemini"))
(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") "/.qwen"))
(subpath (string-append (param "HOME_DIR") "/.gemini"))
(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") "/.qwen"))
(subpath (string-append (param "HOME_DIR") "/.gemini"))
(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") "/.qwen"))
(subpath (string-append (param "HOME_DIR") "/.gemini"))
(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.6",
"version": "0.0.5",
"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(() => 'QWEN.md'), // Mock the original filename
DEFAULT_CONTEXT_FILENAME: 'QWEN.md',
getCurrentGeminiMdFilename: vi.fn(() => 'GEMINI.md'), // Mock the original filename
DEFAULT_CONTEXT_FILENAME: 'GEMINI.md',
GEMINI_CONFIG_DIR: '.gemini',
}));

View File

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

View File

@@ -4,10 +4,6 @@
* 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,7 +15,6 @@ vi.mock('openai');
// Mock logger modules
vi.mock('../../telemetry/loggers.js', () => ({
logApiResponse: vi.fn(),
logApiError: vi.fn(),
}));
vi.mock('../../utils/openaiLogger.js', () => ({
@@ -45,7 +44,6 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
timeout: 120000,
maxRetries: 3,
}),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
// Mock OpenAI client
@@ -257,7 +255,6 @@ 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);
@@ -276,7 +273,6 @@ 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);
@@ -294,18 +290,28 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
});
describe('token estimation on timeout', () => {
it('should surface a clear timeout error when request times out', async () => {
it('should estimate tokens even 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',
};
await expect(
generator.generateContent(request, 'test-prompt-id'),
).rejects.toThrow(/Request timeout after \d+s/);
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',
});
}
});
it('should fall back to character-based estimation if countTokens fails', async () => {

View File

@@ -228,7 +228,6 @@ 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,9 +17,7 @@ import { Config } from '../config/config.js';
vi.mock('../code_assist/codeAssist.js');
vi.mock('@google/genai');
const mockConfig = {
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
const mockConfig = {} as unknown as Config;
describe('createContentGenerator', () => {
it('should create a CodeAssistContentGenerator', async () => {
@@ -75,7 +73,6 @@ 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, DEFAULT_QWEN_MODEL } from '../config/models.js';
import { DEFAULT_GEMINI_MODEL } from '../config/models.js';
import { Config } from '../config/config.js';
import { getEffectiveModel } from './modelCheck.js';
import { UserTierId } from '../code_assist/types.js';
@@ -136,9 +136,7 @@ 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';
// 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;
contentGeneratorConfig.model = config.getModel() || DEFAULT_GEMINI_MODEL;
return contentGeneratorConfig;
}
@@ -151,7 +149,7 @@ export async function createContentGenerator(
gcConfig: Config,
sessionId?: string,
): Promise<ContentGenerator> {
const version = gcConfig.getCliVersion() || 'unknown';
const version = process.env.CLI_VERSION || process.version;
const httpOptions = {
headers: {
'User-Agent': `GeminiCLI/${version} (${process.platform}; ${process.arch})`,

View File

@@ -158,23 +158,14 @@ 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,
authType,
this.config.getContentGeneratorConfig()?.authType,
usageMetadata,
responseText,
),
@@ -185,27 +176,18 @@ 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,
authType,
this.config.getContentGeneratorConfig()?.authType,
errorType,
),
);
@@ -338,7 +320,6 @@ export class GeminiChat {
prompt_id,
response.usageMetadata,
JSON.stringify(response),
response.responseId,
);
this.sendPromise = (async () => {
@@ -582,7 +563,6 @@ export class GeminiChat {
prompt_id,
this.getFinalUsageMetadata(chunks),
JSON.stringify(chunks),
chunks[chunks.length - 1]?.responseId,
);
}
this.recordHistory(inputContent, outputContent);

View File

@@ -23,7 +23,6 @@ vi.mock('openai');
// Mock logger modules
vi.mock('../telemetry/loggers.js', () => ({
logApiResponse: vi.fn(),
logApiError: vi.fn(),
}));
vi.mock('../utils/openaiLogger.js', () => ({
@@ -66,7 +65,6 @@ describe('OpenAIContentGenerator', () => {
top_p: 0.9,
},
}),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
// Mock OpenAI client
@@ -144,7 +142,6 @@ describe('OpenAIContentGenerator', () => {
timeout: 300000,
maxRetries: 5,
}),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
new OpenAIContentGenerator('test-key', 'gpt-4', customConfig);
@@ -903,7 +900,6 @@ describe('OpenAIContentGenerator', () => {
getContentGeneratorConfig: vi.fn().mockReturnValue({
enableOpenAILogging: true,
}),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
const loggingGenerator = new OpenAIContentGenerator(
@@ -1026,7 +1022,6 @@ describe('OpenAIContentGenerator', () => {
getContentGeneratorConfig: vi.fn().mockReturnValue({
enableOpenAILogging: true,
}),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
const loggingGenerator = new OpenAIContentGenerator(
@@ -1821,7 +1816,6 @@ describe('OpenAIContentGenerator', () => {
max_tokens: 500,
},
}),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
const loggingGenerator = new OpenAIContentGenerator(
@@ -2006,7 +2000,6 @@ describe('OpenAIContentGenerator', () => {
getContentGeneratorConfig: vi.fn().mockReturnValue({
enableOpenAILogging: true,
}),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
const loggingGenerator = new OpenAIContentGenerator(
@@ -2263,7 +2256,6 @@ describe('OpenAIContentGenerator', () => {
top_p: undefined,
},
}),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
const testGenerator = new OpenAIContentGenerator(
@@ -2321,7 +2313,6 @@ describe('OpenAIContentGenerator', () => {
frequency_penalty: 0.3,
},
}),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
const testGenerator = new OpenAIContentGenerator(
@@ -2393,616 +2384,4 @@ 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 { AuthType, ContentGenerator } from './contentGenerator.js';
import { ContentGenerator } from './contentGenerator.js';
import OpenAI from 'openai';
import { logApiError, logApiResponse } from '../telemetry/loggers.js';
import { ApiErrorEvent, ApiResponseEvent } from '../telemetry/types.js';
import { logApiResponse } from '../telemetry/loggers.js';
import { ApiResponseEvent } from '../telemetry/types.js';
import { Config } from '../config/config.js';
import { openaiLogger } from '../utils/openaiLogger.js';
@@ -114,7 +114,8 @@ export class OpenAIContentGenerator implements ContentGenerator {
timeoutConfig.maxRetries = contentGeneratorConfig.maxRetries;
}
const version = config.getCliVersion() || 'unknown';
// Set up User-Agent header (same format as contentGenerator.ts)
const version = process.env.CLI_VERSION || process.version;
const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`;
// Check if using OpenRouter and add required headers
@@ -184,46 +185,6 @@ 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,
@@ -244,7 +205,10 @@ export class OpenAIContentGenerator implements ContentGenerator {
model: this.model,
messages,
...samplingParams,
...(this.buildMetadata(userPromptId) || {}),
metadata: {
sessionId: this.config.getSessionId?.(),
promptId: userPromptId,
},
};
if (request.config?.tools) {
@@ -262,7 +226,6 @@ export class OpenAIContentGenerator implements ContentGenerator {
// Log API response event for UI telemetry
const responseEvent = new ApiResponseEvent(
response.responseId || 'unknown',
this.model,
durationMs,
userPromptId,
@@ -291,21 +254,41 @@ export class OpenAIContentGenerator implements ContentGenerator {
? error.message
: String(error);
// 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',
// 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(
this.model,
errorMessage,
durationMs,
userPromptId,
this.config.getContentGeneratorConfig()?.authType,
// 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,
estimatedUsage,
undefined,
errorMessage,
);
logApiError(this.config, errorEvent);
logApiResponse(this.config, errorEvent);
// Log error interaction if enabled
if (this.config.getContentGeneratorConfig()?.enableOpenAILogging) {
@@ -356,7 +339,10 @@ export class OpenAIContentGenerator implements ContentGenerator {
...samplingParams,
stream: true,
stream_options: { include_usage: true },
...(this.buildMetadata(userPromptId) || {}),
metadata: {
sessionId: this.config.getSessionId?.(),
promptId: userPromptId,
},
};
if (request.config?.tools) {
@@ -394,7 +380,6 @@ 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,
@@ -426,21 +411,40 @@ export class OpenAIContentGenerator implements ContentGenerator {
? error.message
: String(error);
// 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',
// 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(
this.model,
errorMessage,
durationMs,
userPromptId,
this.config.getContentGeneratorConfig()?.authType,
// 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,
estimatedUsage,
undefined,
errorMessage,
);
logApiError(this.config, errorEvent);
logApiResponse(this.config, errorEvent);
// Log error interaction if enabled
if (this.config.getContentGeneratorConfig()?.enableOpenAILogging) {
@@ -480,21 +484,40 @@ export class OpenAIContentGenerator implements ContentGenerator {
? error.message
: String(error);
// 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',
// 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(
this.model,
errorMessage,
durationMs,
userPromptId,
this.config.getContentGeneratorConfig()?.authType,
// 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,
estimatedUsage,
undefined,
errorMessage,
);
logApiError(this.config, errorEvent);
logApiResponse(this.config, errorEvent);
// Allow subclasses to suppress error logging for specific scenarios
if (!this.shouldSuppressErrorLogging(error, request)) {

View File

@@ -19,8 +19,6 @@ 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 Qwen logging.
// Defines valid event metadata keys for Clearcut logging.
export enum EventMetadataKey {
GEMINI_CLI_KEY_UNKNOWN = 0,

View File

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

View File

@@ -212,7 +212,6 @@ describe('loggers', () => {
toolUsePromptTokenCount: 2,
};
const event = new ApiResponseEvent(
'test-response-id',
'test-model',
100,
'prompt-id-1',
@@ -230,7 +229,6 @@ 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,
@@ -277,7 +275,6 @@ 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 { QwenLogger } from './qwen-logger/qwen-logger.js';
import { ClearcutLogger } from './clearcut-logger/clearcut-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 {
QwenLogger.getInstance(config)?.logStartSessionEvent(event);
ClearcutLogger.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 {
QwenLogger.getInstance(config)?.logNewPromptEvent(event);
ClearcutLogger.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);
QwenLogger.getInstance(config)?.logToolCallEvent(event);
ClearcutLogger.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 {
QwenLogger.getInstance(config)?.logApiRequestEvent(event);
ClearcutLogger.getInstance(config)?.logApiRequestEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
@@ -168,7 +168,7 @@ export function logFlashFallback(
config: Config,
event: FlashFallbackEvent,
): void {
QwenLogger.getInstance(config)?.logFlashFallbackEvent(event);
ClearcutLogger.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);
QwenLogger.getInstance(config)?.logApiErrorEvent(event);
ClearcutLogger.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);
QwenLogger.getInstance(config)?.logApiResponseEvent(event);
ClearcutLogger.getInstance(config)?.logApiResponseEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
...getCommonAttributes(config),
@@ -298,7 +298,7 @@ export function logLoopDetected(
config: Config,
event: LoopDetectedEvent,
): void {
QwenLogger.getInstance(config)?.logLoopDetectedEvent(event);
ClearcutLogger.getInstance(config)?.logLoopDetectedEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
@@ -318,7 +318,7 @@ export function logNextSpeakerCheck(
config: Config,
event: NextSpeakerCheckEvent,
): void {
QwenLogger.getInstance(config)?.logNextSpeakerCheck(event);
ClearcutLogger.getInstance(config)?.logNextSpeakerCheck(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
@@ -339,7 +339,7 @@ export function logSlashCommand(
config: Config,
event: SlashCommandEvent,
): void {
QwenLogger.getInstance(config)?.logSlashCommandEvent(event);
ClearcutLogger.getInstance(config)?.logSlashCommandEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {

View File

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

@@ -1,484 +0,0 @@
/**
* @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,6 +28,7 @@ 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,
@@ -140,6 +141,7 @@ 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,7 +161,6 @@ 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;
@@ -171,7 +170,6 @@ export class ApiErrorEvent {
auth_type?: string;
constructor(
response_id: string | undefined,
model: string,
error: string,
duration_ms: number,
@@ -182,7 +180,6 @@ 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;
@@ -196,7 +193,6 @@ 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;
@@ -212,7 +208,6 @@ export class ApiResponseEvent {
auth_type?: string;
constructor(
response_id: string,
model: string,
duration_ms: number,
prompt_id: string,
@@ -223,7 +218,6 @@ 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 QWEN.md context files.
// This variable will hold the currently configured filename for GEMINI.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}`, 'QWEN.md');
await createTestFile('content', `dir${i}`, 'subdir1', 'QWEN.md');
await createTestFile('content', `dir${i}`, 'GEMINI.md');
await createTestFile('content', `dir${i}`, 'subdir1', 'GEMINI.md');
}
}
@@ -216,7 +216,7 @@ describe('bfsFileSearch', () => {
for (let i = 0; i < iterations; i++) {
const searchStartTime = performance.now();
const result = await bfsFileSearch(testRootDir, {
fileName: 'QWEN.md',
fileName: 'GEMINI.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} QWEN.md files`);
console.log(`📁 Found ${foundFiles} GEMINI.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/QWEN.md'),
path.join(testRootDir, 'extensions/ext1/GEMINI.md'),
'Extension memory content',
);

View File

@@ -252,7 +252,7 @@ function concatenateInstructions(
}
/**
* Loads hierarchical QWEN.md files and concatenates their content.
* Loads hierarchical GEMINI.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 QWEN.md files found in hierarchy.');
if (debugMode) logger.debug('No GEMINI.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 QWEN.md content
* Processes import statements in GEMINI.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,9 +8,3 @@ 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.5",
"version": "0.0.4",
"publisher": "qwenlm",
"icon": "assets/icon.png",
"repository": {