mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-23 18:19:15 +00:00
Compare commits
28 Commits
release/v0
...
v0.0.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da3addae2e | ||
|
|
0bc45aeefe | ||
|
|
7856f52afb | ||
|
|
e986476fe0 | ||
|
|
cfc1aebee6 | ||
|
|
ef1c8a4bfe | ||
|
|
484292b2ac | ||
|
|
f9659184d4 | ||
|
|
6d5bb1b57c | ||
|
|
fb9f2d292c | ||
|
|
16ea8560b7 | ||
|
|
2655af079a | ||
|
|
807844fb57 | ||
|
|
2202d26ac7 | ||
|
|
58f66ccfc6 | ||
|
|
65c622c0ac | ||
|
|
a3ec2f52c9 | ||
|
|
c96852dc56 | ||
|
|
028a82ebeb | ||
|
|
6b67cd1b57 | ||
|
|
96a9b683b2 | ||
|
|
dcc86699cf | ||
|
|
964509f587 | ||
|
|
e3a5806ae2 | ||
|
|
a45adbdc76 | ||
|
|
41500814b0 | ||
|
|
786832913b | ||
|
|
4807434d9f |
@@ -24,7 +24,7 @@ jobs:
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
with:
|
||||
version: 0.0.4
|
||||
version: 0.0.5
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
settings_json: |
|
||||
{
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
ISSUES_TO_TRIAGE: ${{ steps.find_issues.outputs.issues_to_triage }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
with:
|
||||
version: 0.0.4
|
||||
version: 0.0.5
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
OPENAI_MODEL: ${{ secrets.OPENAI_MODEL }}
|
||||
|
||||
2
.github/workflows/qwen-code-pr-review.yml
vendored
2
.github/workflows/qwen-code-pr-review.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: 🧐 Qwen Pull Request Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
|
||||
2
Makefile
2
Makefile
@@ -53,7 +53,7 @@ debug:
|
||||
|
||||
|
||||
run-npx:
|
||||
npx https://github.com/google-gemini/gemini-cli
|
||||
npx https://github.com/QwenLM/qwen-code
|
||||
|
||||
create-alias:
|
||||
scripts/create_alias.sh
|
||||
|
||||
@@ -101,7 +101,7 @@ Create or edit `.qwen/settings.json` in your home directory:
|
||||
|
||||
- **`/compress`** - Compress conversation history to continue within token limits
|
||||
- **`/clear`** - Clear all conversation history and start fresh
|
||||
- **`/status`** - Check current token usage and limits
|
||||
- **`/stats`** - Check current token usage and limits
|
||||
|
||||
> 📝 **Note**: Session token limit applies to a single conversation, not cumulative API calls.
|
||||
|
||||
@@ -310,7 +310,7 @@ qwen
|
||||
- `/help` - Display available commands
|
||||
- `/clear` - Clear conversation history
|
||||
- `/compress` - Compress history to save tokens
|
||||
- `/status` - Show current session information
|
||||
- `/stats` - Show current session information
|
||||
- `/exit` or `/quit` - Exit Qwen Code
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
@@ -46,7 +46,7 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
- **Usage:** `/directory add <path1>,<path2>`
|
||||
- **Note:** Disabled in restrictive sandbox profiles. If you're using that, use `--include-directories` when starting the session instead.
|
||||
- **`show`**:
|
||||
- **Description:** Display all directories added by `/direcotry add` and `--include-directories`.
|
||||
- **Description:** Display all directories added by `/directory add` and `--include-directories`.
|
||||
- **Usage:** `/directory show`
|
||||
|
||||
- **`/editor`**
|
||||
@@ -70,15 +70,15 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
- **Keyboard Shortcut:** Press **Ctrl+T** at any time to toggle between showing and hiding tool descriptions.
|
||||
|
||||
- **`/memory`**
|
||||
- **Description:** Manage the AI's instructional context (hierarchical memory loaded from `GEMINI.md` files).
|
||||
- **Description:** Manage the AI's instructional context (hierarchical memory loaded from `QWEN.md` files by default; configurable via `contextFileName`).
|
||||
- **Sub-commands:**
|
||||
- **`add`**:
|
||||
- **Description:** Adds the following text to the AI's memory. Usage: `/memory add <text to remember>`
|
||||
- **`show`**:
|
||||
- **Description:** Display the full, concatenated content of the current hierarchical memory that has been loaded from all `GEMINI.md` files. This lets you inspect the instructional context being provided to the Gemini model.
|
||||
- **Description:** Display the full, concatenated content of the current hierarchical memory that has been loaded from all context files (e.g., `QWEN.md`). This lets you inspect the instructional context being provided to the model.
|
||||
- **`refresh`**:
|
||||
- **Description:** Reload the hierarchical instructional memory from all `GEMINI.md` files found in the configured locations (global, project/ancestors, and sub-directories). This command updates the model with the latest `GEMINI.md` content.
|
||||
- **Note:** For more details on how `GEMINI.md` files contribute to hierarchical memory, see the [CLI Configuration documentation](./configuration.md#4-geminimd-files-hierarchical-instructional-context).
|
||||
- **Description:** Reload the hierarchical instructional memory from all context files (default: `QWEN.md`) found in the configured locations (global, project/ancestors, and sub-directories). This updates the model with the latest context content.
|
||||
- **Note:** For more details on how context files contribute to hierarchical memory, see the [CLI Configuration documentation](./configuration.md#context-files-hierarchical-instructional-context).
|
||||
|
||||
- **`/restore`**
|
||||
- **Description:** Restores the project files to the state they were in just before a tool was executed. This is particularly useful for undoing file edits made by a tool. If run without a tool call ID, it will list available checkpoints to restore from.
|
||||
@@ -123,7 +123,7 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
- **Status indicator:** When enabled, shows `[NORMAL]` or `[INSERT]` in the footer
|
||||
|
||||
- **`/init`**
|
||||
- **Description:** To help users easily create a `GEMINI.md` file, this command analyzes the current directory and generates a tailored context file, making it simpler for them to provide project-specific instructions to the Gemini agent.
|
||||
- **Description:** Analyzes the current directory and creates a `QWEN.md` context file by default (or the filename specified by `contextFileName`). If a non-empty file already exists, no changes are made. The command seeds an empty file and prompts the model to populate it with project-specific instructions.
|
||||
|
||||
### Custom Commands
|
||||
|
||||
|
||||
@@ -38,8 +38,8 @@ In addition to a project settings file, a project's `.gemini` directory can cont
|
||||
### Available settings in `settings.json`:
|
||||
|
||||
- **`contextFileName`** (string or array of strings):
|
||||
- **Description:** Specifies the filename for context files (e.g., `GEMINI.md`, `AGENTS.md`). Can be a single filename or a list of accepted filenames.
|
||||
- **Default:** `GEMINI.md`
|
||||
- **Description:** Specifies the filename for context files (e.g., `QWEN.md`, `AGENTS.md`). Can be a single filename or a list of accepted filenames.
|
||||
- **Default:** `QWEN.md`
|
||||
- **Example:** `"contextFileName": "AGENTS.md"`
|
||||
|
||||
- **`bugCommand`** (object):
|
||||
@@ -415,7 +415,7 @@ While not strictly configuration for the CLI's _behavior_, context files (defaul
|
||||
|
||||
- **Purpose:** These Markdown files contain instructions, guidelines, or context that you want the Gemini model to be aware of during your interactions. The system is designed to manage this instructional context hierarchically.
|
||||
|
||||
### Example Context File Content (e.g., `GEMINI.md`)
|
||||
### Example Context File Content (e.g., `QWEN.md`)
|
||||
|
||||
Here's a conceptual example of what a context file at the root of a TypeScript project might contain:
|
||||
|
||||
@@ -450,9 +450,9 @@ Here's a conceptual example of what a context file at the root of a TypeScript p
|
||||
|
||||
This example demonstrates how you can provide general project context, specific coding conventions, and even notes about particular files or components. The more relevant and precise your context files are, the better the AI can assist you. Project-specific context files are highly encouraged to establish conventions and context.
|
||||
|
||||
- **Hierarchical Loading and Precedence:** The CLI implements a sophisticated hierarchical memory system by loading context files (e.g., `GEMINI.md`) from several locations. Content from files lower in this list (more specific) typically overrides or supplements content from files higher up (more general). The exact concatenation order and final context can be inspected using the `/memory show` command. The typical loading order is:
|
||||
- **Hierarchical Loading and Precedence:** The CLI implements a sophisticated hierarchical memory system by loading context files (e.g., `QWEN.md`) from several locations. Content from files lower in this list (more specific) typically overrides or supplements content from files higher up (more general). The exact concatenation order and final context can be inspected using the `/memory show` command. The typical loading order is:
|
||||
1. **Global Context File:**
|
||||
- Location: `~/.gemini/<contextFileName>` (e.g., `~/.gemini/GEMINI.md` in your user home directory).
|
||||
- Location: `~/.qwen/<contextFileName>` (e.g., `~/.qwen/QWEN.md` in your user home directory).
|
||||
- Scope: Provides default instructions for all your projects.
|
||||
2. **Project Root & Ancestors Context Files:**
|
||||
- Location: The CLI searches for the configured context file in the current working directory and then in each parent directory up to either the project root (identified by a `.git` folder) or your home directory.
|
||||
@@ -523,3 +523,5 @@ You can opt out of usage statistics collection at any time by setting the `usage
|
||||
"usageStatisticsEnabled": false
|
||||
}
|
||||
```
|
||||
|
||||
Note: When usage statistics are enabled, events are sent to an Alibaba Cloud RUM collection endpoint.
|
||||
|
||||
@@ -5,14 +5,14 @@ Gemini CLI's core package (`packages/core`) is the backend portion of Gemini CLI
|
||||
## Navigating this section
|
||||
|
||||
- **[Core tools API](./tools-api.md):** Information on how tools are defined, registered, and used by the core.
|
||||
- **[Memory Import Processor](./memport.md):** Documentation for the modular GEMINI.md import feature using @file.md syntax.
|
||||
- **[Memory Import Processor](./memport.md):** Documentation for the modular QWEN.md import feature using @file.md syntax.
|
||||
|
||||
## Role of the core
|
||||
|
||||
While the `packages/cli` portion of Gemini CLI provides the user interface, `packages/core` is responsible for:
|
||||
|
||||
- **Gemini API interaction:** Securely communicating with the Google Gemini API, sending user prompts, and receiving model responses.
|
||||
- **Prompt engineering:** Constructing effective prompts for the Gemini model, potentially incorporating conversation history, tool definitions, and instructional context from `GEMINI.md` files.
|
||||
- **Prompt engineering:** Constructing effective prompts for the model, potentially incorporating conversation history, tool definitions, and instructional context from context files (e.g., `QWEN.md`).
|
||||
- **Tool management & orchestration:**
|
||||
- Registering available tools (e.g., file system tools, shell command execution).
|
||||
- Interpreting tool use requests from the Gemini model.
|
||||
@@ -48,8 +48,8 @@ The file discovery service is responsible for finding files in the project that
|
||||
|
||||
## Memory discovery service
|
||||
|
||||
The memory discovery service is responsible for finding and loading the `GEMINI.md` files that provide context to the model. It searches for these files in a hierarchical manner, starting from the current working directory and moving up to the project root and the user's home directory. It also searches in subdirectories.
|
||||
The memory discovery service is responsible for finding and loading the context files (default: `QWEN.md`) that provide context to the model. It searches for these files in a hierarchical manner, starting from the current working directory and moving up to the project root and the user's home directory. It also searches in subdirectories.
|
||||
|
||||
This allows you to have global, project-level, and component-level context files, which are all combined to provide the model with the most relevant information.
|
||||
|
||||
You can use the [`/memory` command](../cli/commands.md) to `show`, `add`, and `refresh` the content of loaded `GEMINI.md` files.
|
||||
You can use the [`/memory` command](../cli/commands.md) to `show`, `add`, and `refresh` the content of loaded context files.
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
# Memory Import Processor
|
||||
|
||||
The Memory Import Processor is a feature that allows you to modularize your GEMINI.md files by importing content from other files using the `@file.md` syntax.
|
||||
The Memory Import Processor is a feature that allows you to modularize your context files (e.g., `QWEN.md`) by importing content from other files using the `@file.md` syntax.
|
||||
|
||||
## Overview
|
||||
|
||||
This feature enables you to break down large GEMINI.md files into smaller, more manageable components that can be reused across different contexts. The import processor supports both relative and absolute paths, with built-in safety features to prevent circular imports and ensure file access security.
|
||||
This feature enables you to break down large context files (e.g., `QWEN.md`) into smaller, more manageable components that can be reused across different contexts. The import processor supports both relative and absolute paths, with built-in safety features to prevent circular imports and ensure file access security.
|
||||
|
||||
## Syntax
|
||||
|
||||
Use the `@` symbol followed by the path to the file you want to import:
|
||||
|
||||
```markdown
|
||||
# Main GEMINI.md file
|
||||
# Main QWEN.md file
|
||||
|
||||
This is the main content.
|
||||
|
||||
@@ -39,7 +39,7 @@ More content here.
|
||||
### Basic Import
|
||||
|
||||
```markdown
|
||||
# My GEMINI.md
|
||||
# My QWEN.md
|
||||
|
||||
Welcome to my project!
|
||||
|
||||
@@ -110,13 +110,13 @@ The import processor uses the `marked` library to detect code blocks and inline
|
||||
|
||||
## Import Tree Structure
|
||||
|
||||
The processor returns an import tree that shows the hierarchy of imported files, similar to Claude's `/memory` feature. This helps users debug problems with their GEMINI.md files by showing which files were read and their import relationships.
|
||||
The processor returns an import tree that shows the hierarchy of imported files. This helps users debug problems with their context files by showing which files were read and their import relationships.
|
||||
|
||||
Example tree structure:
|
||||
|
||||
```
|
||||
Memory Files
|
||||
L project: GEMINI.md
|
||||
Memory Files
|
||||
L project: QWEN.md
|
||||
L a.md
|
||||
L b.md
|
||||
L c.md
|
||||
@@ -138,7 +138,7 @@ Note: The import tree is mainly for clarity during development and has limited r
|
||||
|
||||
### `processImports(content, basePath, debugMode?, importState?)`
|
||||
|
||||
Processes import statements in GEMINI.md content.
|
||||
Processes import statements in context file content.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ The `gemini-extension.json` file contains the configuration for the extension. T
|
||||
"command": "node my-server.js"
|
||||
}
|
||||
},
|
||||
"contextFileName": "GEMINI.md",
|
||||
"contextFileName": "QWEN.md",
|
||||
"excludeTools": ["run_shell_command"]
|
||||
}
|
||||
```
|
||||
@@ -36,7 +36,7 @@ The `gemini-extension.json` file contains the configuration for the extension. T
|
||||
- `name`: The name of the extension. This is used to uniquely identify the extension and for conflict resolution when extension commands have the same name as user or project commands.
|
||||
- `version`: The version of the extension.
|
||||
- `mcpServers`: A map of MCP servers to configure. The key is the name of the server, and the value is the server configuration. These servers will be loaded on startup just like MCP servers configured in a [`settings.json` file](./cli/configuration.md). If both an extension and a `settings.json` file configure an MCP server with the same name, the server defined in the `settings.json` file takes precedence.
|
||||
- `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the workspace. If this property is not used but a `GEMINI.md` file is present in your extension directory, then that file will be loaded.
|
||||
- `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the workspace. If this property is not used but a `QWEN.md` file is present in your extension directory, then that file will be loaded.
|
||||
- `excludeTools`: An array of tool names to exclude from the model. You can also specify command-specific restrictions for tools that support it, like the `run_shell_command` tool. For example, `"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf` command.
|
||||
|
||||
When Gemini CLI starts, it loads all the extensions and merges their configurations. If there are any conflicts, the workspace configuration takes precedence.
|
||||
|
||||
@@ -4,7 +4,7 @@ This document describes the `save_memory` tool for the Gemini CLI.
|
||||
|
||||
## Description
|
||||
|
||||
Use `save_memory` to save and recall information across your Gemini CLI sessions. With `save_memory`, you can direct the CLI to remember key details across sessions, providing personalized and directed assistance.
|
||||
Use `save_memory` to save and recall information across your Qwen Code sessions. With `save_memory`, you can direct the CLI to remember key details across sessions, providing personalized and directed assistance.
|
||||
|
||||
### Arguments
|
||||
|
||||
@@ -14,9 +14,9 @@ Use `save_memory` to save and recall information across your Gemini CLI sessions
|
||||
|
||||
## How to use `save_memory` with the Gemini CLI
|
||||
|
||||
The tool appends the provided `fact` to a special `GEMINI.md` file located in the user's home directory (`~/.gemini/GEMINI.md`). This file can be configured to have a different name.
|
||||
The tool appends the provided `fact` to your context file in the user's home directory (`~/.qwen/QWEN.md` by default). This filename can be configured via `contextFileName`.
|
||||
|
||||
Once added, the facts are stored under a `## Gemini Added Memories` section. This file is loaded as context in subsequent sessions, allowing the CLI to recall the saved information.
|
||||
Once added, the facts are stored under a `## Qwen Added Memories` section. This file is loaded as context in subsequent sessions, allowing the CLI to recall the saved information.
|
||||
|
||||
Usage:
|
||||
|
||||
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.6",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -11817,7 +11817,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.6",
|
||||
"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.4",
|
||||
"version": "0.0.6",
|
||||
"dependencies": {
|
||||
"@google/genai": "1.9.0",
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
@@ -11962,7 +11962,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "qwen-code-vscode-ide-companion",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.5",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.15.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.6",
|
||||
"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.4"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.6"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.6",
|
||||
"description": "Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -25,7 +25,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.4"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "1.9.0",
|
||||
|
||||
@@ -412,6 +412,7 @@ export async function loadCliConfig(
|
||||
}
|
||||
|
||||
const sandboxConfig = await loadSandboxConfig(settings, argv);
|
||||
const cliVersion = await getCliVersion();
|
||||
|
||||
return new Config({
|
||||
sessionId,
|
||||
@@ -497,6 +498,7 @@ export async function loadCliConfig(
|
||||
},
|
||||
],
|
||||
contentGenerator: settings.contentGenerator,
|
||||
cliVersion,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
getUserTier: vi.fn(),
|
||||
})),
|
||||
getCheckpointingEnabled: vi.fn(() => opts.checkpointing ?? true),
|
||||
getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']),
|
||||
getAllGeminiMdFilenames: vi.fn(() => ['QWEN.md']),
|
||||
setFlashFallbackHandler: vi.fn(),
|
||||
getSessionId: vi.fn(() => 'test-session-id'),
|
||||
getUserTier: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -169,7 +169,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
...actualCore,
|
||||
Config: ConfigClassMock,
|
||||
MCPServerConfig: actualCore.MCPServerConfig,
|
||||
getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']),
|
||||
getAllGeminiMdFilenames: vi.fn(() => ['QWEN.md']),
|
||||
ideContext: ideContextMock,
|
||||
isGitRepository: vi.fn(),
|
||||
};
|
||||
@@ -577,7 +577,7 @@ describe('App UI', () => {
|
||||
},
|
||||
});
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(1);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['GEMINI.md']);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md']);
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<App
|
||||
@@ -589,13 +589,13 @@ describe('App UI', () => {
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(lastFrame()).toContain(
|
||||
'Using: 1 open file (ctrl+e to view) | 1 GEMINI.md file',
|
||||
'Using: 1 open file (ctrl+e to view) | 1 QWEN.md file',
|
||||
);
|
||||
});
|
||||
|
||||
it('should display default "GEMINI.md" in footer when contextFileName is not set and count is 1', async () => {
|
||||
it('should display default "QWEN.md" in footer when contextFileName is not set and count is 1', async () => {
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(1);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['GEMINI.md']);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md']);
|
||||
// For this test, ensure showMemoryUsage is false or debugMode is false if it relies on that
|
||||
mockConfig.getDebugMode.mockReturnValue(false);
|
||||
mockConfig.getShowMemoryUsage.mockReturnValue(false);
|
||||
@@ -609,15 +609,12 @@ describe('App UI', () => {
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve(); // Wait for any async updates
|
||||
expect(lastFrame()).toContain('Using: 1 GEMINI.md file');
|
||||
expect(lastFrame()).toContain('Using: 1 QWEN.md file');
|
||||
});
|
||||
|
||||
it('should display default "GEMINI.md" with plural when contextFileName is not set and count is > 1', async () => {
|
||||
it('should display default "QWEN.md" with plural when contextFileName is not set and count is > 1', async () => {
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(2);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue([
|
||||
'GEMINI.md',
|
||||
'GEMINI.md',
|
||||
]);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md', 'QWEN.md']);
|
||||
mockConfig.getDebugMode.mockReturnValue(false);
|
||||
mockConfig.getShowMemoryUsage.mockReturnValue(false);
|
||||
|
||||
@@ -630,7 +627,7 @@ describe('App UI', () => {
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(lastFrame()).toContain('Using: 2 GEMINI.md files');
|
||||
expect(lastFrame()).toContain('Using: 2 QWEN.md files');
|
||||
});
|
||||
|
||||
it('should display custom contextFileName in footer when set and count is 1', async () => {
|
||||
@@ -727,12 +724,9 @@ describe('App UI', () => {
|
||||
expect(lastFrame()).not.toContain('ANY_FILE.MD');
|
||||
});
|
||||
|
||||
it('should display GEMINI.md and MCP server count when both are present', async () => {
|
||||
it('should display QWEN.md and MCP server count when both are present', async () => {
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(2);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue([
|
||||
'GEMINI.md',
|
||||
'GEMINI.md',
|
||||
]);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md', 'QWEN.md']);
|
||||
mockConfig.getMcpServers.mockReturnValue({
|
||||
server1: {} as MCPServerConfig,
|
||||
});
|
||||
@@ -751,7 +745,7 @@ describe('App UI', () => {
|
||||
expect(lastFrame()).toContain('1 MCP server');
|
||||
});
|
||||
|
||||
it('should display only MCP server count when GEMINI.md count is 0', async () => {
|
||||
it('should display only MCP server count when QWEN.md count is 0', async () => {
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(0);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue([]);
|
||||
mockConfig.getMcpServers.mockReturnValue({
|
||||
|
||||
@@ -11,16 +11,31 @@ import { initCommand } from './initCommand.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
|
||||
// Mock the 'fs' module
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
}));
|
||||
// Mock the 'fs' module with both named and default exports to avoid breaking default import sites
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('fs')>();
|
||||
const existsSync = vi.fn();
|
||||
const writeFileSync = vi.fn();
|
||||
const readFileSync = vi.fn();
|
||||
return {
|
||||
...actual,
|
||||
existsSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
default: {
|
||||
...(actual as unknown as Record<string, unknown>),
|
||||
existsSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
},
|
||||
} as unknown as typeof import('fs');
|
||||
});
|
||||
|
||||
describe('initCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
const targetDir = '/test/dir';
|
||||
const geminiMdPath = path.join(targetDir, 'GEMINI.md');
|
||||
const DEFAULT_CONTEXT_FILENAME = 'QWEN.md';
|
||||
const geminiMdPath = path.join(targetDir, DEFAULT_CONTEXT_FILENAME);
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a fresh mock context for each test
|
||||
@@ -38,9 +53,10 @@ describe('initCommand', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should inform the user if GEMINI.md already exists', async () => {
|
||||
it(`should inform the user if ${DEFAULT_CONTEXT_FILENAME} already exists and is non-empty`, async () => {
|
||||
// Arrange: Simulate that the file exists
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValue('# Existing content');
|
||||
|
||||
// Act: Run the command's action
|
||||
const result = await initCommand.action!(mockContext, '');
|
||||
@@ -49,14 +65,13 @@ describe('initCommand', () => {
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content:
|
||||
'A GEMINI.md file already exists in this directory. No changes were made.',
|
||||
content: `A ${DEFAULT_CONTEXT_FILENAME} file already exists in this directory. No changes were made.`,
|
||||
});
|
||||
// Assert: Ensure no file was written
|
||||
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create GEMINI.md and submit a prompt if it does not exist', async () => {
|
||||
it(`should create ${DEFAULT_CONTEXT_FILENAME} and submit a prompt if it does not exist`, async () => {
|
||||
// Arrange: Simulate that the file does not exist
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
@@ -70,7 +85,7 @@ describe('initCommand', () => {
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: 'info',
|
||||
text: 'Empty GEMINI.md created. Now analyzing the project to populate it.',
|
||||
text: `Empty ${DEFAULT_CONTEXT_FILENAME} created. Now analyzing the project to populate it.`,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
@@ -78,10 +93,20 @@ describe('initCommand', () => {
|
||||
// Assert: Check that the correct prompt is submitted
|
||||
expect(result.type).toBe('submit_prompt');
|
||||
expect(result.content).toContain(
|
||||
'You are an AI agent that brings the power of Gemini',
|
||||
'You are Qwen Code, an interactive CLI agent',
|
||||
);
|
||||
});
|
||||
|
||||
it(`should proceed to initialize when ${DEFAULT_CONTEXT_FILENAME} exists but is empty`, async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValue(' \n ');
|
||||
|
||||
const result = await initCommand.action!(mockContext, '');
|
||||
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(geminiMdPath, '', 'utf8');
|
||||
expect(result.type).toBe('submit_prompt');
|
||||
});
|
||||
|
||||
it('should return an error if config is not available', async () => {
|
||||
// Arrange: Create a context without config
|
||||
const noConfigContext = createMockCommandContext();
|
||||
|
||||
@@ -12,10 +12,11 @@ import {
|
||||
SlashCommandActionReturn,
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import { getCurrentGeminiMdFilename } from '@qwen-code/qwen-code-core';
|
||||
|
||||
export const initCommand: SlashCommand = {
|
||||
name: 'init',
|
||||
description: 'Analyzes the project and creates a tailored GEMINI.md file.',
|
||||
description: 'Analyzes the project and creates a tailored QWEN.md file.',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
@@ -29,32 +30,55 @@ export const initCommand: SlashCommand = {
|
||||
};
|
||||
}
|
||||
const targetDir = context.services.config.getTargetDir();
|
||||
const geminiMdPath = path.join(targetDir, 'GEMINI.md');
|
||||
const contextFileName = getCurrentGeminiMdFilename();
|
||||
const contextFilePath = path.join(targetDir, contextFileName);
|
||||
|
||||
if (fs.existsSync(geminiMdPath)) {
|
||||
try {
|
||||
if (fs.existsSync(contextFilePath)) {
|
||||
// If file exists but is empty (or whitespace), continue to initialize; otherwise, bail out
|
||||
try {
|
||||
const existing = fs.readFileSync(contextFilePath, 'utf8');
|
||||
if (existing && existing.trim().length > 0) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `A ${contextFileName} file already exists in this directory. No changes were made.`,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// If we fail to read, conservatively proceed to (re)create the file
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure an empty context file exists before prompting the model to populate it
|
||||
try {
|
||||
fs.writeFileSync(contextFilePath, '', 'utf8');
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: 'info',
|
||||
text: `Empty ${contextFileName} created. Now analyzing the project to populate it.`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
} catch (err) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Failed to create ${contextFileName}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content:
|
||||
'A GEMINI.md file already exists in this directory. No changes were made.',
|
||||
messageType: 'error',
|
||||
content: `Unexpected error preparing ${contextFileName}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Create an empty GEMINI.md file
|
||||
fs.writeFileSync(geminiMdPath, '', 'utf8');
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: 'info',
|
||||
text: 'Empty GEMINI.md created. Now analyzing the project to populate it.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
return {
|
||||
type: 'submit_prompt',
|
||||
content: `
|
||||
You are an AI agent that brings the power of Gemini directly into the terminal. Your task is to analyze the current directory and generate a comprehensive GEMINI.md file to be used as instructional context for future interactions.
|
||||
You are Qwen Code, an interactive CLI agent. Analyze the current directory and generate a comprehensive ${contextFileName} file to be used as instructional context for future interactions.
|
||||
|
||||
**Analysis Process:**
|
||||
|
||||
@@ -70,7 +94,7 @@ You are an AI agent that brings the power of Gemini directly into the terminal.
|
||||
* **Code Project:** Look for clues like \`package.json\`, \`requirements.txt\`, \`pom.xml\`, \`go.mod\`, \`Cargo.toml\`, \`build.gradle\`, or a \`src\` directory. If you find them, this is likely a software project.
|
||||
* **Non-Code Project:** If you don't find code-related files, this might be a directory for documentation, research papers, notes, or something else.
|
||||
|
||||
**GEMINI.md Content Generation:**
|
||||
**${contextFileName} Content Generation:**
|
||||
|
||||
**For a Code Project:**
|
||||
|
||||
@@ -86,7 +110,7 @@ You are an AI agent that brings the power of Gemini directly into the terminal.
|
||||
|
||||
**Final Output:**
|
||||
|
||||
Write the complete content to the \`GEMINI.md\` file. The output must be well-formatted Markdown.
|
||||
Write the complete content to the \`${contextFileName}\` file. The output must be well-formatted Markdown.
|
||||
`,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -22,6 +22,12 @@ vi.mock('ink-spinner', () => ({
|
||||
default: ({ type }: { type: string }) => `MockSpinner(${type})`,
|
||||
}));
|
||||
|
||||
// Mock ink-link
|
||||
vi.mock('ink-link', () => ({
|
||||
default: ({ children }: { children: React.ReactNode; url: string }) =>
|
||||
children,
|
||||
}));
|
||||
|
||||
describe('QwenOAuthProgress', () => {
|
||||
const mockOnTimeout = vi.fn();
|
||||
const mockOnCancel = vi.fn();
|
||||
@@ -39,7 +45,17 @@ describe('QwenOAuthProgress', () => {
|
||||
const mockDeviceAuth = createMockDeviceAuth();
|
||||
|
||||
const renderComponent = (
|
||||
props: Partial<{ deviceAuth: DeviceAuthorizationInfo }> = {},
|
||||
props: Partial<{
|
||||
deviceAuth: DeviceAuthorizationInfo;
|
||||
authStatus:
|
||||
| 'idle'
|
||||
| 'polling'
|
||||
| 'success'
|
||||
| 'error'
|
||||
| 'timeout'
|
||||
| 'rate_limit';
|
||||
authMessage: string | null;
|
||||
}> = {},
|
||||
) =>
|
||||
render(
|
||||
<QwenOAuthProgress
|
||||
@@ -85,20 +101,43 @@ describe('QwenOAuthProgress', () => {
|
||||
const { lastFrame } = renderComponent({ deviceAuth: mockDeviceAuth });
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Qwen OAuth Authentication');
|
||||
expect(output).toContain('Please visit this URL to authorize:');
|
||||
expect(output).toContain(mockDeviceAuth.verification_uri_complete);
|
||||
// Initially no QR code shown until it's generated, but the status area should be visible
|
||||
expect(output).toContain('MockSpinner(dots)');
|
||||
expect(output).toContain('Waiting for authorization');
|
||||
expect(output).toContain('Time remaining: 5:00');
|
||||
expect(output).toContain('(Press ESC to cancel)');
|
||||
});
|
||||
|
||||
it('should display correct URL in bordered box', () => {
|
||||
it('should display correct URL in Static component when QR code is generated', async () => {
|
||||
const qrcode = await import('qrcode-terminal');
|
||||
const mockGenerate = vi.mocked(qrcode.default.generate);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let qrCallback: any = null;
|
||||
mockGenerate.mockImplementation((url, options, callback) => {
|
||||
qrCallback = callback;
|
||||
});
|
||||
|
||||
const customAuth = createMockDeviceAuth({
|
||||
verification_uri_complete: 'https://custom.com/auth?code=XYZ789',
|
||||
});
|
||||
const { lastFrame } = renderComponent({ deviceAuth: customAuth });
|
||||
|
||||
const { lastFrame, rerender } = renderComponent({
|
||||
deviceAuth: customAuth,
|
||||
});
|
||||
|
||||
// Manually trigger the QR code callback
|
||||
if (qrCallback && typeof qrCallback === 'function') {
|
||||
qrCallback('Mock QR Code Data');
|
||||
}
|
||||
|
||||
rerender(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={customAuth}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('https://custom.com/auth?code=XYZ789');
|
||||
});
|
||||
@@ -204,14 +243,16 @@ describe('QwenOAuthProgress', () => {
|
||||
expect(lastFrame()).toContain('Time remaining: 4:59');
|
||||
});
|
||||
|
||||
it('should not start timer when deviceAuth is null', () => {
|
||||
render(
|
||||
it('should use default 300 second timeout when deviceAuth is null', () => {
|
||||
const { lastFrame } = render(
|
||||
<QwenOAuthProgress onTimeout={mockOnTimeout} onCancel={mockOnCancel} />,
|
||||
);
|
||||
|
||||
// Advance timer and ensure onTimeout is not called
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(mockOnTimeout).not.toHaveBeenCalled();
|
||||
// Should show default 5:00 (300 seconds) timeout
|
||||
expect(lastFrame()).toContain('Time remaining: 5:00');
|
||||
|
||||
// The timer functionality is already tested in other tests,
|
||||
// this test mainly verifies the default timeout value is used
|
||||
});
|
||||
});
|
||||
|
||||
@@ -298,8 +339,41 @@ describe('QwenOAuthProgress', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// Note: QR code display test skipped due to timing complexities with async state updates
|
||||
// The QR code generation is already tested in 'should generate QR code when deviceAuth is provided'
|
||||
it('should display QR code in Static component when available', async () => {
|
||||
const qrcode = await import('qrcode-terminal');
|
||||
const mockGenerate = vi.mocked(qrcode.default.generate);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let qrCallback: any = null;
|
||||
mockGenerate.mockImplementation((url, options, callback) => {
|
||||
qrCallback = callback;
|
||||
});
|
||||
|
||||
const { lastFrame, rerender } = render(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Manually trigger the QR code callback
|
||||
if (qrCallback && typeof qrCallback === 'function') {
|
||||
qrCallback('Mock QR Code Data');
|
||||
}
|
||||
|
||||
rerender(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Or scan the QR code below:');
|
||||
expect(output).toContain('Mock QR Code Data');
|
||||
});
|
||||
|
||||
it('should handle QR code generation errors gracefully', async () => {
|
||||
const qrcode = await import('qrcode-terminal');
|
||||
@@ -320,7 +394,7 @@ describe('QwenOAuthProgress', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should not crash and should not show QR code section
|
||||
// Should not crash and should not show QR code section since QR generation failed
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('Or scan the QR code below:');
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
@@ -415,14 +489,58 @@ describe('QwenOAuthProgress', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Qwen OAuth Authentication');
|
||||
// Initially shows waiting for authorization
|
||||
expect(lastFrame()).toContain('Waiting for authorization');
|
||||
|
||||
rerender(
|
||||
<QwenOAuthProgress onTimeout={mockOnTimeout} onCancel={mockOnCancel} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Waiting for Qwen OAuth authentication...');
|
||||
expect(lastFrame()).not.toContain('Qwen OAuth Authentication');
|
||||
expect(lastFrame()).not.toContain('Waiting for authorization');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Timeout state', () => {
|
||||
it('should render timeout state when authStatus is timeout', () => {
|
||||
const { lastFrame } = renderComponent({
|
||||
authStatus: 'timeout',
|
||||
authMessage: 'Custom timeout message',
|
||||
});
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Qwen OAuth Authentication Timeout');
|
||||
expect(output).toContain('Custom timeout message');
|
||||
expect(output).toContain(
|
||||
'Press any key to return to authentication type selection.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should render default timeout message when no authMessage provided', () => {
|
||||
const { lastFrame } = renderComponent({
|
||||
authStatus: 'timeout',
|
||||
});
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Qwen OAuth Authentication Timeout');
|
||||
expect(output).toContain(
|
||||
'OAuth token expired (over 300 seconds). Please select authentication method again.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should call onCancel for any key press in timeout state', () => {
|
||||
const { stdin } = renderComponent({
|
||||
authStatus: 'timeout',
|
||||
});
|
||||
|
||||
// Simulate any key press
|
||||
stdin.write('a');
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Reset mock and try enter key
|
||||
mockOnCancel.mockClear();
|
||||
stdin.write('\r');
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { Box, Text, useInput, Static } from 'ink';
|
||||
import Spinner from 'ink-spinner';
|
||||
import Link from 'ink-link';
|
||||
import qrcode from 'qrcode-terminal';
|
||||
@@ -26,6 +26,19 @@ interface QwenOAuthProgressProps {
|
||||
authMessage?: string | null;
|
||||
}
|
||||
|
||||
interface StaticItem {
|
||||
key: string;
|
||||
type:
|
||||
| 'title'
|
||||
| 'instructions'
|
||||
| 'url'
|
||||
| 'qr-instructions'
|
||||
| 'qr-code'
|
||||
| 'auth-content';
|
||||
url?: string;
|
||||
qrCode?: string;
|
||||
}
|
||||
|
||||
export function QwenOAuthProgress({
|
||||
onTimeout,
|
||||
onCancel,
|
||||
@@ -54,24 +67,26 @@ export function QwenOAuthProgress({
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate QR code string
|
||||
const generateQR = () => {
|
||||
try {
|
||||
qrcode.generate(
|
||||
deviceAuth.verification_uri_complete,
|
||||
{ small: true },
|
||||
(qrcode: string) => {
|
||||
setQrCodeData(qrcode);
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate QR code:', error);
|
||||
setQrCodeData(null);
|
||||
}
|
||||
};
|
||||
// Only generate QR code if we don't have one yet for this URL
|
||||
if (qrCodeData === null) {
|
||||
const generateQR = () => {
|
||||
try {
|
||||
qrcode.generate(
|
||||
deviceAuth.verification_uri_complete,
|
||||
{ small: true },
|
||||
(qrcode: string) => {
|
||||
setQrCodeData(qrcode);
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate QR code:', error);
|
||||
setQrCodeData(null);
|
||||
}
|
||||
};
|
||||
|
||||
generateQR();
|
||||
}, [deviceAuth]);
|
||||
generateQR();
|
||||
}
|
||||
}, [deviceAuth, qrCodeData]);
|
||||
|
||||
// Countdown timer
|
||||
useEffect(() => {
|
||||
@@ -161,48 +176,77 @@ export function QwenOAuthProgress({
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentBlue}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={Colors.AccentBlue}>
|
||||
Qwen OAuth Authentication
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>Please visit this URL to authorize:</Text>
|
||||
</Box>
|
||||
<Link url={deviceAuth.verification_uri_complete} fallback={false}>
|
||||
<Text color={Colors.AccentGreen} bold>
|
||||
{deviceAuth.verification_uri_complete}
|
||||
</Text>
|
||||
</Link>
|
||||
<>
|
||||
{qrCodeData && (
|
||||
<>
|
||||
<Box marginTop={1}>
|
||||
<Text>Or scan the QR code below:</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text>{qrCodeData}</Text>
|
||||
</Box>
|
||||
</>
|
||||
<Static
|
||||
items={
|
||||
[
|
||||
{
|
||||
key: 'auth-content',
|
||||
type: 'auth-content' as const,
|
||||
url: deviceAuth.verification_uri_complete,
|
||||
qrCode: qrCodeData,
|
||||
},
|
||||
] as StaticItem[]
|
||||
}
|
||||
style={{
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{(item: StaticItem) => (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentBlue}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
key={item.key}
|
||||
>
|
||||
<Text bold color={Colors.AccentBlue}>
|
||||
Qwen OAuth Authentication
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>Please visit this URL to authorize:</Text>
|
||||
</Box>
|
||||
|
||||
<Link url={item.url || ''} fallback={false}>
|
||||
<Text color={Colors.AccentGreen} bold>
|
||||
{item.url || ''}
|
||||
</Text>
|
||||
</Link>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>Or scan the QR code below:</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>{item.qrCode || ''}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Static>
|
||||
)}
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentBlue}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
<Spinner type="dots" /> Waiting for authorization{dots}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
<Spinner type="dots" /> Waiting for authorization{dots}
|
||||
</Text>
|
||||
<Box marginTop={1} justifyContent="space-between">
|
||||
<Text color={Colors.Gray}>
|
||||
Time remaining: {formatTime(timeRemaining)}
|
||||
</Text>
|
||||
<Text color={Colors.AccentPurple}>(Press ESC to cancel)</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} justifyContent="space-between">
|
||||
<Text color={Colors.Gray}>
|
||||
Time remaining: {formatTime(timeRemaining)}
|
||||
</Text>
|
||||
<Text color={Colors.AccentPurple}>(Press ESC to cancel)</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,8 +66,8 @@ export function createShowMemoryAction(
|
||||
type: MessageType.INFO,
|
||||
content:
|
||||
fileCount > 0
|
||||
? 'Hierarchical memory (GEMINI.md or other context files) is loaded but content is empty.'
|
||||
: 'No hierarchical memory (GEMINI.md or other context files) is currently loaded.',
|
||||
? 'Hierarchical memory (QWEN.md or other context files) is loaded but content is empty.'
|
||||
: 'No hierarchical memory (QWEN.md or other context files) is currently loaded.',
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
(subpath (param "CACHE_DIR"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.qwen"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
(subpath (param "CACHE_DIR"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.qwen"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
(subpath (param "CACHE_DIR"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.qwen"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
(subpath (param "CACHE_DIR"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.qwen"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
(subpath (param "CACHE_DIR"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.qwen"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
(subpath (param "CACHE_DIR"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.qwen"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.6",
|
||||
"description": "Qwen Code Core",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -60,8 +60,8 @@ vi.mock('../tools/read-many-files');
|
||||
vi.mock('../tools/memoryTool', () => ({
|
||||
MemoryTool: vi.fn(),
|
||||
setGeminiMdFilename: vi.fn(),
|
||||
getCurrentGeminiMdFilename: vi.fn(() => 'GEMINI.md'), // Mock the original filename
|
||||
DEFAULT_CONTEXT_FILENAME: 'GEMINI.md',
|
||||
getCurrentGeminiMdFilename: vi.fn(() => 'QWEN.md'), // Mock the original filename
|
||||
DEFAULT_CONTEXT_FILENAME: 'QWEN.md',
|
||||
GEMINI_CONFIG_DIR: '.gemini',
|
||||
}));
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
} from './models.js';
|
||||
import { ClearcutLogger } from '../telemetry/clearcut-logger/clearcut-logger.js';
|
||||
import { QwenLogger } from '../telemetry/qwen-logger/qwen-logger.js';
|
||||
import { shouldAttemptBrowserLaunch } from '../utils/browser.js';
|
||||
import { MCPOAuthConfig } from '../mcp/oauth-provider.js';
|
||||
import { IdeClient } from '../ide/ide-client.js';
|
||||
@@ -208,6 +208,7 @@ export interface ConfigParameters {
|
||||
timeout?: number;
|
||||
maxRetries?: number;
|
||||
};
|
||||
cliVersion?: string;
|
||||
}
|
||||
|
||||
export class Config {
|
||||
@@ -281,6 +282,7 @@ export class Config {
|
||||
timeout?: number;
|
||||
maxRetries?: number;
|
||||
};
|
||||
private readonly cliVersion?: string;
|
||||
constructor(params: ConfigParameters) {
|
||||
this.sessionId = params.sessionId;
|
||||
this.embeddingModel =
|
||||
@@ -350,6 +352,7 @@ export class Config {
|
||||
this.enableOpenAILogging = params.enableOpenAILogging ?? false;
|
||||
this.sampling_params = params.sampling_params;
|
||||
this.contentGenerator = params.contentGenerator;
|
||||
this.cliVersion = params.cliVersion;
|
||||
|
||||
if (params.contextFileName) {
|
||||
setGeminiMdFilename(params.contextFileName);
|
||||
@@ -360,7 +363,7 @@ export class Config {
|
||||
}
|
||||
|
||||
if (this.getUsageStatisticsEnabled()) {
|
||||
ClearcutLogger.getInstance(this)?.logStartSessionEvent(
|
||||
QwenLogger.getInstance(this)?.logStartSessionEvent(
|
||||
new StartSessionEvent(this),
|
||||
);
|
||||
} else {
|
||||
@@ -719,6 +722,10 @@ export class Config {
|
||||
return this.contentGenerator?.maxRetries;
|
||||
}
|
||||
|
||||
getCliVersion(): string | undefined {
|
||||
return this.cliVersion;
|
||||
}
|
||||
|
||||
getSystemPromptMappings():
|
||||
| Array<{
|
||||
baseUrls?: string[];
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export const DEFAULT_QWEN_MODEL = 'qwen3-coder-plus';
|
||||
// We do not have a fallback model for now, but note it here anyway.
|
||||
export const DEFAULT_QWEN_FLASH_MODEL = 'qwen3-coder-flash';
|
||||
|
||||
export const DEFAULT_GEMINI_MODEL = 'qwen3-coder-plus';
|
||||
export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash';
|
||||
export const DEFAULT_GEMINI_FLASH_LITE_MODEL = 'gemini-2.5-flash-lite';
|
||||
|
||||
@@ -15,6 +15,7 @@ vi.mock('openai');
|
||||
// Mock logger modules
|
||||
vi.mock('../../telemetry/loggers.js', () => ({
|
||||
logApiResponse: vi.fn(),
|
||||
logApiError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/openaiLogger.js', () => ({
|
||||
@@ -44,6 +45,7 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
|
||||
timeout: 120000,
|
||||
maxRetries: 3,
|
||||
}),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
} as unknown as Config;
|
||||
|
||||
// Mock OpenAI client
|
||||
@@ -255,6 +257,7 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
|
||||
timeout: 300000, // 5 minutes
|
||||
maxRetries: 5,
|
||||
}),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
} as unknown as Config;
|
||||
|
||||
new OpenAIContentGenerator('test-key', 'gpt-4', customConfig);
|
||||
@@ -273,6 +276,7 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
|
||||
it('should handle missing timeout config gracefully', () => {
|
||||
const noTimeoutConfig = {
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
} as unknown as Config;
|
||||
|
||||
new OpenAIContentGenerator('test-key', 'gpt-4', noTimeoutConfig);
|
||||
@@ -290,28 +294,18 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
|
||||
});
|
||||
|
||||
describe('token estimation on timeout', () => {
|
||||
it('should estimate tokens even when request times out', async () => {
|
||||
it('should surface a clear timeout error when request times out', async () => {
|
||||
const timeoutError = new Error('Request timeout');
|
||||
mockOpenAIClient.chat.completions.create.mockRejectedValue(timeoutError);
|
||||
|
||||
// Mock countTokens to return a value
|
||||
const mockCountTokens = vi.spyOn(generator, 'countTokens');
|
||||
mockCountTokens.mockResolvedValue({ totalTokens: 100 });
|
||||
|
||||
const request = {
|
||||
contents: [{ role: 'user' as const, parts: [{ text: 'Hello world' }] }],
|
||||
model: 'gpt-4',
|
||||
};
|
||||
|
||||
try {
|
||||
await generator.generateContent(request, 'test-prompt-id');
|
||||
} catch (_error) {
|
||||
// Verify that countTokens was called for estimation
|
||||
expect(mockCountTokens).toHaveBeenCalledWith({
|
||||
contents: request.contents,
|
||||
model: 'gpt-4',
|
||||
});
|
||||
}
|
||||
await expect(
|
||||
generator.generateContent(request, 'test-prompt-id'),
|
||||
).rejects.toThrow(/Request timeout after \d+s/);
|
||||
});
|
||||
|
||||
it('should fall back to character-based estimation if countTokens fails', async () => {
|
||||
|
||||
@@ -228,6 +228,7 @@ describe('Gemini Client (client.ts)', () => {
|
||||
getGeminiClient: vi.fn(),
|
||||
setFallbackMode: vi.fn(),
|
||||
getDebugMode: vi.fn().mockReturnValue(false),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
};
|
||||
const MockedConfig = vi.mocked(Config, true);
|
||||
MockedConfig.mockImplementation(
|
||||
|
||||
@@ -17,7 +17,9 @@ import { Config } from '../config/config.js';
|
||||
vi.mock('../code_assist/codeAssist.js');
|
||||
vi.mock('@google/genai');
|
||||
|
||||
const mockConfig = {} as unknown as Config;
|
||||
const mockConfig = {
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
} as unknown as Config;
|
||||
|
||||
describe('createContentGenerator', () => {
|
||||
it('should create a CodeAssistContentGenerator', async () => {
|
||||
@@ -73,6 +75,7 @@ describe('createContentGeneratorConfig', () => {
|
||||
getSamplingParams: vi.fn().mockReturnValue(undefined),
|
||||
getContentGeneratorTimeout: vi.fn().mockReturnValue(undefined),
|
||||
getContentGeneratorMaxRetries: vi.fn().mockReturnValue(undefined),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
} as unknown as Config;
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
GoogleGenAI,
|
||||
} from '@google/genai';
|
||||
import { createCodeAssistContentGenerator } from '../code_assist/codeAssist.js';
|
||||
import { DEFAULT_GEMINI_MODEL } from '../config/models.js';
|
||||
import { DEFAULT_GEMINI_MODEL, DEFAULT_QWEN_MODEL } from '../config/models.js';
|
||||
import { Config } from '../config/config.js';
|
||||
import { getEffectiveModel } from './modelCheck.js';
|
||||
import { UserTierId } from '../code_assist/types.js';
|
||||
@@ -136,7 +136,9 @@ export function createContentGeneratorConfig(
|
||||
// For Qwen OAuth, we'll handle the API key dynamically in createContentGenerator
|
||||
// Set a special marker to indicate this is Qwen OAuth
|
||||
contentGeneratorConfig.apiKey = 'QWEN_OAUTH_DYNAMIC_TOKEN';
|
||||
contentGeneratorConfig.model = config.getModel() || DEFAULT_GEMINI_MODEL;
|
||||
|
||||
// Prefer to use qwen3-coder-plus as the default Qwen model if QWEN_MODEL is not set.
|
||||
contentGeneratorConfig.model = process.env.QWEN_MODEL || DEFAULT_QWEN_MODEL;
|
||||
|
||||
return contentGeneratorConfig;
|
||||
}
|
||||
@@ -149,7 +151,7 @@ export async function createContentGenerator(
|
||||
gcConfig: Config,
|
||||
sessionId?: string,
|
||||
): Promise<ContentGenerator> {
|
||||
const version = process.env.CLI_VERSION || process.version;
|
||||
const version = gcConfig.getCliVersion() || 'unknown';
|
||||
const httpOptions = {
|
||||
headers: {
|
||||
'User-Agent': `GeminiCLI/${version} (${process.platform}; ${process.arch})`,
|
||||
|
||||
@@ -158,14 +158,23 @@ export class GeminiChat {
|
||||
prompt_id: string,
|
||||
usageMetadata?: GenerateContentResponseUsageMetadata,
|
||||
responseText?: string,
|
||||
responseId?: string,
|
||||
): Promise<void> {
|
||||
const authType = this.config.getContentGeneratorConfig()?.authType;
|
||||
|
||||
// Don't log API responses for openaiContentGenerator
|
||||
if (authType === AuthType.QWEN_OAUTH || authType === AuthType.USE_OPENAI) {
|
||||
return;
|
||||
}
|
||||
|
||||
logApiResponse(
|
||||
this.config,
|
||||
new ApiResponseEvent(
|
||||
responseId || `gemini-${Date.now()}`,
|
||||
this.config.getModel(),
|
||||
durationMs,
|
||||
prompt_id,
|
||||
this.config.getContentGeneratorConfig()?.authType,
|
||||
authType,
|
||||
usageMetadata,
|
||||
responseText,
|
||||
),
|
||||
@@ -176,18 +185,27 @@ export class GeminiChat {
|
||||
durationMs: number,
|
||||
error: unknown,
|
||||
prompt_id: string,
|
||||
responseId?: string,
|
||||
): void {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const errorType = error instanceof Error ? error.name : 'unknown';
|
||||
|
||||
const authType = this.config.getContentGeneratorConfig()?.authType;
|
||||
|
||||
// Don't log API errors for openaiContentGenerator
|
||||
if (authType === AuthType.QWEN_OAUTH || authType === AuthType.USE_OPENAI) {
|
||||
return;
|
||||
}
|
||||
|
||||
logApiError(
|
||||
this.config,
|
||||
new ApiErrorEvent(
|
||||
responseId,
|
||||
this.config.getModel(),
|
||||
errorMessage,
|
||||
durationMs,
|
||||
prompt_id,
|
||||
this.config.getContentGeneratorConfig()?.authType,
|
||||
authType,
|
||||
errorType,
|
||||
),
|
||||
);
|
||||
@@ -320,6 +338,7 @@ export class GeminiChat {
|
||||
prompt_id,
|
||||
response.usageMetadata,
|
||||
JSON.stringify(response),
|
||||
response.responseId,
|
||||
);
|
||||
|
||||
this.sendPromise = (async () => {
|
||||
@@ -563,6 +582,7 @@ export class GeminiChat {
|
||||
prompt_id,
|
||||
this.getFinalUsageMetadata(chunks),
|
||||
JSON.stringify(chunks),
|
||||
chunks[chunks.length - 1]?.responseId,
|
||||
);
|
||||
}
|
||||
this.recordHistory(inputContent, outputContent);
|
||||
|
||||
@@ -23,6 +23,7 @@ vi.mock('openai');
|
||||
// Mock logger modules
|
||||
vi.mock('../telemetry/loggers.js', () => ({
|
||||
logApiResponse: vi.fn(),
|
||||
logApiError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/openaiLogger.js', () => ({
|
||||
@@ -65,6 +66,7 @@ describe('OpenAIContentGenerator', () => {
|
||||
top_p: 0.9,
|
||||
},
|
||||
}),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
} as unknown as Config;
|
||||
|
||||
// Mock OpenAI client
|
||||
@@ -142,6 +144,7 @@ describe('OpenAIContentGenerator', () => {
|
||||
timeout: 300000,
|
||||
maxRetries: 5,
|
||||
}),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
} as unknown as Config;
|
||||
|
||||
new OpenAIContentGenerator('test-key', 'gpt-4', customConfig);
|
||||
@@ -900,6 +903,7 @@ describe('OpenAIContentGenerator', () => {
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
enableOpenAILogging: true,
|
||||
}),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
} as unknown as Config;
|
||||
|
||||
const loggingGenerator = new OpenAIContentGenerator(
|
||||
@@ -1022,6 +1026,7 @@ describe('OpenAIContentGenerator', () => {
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
enableOpenAILogging: true,
|
||||
}),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
} as unknown as Config;
|
||||
|
||||
const loggingGenerator = new OpenAIContentGenerator(
|
||||
@@ -1816,6 +1821,7 @@ describe('OpenAIContentGenerator', () => {
|
||||
max_tokens: 500,
|
||||
},
|
||||
}),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
} as unknown as Config;
|
||||
|
||||
const loggingGenerator = new OpenAIContentGenerator(
|
||||
@@ -2000,6 +2006,7 @@ describe('OpenAIContentGenerator', () => {
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
enableOpenAILogging: true,
|
||||
}),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
} as unknown as Config;
|
||||
|
||||
const loggingGenerator = new OpenAIContentGenerator(
|
||||
@@ -2256,6 +2263,7 @@ describe('OpenAIContentGenerator', () => {
|
||||
top_p: undefined,
|
||||
},
|
||||
}),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
} as unknown as Config;
|
||||
|
||||
const testGenerator = new OpenAIContentGenerator(
|
||||
@@ -2313,6 +2321,7 @@ describe('OpenAIContentGenerator', () => {
|
||||
frequency_penalty: 0.3,
|
||||
},
|
||||
}),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
} as unknown as Config;
|
||||
|
||||
const testGenerator = new OpenAIContentGenerator(
|
||||
@@ -2384,4 +2393,616 @@ describe('OpenAIContentGenerator', () => {
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('metadata control', () => {
|
||||
it('should include metadata when authType is QWEN_OAUTH', async () => {
|
||||
const qwenConfig = {
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
authType: 'qwen-oauth',
|
||||
enableOpenAILogging: false,
|
||||
}),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
} as unknown as Config;
|
||||
|
||||
const qwenGenerator = new OpenAIContentGenerator(
|
||||
'test-key',
|
||||
'qwen-turbo',
|
||||
qwenConfig,
|
||||
);
|
||||
|
||||
const mockResponse = {
|
||||
id: 'chatcmpl-123',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: 'Response' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
created: 1677652288,
|
||||
model: 'qwen-turbo',
|
||||
};
|
||||
|
||||
mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse);
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
model: 'qwen-turbo',
|
||||
};
|
||||
|
||||
await qwenGenerator.generateContent(request, 'test-prompt-id');
|
||||
|
||||
expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata: {
|
||||
sessionId: 'test-session-id',
|
||||
promptId: 'test-prompt-id',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should include metadata when baseURL is dashscope openai compatible mode', async () => {
|
||||
// Mock environment to set dashscope base URL BEFORE creating the generator
|
||||
vi.stubEnv(
|
||||
'OPENAI_BASE_URL',
|
||||
'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
);
|
||||
|
||||
const dashscopeConfig = {
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
authType: 'openai', // Not QWEN_OAUTH
|
||||
enableOpenAILogging: false,
|
||||
}),
|
||||
getSessionId: vi.fn().mockReturnValue('dashscope-session-id'),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
} as unknown as Config;
|
||||
|
||||
const dashscopeGenerator = new OpenAIContentGenerator(
|
||||
'test-key',
|
||||
'qwen-turbo',
|
||||
dashscopeConfig,
|
||||
);
|
||||
|
||||
// Debug: Check if the client was created with the correct baseURL
|
||||
expect(vi.mocked(OpenAI)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
}),
|
||||
);
|
||||
|
||||
// Mock the client's baseURL property to return the expected value
|
||||
Object.defineProperty(dashscopeGenerator['client'], 'baseURL', {
|
||||
value: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const mockResponse = {
|
||||
id: 'chatcmpl-123',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: 'Response' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
created: 1677652288,
|
||||
model: 'qwen-turbo',
|
||||
};
|
||||
|
||||
mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse);
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
model: 'qwen-turbo',
|
||||
};
|
||||
|
||||
await dashscopeGenerator.generateContent(request, 'dashscope-prompt-id');
|
||||
|
||||
expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata: {
|
||||
sessionId: 'dashscope-session-id',
|
||||
promptId: 'dashscope-prompt-id',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT include metadata for regular OpenAI providers', async () => {
|
||||
const regularConfig = {
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
authType: 'openai',
|
||||
enableOpenAILogging: false,
|
||||
}),
|
||||
getSessionId: vi.fn().mockReturnValue('regular-session-id'),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
} as unknown as Config;
|
||||
|
||||
const regularGenerator = new OpenAIContentGenerator(
|
||||
'test-key',
|
||||
'gpt-4',
|
||||
regularConfig,
|
||||
);
|
||||
|
||||
const mockResponse = {
|
||||
id: 'chatcmpl-123',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: 'Response' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
created: 1677652288,
|
||||
model: 'gpt-4',
|
||||
};
|
||||
|
||||
mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse);
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
model: 'gpt-4',
|
||||
};
|
||||
|
||||
await regularGenerator.generateContent(request, 'regular-prompt-id');
|
||||
|
||||
// Should NOT include metadata
|
||||
expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({
|
||||
metadata: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT include metadata for other auth types', async () => {
|
||||
const otherAuthConfig = {
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
authType: 'gemini-api-key',
|
||||
enableOpenAILogging: false,
|
||||
}),
|
||||
getSessionId: vi.fn().mockReturnValue('other-session-id'),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
} as unknown as Config;
|
||||
|
||||
const otherGenerator = new OpenAIContentGenerator(
|
||||
'test-key',
|
||||
'gpt-4',
|
||||
otherAuthConfig,
|
||||
);
|
||||
|
||||
const mockResponse = {
|
||||
id: 'chatcmpl-123',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: 'Response' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
created: 1677652288,
|
||||
model: 'gpt-4',
|
||||
};
|
||||
|
||||
mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse);
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
model: 'gpt-4',
|
||||
};
|
||||
|
||||
await otherGenerator.generateContent(request, 'other-prompt-id');
|
||||
|
||||
// Should NOT include metadata
|
||||
expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({
|
||||
metadata: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT include metadata for other base URLs', async () => {
|
||||
// Mock environment to set a different base URL
|
||||
vi.stubEnv('OPENAI_BASE_URL', 'https://api.openai.com/v1');
|
||||
|
||||
const otherBaseUrlConfig = {
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
authType: 'openai',
|
||||
enableOpenAILogging: false,
|
||||
}),
|
||||
getSessionId: vi.fn().mockReturnValue('other-base-url-session-id'),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
} as unknown as Config;
|
||||
|
||||
const otherBaseUrlGenerator = new OpenAIContentGenerator(
|
||||
'test-key',
|
||||
'gpt-4',
|
||||
otherBaseUrlConfig,
|
||||
);
|
||||
|
||||
const mockResponse = {
|
||||
id: 'chatcmpl-123',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: 'Response' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
created: 1677652288,
|
||||
model: 'gpt-4',
|
||||
};
|
||||
|
||||
mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse);
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
model: 'gpt-4',
|
||||
};
|
||||
|
||||
await otherBaseUrlGenerator.generateContent(
|
||||
request,
|
||||
'other-base-url-prompt-id',
|
||||
);
|
||||
|
||||
// Should NOT include metadata
|
||||
expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({
|
||||
metadata: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should include metadata in streaming requests when conditions are met', async () => {
|
||||
const qwenConfig = {
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
authType: 'qwen-oauth',
|
||||
enableOpenAILogging: false,
|
||||
}),
|
||||
getSessionId: vi.fn().mockReturnValue('streaming-session-id'),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
} as unknown as Config;
|
||||
|
||||
const qwenGenerator = new OpenAIContentGenerator(
|
||||
'test-key',
|
||||
'qwen-turbo',
|
||||
qwenConfig,
|
||||
);
|
||||
|
||||
const mockStream = [
|
||||
{
|
||||
id: 'chatcmpl-123',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: { content: 'Hello' },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
created: 1677652288,
|
||||
},
|
||||
{
|
||||
id: 'chatcmpl-123',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: { content: ' there!' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
created: 1677652288,
|
||||
},
|
||||
];
|
||||
|
||||
mockOpenAIClient.chat.completions.create.mockResolvedValue({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
for (const chunk of mockStream) {
|
||||
yield chunk;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
model: 'qwen-turbo',
|
||||
};
|
||||
|
||||
const stream = await qwenGenerator.generateContentStream(
|
||||
request,
|
||||
'streaming-prompt-id',
|
||||
);
|
||||
|
||||
// Verify metadata was included in the streaming request
|
||||
expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata: {
|
||||
sessionId: 'streaming-session-id',
|
||||
promptId: 'streaming-prompt-id',
|
||||
},
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
}),
|
||||
);
|
||||
|
||||
// Consume the stream to complete the test
|
||||
const responses = [];
|
||||
for await (const response of stream) {
|
||||
responses.push(response);
|
||||
}
|
||||
expect(responses).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should NOT include metadata in streaming requests when conditions are not met', async () => {
|
||||
const regularConfig = {
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
authType: 'openai',
|
||||
enableOpenAILogging: false,
|
||||
}),
|
||||
getSessionId: vi.fn().mockReturnValue('regular-streaming-session-id'),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
} as unknown as Config;
|
||||
|
||||
const regularGenerator = new OpenAIContentGenerator(
|
||||
'test-key',
|
||||
'gpt-4',
|
||||
regularConfig,
|
||||
);
|
||||
|
||||
const mockStream = [
|
||||
{
|
||||
id: 'chatcmpl-123',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: { content: 'Hello' },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
created: 1677652288,
|
||||
},
|
||||
{
|
||||
id: 'chatcmpl-123',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: { content: ' there!' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
created: 1677652288,
|
||||
},
|
||||
];
|
||||
|
||||
mockOpenAIClient.chat.completions.create.mockResolvedValue({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
for (const chunk of mockStream) {
|
||||
yield chunk;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
model: 'gpt-4',
|
||||
};
|
||||
|
||||
const stream = await regularGenerator.generateContentStream(
|
||||
request,
|
||||
'regular-streaming-prompt-id',
|
||||
);
|
||||
|
||||
// Verify metadata was NOT included in the streaming request
|
||||
expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({
|
||||
metadata: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
|
||||
// Consume the stream to complete the test
|
||||
const responses = [];
|
||||
for await (const response of stream) {
|
||||
responses.push(response);
|
||||
}
|
||||
expect(responses).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle undefined sessionId gracefully', async () => {
|
||||
const qwenConfig = {
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
authType: 'qwen-oauth',
|
||||
enableOpenAILogging: false,
|
||||
}),
|
||||
getSessionId: vi.fn().mockReturnValue(undefined), // Undefined session ID
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
} as unknown as Config;
|
||||
|
||||
const qwenGenerator = new OpenAIContentGenerator(
|
||||
'test-key',
|
||||
'qwen-turbo',
|
||||
qwenConfig,
|
||||
);
|
||||
|
||||
const mockResponse = {
|
||||
id: 'chatcmpl-123',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: 'Response' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
created: 1677652288,
|
||||
model: 'qwen-turbo',
|
||||
};
|
||||
|
||||
mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse);
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
model: 'qwen-turbo',
|
||||
};
|
||||
|
||||
await qwenGenerator.generateContent(
|
||||
request,
|
||||
'undefined-session-prompt-id',
|
||||
);
|
||||
|
||||
expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata: {
|
||||
sessionId: undefined,
|
||||
promptId: 'undefined-session-prompt-id',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle undefined baseURL gracefully', async () => {
|
||||
// Ensure no base URL is set
|
||||
vi.stubEnv('OPENAI_BASE_URL', '');
|
||||
|
||||
const noBaseUrlConfig = {
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
authType: 'openai',
|
||||
enableOpenAILogging: false,
|
||||
}),
|
||||
getSessionId: vi.fn().mockReturnValue('no-base-url-session-id'),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
} as unknown as Config;
|
||||
|
||||
const noBaseUrlGenerator = new OpenAIContentGenerator(
|
||||
'test-key',
|
||||
'gpt-4',
|
||||
noBaseUrlConfig,
|
||||
);
|
||||
|
||||
const mockResponse = {
|
||||
id: 'chatcmpl-123',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: 'Response' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
created: 1677652288,
|
||||
model: 'gpt-4',
|
||||
};
|
||||
|
||||
mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse);
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
model: 'gpt-4',
|
||||
};
|
||||
|
||||
await noBaseUrlGenerator.generateContent(
|
||||
request,
|
||||
'no-base-url-prompt-id',
|
||||
);
|
||||
|
||||
// Should NOT include metadata when baseURL is empty
|
||||
expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({
|
||||
metadata: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle undefined authType gracefully', async () => {
|
||||
const undefinedAuthConfig = {
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
authType: undefined, // Undefined auth type
|
||||
enableOpenAILogging: false,
|
||||
}),
|
||||
getSessionId: vi.fn().mockReturnValue('undefined-auth-session-id'),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
} as unknown as Config;
|
||||
|
||||
const undefinedAuthGenerator = new OpenAIContentGenerator(
|
||||
'test-key',
|
||||
'gpt-4',
|
||||
undefinedAuthConfig,
|
||||
);
|
||||
|
||||
const mockResponse = {
|
||||
id: 'chatcmpl-123',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: 'Response' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
created: 1677652288,
|
||||
model: 'gpt-4',
|
||||
};
|
||||
|
||||
mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse);
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
model: 'gpt-4',
|
||||
};
|
||||
|
||||
await undefinedAuthGenerator.generateContent(
|
||||
request,
|
||||
'undefined-auth-prompt-id',
|
||||
);
|
||||
|
||||
// Should NOT include metadata when authType is undefined
|
||||
expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({
|
||||
metadata: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle undefined config gracefully', async () => {
|
||||
const undefinedConfig = {
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue(undefined), // Undefined config
|
||||
getSessionId: vi.fn().mockReturnValue('undefined-config-session-id'),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
} as unknown as Config;
|
||||
|
||||
const undefinedConfigGenerator = new OpenAIContentGenerator(
|
||||
'test-key',
|
||||
'gpt-4',
|
||||
undefinedConfig,
|
||||
);
|
||||
|
||||
const mockResponse = {
|
||||
id: 'chatcmpl-123',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: 'Response' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
created: 1677652288,
|
||||
model: 'gpt-4',
|
||||
};
|
||||
|
||||
mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse);
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
model: 'gpt-4',
|
||||
};
|
||||
|
||||
await undefinedConfigGenerator.generateContent(
|
||||
request,
|
||||
'undefined-config-prompt-id',
|
||||
);
|
||||
|
||||
// Should NOT include metadata when config is undefined
|
||||
expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({
|
||||
metadata: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,10 +20,10 @@ import {
|
||||
FunctionCall,
|
||||
FunctionResponse,
|
||||
} from '@google/genai';
|
||||
import { ContentGenerator } from './contentGenerator.js';
|
||||
import { AuthType, ContentGenerator } from './contentGenerator.js';
|
||||
import OpenAI from 'openai';
|
||||
import { logApiResponse } from '../telemetry/loggers.js';
|
||||
import { ApiResponseEvent } from '../telemetry/types.js';
|
||||
import { logApiError, logApiResponse } from '../telemetry/loggers.js';
|
||||
import { ApiErrorEvent, ApiResponseEvent } from '../telemetry/types.js';
|
||||
import { Config } from '../config/config.js';
|
||||
import { openaiLogger } from '../utils/openaiLogger.js';
|
||||
|
||||
@@ -114,8 +114,7 @@ export class OpenAIContentGenerator implements ContentGenerator {
|
||||
timeoutConfig.maxRetries = contentGeneratorConfig.maxRetries;
|
||||
}
|
||||
|
||||
// Set up User-Agent header (same format as contentGenerator.ts)
|
||||
const version = process.env.CLI_VERSION || process.version;
|
||||
const version = config.getCliVersion() || 'unknown';
|
||||
const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`;
|
||||
|
||||
// Check if using OpenRouter and add required headers
|
||||
@@ -185,6 +184,46 @@ export class OpenAIContentGenerator implements ContentGenerator {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if metadata should be included in the request.
|
||||
* Only include the `metadata` field if the provider is QWEN_OAUTH
|
||||
* or the baseUrl is 'https://dashscope.aliyuncs.com/compatible-mode/v1'.
|
||||
* This is because some models/providers do not support metadata or need extra configuration.
|
||||
*
|
||||
* @returns true if metadata should be included, false otherwise
|
||||
*/
|
||||
private shouldIncludeMetadata(): boolean {
|
||||
const authType = this.config.getContentGeneratorConfig?.()?.authType;
|
||||
// baseUrl may be undefined; default to empty string if so
|
||||
const baseUrl = this.client?.baseURL || '';
|
||||
|
||||
return (
|
||||
authType === AuthType.QWEN_OAUTH ||
|
||||
baseUrl === 'https://dashscope.aliyuncs.com/compatible-mode/v1'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build metadata object for OpenAI API requests.
|
||||
*
|
||||
* @param userPromptId The user prompt ID to include in metadata
|
||||
* @returns metadata object if shouldIncludeMetadata() returns true, undefined otherwise
|
||||
*/
|
||||
private buildMetadata(
|
||||
userPromptId: string,
|
||||
): { metadata: { sessionId?: string; promptId: string } } | undefined {
|
||||
if (!this.shouldIncludeMetadata()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
sessionId: this.config.getSessionId?.(),
|
||||
promptId: userPromptId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async generateContent(
|
||||
request: GenerateContentParameters,
|
||||
userPromptId: string,
|
||||
@@ -205,10 +244,7 @@ export class OpenAIContentGenerator implements ContentGenerator {
|
||||
model: this.model,
|
||||
messages,
|
||||
...samplingParams,
|
||||
metadata: {
|
||||
sessionId: this.config.getSessionId?.(),
|
||||
promptId: userPromptId,
|
||||
},
|
||||
...(this.buildMetadata(userPromptId) || {}),
|
||||
};
|
||||
|
||||
if (request.config?.tools) {
|
||||
@@ -226,6 +262,7 @@ export class OpenAIContentGenerator implements ContentGenerator {
|
||||
|
||||
// Log API response event for UI telemetry
|
||||
const responseEvent = new ApiResponseEvent(
|
||||
response.responseId || 'unknown',
|
||||
this.model,
|
||||
durationMs,
|
||||
userPromptId,
|
||||
@@ -254,41 +291,21 @@ export class OpenAIContentGenerator implements ContentGenerator {
|
||||
? error.message
|
||||
: String(error);
|
||||
|
||||
// Estimate token usage even when there's an error
|
||||
// This helps track costs and usage even for failed requests
|
||||
let estimatedUsage;
|
||||
try {
|
||||
const tokenCountResult = await this.countTokens({
|
||||
contents: request.contents,
|
||||
model: this.model,
|
||||
});
|
||||
estimatedUsage = {
|
||||
promptTokenCount: tokenCountResult.totalTokens,
|
||||
candidatesTokenCount: 0, // No completion tokens since request failed
|
||||
totalTokenCount: tokenCountResult.totalTokens,
|
||||
};
|
||||
} catch {
|
||||
// If token counting also fails, provide a minimal estimate
|
||||
const contentStr = JSON.stringify(request.contents);
|
||||
const estimatedTokens = Math.ceil(contentStr.length / 4);
|
||||
estimatedUsage = {
|
||||
promptTokenCount: estimatedTokens,
|
||||
candidatesTokenCount: 0,
|
||||
totalTokenCount: estimatedTokens,
|
||||
};
|
||||
}
|
||||
|
||||
// Log API error event for UI telemetry with estimated usage
|
||||
const errorEvent = new ApiResponseEvent(
|
||||
// Log API error event for UI telemetry
|
||||
const errorEvent = new ApiErrorEvent(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(error as any).requestID || 'unknown',
|
||||
this.model,
|
||||
errorMessage,
|
||||
durationMs,
|
||||
userPromptId,
|
||||
this.config.getContentGeneratorConfig()?.authType,
|
||||
estimatedUsage,
|
||||
undefined,
|
||||
errorMessage,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(error as any).type,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(error as any).code,
|
||||
);
|
||||
logApiResponse(this.config, errorEvent);
|
||||
logApiError(this.config, errorEvent);
|
||||
|
||||
// Log error interaction if enabled
|
||||
if (this.config.getContentGeneratorConfig()?.enableOpenAILogging) {
|
||||
@@ -339,10 +356,7 @@ export class OpenAIContentGenerator implements ContentGenerator {
|
||||
...samplingParams,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
metadata: {
|
||||
sessionId: this.config.getSessionId?.(),
|
||||
promptId: userPromptId,
|
||||
},
|
||||
...(this.buildMetadata(userPromptId) || {}),
|
||||
};
|
||||
|
||||
if (request.config?.tools) {
|
||||
@@ -380,6 +394,7 @@ export class OpenAIContentGenerator implements ContentGenerator {
|
||||
|
||||
// Log API response event for UI telemetry
|
||||
const responseEvent = new ApiResponseEvent(
|
||||
responses[responses.length - 1]?.responseId || 'unknown',
|
||||
this.model,
|
||||
durationMs,
|
||||
userPromptId,
|
||||
@@ -411,40 +426,21 @@ export class OpenAIContentGenerator implements ContentGenerator {
|
||||
? error.message
|
||||
: String(error);
|
||||
|
||||
// Estimate token usage even when there's an error in streaming
|
||||
let estimatedUsage;
|
||||
try {
|
||||
const tokenCountResult = await this.countTokens({
|
||||
contents: request.contents,
|
||||
model: this.model,
|
||||
});
|
||||
estimatedUsage = {
|
||||
promptTokenCount: tokenCountResult.totalTokens,
|
||||
candidatesTokenCount: 0, // No completion tokens since request failed
|
||||
totalTokenCount: tokenCountResult.totalTokens,
|
||||
};
|
||||
} catch {
|
||||
// If token counting also fails, provide a minimal estimate
|
||||
const contentStr = JSON.stringify(request.contents);
|
||||
const estimatedTokens = Math.ceil(contentStr.length / 4);
|
||||
estimatedUsage = {
|
||||
promptTokenCount: estimatedTokens,
|
||||
candidatesTokenCount: 0,
|
||||
totalTokenCount: estimatedTokens,
|
||||
};
|
||||
}
|
||||
|
||||
// Log API error event for UI telemetry with estimated usage
|
||||
const errorEvent = new ApiResponseEvent(
|
||||
// Log API error event for UI telemetry
|
||||
const errorEvent = new ApiErrorEvent(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(error as any).requestID || 'unknown',
|
||||
this.model,
|
||||
errorMessage,
|
||||
durationMs,
|
||||
userPromptId,
|
||||
this.config.getContentGeneratorConfig()?.authType,
|
||||
estimatedUsage,
|
||||
undefined,
|
||||
errorMessage,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(error as any).type,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(error as any).code,
|
||||
);
|
||||
logApiResponse(this.config, errorEvent);
|
||||
logApiError(this.config, errorEvent);
|
||||
|
||||
// Log error interaction if enabled
|
||||
if (this.config.getContentGeneratorConfig()?.enableOpenAILogging) {
|
||||
@@ -484,40 +480,21 @@ export class OpenAIContentGenerator implements ContentGenerator {
|
||||
? error.message
|
||||
: String(error);
|
||||
|
||||
// Estimate token usage even when there's an error in streaming setup
|
||||
let estimatedUsage;
|
||||
try {
|
||||
const tokenCountResult = await this.countTokens({
|
||||
contents: request.contents,
|
||||
model: this.model,
|
||||
});
|
||||
estimatedUsage = {
|
||||
promptTokenCount: tokenCountResult.totalTokens,
|
||||
candidatesTokenCount: 0, // No completion tokens since request failed
|
||||
totalTokenCount: tokenCountResult.totalTokens,
|
||||
};
|
||||
} catch {
|
||||
// If token counting also fails, provide a minimal estimate
|
||||
const contentStr = JSON.stringify(request.contents);
|
||||
const estimatedTokens = Math.ceil(contentStr.length / 4);
|
||||
estimatedUsage = {
|
||||
promptTokenCount: estimatedTokens,
|
||||
candidatesTokenCount: 0,
|
||||
totalTokenCount: estimatedTokens,
|
||||
};
|
||||
}
|
||||
|
||||
// Log API error event for UI telemetry with estimated usage
|
||||
const errorEvent = new ApiResponseEvent(
|
||||
// Log API error event for UI telemetry
|
||||
const errorEvent = new ApiErrorEvent(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(error as any).requestID || 'unknown',
|
||||
this.model,
|
||||
errorMessage,
|
||||
durationMs,
|
||||
userPromptId,
|
||||
this.config.getContentGeneratorConfig()?.authType,
|
||||
estimatedUsage,
|
||||
undefined,
|
||||
errorMessage,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(error as any).type,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(error as any).code,
|
||||
);
|
||||
logApiResponse(this.config, errorEvent);
|
||||
logApiError(this.config, errorEvent);
|
||||
|
||||
// Allow subclasses to suppress error logging for specific scenarios
|
||||
if (!this.shouldSuppressErrorLogging(error, request)) {
|
||||
|
||||
@@ -19,6 +19,8 @@ import { LoopDetectionService } from './loopDetectionService.js';
|
||||
|
||||
vi.mock('../telemetry/loggers.js', () => ({
|
||||
logLoopDetected: vi.fn(),
|
||||
logApiError: vi.fn(),
|
||||
logApiResponse: vi.fn(),
|
||||
}));
|
||||
|
||||
const TOOL_CALL_LOOP_THRESHOLD = 5;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// Defines valid event metadata keys for Clearcut logging.
|
||||
// Defines valid event metadata keys for Qwen logging.
|
||||
export enum EventMetadataKey {
|
||||
GEMINI_CLI_KEY_UNKNOWN = 0,
|
||||
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ClearcutLogger } from './clearcut-logger/clearcut-logger.js';
|
||||
import { QwenLogger } from './qwen-logger/qwen-logger.js';
|
||||
import { RumEvent } from './qwen-logger/event-types.js';
|
||||
import { Config } from '../config/config.js';
|
||||
|
||||
describe('Circular Reference Integration Test', () => {
|
||||
it('should handle HttpsProxyAgent-like circular references in clearcut logging', () => {
|
||||
it('should handle HttpsProxyAgent-like circular references in qwen logging', () => {
|
||||
// Create a mock config with proxy
|
||||
const mockConfig = {
|
||||
getTelemetryEnabled: () => true,
|
||||
@@ -44,16 +45,20 @@ describe('Circular Reference Integration Test', () => {
|
||||
proxyAgentLike.sockets['cloudcode-pa.googleapis.com:443'] = [socketLike];
|
||||
|
||||
// Create an event that would contain this circular structure
|
||||
const problematicEvent = {
|
||||
const problematicEvent: RumEvent = {
|
||||
timestamp: Date.now(),
|
||||
event_type: 'exception',
|
||||
type: 'error',
|
||||
name: 'api_error',
|
||||
error: new Error('Network error'),
|
||||
function_args: {
|
||||
filePath: '/test/file.txt',
|
||||
httpAgent: proxyAgentLike, // This would cause the circular reference
|
||||
},
|
||||
};
|
||||
} as RumEvent;
|
||||
|
||||
// Test that ClearcutLogger can handle this
|
||||
const logger = ClearcutLogger.getInstance(mockConfig);
|
||||
// Test that QwenLogger can handle this
|
||||
const logger = QwenLogger.getInstance(mockConfig);
|
||||
|
||||
expect(() => {
|
||||
logger?.enqueueLogEvent(problematicEvent);
|
||||
|
||||
@@ -212,6 +212,7 @@ describe('loggers', () => {
|
||||
toolUsePromptTokenCount: 2,
|
||||
};
|
||||
const event = new ApiResponseEvent(
|
||||
'test-response-id',
|
||||
'test-model',
|
||||
100,
|
||||
'prompt-id-1',
|
||||
@@ -229,6 +230,7 @@ describe('loggers', () => {
|
||||
'event.name': EVENT_API_RESPONSE,
|
||||
'event.timestamp': '2025-01-01T00:00:00.000Z',
|
||||
[SemanticAttributes.HTTP_STATUS_CODE]: 200,
|
||||
response_id: 'test-response-id',
|
||||
model: 'test-model',
|
||||
status_code: 200,
|
||||
duration_ms: 100,
|
||||
@@ -275,6 +277,7 @@ describe('loggers', () => {
|
||||
toolUsePromptTokenCount: 2,
|
||||
};
|
||||
const event = new ApiResponseEvent(
|
||||
'test-response-id-2',
|
||||
'test-model',
|
||||
100,
|
||||
'prompt-id-1',
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
} from './metrics.js';
|
||||
import { isTelemetrySdkInitialized } from './sdk.js';
|
||||
import { uiTelemetryService, UiEvent } from './uiTelemetry.js';
|
||||
import { ClearcutLogger } from './clearcut-logger/clearcut-logger.js';
|
||||
import { QwenLogger } from './qwen-logger/qwen-logger.js';
|
||||
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
|
||||
|
||||
const shouldLogUserPrompts = (config: Config): boolean =>
|
||||
@@ -55,7 +55,7 @@ export function logCliConfiguration(
|
||||
config: Config,
|
||||
event: StartSessionEvent,
|
||||
): void {
|
||||
ClearcutLogger.getInstance(config)?.logStartSessionEvent(event);
|
||||
QwenLogger.getInstance(config)?.logStartSessionEvent(event);
|
||||
if (!isTelemetrySdkInitialized()) return;
|
||||
|
||||
const attributes: LogAttributes = {
|
||||
@@ -84,7 +84,7 @@ export function logCliConfiguration(
|
||||
}
|
||||
|
||||
export function logUserPrompt(config: Config, event: UserPromptEvent): void {
|
||||
ClearcutLogger.getInstance(config)?.logNewPromptEvent(event);
|
||||
QwenLogger.getInstance(config)?.logNewPromptEvent(event);
|
||||
if (!isTelemetrySdkInitialized()) return;
|
||||
|
||||
const attributes: LogAttributes = {
|
||||
@@ -113,7 +113,7 @@ export function logToolCall(config: Config, event: ToolCallEvent): void {
|
||||
'event.timestamp': new Date().toISOString(),
|
||||
} as UiEvent;
|
||||
uiTelemetryService.addEvent(uiEvent);
|
||||
ClearcutLogger.getInstance(config)?.logToolCallEvent(event);
|
||||
QwenLogger.getInstance(config)?.logToolCallEvent(event);
|
||||
if (!isTelemetrySdkInitialized()) return;
|
||||
|
||||
const attributes: LogAttributes = {
|
||||
@@ -146,7 +146,7 @@ export function logToolCall(config: Config, event: ToolCallEvent): void {
|
||||
}
|
||||
|
||||
export function logApiRequest(config: Config, event: ApiRequestEvent): void {
|
||||
ClearcutLogger.getInstance(config)?.logApiRequestEvent(event);
|
||||
QwenLogger.getInstance(config)?.logApiRequestEvent(event);
|
||||
if (!isTelemetrySdkInitialized()) return;
|
||||
|
||||
const attributes: LogAttributes = {
|
||||
@@ -168,7 +168,7 @@ export function logFlashFallback(
|
||||
config: Config,
|
||||
event: FlashFallbackEvent,
|
||||
): void {
|
||||
ClearcutLogger.getInstance(config)?.logFlashFallbackEvent(event);
|
||||
QwenLogger.getInstance(config)?.logFlashFallbackEvent(event);
|
||||
if (!isTelemetrySdkInitialized()) return;
|
||||
|
||||
const attributes: LogAttributes = {
|
||||
@@ -193,7 +193,7 @@ export function logApiError(config: Config, event: ApiErrorEvent): void {
|
||||
'event.timestamp': new Date().toISOString(),
|
||||
} as UiEvent;
|
||||
uiTelemetryService.addEvent(uiEvent);
|
||||
ClearcutLogger.getInstance(config)?.logApiErrorEvent(event);
|
||||
QwenLogger.getInstance(config)?.logApiErrorEvent(event);
|
||||
if (!isTelemetrySdkInitialized()) return;
|
||||
|
||||
const attributes: LogAttributes = {
|
||||
@@ -235,7 +235,7 @@ export function logApiResponse(config: Config, event: ApiResponseEvent): void {
|
||||
'event.timestamp': new Date().toISOString(),
|
||||
} as UiEvent;
|
||||
uiTelemetryService.addEvent(uiEvent);
|
||||
ClearcutLogger.getInstance(config)?.logApiResponseEvent(event);
|
||||
QwenLogger.getInstance(config)?.logApiResponseEvent(event);
|
||||
if (!isTelemetrySdkInitialized()) return;
|
||||
const attributes: LogAttributes = {
|
||||
...getCommonAttributes(config),
|
||||
@@ -298,7 +298,7 @@ export function logLoopDetected(
|
||||
config: Config,
|
||||
event: LoopDetectedEvent,
|
||||
): void {
|
||||
ClearcutLogger.getInstance(config)?.logLoopDetectedEvent(event);
|
||||
QwenLogger.getInstance(config)?.logLoopDetectedEvent(event);
|
||||
if (!isTelemetrySdkInitialized()) return;
|
||||
|
||||
const attributes: LogAttributes = {
|
||||
@@ -318,7 +318,7 @@ export function logNextSpeakerCheck(
|
||||
config: Config,
|
||||
event: NextSpeakerCheckEvent,
|
||||
): void {
|
||||
ClearcutLogger.getInstance(config)?.logNextSpeakerCheck(event);
|
||||
QwenLogger.getInstance(config)?.logNextSpeakerCheck(event);
|
||||
if (!isTelemetrySdkInitialized()) return;
|
||||
|
||||
const attributes: LogAttributes = {
|
||||
@@ -339,7 +339,7 @@ export function logSlashCommand(
|
||||
config: Config,
|
||||
event: SlashCommandEvent,
|
||||
): void {
|
||||
ClearcutLogger.getInstance(config)?.logSlashCommandEvent(event);
|
||||
QwenLogger.getInstance(config)?.logSlashCommandEvent(event);
|
||||
if (!isTelemetrySdkInitialized()) return;
|
||||
|
||||
const attributes: LogAttributes = {
|
||||
|
||||
83
packages/core/src/telemetry/qwen-logger/event-types.ts
Normal file
83
packages/core/src/telemetry/qwen-logger/event-types.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
// RUM Protocol Data Structures
|
||||
export interface RumApp {
|
||||
id: string;
|
||||
env: string;
|
||||
version: string;
|
||||
type: 'cli' | 'extension';
|
||||
}
|
||||
|
||||
export interface RumUser {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface RumSession {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface RumView {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface RumEvent {
|
||||
timestamp?: number;
|
||||
event_type?: 'view' | 'action' | 'exception' | 'resource';
|
||||
type: string; // Event type
|
||||
name: string; // Event name
|
||||
snapshots?: string; // JSON string of event snapshots
|
||||
properties?: Record<string, unknown>;
|
||||
// [key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface RumViewEvent extends RumEvent {
|
||||
view_type?: string; // View rendering type
|
||||
time_spent?: number; // Time spent on current view in ms
|
||||
}
|
||||
|
||||
export interface RumActionEvent extends RumEvent {
|
||||
target_name?: string; // Element user interacted with (for auto-collected actions only)
|
||||
duration?: number; // Action duration in ms
|
||||
method_info?: string; // Action callback, e.g.: onClick()
|
||||
}
|
||||
|
||||
export interface RumExceptionEvent extends RumEvent {
|
||||
source?: string; // Error source, e.g.: console, event
|
||||
file?: string; // Error file
|
||||
subtype?: string; // Secondary classification of error type
|
||||
message?: string; // Concise, readable message explaining the event
|
||||
stack?: string; // Stack trace or supplemental information about the error
|
||||
caused_by?: string; // Exception cause
|
||||
line?: number; // Line number where exception occurred
|
||||
column?: number; // Column number where exception occurred
|
||||
thread_id?: string; // Thread ID
|
||||
binary_images?: string; // Error source
|
||||
}
|
||||
|
||||
export interface RumResourceEvent extends RumEvent {
|
||||
method?: string; // HTTP request method: POST, GET, etc.
|
||||
status_code?: string; // Resource status code
|
||||
message?: string; // Error message content, corresponds to resource.error_msg
|
||||
url?: string; // Resource URL
|
||||
provider_type?: string; // Resource provider type: first-party, cdn, ad, analytics
|
||||
trace_id?: string; // Resource request TraceID
|
||||
success?: number; // Resource loading success: 1 (default) success, 0 failure
|
||||
duration?: number; // Total time spent loading resource in ms (responseEnd - redirectStart)
|
||||
size?: number; // Resource size in bytes, corresponds to decodedBodySize
|
||||
connect_duration?: number; // Time spent establishing connection to server in ms (connectEnd - connectStart)
|
||||
ssl_duration?: number; // Time spent on TLS handshake in ms (connectEnd - secureConnectionStart), 0 if no SSL
|
||||
dns_duration?: number; // Time spent resolving DNS name in ms (domainLookupEnd - domainLookupStart)
|
||||
redirect_duration?: number; // Time spent on HTTP redirects in ms (redirectEnd - redirectStart)
|
||||
first_byte_duration?: number; // Time waiting for first byte of response in ms (responseStart - requestStart)
|
||||
download_duration?: number; // Time spent downloading response in ms (responseEnd - responseStart)
|
||||
timing_data?: string; // JSON string of PerformanceResourceTiming
|
||||
trace_data?: string; // Trace information snapshot JSON string
|
||||
}
|
||||
|
||||
export interface RumPayload {
|
||||
app: RumApp;
|
||||
user: RumUser;
|
||||
session: RumSession;
|
||||
view: RumView;
|
||||
events: RumEvent[];
|
||||
_v: string;
|
||||
}
|
||||
484
packages/core/src/telemetry/qwen-logger/qwen-logger.ts
Normal file
484
packages/core/src/telemetry/qwen-logger/qwen-logger.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Buffer } from 'buffer';
|
||||
import * as https from 'https';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import {
|
||||
StartSessionEvent,
|
||||
EndSessionEvent,
|
||||
UserPromptEvent,
|
||||
ToolCallEvent,
|
||||
ApiRequestEvent,
|
||||
ApiResponseEvent,
|
||||
ApiErrorEvent,
|
||||
FlashFallbackEvent,
|
||||
LoopDetectedEvent,
|
||||
NextSpeakerCheckEvent,
|
||||
SlashCommandEvent,
|
||||
MalformedJsonResponseEvent,
|
||||
} from '../types.js';
|
||||
import {
|
||||
RumEvent,
|
||||
RumViewEvent,
|
||||
RumActionEvent,
|
||||
RumResourceEvent,
|
||||
RumExceptionEvent,
|
||||
RumPayload,
|
||||
} from './event-types.js';
|
||||
// Removed unused EventMetadataKey import
|
||||
import { Config } from '../../config/config.js';
|
||||
import { safeJsonStringify } from '../../utils/safeJsonStringify.js';
|
||||
// Removed unused import
|
||||
import { HttpError, retryWithBackoff } from '../../utils/retry.js';
|
||||
import { getInstallationId } from '../../utils/user_id.js';
|
||||
|
||||
// Usage statistics collection endpoint
|
||||
const USAGE_STATS_HOSTNAME = 'gb4w8c3ygj-default-sea.rum.aliyuncs.com';
|
||||
const USAGE_STATS_PATH = '/';
|
||||
|
||||
const RUN_APP_ID = 'gb4w8c3ygj@851d5d500f08f92';
|
||||
|
||||
export interface LogResponse {
|
||||
nextRequestWaitMs?: number;
|
||||
}
|
||||
|
||||
// Singleton class for batch posting log events to RUM. When a new event comes in, the elapsed time
|
||||
// is checked and events are flushed to RUM if at least a minute has passed since the last flush.
|
||||
export class QwenLogger {
|
||||
private static instance: QwenLogger;
|
||||
private config?: Config;
|
||||
private readonly events: RumEvent[] = [];
|
||||
private last_flush_time: number = Date.now();
|
||||
private flush_interval_ms: number = 1000 * 60; // Wait at least a minute before flushing events.
|
||||
private userId: string;
|
||||
private sessionId: string;
|
||||
private viewId: string;
|
||||
private isFlushInProgress: boolean = false;
|
||||
private isShutdown: boolean = false;
|
||||
|
||||
private constructor(config?: Config) {
|
||||
this.config = config;
|
||||
this.userId = this.generateUserId();
|
||||
this.sessionId =
|
||||
typeof this.config?.getSessionId === 'function'
|
||||
? this.config.getSessionId()
|
||||
: '';
|
||||
this.viewId = randomUUID();
|
||||
}
|
||||
|
||||
private generateUserId(): string {
|
||||
// Use installation ID as user ID for consistency
|
||||
return `user-${getInstallationId()}`;
|
||||
}
|
||||
|
||||
static getInstance(config?: Config): QwenLogger | undefined {
|
||||
if (config === undefined || !config?.getUsageStatisticsEnabled())
|
||||
return undefined;
|
||||
if (!QwenLogger.instance) {
|
||||
QwenLogger.instance = new QwenLogger(config);
|
||||
}
|
||||
|
||||
process.on('exit', QwenLogger.instance.shutdown.bind(QwenLogger.instance));
|
||||
|
||||
return QwenLogger.instance;
|
||||
}
|
||||
|
||||
enqueueLogEvent(event: RumEvent): void {
|
||||
this.events.push(event);
|
||||
}
|
||||
|
||||
createRumEvent(
|
||||
eventType: 'view' | 'action' | 'exception' | 'resource',
|
||||
type: string,
|
||||
name: string,
|
||||
properties: Partial<RumEvent>,
|
||||
): RumEvent {
|
||||
return {
|
||||
timestamp: Date.now(),
|
||||
event_type: eventType,
|
||||
type,
|
||||
name,
|
||||
...(properties || {}),
|
||||
};
|
||||
}
|
||||
|
||||
createViewEvent(
|
||||
type: string,
|
||||
name: string,
|
||||
properties: Partial<RumViewEvent>,
|
||||
): RumEvent {
|
||||
return this.createRumEvent('view', type, name, properties);
|
||||
}
|
||||
|
||||
createActionEvent(
|
||||
type: string,
|
||||
name: string,
|
||||
properties: Partial<RumActionEvent>,
|
||||
): RumEvent {
|
||||
return this.createRumEvent('action', type, name, properties);
|
||||
}
|
||||
|
||||
createResourceEvent(
|
||||
type: string,
|
||||
name: string,
|
||||
properties: Partial<RumResourceEvent>,
|
||||
): RumEvent {
|
||||
return this.createRumEvent('resource', type, name, properties);
|
||||
}
|
||||
|
||||
createExceptionEvent(
|
||||
type: string,
|
||||
name: string,
|
||||
properties: Partial<RumExceptionEvent>,
|
||||
): RumEvent {
|
||||
return this.createRumEvent('exception', type, name, properties);
|
||||
}
|
||||
|
||||
async createRumPayload(): Promise<RumPayload> {
|
||||
const version = this.config?.getCliVersion() || 'unknown';
|
||||
|
||||
return {
|
||||
app: {
|
||||
id: RUN_APP_ID,
|
||||
env: process.env.DEBUG ? 'dev' : 'prod',
|
||||
version: version || 'unknown',
|
||||
type: 'cli',
|
||||
},
|
||||
user: {
|
||||
id: this.userId,
|
||||
},
|
||||
session: {
|
||||
id: this.sessionId,
|
||||
},
|
||||
view: {
|
||||
id: this.viewId,
|
||||
name: 'qwen-code-cli',
|
||||
},
|
||||
events: [...this.events],
|
||||
_v: `qwen-code@${version}`,
|
||||
};
|
||||
}
|
||||
|
||||
flushIfNeeded(): void {
|
||||
if (Date.now() - this.last_flush_time < this.flush_interval_ms) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent concurrent flush operations
|
||||
if (this.isFlushInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.flushToRum().catch((error) => {
|
||||
console.debug('Error flushing to RUM:', error);
|
||||
});
|
||||
}
|
||||
|
||||
async flushToRum(): Promise<LogResponse> {
|
||||
if (this.config?.getDebugMode()) {
|
||||
console.log('Flushing log events to RUM.');
|
||||
}
|
||||
if (this.events.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
this.isFlushInProgress = true;
|
||||
|
||||
const rumPayload = await this.createRumPayload();
|
||||
const flushFn = () =>
|
||||
new Promise<Buffer>((resolve, reject) => {
|
||||
const body = safeJsonStringify(rumPayload);
|
||||
const options = {
|
||||
hostname: USAGE_STATS_HOSTNAME,
|
||||
path: USAGE_STATS_PATH,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
'Content-Type': 'text/plain;charset=UTF-8',
|
||||
},
|
||||
};
|
||||
const bufs: Buffer[] = [];
|
||||
const req = https.request(
|
||||
{
|
||||
...options,
|
||||
agent: this.getProxyAgent(),
|
||||
},
|
||||
(res) => {
|
||||
if (
|
||||
res.statusCode &&
|
||||
(res.statusCode < 200 || res.statusCode >= 300)
|
||||
) {
|
||||
const err: HttpError = new Error(
|
||||
`Request failed with status ${res.statusCode}`,
|
||||
);
|
||||
err.status = res.statusCode;
|
||||
res.resume();
|
||||
return reject(err);
|
||||
}
|
||||
res.on('data', (buf) => bufs.push(buf));
|
||||
res.on('end', () => resolve(Buffer.concat(bufs)));
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end(body);
|
||||
});
|
||||
|
||||
try {
|
||||
await retryWithBackoff(flushFn, {
|
||||
maxAttempts: 3,
|
||||
initialDelayMs: 200,
|
||||
shouldRetry: (err: unknown) => {
|
||||
if (!(err instanceof Error)) return false;
|
||||
const status = (err as HttpError).status as number | undefined;
|
||||
// If status is not available, it's likely a network error
|
||||
if (status === undefined) return true;
|
||||
|
||||
// Retry on 429 (Too many Requests) and 5xx server errors.
|
||||
return status === 429 || (status >= 500 && status < 600);
|
||||
},
|
||||
});
|
||||
|
||||
this.events.splice(0, this.events.length);
|
||||
this.last_flush_time = Date.now();
|
||||
return {};
|
||||
} catch (error) {
|
||||
if (this.config?.getDebugMode()) {
|
||||
console.error('RUM flush failed after multiple retries.', error);
|
||||
}
|
||||
return {};
|
||||
} finally {
|
||||
this.isFlushInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
logStartSessionEvent(event: StartSessionEvent): void {
|
||||
const applicationEvent = this.createViewEvent('session', 'session_start', {
|
||||
properties: {
|
||||
model: event.model,
|
||||
},
|
||||
snapshots: JSON.stringify({
|
||||
embedding_model: event.embedding_model,
|
||||
sandbox_enabled: event.sandbox_enabled,
|
||||
core_tools_enabled: event.core_tools_enabled,
|
||||
approval_mode: event.approval_mode,
|
||||
api_key_enabled: event.api_key_enabled,
|
||||
vertex_ai_enabled: event.vertex_ai_enabled,
|
||||
debug_enabled: event.debug_enabled,
|
||||
mcp_servers: event.mcp_servers,
|
||||
telemetry_enabled: event.telemetry_enabled,
|
||||
telemetry_log_user_prompts_enabled:
|
||||
event.telemetry_log_user_prompts_enabled,
|
||||
}),
|
||||
});
|
||||
|
||||
// Flush start event immediately
|
||||
this.enqueueLogEvent(applicationEvent);
|
||||
this.flushToRum().catch((error: unknown) => {
|
||||
console.debug('Error flushing to RUM:', error);
|
||||
});
|
||||
}
|
||||
|
||||
logNewPromptEvent(event: UserPromptEvent): void {
|
||||
const rumEvent = this.createActionEvent('user_prompt', 'user_prompt', {
|
||||
properties: {
|
||||
auth_type: event.auth_type,
|
||||
prompt_id: event.prompt_id,
|
||||
},
|
||||
snapshots: JSON.stringify({
|
||||
prompt_length: event.prompt_length,
|
||||
}),
|
||||
});
|
||||
|
||||
this.enqueueLogEvent(rumEvent);
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logToolCallEvent(event: ToolCallEvent): void {
|
||||
const rumEvent = this.createActionEvent(
|
||||
'tool_call',
|
||||
`tool_call#${event.function_name}`,
|
||||
{
|
||||
properties: {
|
||||
prompt_id: event.prompt_id,
|
||||
},
|
||||
snapshots: JSON.stringify({
|
||||
function_name: event.function_name,
|
||||
decision: event.decision,
|
||||
success: event.success,
|
||||
duration_ms: event.duration_ms,
|
||||
error: event.error,
|
||||
error_type: event.error_type,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
this.enqueueLogEvent(rumEvent);
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logApiRequestEvent(_event: ApiRequestEvent): void {
|
||||
// ignore for now
|
||||
return;
|
||||
|
||||
// const rumEvent = this.createResourceEvent('api', 'api_request', {
|
||||
// properties: {
|
||||
// model: event.model,
|
||||
// prompt_id: event.prompt_id,
|
||||
// },
|
||||
// });
|
||||
|
||||
// this.enqueueLogEvent(rumEvent);
|
||||
// this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logApiResponseEvent(event: ApiResponseEvent): void {
|
||||
const rumEvent = this.createResourceEvent('api', 'api_response', {
|
||||
status_code: event.status_code?.toString() ?? '',
|
||||
duration: event.duration_ms,
|
||||
success: 1,
|
||||
message: event.error,
|
||||
trace_id: event.response_id,
|
||||
properties: {
|
||||
auth_type: event.auth_type,
|
||||
model: event.model,
|
||||
prompt_id: event.prompt_id,
|
||||
},
|
||||
snapshots: JSON.stringify({
|
||||
input_token_count: event.input_token_count,
|
||||
output_token_count: event.output_token_count,
|
||||
cached_content_token_count: event.cached_content_token_count,
|
||||
thoughts_token_count: event.thoughts_token_count,
|
||||
tool_token_count: event.tool_token_count,
|
||||
}),
|
||||
});
|
||||
|
||||
this.enqueueLogEvent(rumEvent);
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logApiErrorEvent(event: ApiErrorEvent): void {
|
||||
const rumEvent = this.createResourceEvent('api', 'api_error', {
|
||||
status_code: event.status_code?.toString() ?? '',
|
||||
duration: event.duration_ms,
|
||||
success: 0,
|
||||
message: event.error,
|
||||
trace_id: event.response_id,
|
||||
properties: {
|
||||
auth_type: event.auth_type,
|
||||
model: event.model,
|
||||
prompt_id: event.prompt_id,
|
||||
},
|
||||
snapshots: JSON.stringify({
|
||||
error_type: event.error_type,
|
||||
}),
|
||||
});
|
||||
|
||||
this.enqueueLogEvent(rumEvent);
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logFlashFallbackEvent(event: FlashFallbackEvent): void {
|
||||
const rumEvent = this.createActionEvent('fallback', 'flash_fallback', {
|
||||
properties: {
|
||||
auth_type: event.auth_type,
|
||||
},
|
||||
});
|
||||
|
||||
this.enqueueLogEvent(rumEvent);
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logLoopDetectedEvent(event: LoopDetectedEvent): void {
|
||||
const rumEvent = this.createExceptionEvent('error', 'loop_detected', {
|
||||
subtype: 'loop_detected',
|
||||
properties: {
|
||||
prompt_id: event.prompt_id,
|
||||
},
|
||||
snapshots: JSON.stringify({
|
||||
loop_type: event.loop_type,
|
||||
}),
|
||||
});
|
||||
|
||||
this.enqueueLogEvent(rumEvent);
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logNextSpeakerCheck(event: NextSpeakerCheckEvent): void {
|
||||
const rumEvent = this.createActionEvent('check', 'next_speaker_check', {
|
||||
properties: {
|
||||
prompt_id: event.prompt_id,
|
||||
},
|
||||
snapshots: JSON.stringify({
|
||||
finish_reason: event.finish_reason,
|
||||
result: event.result,
|
||||
}),
|
||||
});
|
||||
|
||||
this.enqueueLogEvent(rumEvent);
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logSlashCommandEvent(event: SlashCommandEvent): void {
|
||||
const rumEvent = this.createActionEvent('command', 'slash_command', {
|
||||
snapshots: JSON.stringify({
|
||||
command: event.command,
|
||||
subcommand: event.subcommand,
|
||||
}),
|
||||
});
|
||||
|
||||
this.enqueueLogEvent(rumEvent);
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logMalformedJsonResponseEvent(event: MalformedJsonResponseEvent): void {
|
||||
const rumEvent = this.createExceptionEvent(
|
||||
'error',
|
||||
'malformed_json_response',
|
||||
{
|
||||
subtype: 'malformed_json_response',
|
||||
properties: {
|
||||
model: event.model,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
this.enqueueLogEvent(rumEvent);
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logEndSessionEvent(_event: EndSessionEvent): void {
|
||||
const applicationEvent = this.createViewEvent('session', 'session_end', {});
|
||||
|
||||
// Flush immediately on session end.
|
||||
this.enqueueLogEvent(applicationEvent);
|
||||
this.flushToRum().catch((error: unknown) => {
|
||||
console.debug('Error flushing to RUM:', error);
|
||||
});
|
||||
}
|
||||
|
||||
getProxyAgent() {
|
||||
const proxyUrl = this.config?.getProxy();
|
||||
if (!proxyUrl) return undefined;
|
||||
// undici which is widely used in the repo can only support http & https proxy protocol,
|
||||
// https://github.com/nodejs/undici/issues/2224
|
||||
if (proxyUrl.startsWith('http')) {
|
||||
return new HttpsProxyAgent(proxyUrl);
|
||||
} else {
|
||||
throw new Error('Unsupported proxy type');
|
||||
}
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
if (this.isShutdown) return;
|
||||
|
||||
this.isShutdown = true;
|
||||
const event = new EndSessionEvent(this.config);
|
||||
this.logEndSessionEvent(event);
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,6 @@ import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
|
||||
import { Config } from '../config/config.js';
|
||||
import { SERVICE_NAME } from './constants.js';
|
||||
import { initializeMetrics } from './metrics.js';
|
||||
import { ClearcutLogger } from './clearcut-logger/clearcut-logger.js';
|
||||
import {
|
||||
FileLogExporter,
|
||||
FileMetricExporter,
|
||||
@@ -141,7 +140,6 @@ export async function shutdownTelemetry(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ClearcutLogger.getInstance()?.shutdown();
|
||||
await sdk.shutdown();
|
||||
console.log('OpenTelemetry SDK shut down successfully.');
|
||||
} catch (error) {
|
||||
|
||||
@@ -161,6 +161,7 @@ export class ApiRequestEvent {
|
||||
export class ApiErrorEvent {
|
||||
'event.name': 'api_error';
|
||||
'event.timestamp': string; // ISO 8601
|
||||
response_id?: string;
|
||||
model: string;
|
||||
error: string;
|
||||
error_type?: string;
|
||||
@@ -170,6 +171,7 @@ export class ApiErrorEvent {
|
||||
auth_type?: string;
|
||||
|
||||
constructor(
|
||||
response_id: string | undefined,
|
||||
model: string,
|
||||
error: string,
|
||||
duration_ms: number,
|
||||
@@ -180,6 +182,7 @@ export class ApiErrorEvent {
|
||||
) {
|
||||
this['event.name'] = 'api_error';
|
||||
this['event.timestamp'] = new Date().toISOString();
|
||||
this.response_id = response_id;
|
||||
this.model = model;
|
||||
this.error = error;
|
||||
this.error_type = error_type;
|
||||
@@ -193,6 +196,7 @@ export class ApiErrorEvent {
|
||||
export class ApiResponseEvent {
|
||||
'event.name': 'api_response';
|
||||
'event.timestamp': string; // ISO 8601
|
||||
response_id: string;
|
||||
model: string;
|
||||
status_code?: number | string;
|
||||
duration_ms: number;
|
||||
@@ -208,6 +212,7 @@ export class ApiResponseEvent {
|
||||
auth_type?: string;
|
||||
|
||||
constructor(
|
||||
response_id: string,
|
||||
model: string,
|
||||
duration_ms: number,
|
||||
prompt_id: string,
|
||||
@@ -218,6 +223,7 @@ export class ApiResponseEvent {
|
||||
) {
|
||||
this['event.name'] = 'api_response';
|
||||
this['event.timestamp'] = new Date().toISOString();
|
||||
this.response_id = response_id;
|
||||
this.model = model;
|
||||
this.duration_ms = duration_ms;
|
||||
this.status_code = 200;
|
||||
|
||||
@@ -60,7 +60,7 @@ export const GEMINI_CONFIG_DIR = '.qwen';
|
||||
export const DEFAULT_CONTEXT_FILENAME = 'QWEN.md';
|
||||
export const MEMORY_SECTION_HEADER = '## Qwen Added Memories';
|
||||
|
||||
// This variable will hold the currently configured filename for GEMINI.md context files.
|
||||
// This variable will hold the currently configured filename for QWEN.md context files.
|
||||
// It defaults to DEFAULT_CONTEXT_FILENAME but can be overridden by setGeminiMdFilename.
|
||||
let currentGeminiMdFilename: string | string[] = DEFAULT_CONTEXT_FILENAME;
|
||||
|
||||
|
||||
@@ -202,8 +202,8 @@ describe('bfsFileSearch', () => {
|
||||
await createEmptyDir(`dir${i}`, 'subdir1', 'deep');
|
||||
if (i < 10) {
|
||||
// Add target files in some directories
|
||||
await createTestFile('content', `dir${i}`, 'GEMINI.md');
|
||||
await createTestFile('content', `dir${i}`, 'subdir1', 'GEMINI.md');
|
||||
await createTestFile('content', `dir${i}`, 'QWEN.md');
|
||||
await createTestFile('content', `dir${i}`, 'subdir1', 'QWEN.md');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +216,7 @@ describe('bfsFileSearch', () => {
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const searchStartTime = performance.now();
|
||||
const result = await bfsFileSearch(testRootDir, {
|
||||
fileName: 'GEMINI.md',
|
||||
fileName: 'QWEN.md',
|
||||
maxDirs: 200,
|
||||
debug: false,
|
||||
});
|
||||
@@ -242,7 +242,7 @@ describe('bfsFileSearch', () => {
|
||||
console.log(
|
||||
`📊 Min/Max Duration: ${minDuration.toFixed(2)}ms / ${maxDuration.toFixed(2)}ms`,
|
||||
);
|
||||
console.log(`📁 Found ${foundFiles} GEMINI.md files`);
|
||||
console.log(`📁 Found ${foundFiles} QWEN.md files`);
|
||||
console.log(
|
||||
`🏎️ Processing ~${Math.round(200 / (avgDuration / 1000))} dirs/second`,
|
||||
);
|
||||
|
||||
@@ -365,7 +365,7 @@ My code memory
|
||||
|
||||
it('should load extension context file paths', async () => {
|
||||
const extensionFilePath = await createTestFile(
|
||||
path.join(testRootDir, 'extensions/ext1/GEMINI.md'),
|
||||
path.join(testRootDir, 'extensions/ext1/QWEN.md'),
|
||||
'Extension memory content',
|
||||
);
|
||||
|
||||
|
||||
@@ -252,7 +252,7 @@ function concatenateInstructions(
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads hierarchical GEMINI.md files and concatenates their content.
|
||||
* Loads hierarchical QWEN.md files and concatenates their content.
|
||||
* This function is intended for use by the server.
|
||||
*/
|
||||
export async function loadServerHierarchicalMemory(
|
||||
@@ -282,7 +282,7 @@ export async function loadServerHierarchicalMemory(
|
||||
maxDirs,
|
||||
);
|
||||
if (filePaths.length === 0) {
|
||||
if (debugMode) logger.debug('No GEMINI.md files found in hierarchy.');
|
||||
if (debugMode) logger.debug('No QWEN.md files found in hierarchy.');
|
||||
return { memoryContent: '', fileCount: 0 };
|
||||
}
|
||||
const contentsWithPaths = await readGeminiMdFiles(
|
||||
|
||||
@@ -191,7 +191,7 @@ function findCodeRegions(content: string): Array<[number, number]> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes import statements in GEMINI.md content
|
||||
* Processes import statements in QWEN.md content
|
||||
* Supports @path/to/file syntax for importing content from other files
|
||||
* @param content - The content to process for imports
|
||||
* @param basePath - The directory path where the current file is located
|
||||
|
||||
@@ -8,3 +8,9 @@ import { setSimulate429 } from './src/utils/testUtils.js';
|
||||
|
||||
// Disable 429 simulation globally for all tests
|
||||
setSimulate429(false);
|
||||
|
||||
// Some dependencies (e.g., undici) expect a global File constructor in Node.
|
||||
// Provide a minimal shim for test environment if missing.
|
||||
if (typeof (globalThis as unknown as { File?: unknown }).File === 'undefined') {
|
||||
(globalThis as unknown as { File: unknown }).File = class {} as unknown;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "qwen-code-vscode-ide-companion",
|
||||
"displayName": "Qwen Code Companion",
|
||||
"description": "Enable Qwen Code with direct access to your VS Code workspace.",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.5",
|
||||
"publisher": "qwenlm",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
Reference in New Issue
Block a user