diff --git a/.github/workflows/release-sdk.yml b/.github/workflows/release-sdk.yml index c5e30ee99..7ae0e2d03 100644 --- a/.github/workflows/release-sdk.yml +++ b/.github/workflows/release-sdk.yml @@ -33,6 +33,10 @@ on: type: 'boolean' default: false +concurrency: + group: '${{ github.workflow }}' + cancel-in-progress: false + jobs: release-sdk: runs-on: 'ubuntu-latest' @@ -46,6 +50,7 @@ jobs: packages: 'write' id-token: 'write' issues: 'write' + pull-requests: 'write' outputs: RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' @@ -163,11 +168,11 @@ jobs: echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}" - name: 'Update package version' - working-directory: 'packages/sdk-typescript' env: RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}' run: |- - npm version "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version + # Use npm workspaces so the root lockfile is updated consistently. + npm version -w @qwen-code/sdk "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version - name: 'Commit and Conditionally Push package version' env: @@ -175,7 +180,7 @@ jobs: IS_DRY_RUN: '${{ steps.vars.outputs.is_dry_run }}' RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' run: |- - git add packages/sdk-typescript/package.json + git add packages/sdk-typescript/package.json package-lock.json if git diff --staged --quiet; then echo "No version changes to commit" else @@ -222,6 +227,49 @@ jobs: --notes-start-tag "sdk-typescript-${PREVIOUS_RELEASE_TAG}" \ --generate-notes + - name: 'Create PR to merge release branch into main' + if: |- + ${{ steps.vars.outputs.is_dry_run == 'false' }} + id: 'pr' + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}' + RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' + run: |- + set -euo pipefail + + pr_url="$(gh pr list --head "${RELEASE_BRANCH}" --base main --json url --jq '.[0].url')" + if [[ -z "${pr_url}" ]]; then + pr_url="$(gh pr create \ + --base main \ + --head "${RELEASE_BRANCH}" \ + --title "chore(release): sdk-typescript ${RELEASE_TAG}" \ + --body "Automated release PR for sdk-typescript ${RELEASE_TAG}.")" + fi + + echo "PR_URL=${pr_url}" >> "${GITHUB_OUTPUT}" + + - name: 'Wait for CI checks to complete' + if: |- + ${{ steps.vars.outputs.is_dry_run == 'false' }} + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + PR_URL: '${{ steps.pr.outputs.PR_URL }}' + run: |- + set -euo pipefail + echo "Waiting for CI checks to complete..." + gh pr checks "${PR_URL}" --watch --interval 30 + + - name: 'Enable auto-merge for release PR' + if: |- + ${{ steps.vars.outputs.is_dry_run == 'false' }} + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + PR_URL: '${{ steps.pr.outputs.PR_URL }}' + run: |- + set -euo pipefail + gh pr merge "${PR_URL}" --merge --auto + - name: 'Create Issue on Failure' if: |- ${{ failure() }} diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 658e48351..3e87985b8 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -43,6 +43,7 @@ Qwen Code uses JSON settings files for persistent configuration. There are four In addition to a project settings file, a project's `.qwen` directory can contain other project-specific files related to Qwen Code's operation, such as: - [Custom sandbox profiles](../features/sandbox) (e.g. `.qwen/sandbox-macos-custom.sb`, `.qwen/sandbox.Dockerfile`). +- [Agent Skills](../features/skills) (experimental) under `.qwen/skills/` (each Skill is a directory containing a `SKILL.md`). ### Available settings in `settings.json` @@ -380,6 +381,8 @@ Arguments passed directly when running the CLI can override other configurations | `--telemetry-otlp-protocol` | | Sets the OTLP protocol for telemetry (`grpc` or `http`). | | Defaults to `grpc`. See [telemetry](../../developers/development/telemetry) for more information. | | `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. | | `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | | +| `--experimental-acp` | | Enables ACP mode (Agent Control Protocol). Useful for IDE/editor integrations like [Zed](../integration-zed). | | Experimental. | +| `--experimental-skills` | | Enables experimental [Agent Skills](../features/skills) (registers the `skill` tool and loads Skills from `.qwen/skills/` and `~/.qwen/skills/`). | | Experimental. | | `--extensions` | `-e` | Specifies a list of extensions to use for the session. | Extension names | If not provided, all available extensions are used. Use the special term `qwen -e none` to disable all extensions. Example: `qwen -e my-extension -e my-other-extension` | | `--list-extensions` | `-l` | Lists all available extensions and exits. | | | | `--proxy` | | Sets the proxy for the CLI. | Proxy URL | Example: `--proxy http://localhost:7890`. | diff --git a/docs/users/features/_meta.ts b/docs/users/features/_meta.ts index 5509cc743..bf182b6b7 100644 --- a/docs/users/features/_meta.ts +++ b/docs/users/features/_meta.ts @@ -1,6 +1,7 @@ export default { commands: 'Commands', 'sub-agents': 'SubAgents', + skills: 'Skills (Experimental)', headless: 'Headless Mode', checkpointing: { display: 'hidden', diff --git a/docs/users/features/headless.md b/docs/users/features/headless.md index 203e08a2d..139b3bbba 100644 --- a/docs/users/features/headless.md +++ b/docs/users/features/headless.md @@ -189,19 +189,20 @@ qwen -p "Write code" --output-format stream-json --include-partial-messages | jq Key command-line options for headless usage: -| Option | Description | Example | -| ---------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------ | -| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` | -| `--output-format`, `-o` | Specify output format (text, json, stream-json) | `qwen -p "query" --output-format json` | -| `--input-format` | Specify input format (text, stream-json) | `qwen --input-format text --output-format stream-json` | -| `--include-partial-messages` | Include partial messages in stream-json output | `qwen -p "query" --output-format stream-json --include-partial-messages` | -| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` | -| `--all-files`, `-a` | Include all files in context | `qwen -p "query" --all-files` | -| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` | -| `--yolo`, `-y` | Auto-approve all actions | `qwen -p "query" --yolo` | -| `--approval-mode` | Set approval mode | `qwen -p "query" --approval-mode auto_edit` | -| `--continue` | Resume the most recent session for this project | `qwen --continue -p "Pick up where we left off"` | -| `--resume [sessionId]` | Resume a specific session (or choose interactively) | `qwen --resume 123e... -p "Finish the refactor"` | +| Option | Description | Example | +| ---------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------ | +| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` | +| `--output-format`, `-o` | Specify output format (text, json, stream-json) | `qwen -p "query" --output-format json` | +| `--input-format` | Specify input format (text, stream-json) | `qwen --input-format text --output-format stream-json` | +| `--include-partial-messages` | Include partial messages in stream-json output | `qwen -p "query" --output-format stream-json --include-partial-messages` | +| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` | +| `--all-files`, `-a` | Include all files in context | `qwen -p "query" --all-files` | +| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` | +| `--yolo`, `-y` | Auto-approve all actions | `qwen -p "query" --yolo` | +| `--approval-mode` | Set approval mode | `qwen -p "query" --approval-mode auto_edit` | +| `--continue` | Resume the most recent session for this project | `qwen --continue -p "Pick up where we left off"` | +| `--resume [sessionId]` | Resume a specific session (or choose interactively) | `qwen --resume 123e... -p "Finish the refactor"` | +| `--experimental-skills` | Enable experimental Skills (registers the `skill` tool) | `qwen --experimental-skills -p "What Skills are available?"` | For complete details on all available configuration options, settings files, and environment variables, see the [Configuration Guide](../configuration/settings). diff --git a/docs/users/features/skills.md b/docs/users/features/skills.md new file mode 100644 index 000000000..a0cabcf1a --- /dev/null +++ b/docs/users/features/skills.md @@ -0,0 +1,282 @@ +# Agent Skills (Experimental) + +> Create, manage, and share Skills to extend Qwen Code’s capabilities. + +This guide shows you how to create, use, and manage Agent Skills in **Qwen Code**. Skills are modular capabilities that extend the model’s effectiveness through organized folders containing instructions (and optionally scripts/resources). + +> [!note] +> +> Skills are currently **experimental** and must be enabled with `--experimental-skills`. + +## Prerequisites + +- Qwen Code (recent version) +- Run with the experimental flag enabled: + +```bash +qwen --experimental-skills +``` + +- Basic familiarity with Qwen Code ([Quickstart](../quickstart.md)) + +## What are Agent Skills? + +Agent Skills package expertise into discoverable capabilities. Each Skill consists of a `SKILL.md` file with instructions that the model can load when relevant, plus optional supporting files like scripts and templates. + +### How Skills are invoked + +Skills are **model-invoked** — the model autonomously decides when to use them based on your request and the Skill’s description. This is different from slash commands, which are **user-invoked** (you explicitly type `/command`). + +### Benefits + +- Extend Qwen Code for your workflows +- Share expertise across your team via git +- Reduce repetitive prompting +- Compose multiple Skills for complex tasks + +## Create a Skill + +Skills are stored as directories containing a `SKILL.md` file. + +### Personal Skills + +Personal Skills are available across all your projects. Store them in `~/.qwen/skills/`: + +```bash +mkdir -p ~/.qwen/skills/my-skill-name +``` + +Use personal Skills for: + +- Your individual workflows and preferences +- Experimental Skills you’re developing +- Personal productivity helpers + +### Project Skills + +Project Skills are shared with your team. Store them in `.qwen/skills/` within your project: + +```bash +mkdir -p .qwen/skills/my-skill-name +``` + +Use project Skills for: + +- Team workflows and conventions +- Project-specific expertise +- Shared utilities and scripts + +Project Skills can be checked into git and automatically become available to teammates. + +## Write `SKILL.md` + +Create a `SKILL.md` file with YAML frontmatter and Markdown content: + +```yaml +--- +name: your-skill-name +description: Brief description of what this Skill does and when to use it +--- + +# Your Skill Name + +## Instructions +Provide clear, step-by-step guidance for Qwen Code. + +## Examples +Show concrete examples of using this Skill. +``` + +### Field requirements + +Qwen Code currently validates that: + +- `name` is a non-empty string +- `description` is a non-empty string + +Recommended conventions (not strictly enforced yet): + +- Use lowercase letters, numbers, and hyphens in `name` +- Make `description` specific: include both **what** the Skill does and **when** to use it (key words users will naturally mention) + +## Add supporting files + +Create additional files alongside `SKILL.md`: + +```text +my-skill/ +├── SKILL.md (required) +├── reference.md (optional documentation) +├── examples.md (optional examples) +├── scripts/ +│ └── helper.py (optional utility) +└── templates/ + └── template.txt (optional template) +``` + +Reference these files from `SKILL.md`: + +````markdown +For advanced usage, see [reference.md](reference.md). + +Run the helper script: + +```bash +python scripts/helper.py input.txt +``` +```` + +## View available Skills + +When `--experimental-skills` is enabled, Qwen Code discovers Skills from: + +- Personal Skills: `~/.qwen/skills/` +- Project Skills: `.qwen/skills/` + +To view available Skills, ask Qwen Code directly: + +```text +What Skills are available? +``` + +Or inspect the filesystem: + +```bash +# List personal Skills +ls ~/.qwen/skills/ + +# List project Skills (if in a project directory) +ls .qwen/skills/ + +# View a specific Skill’s content +cat ~/.qwen/skills/my-skill/SKILL.md +``` + +## Test a Skill + +After creating a Skill, test it by asking questions that match your description. + +Example: if your description mentions “PDF files”: + +```text +Can you help me extract text from this PDF? +``` + +The model autonomously decides to use your Skill if it matches the request — you don’t need to explicitly invoke it. + +## Debug a Skill + +If Qwen Code doesn’t use your Skill, check these common issues: + +### Make the description specific + +Too vague: + +```yaml +description: Helps with documents +``` + +Specific: + +```yaml +description: Extract text and tables from PDF files, fill forms, merge documents. Use when working with PDFs, forms, or document extraction. +``` + +### Verify file path + +- Personal Skills: `~/.qwen/skills//SKILL.md` +- Project Skills: `.qwen/skills//SKILL.md` + +```bash +# Personal +ls ~/.qwen/skills/my-skill/SKILL.md + +# Project +ls .qwen/skills/my-skill/SKILL.md +``` + +### Check YAML syntax + +Invalid YAML prevents the Skill metadata from loading correctly. + +```bash +cat SKILL.md | head -n 15 +``` + +Ensure: + +- Opening `---` on line 1 +- Closing `---` before Markdown content +- Valid YAML syntax (no tabs, correct indentation) + +### View errors + +Run Qwen Code with debug mode to see Skill loading errors: + +```bash +qwen --experimental-skills --debug +``` + +## Share Skills with your team + +You can share Skills through project repositories: + +1. Add the Skill under `.qwen/skills/` +2. Commit and push +3. Teammates pull the changes and run with `--experimental-skills` + +```bash +git add .qwen/skills/ +git commit -m "Add team Skill for PDF processing" +git push +``` + +## Update a Skill + +Edit `SKILL.md` directly: + +```bash +# Personal Skill +code ~/.qwen/skills/my-skill/SKILL.md + +# Project Skill +code .qwen/skills/my-skill/SKILL.md +``` + +Changes take effect the next time you start Qwen Code. If Qwen Code is already running, restart it to load the updates. + +## Remove a Skill + +Delete the Skill directory: + +```bash +# Personal +rm -rf ~/.qwen/skills/my-skill + +# Project +rm -rf .qwen/skills/my-skill +git commit -m "Remove unused Skill" +``` + +## Best practices + +### Keep Skills focused + +One Skill should address one capability: + +- Focused: “PDF form filling”, “Excel analysis”, “Git commit messages” +- Too broad: “Document processing” (split into smaller Skills) + +### Write clear descriptions + +Help the model discover when to use Skills by including specific triggers: + +```yaml +description: Analyze Excel spreadsheets, create pivot tables, and generate charts. Use when working with Excel files, spreadsheets, or .xlsx data. +``` + +### Test with your team + +- Does the Skill activate when expected? +- Are the instructions clear? +- Are there missing examples or edge cases? diff --git a/integration-tests/file-system.test.ts b/integration-tests/file-system.test.ts index bd15c76eb..9e8176290 100644 --- a/integration-tests/file-system.test.ts +++ b/integration-tests/file-system.test.ts @@ -202,8 +202,8 @@ describe('file-system', () => { const readAttempt = toolLogs.find( (log) => log.toolRequest.name === 'read_file', ); - const writeAttempt = toolLogs.find( - (log) => log.toolRequest.name === 'write_file', + const editAttempt = toolLogs.find( + (log) => log.toolRequest.name === 'edit_file', ); const successfulReplace = toolLogs.find( (log) => log.toolRequest.name === 'replace' && log.toolRequest.success, @@ -226,15 +226,15 @@ describe('file-system', () => { // CRITICAL: Verify that no matter what the model did, it never successfully // wrote or replaced anything. - if (writeAttempt) { + if (editAttempt) { console.error( - 'A write_file attempt was made when no file should be written.', + 'A edit_file attempt was made when no file should be written.', ); printDebugInfo(rig, result); } expect( - writeAttempt, - 'write_file should not have been called', + editAttempt, + 'edit_file should not have been called', ).toBeUndefined(); if (successfulReplace) { diff --git a/integration-tests/sdk-typescript/permission-control.test.ts b/integration-tests/sdk-typescript/permission-control.test.ts index e8d201e6e..974f72b37 100644 --- a/integration-tests/sdk-typescript/permission-control.test.ts +++ b/integration-tests/sdk-typescript/permission-control.test.ts @@ -952,7 +952,8 @@ describe('Permission Control (E2E)', () => { TEST_TIMEOUT, ); - it( + // FIXME: This test is flaky and sometimes fails with no tool calls. + it.skip( 'should allow read-only tools without restrictions', async () => { // Create test files for the model to read diff --git a/package-lock.json b/package-lock.json index 442bc8a1d..330b90e08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -134,6 +134,36 @@ "node": ">=6.0.0" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.36.3", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.36.3.tgz", + "integrity": "sha512-+c0mMLxL/17yFZ4P5+U6bTWiCSFZUKJddrv01ud2aFBWnTPLdRncYV76D3q1tqfnL7aCnhRtykFnoCFzvr4U3Q==", + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -3822,6 +3852,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", @@ -4820,7 +4860,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "dev": true, "license": "MIT", "dependencies": { "event-target-shim": "^5.0.0" @@ -4907,6 +4946,18 @@ "node": ">= 14" } }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -5478,7 +5529,6 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, "node_modules/atomically": { @@ -6437,7 +6487,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -7063,7 +7112,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -7576,7 +7624,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -8106,7 +8153,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -8652,7 +8698,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -8665,11 +8710,16 @@ "node": ">= 6" } }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, "node_modules/form-data/node_modules/mime-types": { "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -8678,6 +8728,28 @@ "node": ">= 0.6" } }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/formdata-node/node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -9262,7 +9334,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -9441,6 +9512,15 @@ "node": ">=16.17.0" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -11940,6 +12020,48 @@ "node": ">=10.5.0" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-pty": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz", @@ -17834,6 +17956,7 @@ "version": "0.6.0", "hasInstallScript": true, "dependencies": { + "@anthropic-ai/sdk": "^0.36.1", "@google/genai": "1.30.0", "@modelcontextprotocol/sdk": "^1.25.1", "@opentelemetry/api": "^1.9.0", @@ -18470,7 +18593,7 @@ }, "packages/sdk-typescript": { "name": "@qwen-code/sdk", - "version": "0.6.0", + "version": "0.1.0", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.1", diff --git a/packages/cli/src/config/auth.ts b/packages/cli/src/config/auth.ts index 7f87db455..9f5d50a07 100644 --- a/packages/cli/src/config/auth.ts +++ b/packages/cli/src/config/auth.ts @@ -26,6 +26,20 @@ export function validateAuthMethod(authMethod: string): string | null { return null; } + if (authMethod === AuthType.USE_ANTHROPIC) { + const hasApiKey = process.env['ANTHROPIC_API_KEY']; + if (!hasApiKey) { + return 'ANTHROPIC_API_KEY environment variable not found.'; + } + + const hasBaseUrl = process.env['ANTHROPIC_BASE_URL']; + if (!hasBaseUrl) { + return 'ANTHROPIC_BASE_URL environment variable not found.'; + } + + return null; + } + if (authMethod === AuthType.USE_GEMINI) { const hasApiKey = process.env['GEMINI_API_KEY']; if (!hasApiKey) { diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 6eb75c3b1..0b95f7857 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -2114,7 +2114,14 @@ describe('loadCliConfig model selection', () => { }); it('always prefers model from argvs', async () => { - process.argv = ['node', 'script.js', '--model', 'qwen3-coder-plus']; + process.argv = [ + 'node', + 'script.js', + '--auth-type', + 'openai', + '--model', + 'qwen3-coder-plus', + ]; const argv = await parseArguments({} as Settings); const config = await loadCliConfig( { @@ -2134,7 +2141,14 @@ describe('loadCliConfig model selection', () => { }); it('selects the model from argvs if provided', async () => { - process.argv = ['node', 'script.js', '--model', 'qwen3-coder-plus']; + process.argv = [ + 'node', + 'script.js', + '--auth-type', + 'openai', + '--model', + 'qwen3-coder-plus', + ]; const argv = await parseArguments({} as Settings); const config = await loadCliConfig( { diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 07c65db9c..7cd7d685a 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -112,6 +112,7 @@ export interface CliArgs { allowedMcpServerNames: string[] | undefined; allowedTools: string[] | undefined; experimentalAcp: boolean | undefined; + experimentalSkills: boolean | undefined; extensions: string[] | undefined; listExtensions: boolean | undefined; openaiLogging: boolean | undefined; @@ -307,6 +308,11 @@ export async function parseArguments(settings: Settings): Promise { type: 'boolean', description: 'Starts the agent in ACP mode', }) + .option('experimental-skills', { + type: 'boolean', + description: 'Enable experimental Skills feature', + default: false, + }) .option('channel', { type: 'string', choices: ['VSCode', 'ACP', 'SDK', 'CI'], @@ -462,6 +468,7 @@ export async function parseArguments(settings: Settings): Promise { type: 'string', choices: [ AuthType.USE_OPENAI, + AuthType.USE_ANTHROPIC, AuthType.QWEN_OAUTH, AuthType.USE_GEMINI, AuthType.USE_VERTEX_AI, @@ -870,11 +877,30 @@ export async function loadCliConfig( ); } + const selectedAuthType = + (argv.authType as AuthType | undefined) || + settings.security?.auth?.selectedType; + + const apiKey = + (selectedAuthType === AuthType.USE_OPENAI + ? argv.openaiApiKey || + process.env['OPENAI_API_KEY'] || + settings.security?.auth?.apiKey + : '') || ''; + const baseUrl = + (selectedAuthType === AuthType.USE_OPENAI + ? argv.openaiBaseUrl || + process.env['OPENAI_BASE_URL'] || + settings.security?.auth?.baseUrl + : '') || ''; const resolvedModel = argv.model || - process.env['OPENAI_MODEL'] || - process.env['QWEN_MODEL'] || - settings.model?.name; + (selectedAuthType === AuthType.USE_OPENAI + ? process.env['OPENAI_MODEL'] || + process.env['QWEN_MODEL'] || + settings.model?.name + : '') || + ''; const sandboxConfig = await loadSandboxConfig(settings, argv); const screenReader = @@ -956,27 +982,20 @@ export async function loadCliConfig( maxSessionTurns: argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1, experimentalZedIntegration: argv.experimentalAcp || false, + experimentalSkills: argv.experimentalSkills || false, listExtensions: argv.listExtensions || false, extensions: allExtensions, blockedMcpServers, noBrowser: !!process.env['NO_BROWSER'], - authType: - (argv.authType as AuthType | undefined) || - settings.security?.auth?.selectedType, + authType: selectedAuthType, inputFormat, outputFormat, includePartialMessages, generationConfig: { ...(settings.model?.generationConfig || {}), model: resolvedModel, - apiKey: - argv.openaiApiKey || - process.env['OPENAI_API_KEY'] || - settings.security?.auth?.apiKey, - baseUrl: - argv.openaiBaseUrl || - process.env['OPENAI_BASE_URL'] || - settings.security?.auth?.baseUrl, + apiKey, + baseUrl, enableOpenAILogging: (typeof argv.openaiLogging === 'undefined' ? settings.model?.enableOpenAILogging diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index c2b25c63c..9fa0b8261 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -461,6 +461,7 @@ describe('gemini.tsx main function kitty protocol', () => { allowedMcpServerNames: undefined, allowedTools: undefined, experimentalAcp: undefined, + experimentalSkills: undefined, extensions: undefined, listExtensions: undefined, openaiLogging: undefined, diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index ba569fe1b..c13f33c95 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -228,6 +228,7 @@ export const useAuthCommand = ( ![ AuthType.QWEN_OAUTH, AuthType.USE_OPENAI, + AuthType.USE_ANTHROPIC, AuthType.USE_GEMINI, AuthType.USE_VERTEX_AI, ].includes(defaultAuthType as AuthType) @@ -240,6 +241,7 @@ export const useAuthCommand = ( validValues: [ AuthType.QWEN_OAUTH, AuthType.USE_OPENAI, + AuthType.USE_ANTHROPIC, AuthType.USE_GEMINI, AuthType.USE_VERTEX_AI, ].join(', '), diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 7154e6aa1..e70ea0538 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -526,10 +526,15 @@ export const useGeminiStream = ( return currentThoughtBuffer; } - const newThoughtBuffer = currentThoughtBuffer + thoughtText; + let newThoughtBuffer = currentThoughtBuffer + thoughtText; + + const pendingType = pendingHistoryItemRef.current?.type; + const isPendingThought = + pendingType === 'gemini_thought' || + pendingType === 'gemini_thought_content'; // If we're not already showing a thought, start a new one - if (pendingHistoryItemRef.current?.type !== 'gemini_thought') { + if (!isPendingThought) { // If there's a pending non-thought item, finalize it first if (pendingHistoryItemRef.current) { addItem(pendingHistoryItemRef.current, userMessageTimestamp); @@ -537,11 +542,37 @@ export const useGeminiStream = ( setPendingHistoryItem({ type: 'gemini_thought', text: '' }); } - // Update the existing thought message with accumulated content - setPendingHistoryItem({ - type: 'gemini_thought', - text: newThoughtBuffer, - }); + // Split large thought messages for better rendering performance (same rationale + // as regular content streaming). This helps avoid terminal flicker caused by + // constantly re-rendering an ever-growing "pending" block. + const splitPoint = findLastSafeSplitPoint(newThoughtBuffer); + const nextPendingType: 'gemini_thought' | 'gemini_thought_content' = + isPendingThought && pendingType === 'gemini_thought_content' + ? 'gemini_thought_content' + : 'gemini_thought'; + + if (splitPoint === newThoughtBuffer.length) { + // Update the existing thought message with accumulated content + setPendingHistoryItem({ + type: nextPendingType, + text: newThoughtBuffer, + }); + } else { + const beforeText = newThoughtBuffer.substring(0, splitPoint); + const afterText = newThoughtBuffer.substring(splitPoint); + addItem( + { + type: nextPendingType, + text: beforeText, + }, + userMessageTimestamp, + ); + setPendingHistoryItem({ + type: 'gemini_thought_content', + text: afterText, + }); + newThoughtBuffer = afterText; + } // Also update the thought state for the loading indicator mergeThought(eventValue); diff --git a/packages/cli/src/ui/models/availableModels.ts b/packages/cli/src/ui/models/availableModels.ts index 9a04101f0..d9c9eb725 100644 --- a/packages/cli/src/ui/models/availableModels.ts +++ b/packages/cli/src/ui/models/availableModels.ts @@ -60,6 +60,11 @@ export function getOpenAIAvailableModelFromEnv(): AvailableModel | null { return id ? { id, label: id } : null; } +export function getAnthropicAvailableModelFromEnv(): AvailableModel | null { + const id = process.env['ANTHROPIC_MODEL']?.trim(); + return id ? { id, label: id } : null; +} + export function getAvailableModelsForAuthType( authType: AuthType, ): AvailableModel[] { @@ -70,6 +75,10 @@ export function getAvailableModelsForAuthType( const openAIModel = getOpenAIAvailableModelFromEnv(); return openAIModel ? [openAIModel] : []; } + case AuthType.USE_ANTHROPIC: { + const anthropicModel = getAnthropicAvailableModelFromEnv(); + return anthropicModel ? [anthropicModel] : []; + } default: // For other auth types, return empty array for now // This can be expanded later according to the design doc diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts index 29d602725..f6b7ee392 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts @@ -20,6 +20,11 @@ const makeConfig = (tools: Record) => getToolRegistry: () => ({ getTool: (name: string) => tools[name], }), + getContentGenerator: () => ({ + // Default to showing full thinking content during resume unless explicitly + // summarized; tests don't care about summarized thinking behavior. + useSummarizedThinking: () => false, + }), }) as unknown as Config; describe('resumeHistoryUtils', () => { diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.ts index 3c69bfd47..686e87e2d 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.ts @@ -204,7 +204,11 @@ function convertToHistoryItems( const parts = record.message?.parts as Part[] | undefined; // Extract thought content - const thoughtText = extractThoughtTextFromParts(parts); + const thoughtText = !config + .getContentGenerator() + .useSummarizedThinking() + ? extractThoughtTextFromParts(parts) + : ''; // Extract text content (non-function-call, non-thought) const text = extractTextFromParts(parts); diff --git a/packages/cli/src/utils/systemInfo.ts b/packages/cli/src/utils/systemInfo.ts index 84927a951..5f067b3ae 100644 --- a/packages/cli/src/utils/systemInfo.ts +++ b/packages/cli/src/utils/systemInfo.ts @@ -153,7 +153,8 @@ export async function getExtendedSystemInfo( // Get base URL if using OpenAI auth const baseUrl = - baseInfo.selectedAuthType === AuthType.USE_OPENAI + baseInfo.selectedAuthType === AuthType.USE_OPENAI || + baseInfo.selectedAuthType === AuthType.USE_ANTHROPIC ? context.services.config?.getContentGeneratorConfig()?.baseUrl : undefined; diff --git a/packages/cli/src/validateNonInterActiveAuth.test.ts b/packages/cli/src/validateNonInterActiveAuth.test.ts index 867777b34..2997847d3 100644 --- a/packages/cli/src/validateNonInterActiveAuth.test.ts +++ b/packages/cli/src/validateNonInterActiveAuth.test.ts @@ -19,6 +19,9 @@ describe('validateNonInterActiveAuth', () => { let originalEnvVertexAi: string | undefined; let originalEnvGcp: string | undefined; let originalEnvOpenAiApiKey: string | undefined; + let originalEnvQwenOauth: string | undefined; + let originalEnvGoogleApiKey: string | undefined; + let originalEnvAnthropicApiKey: string | undefined; let consoleErrorSpy: ReturnType; let processExitSpy: ReturnType>; let refreshAuthMock: ReturnType; @@ -29,10 +32,16 @@ describe('validateNonInterActiveAuth', () => { originalEnvVertexAi = process.env['GOOGLE_GENAI_USE_VERTEXAI']; originalEnvGcp = process.env['GOOGLE_GENAI_USE_GCA']; originalEnvOpenAiApiKey = process.env['OPENAI_API_KEY']; + originalEnvQwenOauth = process.env['QWEN_OAUTH']; + originalEnvGoogleApiKey = process.env['GOOGLE_API_KEY']; + originalEnvAnthropicApiKey = process.env['ANTHROPIC_API_KEY']; delete process.env['GEMINI_API_KEY']; delete process.env['GOOGLE_GENAI_USE_VERTEXAI']; delete process.env['GOOGLE_GENAI_USE_GCA']; delete process.env['OPENAI_API_KEY']; + delete process.env['QWEN_OAUTH']; + delete process.env['GOOGLE_API_KEY']; + delete process.env['ANTHROPIC_API_KEY']; consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { throw new Error(`process.exit(${code}) called`); @@ -80,6 +89,21 @@ describe('validateNonInterActiveAuth', () => { } else { delete process.env['OPENAI_API_KEY']; } + if (originalEnvQwenOauth !== undefined) { + process.env['QWEN_OAUTH'] = originalEnvQwenOauth; + } else { + delete process.env['QWEN_OAUTH']; + } + if (originalEnvGoogleApiKey !== undefined) { + process.env['GOOGLE_API_KEY'] = originalEnvGoogleApiKey; + } else { + delete process.env['GOOGLE_API_KEY']; + } + if (originalEnvAnthropicApiKey !== undefined) { + process.env['ANTHROPIC_API_KEY'] = originalEnvAnthropicApiKey; + } else { + delete process.env['ANTHROPIC_API_KEY']; + } vi.restoreAllMocks(); }); diff --git a/packages/cli/src/validateNonInterActiveAuth.ts b/packages/cli/src/validateNonInterActiveAuth.ts index e89eaa0e3..be5425a97 100644 --- a/packages/cli/src/validateNonInterActiveAuth.ts +++ b/packages/cli/src/validateNonInterActiveAuth.ts @@ -27,6 +27,9 @@ function getAuthTypeFromEnv(): AuthType | undefined { if (process.env['GOOGLE_API_KEY']) { return AuthType.USE_VERTEX_AI; } + if (process.env['ANTHROPIC_API_KEY']) { + return AuthType.USE_ANTHROPIC; + } return undefined; } diff --git a/packages/core/package.json b/packages/core/package.json index 92d220bb5..de06c78bc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -23,6 +23,7 @@ "scripts/postinstall.js" ], "dependencies": { + "@anthropic-ai/sdk": "^0.36.1", "@google/genai": "1.30.0", "@modelcontextprotocol/sdk": "^1.25.1", "@opentelemetry/api": "^1.9.0", diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 4e241ca6f..1b163b9a6 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -16,7 +16,6 @@ import { QwenLogger, } from '../telemetry/index.js'; import type { ContentGeneratorConfig } from '../core/contentGenerator.js'; -import { DEFAULT_DASHSCOPE_BASE_URL } from '../core/openaiContentGenerator/constants.js'; import { AuthType, createContentGeneratorConfig, @@ -273,7 +272,7 @@ describe('Server Config (config.ts)', () => { authType, { model: MODEL, - baseUrl: DEFAULT_DASHSCOPE_BASE_URL, + baseUrl: undefined, }, ); // Verify that contentGeneratorConfig is updated diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 86a21ef24..34dbb4649 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -54,6 +54,7 @@ import { canUseRipgrep } from '../utils/ripgrepUtils.js'; import { RipGrepTool } from '../tools/ripGrep.js'; import { ShellTool } from '../tools/shell.js'; import { SmartEditTool } from '../tools/smart-edit.js'; +import { SkillTool } from '../tools/skill.js'; import { TaskTool } from '../tools/task.js'; import { TodoWriteTool } from '../tools/todoWrite.js'; import { ToolRegistry } from '../tools/tool-registry.js'; @@ -65,6 +66,7 @@ import { WriteFileTool } from '../tools/write-file.js'; import { ideContextStore } from '../ide/ideContext.js'; import { InputFormat, OutputFormat } from '../output/types.js'; import { PromptRegistry } from '../prompts/prompt-registry.js'; +import { SkillManager } from '../skills/skill-manager.js'; import { SubagentManager } from '../subagents/subagent-manager.js'; import type { SubagentConfig } from '../subagents/types.js'; import { @@ -94,7 +96,6 @@ import { } from './constants.js'; import { DEFAULT_QWEN_EMBEDDING_MODEL, DEFAULT_QWEN_MODEL } from './models.js'; import { Storage } from './storage.js'; -import { DEFAULT_DASHSCOPE_BASE_URL } from '../core/openaiContentGenerator/constants.js'; import { ChatRecordingService } from '../services/chatRecordingService.js'; import { SessionService, @@ -305,6 +306,7 @@ export interface ConfigParameters { extensionContextFilePaths?: string[]; maxSessionTurns?: number; sessionTokenLimit?: number; + experimentalSkills?: boolean; experimentalZedIntegration?: boolean; listExtensions?: boolean; extensions?: GeminiCLIExtension[]; @@ -389,6 +391,7 @@ export class Config { private toolRegistry!: ToolRegistry; private promptRegistry!: PromptRegistry; private subagentManager!: SubagentManager; + private skillManager!: SkillManager; private fileSystemService: FileSystemService; private contentGeneratorConfig!: ContentGeneratorConfig; private contentGenerator!: ContentGenerator; @@ -458,6 +461,7 @@ export class Config { | undefined; private readonly cliVersion?: string; private readonly experimentalZedIntegration: boolean = false; + private readonly experimentalSkills: boolean = false; private readonly chatRecordingEnabled: boolean; private readonly loadMemoryFromIncludeDirectories: boolean = false; private readonly webSearch?: { @@ -557,6 +561,7 @@ export class Config { this.sessionTokenLimit = params.sessionTokenLimit ?? -1; this.experimentalZedIntegration = params.experimentalZedIntegration ?? false; + this.experimentalSkills = params.experimentalSkills ?? false; this.listExtensions = params.listExtensions ?? false; this._extensions = params.extensions ?? []; this._blockedMcpServers = params.blockedMcpServers ?? []; @@ -568,7 +573,7 @@ export class Config { this._generationConfig = { model: params.model, ...(params.generationConfig || {}), - baseUrl: params.generationConfig?.baseUrl || DEFAULT_DASHSCOPE_BASE_URL, + baseUrl: params.generationConfig?.baseUrl, }; this.contentGeneratorConfig = this ._generationConfig as ContentGeneratorConfig; @@ -644,6 +649,7 @@ export class Config { } this.promptRegistry = new PromptRegistry(); this.subagentManager = new SubagentManager(this); + this.skillManager = new SkillManager(this); // Load session subagents if they were provided before initialization if (this.sessionSubagents.length > 0) { @@ -1066,6 +1072,10 @@ export class Config { return this.experimentalZedIntegration; } + getExperimentalSkills(): boolean { + return this.experimentalSkills; + } + getListExtensions(): boolean { return this.listExtensions; } @@ -1296,6 +1306,10 @@ export class Config { return this.subagentManager; } + getSkillManager(): SkillManager { + return this.skillManager; + } + async createToolRegistry( sendSdkMcpMessage?: SendSdkMcpMessage, ): Promise { @@ -1338,6 +1352,9 @@ export class Config { }; registerCoreTool(TaskTool, this); + if (this.getExperimentalSkills()) { + registerCoreTool(SkillTool, this); + } registerCoreTool(LSTool, this); registerCoreTool(ReadFileTool, this); diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 29484db3e..49b59ca1f 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -126,6 +126,10 @@ export class Storage { return path.join(this.getExtensionsDir(), 'qwen-extension.json'); } + getUserSkillsDir(): string { + return path.join(Storage.getGlobalQwenDir(), 'skills'); + } + getHistoryFilePath(): string { return path.join(this.getProjectTempDir(), 'shell_history'); } diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts new file mode 100644 index 000000000..483edac1a --- /dev/null +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts @@ -0,0 +1,500 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { + CountTokensParameters, + GenerateContentParameters, +} from '@google/genai'; +import { FinishReason, GenerateContentResponse } from '@google/genai'; + +// Mock the request tokenizer module BEFORE importing the class that uses it. +const mockTokenizer = { + calculateTokens: vi.fn(), + dispose: vi.fn(), +}; + +vi.mock('../../utils/request-tokenizer/index.js', () => ({ + getDefaultTokenizer: vi.fn(() => mockTokenizer), + DefaultRequestTokenizer: vi.fn(() => mockTokenizer), + disposeDefaultTokenizer: vi.fn(), +})); + +type AnthropicCreateArgs = [unknown, { signal?: AbortSignal }?]; + +const anthropicMockState: { + constructorOptions?: Record; + lastCreateArgs?: AnthropicCreateArgs; + createImpl: ReturnType; +} = { + constructorOptions: undefined, + lastCreateArgs: undefined, + createImpl: vi.fn(), +}; + +vi.mock('@anthropic-ai/sdk', () => { + class AnthropicMock { + messages: { create: (...args: AnthropicCreateArgs) => unknown }; + + constructor(options: Record) { + anthropicMockState.constructorOptions = options; + this.messages = { + create: (...args: AnthropicCreateArgs) => { + anthropicMockState.lastCreateArgs = args; + return anthropicMockState.createImpl(...args); + }, + }; + } + } + + return { + default: AnthropicMock, + __anthropicState: anthropicMockState, + }; +}); + +// Now import the modules that depend on the mocked modules. +import type { Config } from '../../config/config.js'; + +const importGenerator = async (): Promise<{ + AnthropicContentGenerator: typeof import('./anthropicContentGenerator.js').AnthropicContentGenerator; +}> => import('./anthropicContentGenerator.js'); + +const importConverter = async (): Promise<{ + AnthropicContentConverter: typeof import('./converter.js').AnthropicContentConverter; +}> => import('./converter.js'); + +describe('AnthropicContentGenerator', () => { + let mockConfig: Config; + let anthropicState: { + constructorOptions?: Record; + lastCreateArgs?: AnthropicCreateArgs; + createImpl: ReturnType; + }; + + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + + mockTokenizer.calculateTokens.mockResolvedValue({ + totalTokens: 50, + breakdown: { + textTokens: 50, + imageTokens: 0, + audioTokens: 0, + otherTokens: 0, + }, + processingTime: 1, + }); + anthropicState = anthropicMockState; + + anthropicState.createImpl.mockReset(); + anthropicState.lastCreateArgs = undefined; + anthropicState.constructorOptions = undefined; + + mockConfig = { + getCliVersion: vi.fn().mockReturnValue('1.2.3'), + } as unknown as Config; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('passes a QwenCode User-Agent header to the Anthropic SDK', async () => { + const { AnthropicContentGenerator } = await importGenerator(); + void new AnthropicContentGenerator( + { + model: 'claude-test', + apiKey: 'test-key', + baseUrl: 'https://example.invalid', + timeout: 10_000, + maxRetries: 2, + samplingParams: {}, + schemaCompliance: 'auto', + }, + mockConfig, + ); + + const headers = (anthropicState.constructorOptions?.['defaultHeaders'] || + {}) as Record; + expect(headers['User-Agent']).toContain('QwenCode/1.2.3'); + expect(headers['User-Agent']).toContain( + `(${process.platform}; ${process.arch})`, + ); + }); + + it('adds the effort beta header when reasoning.effort is set', async () => { + const { AnthropicContentGenerator } = await importGenerator(); + void new AnthropicContentGenerator( + { + model: 'claude-test', + apiKey: 'test-key', + baseUrl: 'https://example.invalid', + timeout: 10_000, + maxRetries: 2, + samplingParams: {}, + schemaCompliance: 'auto', + reasoning: { effort: 'medium' }, + }, + mockConfig, + ); + + const headers = (anthropicState.constructorOptions?.['defaultHeaders'] || + {}) as Record; + expect(headers['anthropic-beta']).toContain('effort-2025-11-24'); + }); + + it('does not add the effort beta header when reasoning.effort is not set', async () => { + const { AnthropicContentGenerator } = await importGenerator(); + void new AnthropicContentGenerator( + { + model: 'claude-test', + apiKey: 'test-key', + baseUrl: 'https://example.invalid', + timeout: 10_000, + maxRetries: 2, + samplingParams: {}, + schemaCompliance: 'auto', + }, + mockConfig, + ); + + const headers = (anthropicState.constructorOptions?.['defaultHeaders'] || + {}) as Record; + expect(headers['anthropic-beta']).not.toContain('effort-2025-11-24'); + }); + + it('omits the anthropic beta header when reasoning is disabled', async () => { + const { AnthropicContentGenerator } = await importGenerator(); + void new AnthropicContentGenerator( + { + model: 'claude-test', + apiKey: 'test-key', + baseUrl: 'https://example.invalid', + timeout: 10_000, + maxRetries: 2, + samplingParams: {}, + schemaCompliance: 'auto', + reasoning: false, + }, + mockConfig, + ); + + const headers = (anthropicState.constructorOptions?.['defaultHeaders'] || + {}) as Record; + expect(headers['anthropic-beta']).toBeUndefined(); + }); + + describe('generateContent', () => { + it('builds request with config sampling params (config overrides request) and thinking budget', async () => { + const { AnthropicContentConverter } = await importConverter(); + const { AnthropicContentGenerator } = await importGenerator(); + + const convertResponseSpy = vi + .spyOn( + AnthropicContentConverter.prototype, + 'convertAnthropicResponseToGemini', + ) + .mockReturnValue( + (() => { + const r = new GenerateContentResponse(); + r.responseId = 'gemini-1'; + return r; + })(), + ); + + anthropicState.createImpl.mockResolvedValue({ + id: 'anthropic-1', + model: 'claude-test', + content: [{ type: 'text', text: 'hi' }], + }); + + const generator = new AnthropicContentGenerator( + { + model: 'claude-test', + apiKey: 'test-key', + baseUrl: 'https://example.invalid', + timeout: 10_000, + maxRetries: 2, + samplingParams: { + temperature: 0.7, + max_tokens: 1000, + top_p: 0.9, + top_k: 20, + }, + schemaCompliance: 'auto', + reasoning: { effort: 'high', budget_tokens: 1000 }, + }, + mockConfig, + ); + + const abortController = new AbortController(); + const request: GenerateContentParameters = { + model: 'models/ignored', + contents: 'Hello', + config: { + temperature: 0.1, + maxOutputTokens: 200, + topP: 0.5, + topK: 5, + abortSignal: abortController.signal, + }, + }; + + const result = await generator.generateContent(request); + expect(result.responseId).toBe('gemini-1'); + + expect(anthropicState.lastCreateArgs).toBeDefined(); + const [anthropicRequest, options] = + anthropicState.lastCreateArgs as AnthropicCreateArgs; + + expect(options?.signal).toBe(abortController.signal); + + expect(anthropicRequest).toEqual( + expect.objectContaining({ + model: 'claude-test', + max_tokens: 1000, + temperature: 0.7, + top_p: 0.9, + top_k: 20, + thinking: { type: 'enabled', budget_tokens: 1000 }, + output_config: { effort: 'high' }, + }), + ); + + expect(convertResponseSpy).toHaveBeenCalledTimes(1); + }); + + it('omits thinking when request.config.thinkingConfig.includeThoughts is false', async () => { + const { AnthropicContentGenerator } = await importGenerator(); + anthropicState.createImpl.mockResolvedValue({ + id: 'anthropic-1', + model: 'claude-test', + content: [{ type: 'text', text: 'hi' }], + }); + + const generator = new AnthropicContentGenerator( + { + model: 'claude-test', + apiKey: 'test-key', + timeout: 10_000, + maxRetries: 2, + samplingParams: { max_tokens: 500 }, + schemaCompliance: 'auto', + reasoning: { effort: 'high' }, + }, + mockConfig, + ); + + await generator.generateContent({ + model: 'models/ignored', + contents: 'Hello', + config: { thinkingConfig: { includeThoughts: false } }, + } as unknown as GenerateContentParameters); + + const [anthropicRequest] = + anthropicState.lastCreateArgs as AnthropicCreateArgs; + expect(anthropicRequest).toEqual( + expect.not.objectContaining({ thinking: expect.anything() }), + ); + }); + }); + + describe('countTokens', () => { + it('counts tokens using the request tokenizer', async () => { + const { AnthropicContentGenerator } = await importGenerator(); + const generator = new AnthropicContentGenerator( + { + model: 'claude-test', + apiKey: 'test-key', + timeout: 10_000, + maxRetries: 2, + samplingParams: {}, + schemaCompliance: 'auto', + }, + mockConfig, + ); + + const request: CountTokensParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello world' }] }], + model: 'claude-test', + }; + + const result = await generator.countTokens(request); + expect(mockTokenizer.calculateTokens).toHaveBeenCalledWith(request, { + textEncoding: 'cl100k_base', + }); + expect(result.totalTokens).toBe(50); + }); + + it('falls back to character approximation when tokenizer throws', async () => { + const { AnthropicContentGenerator } = await importGenerator(); + mockTokenizer.calculateTokens.mockRejectedValueOnce(new Error('boom')); + const generator = new AnthropicContentGenerator( + { + model: 'claude-test', + apiKey: 'test-key', + timeout: 10_000, + maxRetries: 2, + samplingParams: {}, + schemaCompliance: 'auto', + }, + mockConfig, + ); + + const request: CountTokensParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + model: 'claude-test', + }; + + const content = JSON.stringify(request.contents); + const expected = Math.ceil(content.length / 4); + const result = await generator.countTokens(request); + expect(result.totalTokens).toBe(expected); + }); + }); + + describe('generateContentStream', () => { + it('requests stream=true and converts streamed events into Gemini chunks', async () => { + const { AnthropicContentGenerator } = await importGenerator(); + anthropicState.createImpl.mockResolvedValue( + (async function* () { + yield { + type: 'message_start', + message: { + id: 'msg-1', + model: 'claude-test', + usage: { cache_read_input_tokens: 2, input_tokens: 3 }, + }, + }; + + yield { + type: 'content_block_start', + index: 0, + content_block: { type: 'text' }, + }; + yield { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'Hello' }, + }; + yield { type: 'content_block_stop', index: 0 }; + + yield { + type: 'content_block_start', + index: 1, + content_block: { type: 'thinking', signature: '' }, + }; + yield { + type: 'content_block_delta', + index: 1, + delta: { type: 'thinking_delta', thinking: 'Think' }, + }; + yield { + type: 'content_block_delta', + index: 1, + delta: { type: 'signature_delta', signature: 'abc' }, + }; + yield { type: 'content_block_stop', index: 1 }; + + yield { + type: 'content_block_start', + index: 2, + content_block: { + type: 'tool_use', + id: 't1', + name: 'tool', + input: {}, + }, + }; + yield { + type: 'content_block_delta', + index: 2, + delta: { type: 'input_json_delta', partial_json: '{"x":' }, + }; + yield { + type: 'content_block_delta', + index: 2, + delta: { type: 'input_json_delta', partial_json: '1}' }, + }; + yield { type: 'content_block_stop', index: 2 }; + + yield { + type: 'message_delta', + delta: { stop_reason: 'end_turn' }, + usage: { + output_tokens: 5, + input_tokens: 7, + cache_read_input_tokens: 2, + }, + }; + yield { type: 'message_stop' }; + })(), + ); + + const generator = new AnthropicContentGenerator( + { + model: 'claude-test', + apiKey: 'test-key', + timeout: 10_000, + maxRetries: 2, + samplingParams: { max_tokens: 123 }, + schemaCompliance: 'auto', + }, + mockConfig, + ); + + const stream = await generator.generateContentStream({ + model: 'models/ignored', + contents: 'Hello', + } as unknown as GenerateContentParameters); + + const chunks: GenerateContentResponse[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + + const [anthropicRequest] = + anthropicState.lastCreateArgs as AnthropicCreateArgs; + expect(anthropicRequest).toEqual( + expect.objectContaining({ stream: true }), + ); + + // Text chunk. + expect(chunks[0]?.candidates?.[0]?.content?.parts?.[0]).toEqual({ + text: 'Hello', + }); + + // Thinking chunk. + expect(chunks[1]?.candidates?.[0]?.content?.parts?.[0]).toEqual({ + text: 'Think', + thought: true, + }); + + // Signature chunk. + expect(chunks[2]?.candidates?.[0]?.content?.parts?.[0]).toEqual({ + thought: true, + thoughtSignature: 'abc', + }); + + // Tool call chunk. + expect(chunks[3]?.candidates?.[0]?.content?.parts?.[0]).toEqual({ + functionCall: { id: 't1', name: 'tool', args: { x: 1 } }, + }); + + // Usage/finish chunks exist; check the last one. + const last = chunks[chunks.length - 1]!; + expect(last.candidates?.[0]?.finishReason).toBe(FinishReason.STOP); + expect(last.usageMetadata).toEqual({ + cachedContentTokenCount: 2, + promptTokenCount: 9, // cached(2) + input(7) + candidatesTokenCount: 5, + totalTokenCount: 14, + }); + }); + }); +}); diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts new file mode 100644 index 000000000..228f93853 --- /dev/null +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts @@ -0,0 +1,502 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import Anthropic from '@anthropic-ai/sdk'; +import type { + CountTokensParameters, + CountTokensResponse, + EmbedContentParameters, + EmbedContentResponse, + GenerateContentParameters, + GenerateContentResponseUsageMetadata, + Part, +} from '@google/genai'; +import { GenerateContentResponse } from '@google/genai'; +import type { Config } from '../../config/config.js'; +import type { + ContentGenerator, + ContentGeneratorConfig, +} from '../contentGenerator.js'; +type Message = Anthropic.Message; +type MessageCreateParamsNonStreaming = + Anthropic.MessageCreateParamsNonStreaming; +type MessageCreateParamsStreaming = Anthropic.MessageCreateParamsStreaming; +type RawMessageStreamEvent = Anthropic.RawMessageStreamEvent; +import { getDefaultTokenizer } from '../../utils/request-tokenizer/index.js'; +import { safeJsonParse } from '../../utils/safeJsonParse.js'; +import { AnthropicContentConverter } from './converter.js'; + +type StreamingBlockState = { + type: string; + id?: string; + name?: string; + inputJson: string; + signature: string; +}; + +type MessageCreateParamsWithThinking = MessageCreateParamsNonStreaming & { + thinking?: { type: 'enabled'; budget_tokens: number }; + // Anthropic beta feature: output_config.effort (requires beta header effort-2025-11-24) + // This is not yet represented in the official SDK types we depend on. + output_config?: { effort: 'low' | 'medium' | 'high' }; +}; + +export class AnthropicContentGenerator implements ContentGenerator { + private client: Anthropic; + private converter: AnthropicContentConverter; + + constructor( + private contentGeneratorConfig: ContentGeneratorConfig, + private readonly cliConfig: Config, + ) { + const defaultHeaders = this.buildHeaders(); + const baseURL = contentGeneratorConfig.baseUrl; + + this.client = new Anthropic({ + apiKey: contentGeneratorConfig.apiKey, + baseURL, + timeout: contentGeneratorConfig.timeout, + maxRetries: contentGeneratorConfig.maxRetries, + defaultHeaders, + }); + + this.converter = new AnthropicContentConverter( + contentGeneratorConfig.model, + contentGeneratorConfig.schemaCompliance, + ); + } + + async generateContent( + request: GenerateContentParameters, + ): Promise { + const anthropicRequest = await this.buildRequest(request); + const response = (await this.client.messages.create(anthropicRequest, { + signal: request.config?.abortSignal, + })) as Message; + + return this.converter.convertAnthropicResponseToGemini(response); + } + + async generateContentStream( + request: GenerateContentParameters, + ): Promise> { + const anthropicRequest = await this.buildRequest(request); + const streamingRequest: MessageCreateParamsStreaming & { + thinking?: { type: 'enabled'; budget_tokens: number }; + } = { + ...anthropicRequest, + stream: true, + }; + + const stream = (await this.client.messages.create( + streamingRequest as MessageCreateParamsStreaming, + { + signal: request.config?.abortSignal, + }, + )) as AsyncIterable; + + return this.processStream(stream); + } + + async countTokens( + request: CountTokensParameters, + ): Promise { + try { + const tokenizer = getDefaultTokenizer(); + const result = await tokenizer.calculateTokens(request, { + textEncoding: 'cl100k_base', + }); + + return { + totalTokens: result.totalTokens, + }; + } catch (error) { + console.warn( + 'Failed to calculate tokens with tokenizer, ' + + 'falling back to simple method:', + error, + ); + + const content = JSON.stringify(request.contents); + const totalTokens = Math.ceil(content.length / 4); + return { + totalTokens, + }; + } + } + + async embedContent( + _request: EmbedContentParameters, + ): Promise { + throw new Error('Anthropic does not support embeddings.'); + } + + useSummarizedThinking(): boolean { + return false; + } + + private buildHeaders(): Record { + const version = this.cliConfig.getCliVersion() || 'unknown'; + const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; + + const betas: string[] = []; + const reasoning = this.contentGeneratorConfig.reasoning; + + // Interleaved thinking is used when we send the `thinking` field. + if (reasoning !== false) { + betas.push('interleaved-thinking-2025-05-14'); + } + + // Effort (beta) is enabled when reasoning.effort is set. + if (reasoning !== false && reasoning?.effort !== undefined) { + betas.push('effort-2025-11-24'); + } + + const headers: Record = { + 'User-Agent': userAgent, + }; + + if (betas.length) { + headers['anthropic-beta'] = betas.join(','); + } + + return headers; + } + + private async buildRequest( + request: GenerateContentParameters, + ): Promise { + const { system, messages } = + this.converter.convertGeminiRequestToAnthropic(request); + + const tools = request.config?.tools + ? await this.converter.convertGeminiToolsToAnthropic(request.config.tools) + : undefined; + + const sampling = this.buildSamplingParameters(request); + const thinking = this.buildThinkingConfig(request); + const outputConfig = this.buildOutputConfig(); + + return { + model: this.contentGeneratorConfig.model, + system, + messages, + tools, + ...sampling, + ...(thinking ? { thinking } : {}), + ...(outputConfig ? { output_config: outputConfig } : {}), + }; + } + + private buildSamplingParameters(request: GenerateContentParameters): { + max_tokens: number; + temperature?: number; + top_p?: number; + top_k?: number; + } { + const configSamplingParams = this.contentGeneratorConfig.samplingParams; + const requestConfig = request.config || {}; + + const getParam = ( + configKey: keyof NonNullable, + requestKey?: keyof NonNullable, + ): T | undefined => { + const configValue = configSamplingParams?.[configKey] as T | undefined; + const requestValue = requestKey + ? (requestConfig[requestKey] as T | undefined) + : undefined; + return configValue !== undefined ? configValue : requestValue; + }; + + const maxTokens = + getParam('max_tokens', 'maxOutputTokens') ?? 10_000; + + return { + max_tokens: maxTokens, + temperature: getParam('temperature', 'temperature') ?? 1, + top_p: getParam('top_p', 'topP'), + top_k: getParam('top_k', 'topK'), + }; + } + + private buildThinkingConfig( + request: GenerateContentParameters, + ): { type: 'enabled'; budget_tokens: number } | undefined { + if (request.config?.thinkingConfig?.includeThoughts === false) { + return undefined; + } + + const reasoning = this.contentGeneratorConfig.reasoning; + + if (reasoning === false) { + return undefined; + } + + if (reasoning?.budget_tokens !== undefined) { + return { + type: 'enabled', + budget_tokens: reasoning.budget_tokens, + }; + } + + const effort = reasoning?.effort; + // When using interleaved thinking with tools, this budget token limit is the entire context window(200k tokens). + const budgetTokens = + effort === 'low' ? 16_000 : effort === 'high' ? 64_000 : 32_000; + + return { + type: 'enabled', + budget_tokens: budgetTokens, + }; + } + + private buildOutputConfig(): + | { effort: 'low' | 'medium' | 'high' } + | undefined { + const reasoning = this.contentGeneratorConfig.reasoning; + if (reasoning === false || reasoning === undefined) { + return undefined; + } + + if (reasoning.effort === undefined) { + return undefined; + } + + return { effort: reasoning.effort }; + } + + private async *processStream( + stream: AsyncIterable, + ): AsyncGenerator { + let messageId: string | undefined; + let model = this.contentGeneratorConfig.model; + let cachedTokens = 0; + let promptTokens = 0; + let completionTokens = 0; + let finishReason: string | undefined; + + const blocks = new Map(); + const collectedResponses: GenerateContentResponse[] = []; + + for await (const event of stream) { + switch (event.type) { + case 'message_start': { + messageId = event.message.id ?? messageId; + model = event.message.model ?? model; + cachedTokens = + event.message.usage?.cache_read_input_tokens ?? cachedTokens; + promptTokens = event.message.usage?.input_tokens ?? promptTokens; + break; + } + case 'content_block_start': { + const index = event.index ?? 0; + const type = String(event.content_block.type || 'text'); + const initialInput = + type === 'tool_use' && 'input' in event.content_block + ? JSON.stringify(event.content_block.input) + : ''; + blocks.set(index, { + type, + id: + 'id' in event.content_block ? event.content_block.id : undefined, + name: + 'name' in event.content_block + ? event.content_block.name + : undefined, + inputJson: initialInput !== '{}' ? initialInput : '', + signature: + type === 'thinking' && + 'signature' in event.content_block && + typeof event.content_block.signature === 'string' + ? event.content_block.signature + : '', + }); + break; + } + case 'content_block_delta': { + const index = event.index ?? 0; + const deltaType = (event.delta as { type?: string }).type || ''; + const blockState = blocks.get(index); + + if (deltaType === 'text_delta') { + const text = 'text' in event.delta ? event.delta.text : ''; + if (text) { + const chunk = this.buildGeminiChunk({ text }, messageId, model); + collectedResponses.push(chunk); + yield chunk; + } + } else if (deltaType === 'thinking_delta') { + const thinking = + (event.delta as { thinking?: string }).thinking || ''; + if (thinking) { + const chunk = this.buildGeminiChunk( + { text: thinking, thought: true }, + messageId, + model, + ); + collectedResponses.push(chunk); + yield chunk; + } + } else if (deltaType === 'signature_delta' && blockState) { + const signature = + (event.delta as { signature?: string }).signature || ''; + if (signature) { + blockState.signature += signature; + const chunk = this.buildGeminiChunk( + { thought: true, thoughtSignature: signature }, + messageId, + model, + ); + collectedResponses.push(chunk); + yield chunk; + } + } else if (deltaType === 'input_json_delta' && blockState) { + const jsonDelta = + (event.delta as { partial_json?: string }).partial_json || ''; + if (jsonDelta) { + blockState.inputJson += jsonDelta; + } + } + break; + } + case 'content_block_stop': { + const index = event.index ?? 0; + const blockState = blocks.get(index); + if (blockState?.type === 'tool_use') { + const args = safeJsonParse(blockState.inputJson || '{}', {}); + const chunk = this.buildGeminiChunk( + { + functionCall: { + id: blockState.id, + name: blockState.name, + args, + }, + }, + messageId, + model, + ); + collectedResponses.push(chunk); + yield chunk; + } + blocks.delete(index); + break; + } + case 'message_delta': { + const stopReasonValue = event.delta.stop_reason; + if (stopReasonValue) { + finishReason = stopReasonValue; + } + + // Some Anthropic-compatible providers may include additional usage fields + // (e.g. `input_tokens`, `cache_read_input_tokens`) even though the official + // Anthropic SDK types only expose `output_tokens` here. + const usageUnknown = event.usage as unknown; + const usageRecord = + usageUnknown && typeof usageUnknown === 'object' + ? (usageUnknown as Record) + : undefined; + + if (event.usage?.output_tokens !== undefined) { + completionTokens = event.usage.output_tokens; + } + if (usageRecord?.['input_tokens'] !== undefined) { + const inputTokens = usageRecord['input_tokens']; + if (typeof inputTokens === 'number') { + promptTokens = inputTokens; + } + } + if (usageRecord?.['cache_read_input_tokens'] !== undefined) { + const cacheRead = usageRecord['cache_read_input_tokens']; + if (typeof cacheRead === 'number') { + cachedTokens = cacheRead; + } + } + + if (finishReason || event.usage) { + const chunk = this.buildGeminiChunk( + undefined, + messageId, + model, + finishReason, + { + cachedContentTokenCount: cachedTokens, + promptTokenCount: cachedTokens + promptTokens, + candidatesTokenCount: completionTokens, + totalTokenCount: cachedTokens + promptTokens + completionTokens, + }, + ); + collectedResponses.push(chunk); + yield chunk; + } + break; + } + case 'message_stop': { + if (promptTokens || completionTokens) { + const chunk = this.buildGeminiChunk( + undefined, + messageId, + model, + finishReason, + { + cachedContentTokenCount: cachedTokens, + promptTokenCount: cachedTokens + promptTokens, + candidatesTokenCount: completionTokens, + totalTokenCount: cachedTokens + promptTokens + completionTokens, + }, + ); + collectedResponses.push(chunk); + yield chunk; + } + break; + } + default: + break; + } + } + } + + private buildGeminiChunk( + part?: { + text?: string; + thought?: boolean; + thoughtSignature?: string; + functionCall?: unknown; + }, + responseId?: string, + model?: string, + finishReason?: string, + usageMetadata?: GenerateContentResponseUsageMetadata, + ): GenerateContentResponse { + const response = new GenerateContentResponse(); + response.responseId = responseId; + response.createTime = Date.now().toString(); + response.modelVersion = model || this.contentGeneratorConfig.model; + response.promptFeedback = { safetyRatings: [] }; + + const candidateParts = part ? [part as unknown as Part] : []; + const mappedFinishReason = + finishReason !== undefined + ? this.converter.mapAnthropicFinishReasonToGemini(finishReason) + : undefined; + response.candidates = [ + { + content: { + parts: candidateParts, + role: 'model' as const, + }, + index: 0, + safetyRatings: [], + ...(mappedFinishReason ? { finishReason: mappedFinishReason } : {}), + }, + ]; + + if (usageMetadata) { + response.usageMetadata = usageMetadata; + } + + return response; + } +} diff --git a/packages/core/src/core/anthropicContentGenerator/converter.test.ts b/packages/core/src/core/anthropicContentGenerator/converter.test.ts new file mode 100644 index 000000000..f2ab79411 --- /dev/null +++ b/packages/core/src/core/anthropicContentGenerator/converter.test.ts @@ -0,0 +1,377 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CallableTool, Content, Tool } from '@google/genai'; +import { FinishReason } from '@google/genai'; +import type Anthropic from '@anthropic-ai/sdk'; + +// Mock schema conversion so we can force edge-cases (e.g. missing `type`). +vi.mock('../../utils/schemaConverter.js', () => ({ + convertSchema: vi.fn((schema: unknown) => schema), +})); + +import { convertSchema } from '../../utils/schemaConverter.js'; +import { AnthropicContentConverter } from './converter.js'; + +describe('AnthropicContentConverter', () => { + let converter: AnthropicContentConverter; + + beforeEach(() => { + vi.clearAllMocks(); + converter = new AnthropicContentConverter('test-model', 'auto'); + }); + + describe('convertGeminiRequestToAnthropic', () => { + it('extracts systemInstruction text from string', () => { + const { system } = converter.convertGeminiRequestToAnthropic({ + model: 'models/test', + contents: 'hi', + config: { systemInstruction: 'sys' }, + }); + + expect(system).toBe('sys'); + }); + + it('extracts systemInstruction text from parts and joins with newlines', () => { + const { system } = converter.convertGeminiRequestToAnthropic({ + model: 'models/test', + contents: 'hi', + config: { + systemInstruction: { + role: 'system', + parts: [{ text: 'a' }, { text: 'b' }], + } as unknown as Content, + }, + }); + + expect(system).toBe('a\nb'); + }); + + it('converts a plain string content into a user message', () => { + const { messages } = converter.convertGeminiRequestToAnthropic({ + model: 'models/test', + contents: 'Hello', + }); + + expect(messages).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'Hello' }] }, + ]); + }); + + it('converts user content parts into a user message with text blocks', () => { + const { messages } = converter.convertGeminiRequestToAnthropic({ + model: 'models/test', + contents: [ + { + role: 'user', + parts: [{ text: 'Hello' }, { text: 'World' }], + }, + ], + }); + + expect(messages).toEqual([ + { + role: 'user', + content: [ + { type: 'text', text: 'Hello' }, + { type: 'text', text: 'World' }, + ], + }, + ]); + }); + + it('converts assistant thought parts into Anthropic thinking blocks', () => { + const { messages } = converter.convertGeminiRequestToAnthropic({ + model: 'models/test', + contents: [ + { + role: 'model', + parts: [ + { text: 'internal', thought: true, thoughtSignature: 'sig' }, + { text: 'visible' }, + ], + }, + ], + }); + + expect(messages).toEqual([ + { + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'internal', signature: 'sig' }, + { type: 'text', text: 'visible' }, + ], + }, + ]); + }); + + it('converts functionCall parts from model role into tool_use blocks', () => { + const { messages } = converter.convertGeminiRequestToAnthropic({ + model: 'models/test', + contents: [ + { + role: 'model', + parts: [ + { text: 'preface' }, + { + functionCall: { + id: 'call-1', + name: 'tool_name', + args: { a: 1 }, + }, + }, + ], + }, + ], + }); + + expect(messages).toEqual([ + { + role: 'assistant', + content: [ + { type: 'text', text: 'preface' }, + { + type: 'tool_use', + id: 'call-1', + name: 'tool_name', + input: { a: 1 }, + }, + ], + }, + ]); + }); + + it('converts functionResponse parts into user tool_result messages', () => { + const { messages } = converter.convertGeminiRequestToAnthropic({ + model: 'models/test', + contents: [ + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'call-1', + name: 'tool_name', + response: { output: 'ok' }, + }, + }, + ], + }, + ], + }); + + expect(messages).toEqual([ + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-1', + content: 'ok', + }, + ], + }, + ]); + }); + + it('extracts function response error field when present', () => { + const { messages } = converter.convertGeminiRequestToAnthropic({ + model: 'models/test', + contents: [ + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'call-1', + name: 'tool_name', + response: { error: 'boom' }, + }, + }, + ], + }, + ], + }); + + expect(messages[0]).toEqual({ + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-1', + content: 'boom', + }, + ], + }); + }); + }); + + describe('convertGeminiToolsToAnthropic', () => { + it('converts Tool.functionDeclarations to Anthropic tools and runs schema conversion', async () => { + const tools = [ + { + functionDeclarations: [ + { + name: 'get_weather', + description: 'Get weather', + parametersJsonSchema: { + type: 'object', + properties: { location: { type: 'string' } }, + required: ['location'], + }, + }, + ], + }, + ] as Tool[]; + + const result = await converter.convertGeminiToolsToAnthropic(tools); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + name: 'get_weather', + description: 'Get weather', + input_schema: { + type: 'object', + properties: { location: { type: 'string' } }, + required: ['location'], + }, + }); + + expect(vi.mocked(convertSchema)).toHaveBeenCalledTimes(1); + }); + + it('resolves CallableTool.tool() and converts its functionDeclarations', async () => { + const callable = [ + { + tool: async () => + ({ + functionDeclarations: [ + { + name: 'dynamic_tool', + description: 'resolved tool', + parametersJsonSchema: { type: 'object', properties: {} }, + }, + ], + }) as unknown as Tool, + }, + ] as CallableTool[]; + + const result = await converter.convertGeminiToolsToAnthropic(callable); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('dynamic_tool'); + }); + + it('defaults missing parameters to an empty object schema', async () => { + const tools = [ + { + functionDeclarations: [ + { name: 'no_params', description: 'no params' }, + ], + }, + ] as Tool[]; + + const result = await converter.convertGeminiToolsToAnthropic(tools); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + name: 'no_params', + description: 'no params', + input_schema: { type: 'object', properties: {} }, + }); + }); + + it('forces input_schema.type to "object" when schema conversion yields no type', async () => { + vi.mocked(convertSchema).mockImplementationOnce(() => ({ + properties: {}, + })); + const tools = [ + { + functionDeclarations: [ + { + name: 'edge', + description: 'edge', + parametersJsonSchema: { type: 'object', properties: {} }, + }, + ], + }, + ] as Tool[]; + + const result = await converter.convertGeminiToolsToAnthropic(tools); + expect(result[0]?.input_schema?.type).toBe('object'); + }); + }); + + describe('convertAnthropicResponseToGemini', () => { + it('converts text, tool_use, thinking, and redacted_thinking blocks', () => { + const response = converter.convertAnthropicResponseToGemini({ + id: 'msg-1', + model: 'claude-test', + stop_reason: 'end_turn', + content: [ + { type: 'thinking', thinking: 'thought', signature: 'sig' }, + { type: 'text', text: 'hello' }, + { type: 'tool_use', id: 't1', name: 'tool', input: { x: 1 } }, + { type: 'redacted_thinking' }, + ], + usage: { input_tokens: 3, output_tokens: 5 }, + } as unknown as Anthropic.Message); + + expect(response.responseId).toBe('msg-1'); + expect(response.modelVersion).toBe('claude-test'); + expect(response.candidates?.[0]?.finishReason).toBe(FinishReason.STOP); + expect(response.usageMetadata).toEqual({ + promptTokenCount: 3, + candidatesTokenCount: 5, + totalTokenCount: 8, + }); + + const parts = response.candidates?.[0]?.content?.parts || []; + expect(parts).toEqual([ + { text: 'thought', thought: true, thoughtSignature: 'sig' }, + { text: 'hello' }, + { functionCall: { id: 't1', name: 'tool', args: { x: 1 } } }, + { text: '', thought: true }, + ]); + }); + + it('handles tool_use input that is a JSON string', () => { + const response = converter.convertAnthropicResponseToGemini({ + id: 'msg-1', + model: 'claude-test', + stop_reason: null, + content: [ + { type: 'tool_use', id: 't1', name: 'tool', input: '{"x":1}' }, + ], + } as unknown as Anthropic.Message); + + const parts = response.candidates?.[0]?.content?.parts || []; + expect(parts).toEqual([ + { functionCall: { id: 't1', name: 'tool', args: { x: 1 } } }, + ]); + }); + }); + + describe('mapAnthropicFinishReasonToGemini', () => { + it('maps known reasons', () => { + expect(converter.mapAnthropicFinishReasonToGemini('end_turn')).toBe( + FinishReason.STOP, + ); + expect(converter.mapAnthropicFinishReasonToGemini('max_tokens')).toBe( + FinishReason.MAX_TOKENS, + ); + expect(converter.mapAnthropicFinishReasonToGemini('content_filter')).toBe( + FinishReason.SAFETY, + ); + }); + + it('returns undefined for null/empty', () => { + expect(converter.mapAnthropicFinishReasonToGemini(null)).toBeUndefined(); + expect(converter.mapAnthropicFinishReasonToGemini('')).toBeUndefined(); + }); + }); +}); diff --git a/packages/core/src/core/anthropicContentGenerator/converter.ts b/packages/core/src/core/anthropicContentGenerator/converter.ts new file mode 100644 index 000000000..2fb9b7fee --- /dev/null +++ b/packages/core/src/core/anthropicContentGenerator/converter.ts @@ -0,0 +1,448 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + Candidate, + CallableTool, + Content, + ContentListUnion, + ContentUnion, + FunctionCall, + FunctionResponse, + GenerateContentParameters, + Part, + PartUnion, + Tool, + ToolListUnion, +} from '@google/genai'; +import { FinishReason, GenerateContentResponse } from '@google/genai'; +import type Anthropic from '@anthropic-ai/sdk'; +import { safeJsonParse } from '../../utils/safeJsonParse.js'; +import { + convertSchema, + type SchemaComplianceMode, +} from '../../utils/schemaConverter.js'; + +type AnthropicMessageParam = Anthropic.MessageParam; +type AnthropicToolParam = Anthropic.Tool; +type AnthropicContentBlockParam = Anthropic.ContentBlockParam; + +type ThoughtPart = { text: string; signature?: string }; + +interface ParsedParts { + thoughtParts: ThoughtPart[]; + contentParts: string[]; + functionCalls: FunctionCall[]; + functionResponses: FunctionResponse[]; +} + +export class AnthropicContentConverter { + private model: string; + private schemaCompliance: SchemaComplianceMode; + + constructor(model: string, schemaCompliance: SchemaComplianceMode = 'auto') { + this.model = model; + this.schemaCompliance = schemaCompliance; + } + + convertGeminiRequestToAnthropic(request: GenerateContentParameters): { + system?: string; + messages: AnthropicMessageParam[]; + } { + const messages: AnthropicMessageParam[] = []; + + const system = this.extractTextFromContentUnion( + request.config?.systemInstruction, + ); + + this.processContents(request.contents, messages); + + return { + system: system || undefined, + messages, + }; + } + + async convertGeminiToolsToAnthropic( + geminiTools: ToolListUnion, + ): Promise { + const tools: AnthropicToolParam[] = []; + + for (const tool of geminiTools) { + let actualTool: Tool; + + if ('tool' in tool) { + actualTool = await (tool as CallableTool).tool(); + } else { + actualTool = tool as Tool; + } + + if (!actualTool.functionDeclarations) { + continue; + } + + for (const func of actualTool.functionDeclarations) { + if (!func.name) continue; + + let inputSchema: Record | undefined; + if (func.parametersJsonSchema) { + inputSchema = { + ...(func.parametersJsonSchema as Record), + }; + } else if (func.parameters) { + inputSchema = func.parameters as Record; + } + + if (!inputSchema) { + inputSchema = { type: 'object', properties: {} }; + } + + inputSchema = convertSchema(inputSchema, this.schemaCompliance); + if (typeof inputSchema['type'] !== 'string') { + inputSchema['type'] = 'object'; + } + + tools.push({ + name: func.name, + description: func.description, + input_schema: inputSchema as Anthropic.Tool.InputSchema, + }); + } + } + + return tools; + } + + convertAnthropicResponseToGemini( + response: Anthropic.Message, + ): GenerateContentResponse { + const geminiResponse = new GenerateContentResponse(); + const parts: Part[] = []; + + for (const block of response.content || []) { + const blockType = String((block as { type?: string })['type'] || ''); + if (blockType === 'text') { + const text = + typeof (block as { text?: string }).text === 'string' + ? (block as { text?: string }).text + : ''; + if (text) { + parts.push({ text }); + } + } else if (blockType === 'tool_use') { + const toolUse = block as { + id?: string; + name?: string; + input?: unknown; + }; + parts.push({ + functionCall: { + id: typeof toolUse.id === 'string' ? toolUse.id : undefined, + name: typeof toolUse.name === 'string' ? toolUse.name : undefined, + args: this.safeInputToArgs(toolUse.input), + }, + }); + } else if (blockType === 'thinking') { + const thinking = + typeof (block as { thinking?: string }).thinking === 'string' + ? (block as { thinking?: string }).thinking + : ''; + const signature = + typeof (block as { signature?: string }).signature === 'string' + ? (block as { signature?: string }).signature + : ''; + if (thinking || signature) { + const thoughtPart: Part = { + text: thinking, + thought: true, + thoughtSignature: signature, + }; + parts.push(thoughtPart); + } + } else if (blockType === 'redacted_thinking') { + parts.push({ text: '', thought: true }); + } + } + + const candidate: Candidate = { + content: { + parts, + role: 'model' as const, + }, + index: 0, + safetyRatings: [], + }; + + const finishReason = this.mapAnthropicFinishReasonToGemini( + response.stop_reason, + ); + if (finishReason) { + candidate.finishReason = finishReason; + } + + geminiResponse.candidates = [candidate]; + geminiResponse.responseId = response.id; + geminiResponse.createTime = Date.now().toString(); + geminiResponse.modelVersion = response.model || this.model; + geminiResponse.promptFeedback = { safetyRatings: [] }; + + if (response.usage) { + const promptTokens = response.usage.input_tokens || 0; + const completionTokens = response.usage.output_tokens || 0; + geminiResponse.usageMetadata = { + promptTokenCount: promptTokens, + candidatesTokenCount: completionTokens, + totalTokenCount: promptTokens + completionTokens, + }; + } + + return geminiResponse; + } + + private processContents( + contents: ContentListUnion, + messages: AnthropicMessageParam[], + ): void { + if (Array.isArray(contents)) { + for (const content of contents) { + this.processContent(content, messages); + } + } else if (contents) { + this.processContent(contents, messages); + } + } + + private processContent( + content: ContentUnion | PartUnion, + messages: AnthropicMessageParam[], + ): void { + if (typeof content === 'string') { + messages.push({ + role: 'user', + content: [{ type: 'text', text: content }], + }); + return; + } + + if (!this.isContentObject(content)) return; + + const parsed = this.parseParts(content.parts || []); + + if (parsed.functionResponses.length > 0) { + for (const response of parsed.functionResponses) { + messages.push({ + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: response.id || '', + content: this.extractFunctionResponseContent(response.response), + }, + ], + }); + } + return; + } + + if (content.role === 'model' && parsed.functionCalls.length > 0) { + const thinkingBlocks: AnthropicContentBlockParam[] = + parsed.thoughtParts.map((part) => { + const thinkingBlock: unknown = { + type: 'thinking', + thinking: part.text, + }; + if (part.signature) { + (thinkingBlock as { signature?: string }).signature = + part.signature; + } + return thinkingBlock as AnthropicContentBlockParam; + }); + const toolUses: AnthropicContentBlockParam[] = parsed.functionCalls.map( + (call, index) => ({ + type: 'tool_use', + id: call.id || `tool_${index}`, + name: call.name || '', + input: (call.args as Record) || {}, + }), + ); + + const textBlocks: AnthropicContentBlockParam[] = parsed.contentParts.map( + (text) => ({ + type: 'text' as const, + text, + }), + ); + + messages.push({ + role: 'assistant', + content: [...thinkingBlocks, ...textBlocks, ...toolUses], + }); + return; + } + + const role = content.role === 'model' ? 'assistant' : 'user'; + const thinkingBlocks: AnthropicContentBlockParam[] = + role === 'assistant' + ? parsed.thoughtParts.map((part) => { + const thinkingBlock: unknown = { + type: 'thinking', + thinking: part.text, + }; + if (part.signature) { + (thinkingBlock as { signature?: string }).signature = + part.signature; + } + return thinkingBlock as AnthropicContentBlockParam; + }) + : []; + const textBlocks: AnthropicContentBlockParam[] = [ + ...thinkingBlocks, + ...parsed.contentParts.map((text) => ({ + type: 'text' as const, + text, + })), + ]; + if (textBlocks.length > 0) { + messages.push({ role, content: textBlocks }); + } + } + + private parseParts(parts: Part[]): ParsedParts { + const thoughtParts: ThoughtPart[] = []; + const contentParts: string[] = []; + const functionCalls: FunctionCall[] = []; + const functionResponses: FunctionResponse[] = []; + + for (const part of parts) { + if (typeof part === 'string') { + contentParts.push(part); + } else if ( + 'text' in part && + part.text && + !('thought' in part && part.thought) + ) { + contentParts.push(part.text); + } else if ('text' in part && 'thought' in part && part.thought) { + thoughtParts.push({ + text: part.text || '', + signature: + 'thoughtSignature' in part && + typeof part.thoughtSignature === 'string' + ? part.thoughtSignature + : undefined, + }); + } else if ('functionCall' in part && part.functionCall) { + functionCalls.push(part.functionCall); + } else if ('functionResponse' in part && part.functionResponse) { + functionResponses.push(part.functionResponse); + } + } + + return { + thoughtParts, + contentParts, + functionCalls, + functionResponses, + }; + } + + private extractTextFromContentUnion(contentUnion: unknown): string { + if (typeof contentUnion === 'string') { + return contentUnion; + } + + if (Array.isArray(contentUnion)) { + return contentUnion + .map((item) => this.extractTextFromContentUnion(item)) + .filter(Boolean) + .join('\n'); + } + + if (typeof contentUnion === 'object' && contentUnion !== null) { + if ('parts' in contentUnion) { + const content = contentUnion as Content; + return ( + content.parts + ?.map((part: Part) => { + if (typeof part === 'string') return part; + if ('text' in part) return part.text || ''; + return ''; + }) + .filter(Boolean) + .join('\n') || '' + ); + } + } + + return ''; + } + + private extractFunctionResponseContent(response: unknown): string { + if (response === null || response === undefined) { + return ''; + } + + if (typeof response === 'string') { + return response; + } + + if (typeof response === 'object') { + const responseObject = response as Record; + const output = responseObject['output']; + if (typeof output === 'string') { + return output; + } + + const error = responseObject['error']; + if (typeof error === 'string') { + return error; + } + } + + try { + const serialized = JSON.stringify(response); + return serialized ?? String(response); + } catch { + return String(response); + } + } + + private safeInputToArgs(input: unknown): Record { + if (input && typeof input === 'object') { + return input as Record; + } + if (typeof input === 'string') { + return safeJsonParse(input, {}); + } + return {}; + } + + mapAnthropicFinishReasonToGemini( + reason?: string | null, + ): FinishReason | undefined { + if (!reason) return undefined; + const mapping: Record = { + end_turn: FinishReason.STOP, + stop_sequence: FinishReason.STOP, + tool_use: FinishReason.STOP, + max_tokens: FinishReason.MAX_TOKENS, + content_filter: FinishReason.SAFETY, + }; + return mapping[reason] || FinishReason.FINISH_REASON_UNSPECIFIED; + } + + private isContentObject( + content: unknown, + ): content is { role: string; parts: Part[] } { + return ( + typeof content === 'object' && + content !== null && + 'role' in content && + 'parts' in content && + Array.isArray((content as Record)['parts']) + ); + } +} diff --git a/packages/core/src/core/anthropicContentGenerator/index.ts b/packages/core/src/core/anthropicContentGenerator/index.ts new file mode 100644 index 000000000..ce480790a --- /dev/null +++ b/packages/core/src/core/anthropicContentGenerator/index.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + ContentGenerator, + ContentGeneratorConfig, +} from '../contentGenerator.js'; +import type { Config } from '../../config/config.js'; +import { AnthropicContentGenerator } from './anthropicContentGenerator.js'; + +export { AnthropicContentGenerator } from './anthropicContentGenerator.js'; + +export function createAnthropicContentGenerator( + contentGeneratorConfig: ContentGeneratorConfig, + cliConfig: Config, +): ContentGenerator { + return new AnthropicContentGenerator(contentGeneratorConfig, cliConfig); +} diff --git a/packages/core/src/core/contentGenerator.test.ts b/packages/core/src/core/contentGenerator.test.ts index e70b4d13e..4b176c989 100644 --- a/packages/core/src/core/contentGenerator.test.ts +++ b/packages/core/src/core/contentGenerator.test.ts @@ -8,7 +8,7 @@ import { describe, it, expect, vi } from 'vitest'; import { createContentGenerator, AuthType } from './contentGenerator.js'; import { GoogleGenAI } from '@google/genai'; import type { Config } from '../config/config.js'; -import { LoggingContentGenerator } from './geminiContentGenerator/loggingContentGenerator.js'; +import { LoggingContentGenerator } from './loggingContentGenerator/index.js'; vi.mock('@google/genai'); diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 8ba85160c..f6f83761f 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -14,6 +14,7 @@ import type { } from '@google/genai'; import { DEFAULT_QWEN_MODEL } from '../config/models.js'; import type { Config } from '../config/config.js'; +import { LoggingContentGenerator } from './loggingContentGenerator/index.js'; /** * Interface abstracting the core functionalities for generating content and counting tokens. @@ -37,10 +38,11 @@ export interface ContentGenerator { } export enum AuthType { - USE_GEMINI = 'gemini-api-key', - USE_VERTEX_AI = 'vertex-ai', USE_OPENAI = 'openai', QWEN_OAUTH = 'qwen-oauth', + USE_GEMINI = 'gemini', + USE_VERTEX_AI = 'vertex-ai', + USE_ANTHROPIC = 'anthropic', } export type ContentGeneratorConfig = { @@ -63,9 +65,12 @@ export type ContentGeneratorConfig = { temperature?: number; max_tokens?: number; }; - reasoning?: { - effort?: 'low' | 'medium' | 'high'; - }; + reasoning?: + | false + | { + effort?: 'low' | 'medium' | 'high'; + budget_tokens?: number; + }; proxy?: string | undefined; userAgent?: string; // Schema compliance mode for tool definitions @@ -77,7 +82,7 @@ export function createContentGeneratorConfig( authType: AuthType | undefined, generationConfig?: Partial, ): ContentGeneratorConfig { - const newContentGeneratorConfig: Partial = { + let newContentGeneratorConfig: Partial = { ...(generationConfig || {}), authType, proxy: config?.getProxy(), @@ -94,8 +99,16 @@ export function createContentGeneratorConfig( } if (authType === AuthType.USE_OPENAI) { + newContentGeneratorConfig = { + ...newContentGeneratorConfig, + apiKey: newContentGeneratorConfig.apiKey || process.env['OPENAI_API_KEY'], + baseUrl: + newContentGeneratorConfig.baseUrl || process.env['OPENAI_BASE_URL'], + model: newContentGeneratorConfig.model || process.env['OPENAI_MODEL'], + }; + if (!newContentGeneratorConfig.apiKey) { - throw new Error('OpenAI API key is required'); + throw new Error('OPENAI_API_KEY environment variable not found.'); } return { @@ -104,10 +117,62 @@ export function createContentGeneratorConfig( } as ContentGeneratorConfig; } - return { - ...newContentGeneratorConfig, - model: newContentGeneratorConfig?.model || DEFAULT_QWEN_MODEL, - } as ContentGeneratorConfig; + if (authType === AuthType.USE_ANTHROPIC) { + newContentGeneratorConfig = { + ...newContentGeneratorConfig, + apiKey: + newContentGeneratorConfig.apiKey || process.env['ANTHROPIC_API_KEY'], + baseUrl: + newContentGeneratorConfig.baseUrl || process.env['ANTHROPIC_BASE_URL'], + model: newContentGeneratorConfig.model || process.env['ANTHROPIC_MODEL'], + }; + + if (!newContentGeneratorConfig.apiKey) { + throw new Error('ANTHROPIC_API_KEY environment variable not found.'); + } + + if (!newContentGeneratorConfig.baseUrl) { + throw new Error('ANTHROPIC_BASE_URL environment variable not found.'); + } + + if (!newContentGeneratorConfig.model) { + throw new Error('ANTHROPIC_MODEL environment variable not found.'); + } + } + + if (authType === AuthType.USE_GEMINI) { + newContentGeneratorConfig = { + ...newContentGeneratorConfig, + apiKey: newContentGeneratorConfig.apiKey || process.env['GEMINI_API_KEY'], + model: newContentGeneratorConfig.model || process.env['GEMINI_MODEL'], + }; + + if (!newContentGeneratorConfig.apiKey) { + throw new Error('GEMINI_API_KEY environment variable not found.'); + } + + if (!newContentGeneratorConfig.model) { + throw new Error('GEMINI_MODEL environment variable not found.'); + } + } + + if (authType === AuthType.USE_VERTEX_AI) { + newContentGeneratorConfig = { + ...newContentGeneratorConfig, + apiKey: newContentGeneratorConfig.apiKey || process.env['GOOGLE_API_KEY'], + model: newContentGeneratorConfig.model || process.env['GOOGLE_MODEL'], + }; + + if (!newContentGeneratorConfig.apiKey) { + throw new Error('GOOGLE_API_KEY environment variable not found.'); + } + + if (!newContentGeneratorConfig.model) { + throw new Error('GOOGLE_MODEL environment variable not found.'); + } + } + + return newContentGeneratorConfig as ContentGeneratorConfig; } export async function createContentGenerator( @@ -115,19 +180,9 @@ export async function createContentGenerator( gcConfig: Config, isInitialAuth?: boolean, ): Promise { - if ( - config.authType === AuthType.USE_GEMINI || - config.authType === AuthType.USE_VERTEX_AI - ) { - const { createGeminiContentGenerator } = await import( - './geminiContentGenerator/index.js' - ); - return createGeminiContentGenerator(config, gcConfig); - } - if (config.authType === AuthType.USE_OPENAI) { if (!config.apiKey) { - throw new Error('OpenAI API key is required'); + throw new Error('OPENAI_API_KEY environment variable not found.'); } // Import OpenAIContentGenerator dynamically to avoid circular dependencies @@ -136,7 +191,8 @@ export async function createContentGenerator( ); // Always use OpenAIContentGenerator, logging is controlled by enableOpenAILogging flag - return createOpenAIContentGenerator(config, gcConfig); + const generator = createOpenAIContentGenerator(config, gcConfig); + return new LoggingContentGenerator(generator, gcConfig); } if (config.authType === AuthType.QWEN_OAUTH) { @@ -157,7 +213,8 @@ export async function createContentGenerator( ); // Create the content generator with dynamic token management - return new QwenContentGenerator(qwenClient, config, gcConfig); + const generator = new QwenContentGenerator(qwenClient, config, gcConfig); + return new LoggingContentGenerator(generator, gcConfig); } catch (error) { throw new Error( `${error instanceof Error ? error.message : String(error)}`, @@ -165,6 +222,30 @@ export async function createContentGenerator( } } + if (config.authType === AuthType.USE_ANTHROPIC) { + if (!config.apiKey) { + throw new Error('ANTHROPIC_API_KEY environment variable not found.'); + } + + const { createAnthropicContentGenerator } = await import( + './anthropicContentGenerator/index.js' + ); + + const generator = createAnthropicContentGenerator(config, gcConfig); + return new LoggingContentGenerator(generator, gcConfig); + } + + if ( + config.authType === AuthType.USE_GEMINI || + config.authType === AuthType.USE_VERTEX_AI + ) { + const { createGeminiContentGenerator } = await import( + './geminiContentGenerator/index.js' + ); + const generator = createGeminiContentGenerator(config, gcConfig); + return new LoggingContentGenerator(generator, gcConfig); + } + throw new Error( `Error creating contentGenerator: Unsupported authType: ${config.authType}`, ); diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index b849fdbf3..39b732cd9 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -720,66 +720,6 @@ describe('GeminiChat', () => { ); }); - it('should handle summarized thinking by conditionally including thoughts in history', async () => { - // Case 1: useSummarizedThinking is true -> thoughts NOT in history - vi.mocked(mockContentGenerator.useSummarizedThinking).mockReturnValue( - true, - ); - const stream1 = (async function* () { - yield { - candidates: [ - { - content: { - role: 'model', - parts: [{ thought: true, text: 'T1' }, { text: 'A1' }], - }, - finishReason: 'STOP', - }, - ], - } as unknown as GenerateContentResponse; - })(); - vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue( - stream1, - ); - - const res1 = await chat.sendMessageStream('m1', { message: 'h1' }, 'p1'); - for await (const _ of res1); - - const history1 = chat.getHistory(); - expect(history1[1].parts).toEqual([{ text: 'A1' }]); - - // Case 2: useSummarizedThinking is false -> thoughts ARE in history - chat.clearHistory(); - vi.mocked(mockContentGenerator.useSummarizedThinking).mockReturnValue( - false, - ); - const stream2 = (async function* () { - yield { - candidates: [ - { - content: { - role: 'model', - parts: [{ thought: true, text: 'T2' }, { text: 'A2' }], - }, - finishReason: 'STOP', - }, - ], - } as unknown as GenerateContentResponse; - })(); - vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue( - stream2, - ); - - const res2 = await chat.sendMessageStream('m1', { message: 'h1' }, 'p2'); - for await (const _ of res2); - - const history2 = chat.getHistory(); - expect(history2[1].parts).toEqual([ - { text: 'T2', thought: true }, - { text: 'A2' }, - ]); - }); - it('should keep parts with thoughtSignature when consolidating history', async () => { const stream = (async function* () { yield { diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 8968a7eb7..04add3419 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -559,14 +559,25 @@ export class GeminiChat { yield chunk; // Yield every chunk to the UI immediately. } - let thoughtText = ''; - // Only include thoughts if not using summarized thinking. - if (!this.config.getContentGenerator().useSummarizedThinking()) { - thoughtText = allModelParts - .filter((part) => part.thought) - .map((part) => part.text) - .join('') - .trim(); + let thoughtContentPart: Part | undefined; + const thoughtText = allModelParts + .filter((part) => part.thought) + .map((part) => part.text) + .join('') + .trim(); + + if (thoughtText !== '') { + thoughtContentPart = { + text: thoughtText, + thought: true, + }; + + const thoughtSignature = allModelParts.filter( + (part) => part.thoughtSignature && part.thought, + )?.[0]?.thoughtSignature; + if (thoughtContentPart && thoughtSignature) { + thoughtContentPart.thoughtSignature = thoughtSignature; + } } const contentParts = allModelParts.filter((part) => !part.thought); @@ -592,11 +603,11 @@ export class GeminiChat { .trim(); // Record assistant turn with raw Content and metadata - if (thoughtText || contentText || hasToolCall || usageMetadata) { + if (thoughtContentPart || contentText || hasToolCall || usageMetadata) { this.chatRecordingService?.recordAssistantTurn({ model, message: [ - ...(thoughtText ? [{ text: thoughtText, thought: true }] : []), + ...(thoughtContentPart ? [thoughtContentPart] : []), ...(contentText ? [{ text: contentText }] : []), ...(hasToolCall ? contentParts @@ -632,7 +643,7 @@ export class GeminiChat { this.history.push({ role: 'model', parts: [ - ...(thoughtText ? [{ text: thoughtText, thought: true }] : []), + ...(thoughtContentPart ? [thoughtContentPart] : []), ...consolidatedHistoryParts, ], }); diff --git a/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts b/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts index 171ce3d1c..0008b8eb5 100644 --- a/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts +++ b/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts @@ -39,7 +39,7 @@ export class GeminiContentGenerator implements ContentGenerator { this.contentGeneratorConfig = contentGeneratorConfig; } - private buildSamplingParameters( + private buildGenerateContentConfig( request: GenerateContentParameters, ): GenerateContentConfig { const configSamplingParams = this.contentGeneratorConfig?.samplingParams; @@ -84,17 +84,7 @@ export class GeminiContentGenerator implements ContentGenerator { 'frequencyPenalty', ), thinkingConfig: getParameterValue( - this.contentGeneratorConfig?.reasoning - ? { - includeThoughts: true, - thinkingLevel: (this.contentGeneratorConfig.reasoning.effort === - 'low' - ? 'LOW' - : this.contentGeneratorConfig.reasoning.effort === 'high' - ? 'HIGH' - : 'THINKING_LEVEL_UNSPECIFIED') as ThinkingLevel, - } - : undefined, + this.buildThinkingConfig(), 'thinkingConfig', { includeThoughts: true, @@ -104,13 +94,40 @@ export class GeminiContentGenerator implements ContentGenerator { }; } + private buildThinkingConfig(): + | { includeThoughts: boolean; thinkingLevel?: ThinkingLevel } + | undefined { + const reasoning = this.contentGeneratorConfig?.reasoning; + + if (reasoning === false) { + return { includeThoughts: false }; + } + + if (reasoning) { + const thinkingLevel = ( + reasoning.effort === 'low' + ? 'LOW' + : reasoning.effort === 'high' + ? 'HIGH' + : 'THINKING_LEVEL_UNSPECIFIED' + ) as ThinkingLevel; + + return { + includeThoughts: true, + thinkingLevel, + }; + } + + return undefined; + } + async generateContent( request: GenerateContentParameters, _userPromptId: string, ): Promise { const finalRequest = { ...request, - config: this.buildSamplingParameters(request), + config: this.buildGenerateContentConfig(request), }; return this.googleGenAI.models.generateContent(finalRequest); } @@ -121,7 +138,7 @@ export class GeminiContentGenerator implements ContentGenerator { ): Promise> { const finalRequest = { ...request, - config: this.buildSamplingParameters(request), + config: this.buildGenerateContentConfig(request), }; return this.googleGenAI.models.generateContentStream(finalRequest); } diff --git a/packages/core/src/core/geminiContentGenerator/index.test.ts b/packages/core/src/core/geminiContentGenerator/index.test.ts index ac3f9f627..c7effd220 100644 --- a/packages/core/src/core/geminiContentGenerator/index.test.ts +++ b/packages/core/src/core/geminiContentGenerator/index.test.ts @@ -7,7 +7,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createGeminiContentGenerator } from './index.js'; import { GeminiContentGenerator } from './geminiContentGenerator.js'; -import { LoggingContentGenerator } from './loggingContentGenerator.js'; import type { Config } from '../../config/config.js'; import { AuthType } from '../contentGenerator.js'; @@ -15,10 +14,6 @@ vi.mock('./geminiContentGenerator.js', () => ({ GeminiContentGenerator: vi.fn().mockImplementation(() => ({})), })); -vi.mock('./loggingContentGenerator.js', () => ({ - LoggingContentGenerator: vi.fn().mockImplementation((wrapped) => wrapped), -})); - describe('createGeminiContentGenerator', () => { let mockConfig: Config; @@ -31,7 +26,7 @@ describe('createGeminiContentGenerator', () => { } as unknown as Config; }); - it('should create a GeminiContentGenerator wrapped in LoggingContentGenerator', () => { + it('should create a GeminiContentGenerator', () => { const config = { model: 'gemini-1.5-flash', apiKey: 'test-key', @@ -41,7 +36,6 @@ describe('createGeminiContentGenerator', () => { const generator = createGeminiContentGenerator(config, mockConfig); expect(GeminiContentGenerator).toHaveBeenCalled(); - expect(LoggingContentGenerator).toHaveBeenCalled(); expect(generator).toBeDefined(); }); }); diff --git a/packages/core/src/core/geminiContentGenerator/index.ts b/packages/core/src/core/geminiContentGenerator/index.ts index 60e74cbf4..4a615c0d8 100644 --- a/packages/core/src/core/geminiContentGenerator/index.ts +++ b/packages/core/src/core/geminiContentGenerator/index.ts @@ -11,10 +11,8 @@ import type { } from '../contentGenerator.js'; import type { Config } from '../../config/config.js'; import { InstallationManager } from '../../utils/installationManager.js'; -import { LoggingContentGenerator } from './loggingContentGenerator.js'; export { GeminiContentGenerator } from './geminiContentGenerator.js'; -export { LoggingContentGenerator } from './loggingContentGenerator.js'; /** * Create a Gemini content generator. @@ -51,5 +49,5 @@ export function createGeminiContentGenerator( config, ); - return new LoggingContentGenerator(geminiContentGenerator, gcConfig); + return geminiContentGenerator; } diff --git a/packages/core/src/core/loggingContentGenerator/index.ts b/packages/core/src/core/loggingContentGenerator/index.ts new file mode 100644 index 000000000..c3957292c --- /dev/null +++ b/packages/core/src/core/loggingContentGenerator/index.ts @@ -0,0 +1,7 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +export { LoggingContentGenerator } from './loggingContentGenerator.js'; diff --git a/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.test.ts b/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.test.ts new file mode 100644 index 000000000..0a61e5573 --- /dev/null +++ b/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.test.ts @@ -0,0 +1,371 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { + GenerateContentParameters, + GenerateContentResponseUsageMetadata, +} from '@google/genai'; +import { GenerateContentResponse } from '@google/genai'; +import type { Config } from '../../config/config.js'; +import type { ContentGenerator } from '../contentGenerator.js'; +import { LoggingContentGenerator } from './index.js'; +import { OpenAIContentConverter } from '../openaiContentGenerator/converter.js'; +import { + logApiRequest, + logApiResponse, + logApiError, +} from '../../telemetry/loggers.js'; +import { OpenAILogger } from '../../utils/openaiLogger.js'; +import type OpenAI from 'openai'; + +vi.mock('../../telemetry/loggers.js', () => ({ + logApiRequest: vi.fn(), + logApiResponse: vi.fn(), + logApiError: vi.fn(), +})); + +vi.mock('../../utils/openaiLogger.js', () => ({ + OpenAILogger: vi.fn().mockImplementation(() => ({ + logInteraction: vi.fn().mockResolvedValue(undefined), + })), +})); + +const convertGeminiRequestToOpenAISpy = vi + .spyOn(OpenAIContentConverter.prototype, 'convertGeminiRequestToOpenAI') + .mockReturnValue([{ role: 'user', content: 'converted' }]); +const convertGeminiToolsToOpenAISpy = vi + .spyOn(OpenAIContentConverter.prototype, 'convertGeminiToolsToOpenAI') + .mockResolvedValue([{ type: 'function', function: { name: 'tool' } }]); +const convertGeminiResponseToOpenAISpy = vi + .spyOn(OpenAIContentConverter.prototype, 'convertGeminiResponseToOpenAI') + .mockReturnValue({ + id: 'openai-response', + object: 'chat.completion', + created: 123456789, + model: 'test-model', + choices: [], + } as OpenAI.Chat.ChatCompletion); + +const createConfig = (overrides: Record = {}): Config => + ({ + getContentGeneratorConfig: () => ({ + authType: 'openai', + enableOpenAILogging: false, + ...overrides, + }), + }) as Config; + +const createWrappedGenerator = ( + generateContent: ContentGenerator['generateContent'], + generateContentStream: ContentGenerator['generateContentStream'], +): ContentGenerator => + ({ + generateContent, + generateContentStream, + countTokens: vi.fn(), + embedContent: vi.fn(), + useSummarizedThinking: vi.fn().mockReturnValue(false), + }) as ContentGenerator; + +const createResponse = ( + responseId: string, + modelVersion: string, + parts: Array>, + usageMetadata?: GenerateContentResponseUsageMetadata, + finishReason?: string, +): GenerateContentResponse => { + const response = new GenerateContentResponse(); + response.responseId = responseId; + response.modelVersion = modelVersion; + response.usageMetadata = usageMetadata; + response.candidates = [ + { + content: { + role: 'model', + parts: parts as never[], + }, + finishReason: finishReason as never, + index: 0, + safetyRatings: [], + }, + ]; + return response; +}; + +describe('LoggingContentGenerator', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + convertGeminiRequestToOpenAISpy.mockClear(); + convertGeminiToolsToOpenAISpy.mockClear(); + convertGeminiResponseToOpenAISpy.mockClear(); + }); + + it('logs request/response, normalizes thought parts, and logs OpenAI interaction', async () => { + const wrapped = createWrappedGenerator( + vi.fn().mockResolvedValue( + createResponse( + 'resp-1', + 'model-v2', + [{ text: 'ok' }], + { + promptTokenCount: 3, + candidatesTokenCount: 5, + totalTokenCount: 8, + }, + 'STOP', + ), + ), + vi.fn(), + ); + const generator = new LoggingContentGenerator( + wrapped, + createConfig({ + enableOpenAILogging: true, + openAILoggingDir: 'logs', + schemaCompliance: 'openapi_30', + }), + ); + + const request = { + model: 'test-model', + contents: [ + { + role: 'user', + parts: [ + { text: 'Hello', thought: 'internal' }, + { + functionCall: { id: 'call-1', name: 'tool', args: '{}' }, + thought: 'strip-me', + }, + null, + ], + }, + ], + config: { + temperature: 0.3, + topP: 0.9, + maxOutputTokens: 256, + presencePenalty: 0.2, + frequencyPenalty: 0.1, + tools: [ + { + functionDeclarations: [ + { name: 'tool', description: 'desc', parameters: {} }, + ], + }, + ], + }, + } as unknown as GenerateContentParameters; + + const response = await generator.generateContent(request, 'prompt-1'); + + expect(response.responseId).toBe('resp-1'); + expect(logApiRequest).toHaveBeenCalledTimes(1); + const [, requestEvent] = vi.mocked(logApiRequest).mock.calls[0]; + const loggedContents = JSON.parse(requestEvent.request_text || '[]'); + expect(loggedContents[0].parts[0]).toEqual({ + text: 'Hello\n[Thought: internal]', + }); + expect(loggedContents[0].parts[1]).toEqual({ + functionCall: { id: 'call-1', name: 'tool', args: '{}' }, + }); + + expect(logApiResponse).toHaveBeenCalledTimes(1); + const [, responseEvent] = vi.mocked(logApiResponse).mock.calls[0]; + expect(responseEvent.response_id).toBe('resp-1'); + expect(responseEvent.model).toBe('model-v2'); + expect(responseEvent.prompt_id).toBe('prompt-1'); + expect(responseEvent.input_token_count).toBe(3); + + expect(convertGeminiRequestToOpenAISpy).toHaveBeenCalledTimes(1); + expect(convertGeminiToolsToOpenAISpy).toHaveBeenCalledTimes(1); + expect(convertGeminiResponseToOpenAISpy).toHaveBeenCalledTimes(1); + + const openaiLoggerInstance = vi.mocked(OpenAILogger).mock.results[0] + ?.value as { logInteraction: ReturnType }; + expect(openaiLoggerInstance.logInteraction).toHaveBeenCalledTimes(1); + const [openaiRequest, openaiResponse, openaiError] = + openaiLoggerInstance.logInteraction.mock.calls[0]; + expect(openaiRequest).toEqual( + expect.objectContaining({ + model: 'test-model', + messages: [{ role: 'user', content: 'converted' }], + tools: [{ type: 'function', function: { name: 'tool' } }], + temperature: 0.3, + top_p: 0.9, + max_tokens: 256, + presence_penalty: 0.2, + frequency_penalty: 0.1, + }), + ); + expect(openaiResponse).toEqual({ + id: 'openai-response', + object: 'chat.completion', + created: 123456789, + model: 'test-model', + choices: [], + }); + expect(openaiError).toBeUndefined(); + }); + + it('logs errors with status code and request id, then rethrows', async () => { + const error = Object.assign(new Error('boom'), { + code: 429, + request_id: 'req-99', + type: 'rate_limit', + }); + const wrapped = createWrappedGenerator( + vi.fn().mockRejectedValue(error), + vi.fn(), + ); + const generator = new LoggingContentGenerator( + wrapped, + createConfig({ enableOpenAILogging: true }), + ); + + const request = { + model: 'test-model', + contents: 'Hello', + } as unknown as GenerateContentParameters; + + await expect( + generator.generateContent(request, 'prompt-2'), + ).rejects.toThrow('boom'); + + expect(logApiError).toHaveBeenCalledTimes(1); + const [, errorEvent] = vi.mocked(logApiError).mock.calls[0]; + expect(errorEvent.response_id).toBe('req-99'); + expect(errorEvent.status_code).toBe(429); + expect(errorEvent.error_type).toBe('rate_limit'); + expect(errorEvent.prompt_id).toBe('prompt-2'); + + const openaiLoggerInstance = vi.mocked(OpenAILogger).mock.results[0] + ?.value as { logInteraction: ReturnType }; + const [, , loggedError] = openaiLoggerInstance.logInteraction.mock.calls[0]; + expect(loggedError).toBeInstanceOf(Error); + expect((loggedError as Error).message).toBe('boom'); + }); + + it('logs streaming responses and consolidates tool calls', async () => { + const usage1 = { + promptTokenCount: 1, + } as GenerateContentResponseUsageMetadata; + const usage2 = { + promptTokenCount: 2, + candidatesTokenCount: 4, + totalTokenCount: 6, + } as GenerateContentResponseUsageMetadata; + + const response1 = createResponse( + 'resp-1', + 'model-stream', + [ + { text: 'Hello' }, + { functionCall: { id: 'call-1', name: 'tool', args: '{}' } }, + ], + usage1, + ); + const response2 = createResponse( + 'resp-2', + 'model-stream', + [ + { text: ' world' }, + { functionCall: { id: 'call-1', name: 'tool', args: '{"x":1}' } }, + { functionResponse: { name: 'tool', response: { output: 'ok' } } }, + ], + usage2, + 'STOP', + ); + + const wrapped = createWrappedGenerator( + vi.fn(), + vi.fn().mockResolvedValue( + (async function* () { + yield response1; + yield response2; + })(), + ), + ); + const generator = new LoggingContentGenerator( + wrapped, + createConfig({ enableOpenAILogging: true }), + ); + + const request = { + model: 'test-model', + contents: 'Hello', + } as unknown as GenerateContentParameters; + + const stream = await generator.generateContentStream(request, 'prompt-3'); + const seen: GenerateContentResponse[] = []; + for await (const item of stream) { + seen.push(item); + } + expect(seen).toHaveLength(2); + + expect(logApiResponse).toHaveBeenCalledTimes(1); + const [, responseEvent] = vi.mocked(logApiResponse).mock.calls[0]; + expect(responseEvent.response_id).toBe('resp-1'); + expect(responseEvent.input_token_count).toBe(2); + + expect(convertGeminiResponseToOpenAISpy).toHaveBeenCalledTimes(1); + const [consolidatedResponse] = + convertGeminiResponseToOpenAISpy.mock.calls[0]; + const consolidatedParts = + consolidatedResponse.candidates?.[0]?.content?.parts || []; + expect(consolidatedParts).toEqual([ + { text: 'Hello' }, + { functionCall: { id: 'call-1', name: 'tool', args: '{"x":1}' } }, + { text: ' world' }, + { functionResponse: { name: 'tool', response: { output: 'ok' } } }, + ]); + expect(consolidatedResponse.usageMetadata).toBe(usage2); + expect(consolidatedResponse.responseId).toBe('resp-2'); + expect(consolidatedResponse.candidates?.[0]?.finishReason).toBe('STOP'); + }); + + it('logs stream errors and skips response logging', async () => { + const response1 = createResponse('resp-1', 'model-stream', [ + { text: 'partial' }, + ]); + const streamError = new Error('stream-fail'); + const wrapped = createWrappedGenerator( + vi.fn(), + vi.fn().mockResolvedValue( + (async function* () { + yield response1; + throw streamError; + })(), + ), + ); + const generator = new LoggingContentGenerator( + wrapped, + createConfig({ enableOpenAILogging: true }), + ); + + const request = { + model: 'test-model', + contents: 'Hello', + } as unknown as GenerateContentParameters; + + const stream = await generator.generateContentStream(request, 'prompt-4'); + await expect(async () => { + for await (const _item of stream) { + // Consume stream to trigger error. + } + }).rejects.toThrow('stream-fail'); + + expect(logApiResponse).not.toHaveBeenCalled(); + expect(logApiError).toHaveBeenCalledTimes(1); + const openaiLoggerInstance = vi.mocked(OpenAILogger).mock.results[0] + ?.value as { logInteraction: ReturnType }; + expect(openaiLoggerInstance.logInteraction).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/src/core/geminiContentGenerator/loggingContentGenerator.ts b/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.ts similarity index 51% rename from packages/core/src/core/geminiContentGenerator/loggingContentGenerator.ts rename to packages/core/src/core/loggingContentGenerator/loggingContentGenerator.ts index 60d0fc247..34e9128a8 100644 --- a/packages/core/src/core/geminiContentGenerator/loggingContentGenerator.ts +++ b/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.ts @@ -4,20 +4,22 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { - Content, - CountTokensParameters, - CountTokensResponse, - EmbedContentParameters, - EmbedContentResponse, - GenerateContentParameters, - GenerateContentResponseUsageMetadata, +import { GenerateContentResponse, - ContentListUnion, - ContentUnion, - Part, - PartUnion, + type Content, + type CountTokensParameters, + type CountTokensResponse, + type EmbedContentParameters, + type EmbedContentResponse, + type GenerateContentParameters, + type GenerateContentResponseUsageMetadata, + type ContentListUnion, + type ContentUnion, + type Part, + type PartUnion, + type FinishReason, } from '@google/genai'; +import type OpenAI from 'openai'; import { ApiRequestEvent, ApiResponseEvent, @@ -31,6 +33,8 @@ import { } from '../../telemetry/loggers.js'; import type { ContentGenerator } from '../contentGenerator.js'; import { isStructuredError } from '../../utils/quotaErrorDetection.js'; +import { OpenAIContentConverter } from '../openaiContentGenerator/converter.js'; +import { OpenAILogger } from '../../utils/openaiLogger.js'; interface StructuredError { status: number; @@ -40,10 +44,19 @@ interface StructuredError { * A decorator that wraps a ContentGenerator to add logging to API calls. */ export class LoggingContentGenerator implements ContentGenerator { + private openaiLogger?: OpenAILogger; + private schemaCompliance?: 'auto' | 'openapi_30'; + constructor( private readonly wrapped: ContentGenerator, private readonly config: Config, - ) {} + ) { + const generatorConfig = this.config.getContentGeneratorConfig(); + if (generatorConfig?.enableOpenAILogging) { + this.openaiLogger = new OpenAILogger(generatorConfig.openAILoggingDir); + this.schemaCompliance = generatorConfig.schemaCompliance; + } + } getWrapped(): ContentGenerator { return this.wrapped; @@ -91,21 +104,31 @@ export class LoggingContentGenerator implements ContentGenerator { prompt_id: string, ): void { const errorMessage = error instanceof Error ? error.message : String(error); - const errorType = error instanceof Error ? error.name : 'unknown'; + const errorType = + (error as { type?: string })?.type || + (error instanceof Error ? error.name : 'unknown'); + const errorResponseId = + (error as { requestID?: string; request_id?: string })?.requestID || + (error as { requestID?: string; request_id?: string })?.request_id || + responseId; + const errorStatus = + (error as { code?: string | number; status?: number })?.code ?? + (error as { status?: number })?.status ?? + (isStructuredError(error) + ? (error as StructuredError).status + : undefined); logApiError( this.config, new ApiErrorEvent( - responseId, + errorResponseId, model, errorMessage, durationMs, prompt_id, this.config.getContentGeneratorConfig()?.authType, errorType, - isStructuredError(error) - ? (error as StructuredError).status - : undefined, + errorStatus, ), ); } @@ -116,6 +139,7 @@ export class LoggingContentGenerator implements ContentGenerator { ): Promise { const startTime = Date.now(); this.logApiRequest(this.toContents(req.contents), req.model, userPromptId); + const openaiRequest = await this.buildOpenAIRequestForLogging(req); try { const response = await this.wrapped.generateContent(req, userPromptId); const durationMs = Date.now() - startTime; @@ -127,10 +151,12 @@ export class LoggingContentGenerator implements ContentGenerator { response.usageMetadata, JSON.stringify(response), ); + await this.logOpenAIInteraction(openaiRequest, response); return response; } catch (error) { const durationMs = Date.now() - startTime; this._logApiError(undefined, durationMs, error, req.model, userPromptId); + await this.logOpenAIInteraction(openaiRequest, undefined, error); throw error; } } @@ -141,6 +167,7 @@ export class LoggingContentGenerator implements ContentGenerator { ): Promise> { const startTime = Date.now(); this.logApiRequest(this.toContents(req.contents), req.model, userPromptId); + const openaiRequest = await this.buildOpenAIRequestForLogging(req); let stream: AsyncGenerator; try { @@ -148,6 +175,7 @@ export class LoggingContentGenerator implements ContentGenerator { } catch (error) { const durationMs = Date.now() - startTime; this._logApiError(undefined, durationMs, error, req.model, userPromptId); + await this.logOpenAIInteraction(openaiRequest, undefined, error); throw error; } @@ -156,6 +184,7 @@ export class LoggingContentGenerator implements ContentGenerator { startTime, userPromptId, req.model, + openaiRequest, ); } @@ -164,6 +193,7 @@ export class LoggingContentGenerator implements ContentGenerator { startTime: number, userPromptId: string, model: string, + openaiRequest?: OpenAI.Chat.ChatCompletionCreateParams, ): AsyncGenerator { const responses: GenerateContentResponse[] = []; @@ -186,6 +216,9 @@ export class LoggingContentGenerator implements ContentGenerator { lastUsageMetadata, JSON.stringify(responses), ); + const consolidatedResponse = + this.consolidateGeminiResponsesForLogging(responses); + await this.logOpenAIInteraction(openaiRequest, consolidatedResponse); } catch (error) { const durationMs = Date.now() - startTime; this._logApiError( @@ -195,10 +228,182 @@ export class LoggingContentGenerator implements ContentGenerator { responses[0]?.modelVersion || model, userPromptId, ); + await this.logOpenAIInteraction(openaiRequest, undefined, error); throw error; } } + private async buildOpenAIRequestForLogging( + request: GenerateContentParameters, + ): Promise { + if (!this.openaiLogger) { + return undefined; + } + + const converter = new OpenAIContentConverter( + request.model, + this.schemaCompliance, + ); + const messages = converter.convertGeminiRequestToOpenAI(request, { + cleanOrphanToolCalls: false, + }); + + const openaiRequest: OpenAI.Chat.ChatCompletionCreateParams = { + model: request.model, + messages, + }; + + if (request.config?.tools) { + openaiRequest.tools = await converter.convertGeminiToolsToOpenAI( + request.config.tools, + ); + } + + if (request.config?.temperature !== undefined) { + openaiRequest.temperature = request.config.temperature; + } + if (request.config?.topP !== undefined) { + openaiRequest.top_p = request.config.topP; + } + if (request.config?.maxOutputTokens !== undefined) { + openaiRequest.max_tokens = request.config.maxOutputTokens; + } + if (request.config?.presencePenalty !== undefined) { + openaiRequest.presence_penalty = request.config.presencePenalty; + } + if (request.config?.frequencyPenalty !== undefined) { + openaiRequest.frequency_penalty = request.config.frequencyPenalty; + } + + return openaiRequest; + } + + private async logOpenAIInteraction( + openaiRequest: OpenAI.Chat.ChatCompletionCreateParams | undefined, + response?: GenerateContentResponse, + error?: unknown, + ): Promise { + if (!this.openaiLogger || !openaiRequest) { + return; + } + + const openaiResponse = response + ? this.convertGeminiResponseToOpenAIForLogging(response, openaiRequest) + : undefined; + + await this.openaiLogger.logInteraction( + openaiRequest, + openaiResponse, + error instanceof Error + ? error + : error + ? new Error(String(error)) + : undefined, + ); + } + + private convertGeminiResponseToOpenAIForLogging( + response: GenerateContentResponse, + openaiRequest: OpenAI.Chat.ChatCompletionCreateParams, + ): OpenAI.Chat.ChatCompletion { + const converter = new OpenAIContentConverter( + openaiRequest.model, + this.schemaCompliance, + ); + + return converter.convertGeminiResponseToOpenAI(response); + } + + private consolidateGeminiResponsesForLogging( + responses: GenerateContentResponse[], + ): GenerateContentResponse | undefined { + if (responses.length === 0) { + return undefined; + } + + const consolidated = new GenerateContentResponse(); + const combinedParts: Part[] = []; + const functionCallIndex = new Map(); + let finishReason: FinishReason | undefined; + let usageMetadata: GenerateContentResponseUsageMetadata | undefined; + + for (const response of responses) { + if (response.usageMetadata) { + usageMetadata = response.usageMetadata; + } + + const candidate = response.candidates?.[0]; + if (candidate?.finishReason) { + finishReason = candidate.finishReason; + } + + const parts = candidate?.content?.parts ?? []; + for (const part of parts as Part[]) { + if (typeof part === 'string') { + combinedParts.push({ text: part }); + continue; + } + + if ('text' in part) { + if (part.text) { + combinedParts.push({ + text: part.text, + ...(part.thought ? { thought: true } : {}), + ...(part.thoughtSignature + ? { thoughtSignature: part.thoughtSignature } + : {}), + }); + } + continue; + } + + if ('functionCall' in part && part.functionCall) { + const callKey = + part.functionCall.id || part.functionCall.name || 'tool_call'; + const existingIndex = functionCallIndex.get(callKey); + const functionPart = { functionCall: part.functionCall }; + if (existingIndex !== undefined) { + combinedParts[existingIndex] = functionPart; + } else { + functionCallIndex.set(callKey, combinedParts.length); + combinedParts.push(functionPart); + } + continue; + } + + if ('functionResponse' in part && part.functionResponse) { + combinedParts.push({ functionResponse: part.functionResponse }); + continue; + } + + combinedParts.push(part); + } + } + + const lastResponse = responses[responses.length - 1]; + const lastCandidate = lastResponse.candidates?.[0]; + + consolidated.responseId = lastResponse.responseId; + consolidated.createTime = lastResponse.createTime; + consolidated.modelVersion = lastResponse.modelVersion; + consolidated.promptFeedback = lastResponse.promptFeedback; + consolidated.usageMetadata = usageMetadata; + + consolidated.candidates = [ + { + content: { + role: lastCandidate?.content?.role || 'model', + parts: combinedParts, + }, + ...(finishReason ? { finishReason } : {}), + index: 0, + safetyRatings: lastCandidate?.safetyRatings || [], + }, + ]; + + return consolidated; + } + async countTokens(req: CountTokensParameters): Promise { return this.wrapped.countTokens(req); } diff --git a/packages/core/src/core/openaiContentGenerator/converter.ts b/packages/core/src/core/openaiContentGenerator/converter.ts index 8187746da..79bb43365 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.ts @@ -236,8 +236,9 @@ export class OpenAIContentConverter { */ convertGeminiRequestToOpenAI( request: GenerateContentParameters, + options: { cleanOrphanToolCalls: boolean } = { cleanOrphanToolCalls: true }, ): OpenAI.Chat.ChatCompletionMessageParam[] { - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = []; + let messages: OpenAI.Chat.ChatCompletionMessageParam[] = []; // Handle system instruction from config this.addSystemInstructionMessage(request, messages); @@ -246,11 +247,89 @@ export class OpenAIContentConverter { this.processContents(request.contents, messages); // Clean up orphaned tool calls and merge consecutive assistant messages - const cleanedMessages = this.cleanOrphanedToolCalls(messages); - const mergedMessages = - this.mergeConsecutiveAssistantMessages(cleanedMessages); + if (options.cleanOrphanToolCalls) { + messages = this.cleanOrphanedToolCalls(messages); + } + messages = this.mergeConsecutiveAssistantMessages(messages); - return mergedMessages; + return messages; + } + + /** + * Convert Gemini response to OpenAI completion format (for logging). + */ + convertGeminiResponseToOpenAI( + response: GenerateContentResponse, + ): OpenAI.Chat.ChatCompletion { + const candidate = response.candidates?.[0]; + const parts = (candidate?.content?.parts || []) as Part[]; + const parsedParts = this.parseParts(parts); + + const message: ExtendedCompletionMessage = { + role: 'assistant', + content: parsedParts.contentParts.join('') || null, + refusal: null, + }; + + const reasoningContent = parsedParts.thoughtParts.join(''); + if (reasoningContent) { + message.reasoning_content = reasoningContent; + } + + if (parsedParts.functionCalls.length > 0) { + message.tool_calls = parsedParts.functionCalls.map((call, index) => ({ + id: call.id || `call_${index}`, + type: 'function' as const, + function: { + name: call.name || '', + arguments: JSON.stringify(call.args || {}), + }, + })); + } + + const finishReason = this.mapGeminiFinishReasonToOpenAI( + candidate?.finishReason, + ); + + const usageMetadata = response.usageMetadata; + const usage: OpenAI.CompletionUsage = { + prompt_tokens: usageMetadata?.promptTokenCount || 0, + completion_tokens: usageMetadata?.candidatesTokenCount || 0, + total_tokens: usageMetadata?.totalTokenCount || 0, + }; + + if (usageMetadata?.cachedContentTokenCount !== undefined) { + ( + usage as OpenAI.CompletionUsage & { + prompt_tokens_details?: { cached_tokens?: number }; + } + ).prompt_tokens_details = { + cached_tokens: usageMetadata.cachedContentTokenCount, + }; + } + + const createdMs = response.createTime + ? Number(response.createTime) + : Date.now(); + const createdSeconds = Number.isFinite(createdMs) + ? Math.floor(createdMs / 1000) + : Math.floor(Date.now() / 1000); + + return { + id: response.responseId || `gemini-${Date.now()}`, + object: 'chat.completion', + created: createdSeconds, + model: response.modelVersion || this.model, + choices: [ + { + index: 0, + message, + finish_reason: finishReason, + logprobs: null, + }, + ], + usage, + }; } /** @@ -836,84 +915,6 @@ export class OpenAIContentConverter { return response; } - /** - * Convert Gemini response format to OpenAI chat completion format for logging - */ - convertGeminiResponseToOpenAI( - response: GenerateContentResponse, - ): OpenAI.Chat.ChatCompletion { - const candidate = response.candidates?.[0]; - const content = candidate?.content; - - let messageContent: string | null = null; - const toolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[] = []; - - if (content?.parts) { - const textParts: string[] = []; - - for (const part of content.parts) { - if ('text' in part && part.text) { - textParts.push(part.text); - } else if ('functionCall' in part && part.functionCall) { - toolCalls.push({ - id: part.functionCall.id || `call_${toolCalls.length}`, - type: 'function' as const, - function: { - name: part.functionCall.name || '', - arguments: JSON.stringify(part.functionCall.args || {}), - }, - }); - } - } - - messageContent = textParts.join('').trimEnd(); - } - - const choice: OpenAI.Chat.ChatCompletion.Choice = { - index: 0, - message: { - role: 'assistant', - content: messageContent, - refusal: null, - }, - finish_reason: this.mapGeminiFinishReasonToOpenAI( - candidate?.finishReason, - ) as OpenAI.Chat.ChatCompletion.Choice['finish_reason'], - logprobs: null, - }; - - if (toolCalls.length > 0) { - choice.message.tool_calls = toolCalls; - } - - const openaiResponse: OpenAI.Chat.ChatCompletion = { - id: response.responseId || `chatcmpl-${Date.now()}`, - object: 'chat.completion', - created: response.createTime - ? Number(response.createTime) - : Math.floor(Date.now() / 1000), - model: this.model, - choices: [choice], - }; - - // Add usage metadata if available - if (response.usageMetadata) { - openaiResponse.usage = { - prompt_tokens: response.usageMetadata.promptTokenCount || 0, - completion_tokens: response.usageMetadata.candidatesTokenCount || 0, - total_tokens: response.usageMetadata.totalTokenCount || 0, - }; - - if (response.usageMetadata.cachedContentTokenCount) { - openaiResponse.usage.prompt_tokens_details = { - cached_tokens: response.usageMetadata.cachedContentTokenCount, - }; - } - } - - return openaiResponse; - } - /** * Map OpenAI finish reasons to Gemini finish reasons */ @@ -931,29 +932,24 @@ export class OpenAIContentConverter { return mapping[openaiReason] || FinishReason.FINISH_REASON_UNSPECIFIED; } - /** - * Map Gemini finish reasons to OpenAI finish reasons - */ - private mapGeminiFinishReasonToOpenAI(geminiReason?: unknown): string { - if (!geminiReason) return 'stop'; + private mapGeminiFinishReasonToOpenAI( + geminiReason?: FinishReason, + ): 'stop' | 'length' | 'tool_calls' | 'content_filter' | 'function_call' { + if (!geminiReason) { + return 'stop'; + } switch (geminiReason) { - case 'STOP': - case 1: // FinishReason.STOP + case FinishReason.STOP: return 'stop'; - case 'MAX_TOKENS': - case 2: // FinishReason.MAX_TOKENS + case FinishReason.MAX_TOKENS: return 'length'; - case 'SAFETY': - case 3: // FinishReason.SAFETY + case FinishReason.SAFETY: return 'content_filter'; - case 'RECITATION': - case 4: // FinishReason.RECITATION - return 'content_filter'; - case 'OTHER': - case 5: // FinishReason.OTHER - return 'stop'; default: + if (geminiReason === ('RECITATION' as FinishReason)) { + return 'content_filter'; + } return 'stop'; } } diff --git a/packages/core/src/core/openaiContentGenerator/errorHandler.test.ts b/packages/core/src/core/openaiContentGenerator/errorHandler.test.ts index b54a9607e..e124d92a2 100644 --- a/packages/core/src/core/openaiContentGenerator/errorHandler.test.ts +++ b/packages/core/src/core/openaiContentGenerator/errorHandler.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { GenerateContentParameters } from '@google/genai'; import { EnhancedErrorHandler } from './errorHandler.js'; -import type { RequestContext } from './telemetryService.js'; +import type { RequestContext } from './errorHandler.js'; describe('EnhancedErrorHandler', () => { let errorHandler: EnhancedErrorHandler; diff --git a/packages/core/src/core/openaiContentGenerator/errorHandler.ts b/packages/core/src/core/openaiContentGenerator/errorHandler.ts index 9091116d3..fe74a87a9 100644 --- a/packages/core/src/core/openaiContentGenerator/errorHandler.ts +++ b/packages/core/src/core/openaiContentGenerator/errorHandler.ts @@ -5,7 +5,15 @@ */ import type { GenerateContentParameters } from '@google/genai'; -import type { RequestContext } from './telemetryService.js'; + +export interface RequestContext { + userPromptId: string; + model: string; + authType: string; + startTime: number; + duration: number; + isStreaming: boolean; +} export interface ErrorHandler { handle( diff --git a/packages/core/src/core/openaiContentGenerator/index.ts b/packages/core/src/core/openaiContentGenerator/index.ts index 8559258cb..fee32a049 100644 --- a/packages/core/src/core/openaiContentGenerator/index.ts +++ b/packages/core/src/core/openaiContentGenerator/index.ts @@ -91,11 +91,4 @@ export function determineProvider( return new DefaultOpenAICompatibleProvider(contentGeneratorConfig, cliConfig); } -// Services -export { - type TelemetryService, - type RequestContext, - DefaultTelemetryService, -} from './telemetryService.js'; - export { type ErrorHandler, EnhancedErrorHandler } from './errorHandler.js'; diff --git a/packages/core/src/core/openaiContentGenerator/openaiContentGenerator.ts b/packages/core/src/core/openaiContentGenerator/openaiContentGenerator.ts index 4dae3f19d..734ed6afb 100644 --- a/packages/core/src/core/openaiContentGenerator/openaiContentGenerator.ts +++ b/packages/core/src/core/openaiContentGenerator/openaiContentGenerator.ts @@ -11,7 +11,6 @@ import type { } from '@google/genai'; import type { PipelineConfig } from './pipeline.js'; import { ContentGenerationPipeline } from './pipeline.js'; -import { DefaultTelemetryService } from './telemetryService.js'; import { EnhancedErrorHandler } from './errorHandler.js'; import { getDefaultTokenizer } from '../../utils/request-tokenizer/index.js'; import type { ContentGeneratorConfig } from '../contentGenerator.js'; @@ -29,11 +28,6 @@ export class OpenAIContentGenerator implements ContentGenerator { cliConfig, provider, contentGeneratorConfig, - telemetryService: new DefaultTelemetryService( - cliConfig, - contentGeneratorConfig.enableOpenAILogging, - contentGeneratorConfig.openAILoggingDir, - ), errorHandler: new EnhancedErrorHandler( (error: unknown, request: GenerateContentParameters) => this.shouldSuppressErrorLogging(error, request), diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts index bccd489e9..93adcb090 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts @@ -15,7 +15,6 @@ import { OpenAIContentConverter } from './converter.js'; import type { Config } from '../../config/config.js'; import type { ContentGeneratorConfig, AuthType } from '../contentGenerator.js'; import type { OpenAICompatibleProvider } from './provider/index.js'; -import type { TelemetryService } from './telemetryService.js'; import type { ErrorHandler } from './errorHandler.js'; // Mock dependencies @@ -28,7 +27,6 @@ describe('ContentGenerationPipeline', () => { let mockProvider: OpenAICompatibleProvider; let mockClient: OpenAI; let mockConverter: OpenAIContentConverter; - let mockTelemetryService: TelemetryService; let mockErrorHandler: ErrorHandler; let mockContentGeneratorConfig: ContentGeneratorConfig; let mockCliConfig: Config; @@ -63,13 +61,6 @@ describe('ContentGenerationPipeline', () => { getDefaultGenerationConfig: vi.fn().mockReturnValue({}), }; - // Mock telemetry service - mockTelemetryService = { - logSuccess: vi.fn().mockResolvedValue(undefined), - logError: vi.fn().mockResolvedValue(undefined), - logStreamingSuccess: vi.fn().mockResolvedValue(undefined), - }; - // Mock error handler mockErrorHandler = { handle: vi.fn().mockImplementation((error: unknown) => { @@ -99,7 +90,6 @@ describe('ContentGenerationPipeline', () => { cliConfig: mockCliConfig, provider: mockProvider, contentGeneratorConfig: mockContentGeneratorConfig, - telemetryService: mockTelemetryService, errorHandler: mockErrorHandler, }; @@ -172,17 +162,6 @@ describe('ContentGenerationPipeline', () => { expect(mockConverter.convertOpenAIResponseToGemini).toHaveBeenCalledWith( mockOpenAIResponse, ); - expect(mockTelemetryService.logSuccess).toHaveBeenCalledWith( - expect.objectContaining({ - userPromptId, - model: 'test-model', - authType: 'openai', - isStreaming: false, - }), - mockGeminiResponse, - expect.any(Object), - mockOpenAIResponse, - ); }); it('should handle tools in request', async () => { @@ -268,16 +247,6 @@ describe('ContentGenerationPipeline', () => { 'API Error', ); - expect(mockTelemetryService.logError).toHaveBeenCalledWith( - expect.objectContaining({ - userPromptId, - model: 'test-model', - authType: 'openai', - isStreaming: false, - }), - testError, - expect.any(Object), - ); expect(mockErrorHandler.handle).toHaveBeenCalledWith( testError, expect.any(Object), @@ -376,17 +345,6 @@ describe('ContentGenerationPipeline', () => { signal: undefined, }), ); - expect(mockTelemetryService.logStreamingSuccess).toHaveBeenCalledWith( - expect.objectContaining({ - userPromptId, - model: 'test-model', - authType: 'openai', - isStreaming: true, - }), - [mockGeminiResponse1, mockGeminiResponse2], - expect.any(Object), - [mockChunk1, mockChunk2], - ); }); it('should filter empty responses', async () => { @@ -490,16 +448,6 @@ describe('ContentGenerationPipeline', () => { expect(results).toHaveLength(0); // No results due to error expect(mockConverter.resetStreamingToolCalls).toHaveBeenCalledTimes(2); // Once at start, once on error - expect(mockTelemetryService.logError).toHaveBeenCalledWith( - expect.objectContaining({ - userPromptId, - model: 'test-model', - authType: 'openai', - isStreaming: true, - }), - testError, - expect.any(Object), - ); expect(mockErrorHandler.handle).toHaveBeenCalledWith( testError, expect.any(Object), @@ -650,18 +598,6 @@ describe('ContentGenerationPipeline', () => { candidatesTokenCount: 20, totalTokenCount: 30, }); - - expect(mockTelemetryService.logStreamingSuccess).toHaveBeenCalledWith( - expect.objectContaining({ - userPromptId, - model: 'test-model', - authType: 'openai', - isStreaming: true, - }), - results, - expect.any(Object), - [mockChunk1, mockChunk2, mockChunk3], - ); }); it('should handle ideal case where last chunk has both finishReason and usageMetadata', async () => { @@ -853,18 +789,6 @@ describe('ContentGenerationPipeline', () => { candidatesTokenCount: 20, totalTokenCount: 30, }); - - expect(mockTelemetryService.logStreamingSuccess).toHaveBeenCalledWith( - expect.objectContaining({ - userPromptId, - model: 'test-model', - authType: 'openai', - isStreaming: true, - }), - results, - expect.any(Object), - [mockChunk1, mockChunk2, mockChunk3], - ); }); it('should handle providers that send finishReason and valid usage in same chunk', async () => { @@ -1118,19 +1042,6 @@ describe('ContentGenerationPipeline', () => { await pipeline.execute(request, userPromptId); // Assert - expect(mockTelemetryService.logSuccess).toHaveBeenCalledWith( - expect.objectContaining({ - userPromptId, - model: 'test-model', - authType: 'openai', - isStreaming: false, - startTime: expect.any(Number), - duration: expect.any(Number), - }), - expect.any(Object), - expect.any(Object), - expect.any(Object), - ); }); it('should create context with correct properties for streaming request', async () => { @@ -1173,19 +1084,6 @@ describe('ContentGenerationPipeline', () => { } // Assert - expect(mockTelemetryService.logStreamingSuccess).toHaveBeenCalledWith( - expect.objectContaining({ - userPromptId, - model: 'test-model', - authType: 'openai', - isStreaming: true, - startTime: expect.any(Number), - duration: expect.any(Number), - }), - expect.any(Array), - expect.any(Object), - expect.any(Array), - ); }); it('should collect all OpenAI chunks for logging even when Gemini responses are filtered', async () => { @@ -1329,22 +1227,6 @@ describe('ContentGenerationPipeline', () => { // Should only yield the final response (empty ones are filtered) expect(responses).toHaveLength(1); expect(responses[0]).toBe(finalGeminiResponse); - - // Verify telemetry was called with ALL OpenAI chunks, including the filtered ones - expect(mockTelemetryService.logStreamingSuccess).toHaveBeenCalledWith( - expect.objectContaining({ - model: 'test-model', - duration: expect.any(Number), - userPromptId: 'test-prompt-id', - authType: 'openai', - }), - [finalGeminiResponse], // Only the non-empty Gemini response - expect.objectContaining({ - model: 'test-model', - messages: [{ role: 'user', content: 'test' }], - }), - [partialToolCallChunk1, partialToolCallChunk2, finishChunk], // ALL OpenAI chunks - ); }); }); }); diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.ts b/packages/core/src/core/openaiContentGenerator/pipeline.ts index 8ebc4bfcc..88ac38f6a 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.ts @@ -13,14 +13,12 @@ import type { Config } from '../../config/config.js'; import type { ContentGeneratorConfig } from '../contentGenerator.js'; import type { OpenAICompatibleProvider } from './provider/index.js'; import { OpenAIContentConverter } from './converter.js'; -import type { TelemetryService, RequestContext } from './telemetryService.js'; -import type { ErrorHandler } from './errorHandler.js'; +import type { ErrorHandler, RequestContext } from './errorHandler.js'; export interface PipelineConfig { cliConfig: Config; provider: OpenAICompatibleProvider; contentGeneratorConfig: ContentGeneratorConfig; - telemetryService: TelemetryService; errorHandler: ErrorHandler; } @@ -46,7 +44,7 @@ export class ContentGenerationPipeline { request, userPromptId, false, - async (openaiRequest, context) => { + async (openaiRequest) => { const openaiResponse = (await this.client.chat.completions.create( openaiRequest, { @@ -57,14 +55,6 @@ export class ContentGenerationPipeline { const geminiResponse = this.converter.convertOpenAIResponseToGemini(openaiResponse); - // Log success - await this.config.telemetryService.logSuccess( - context, - geminiResponse, - openaiRequest, - openaiResponse, - ); - return geminiResponse; }, ); @@ -88,12 +78,7 @@ export class ContentGenerationPipeline { )) as AsyncIterable; // Stage 2: Process stream with conversion and logging - return this.processStreamWithLogging( - stream, - context, - openaiRequest, - request, - ); + return this.processStreamWithLogging(stream, context, request); }, ); } @@ -110,11 +95,9 @@ export class ContentGenerationPipeline { private async *processStreamWithLogging( stream: AsyncIterable, context: RequestContext, - openaiRequest: OpenAI.Chat.ChatCompletionCreateParams, request: GenerateContentParameters, ): AsyncGenerator { const collectedGeminiResponses: GenerateContentResponse[] = []; - const collectedOpenAIChunks: OpenAI.Chat.ChatCompletionChunk[] = []; // Reset streaming tool calls to prevent data pollution from previous streams this.converter.resetStreamingToolCalls(); @@ -125,9 +108,6 @@ export class ContentGenerationPipeline { try { // Stage 2a: Convert and yield each chunk while preserving original for await (const chunk of stream) { - // Always collect OpenAI chunks for logging, regardless of Gemini conversion result - collectedOpenAIChunks.push(chunk); - const response = this.converter.convertOpenAIChunkToGemini(chunk); // Stage 2b: Filter empty responses to avoid downstream issues @@ -164,15 +144,8 @@ export class ContentGenerationPipeline { yield pendingFinishResponse; } - // Stage 2e: Stream completed successfully - perform logging with original OpenAI chunks + // Stage 2e: Stream completed successfully context.duration = Date.now() - context.startTime; - - await this.config.telemetryService.logStreamingSuccess( - context, - collectedGeminiResponses, - openaiRequest, - collectedOpenAIChunks, - ); } catch (error) { // Clear streaming tool calls on error to prevent data pollution this.converter.resetStreamingToolCalls(); @@ -258,7 +231,7 @@ export class ContentGenerationPipeline { const baseRequest: OpenAI.Chat.ChatCompletionCreateParams = { model: this.contentGeneratorConfig.model, messages, - ...this.buildSamplingParameters(request), + ...this.buildGenerateContentConfig(request), }; // Add streaming options if present @@ -280,7 +253,7 @@ export class ContentGenerationPipeline { return this.config.provider.buildRequest(baseRequest, userPromptId); } - private buildSamplingParameters( + private buildGenerateContentConfig( request: GenerateContentParameters, ): Record { const defaultSamplingParams = @@ -316,7 +289,7 @@ export class ContentGenerationPipeline { return value !== undefined ? { [key]: value } : {}; }; - const params = { + const params: Record = { // Parameters with request fallback but no defaults ...addParameterIfDefined('temperature', 'temperature', 'temperature'), ...addParameterIfDefined('top_p', 'top_p', 'topP'), @@ -337,11 +310,24 @@ export class ContentGenerationPipeline { 'frequency_penalty', 'frequencyPenalty', ), + ...this.buildReasoningConfig(), }; return params; } + private buildReasoningConfig(): Record { + const reasoning = this.contentGeneratorConfig.reasoning; + + if (reasoning === false) { + return {}; + } + + return { + reasoning_effort: reasoning?.effort ?? 'medium', + }; + } + /** * Common error handling wrapper for execute methods */ @@ -369,13 +355,7 @@ export class ContentGenerationPipeline { return result; } catch (error) { // Use shared error handling logic - return await this.handleError( - error, - context, - request, - userPromptId, - isStreaming, - ); + return await this.handleError(error, context, request); } } @@ -387,37 +367,8 @@ export class ContentGenerationPipeline { error: unknown, context: RequestContext, request: GenerateContentParameters, - userPromptId?: string, - isStreaming?: boolean, ): Promise { context.duration = Date.now() - context.startTime; - - // Build request for logging (may fail, but we still want to log the error) - let openaiRequest: OpenAI.Chat.ChatCompletionCreateParams; - try { - if (userPromptId !== undefined && isStreaming !== undefined) { - openaiRequest = await this.buildRequest( - request, - userPromptId, - isStreaming, - ); - } else { - // For processStreamWithLogging, we don't have userPromptId/isStreaming, - // so create a minimal request - openaiRequest = { - model: this.contentGeneratorConfig.model, - messages: [], - }; - } - } catch (_buildError) { - // If we can't build the request, create a minimal one for logging - openaiRequest = { - model: this.contentGeneratorConfig.model, - messages: [], - }; - } - - await this.config.telemetryService.logError(context, error, openaiRequest); this.config.errorHandler.handle(error, context, request); } diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts index 4c3d7dfdf..5658eee47 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts @@ -39,7 +39,8 @@ export class DashScopeOpenAICompatibleProvider return ( authType === AuthType.QWEN_OAUTH || baseUrl === 'https://dashscope.aliyuncs.com/compatible-mode/v1' || - baseUrl === 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1' + baseUrl === 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1' || + !baseUrl ); } @@ -144,9 +145,7 @@ export class DashScopeOpenAICompatibleProvider getDefaultGenerationConfig(): GenerateContentConfig { return { - temperature: 0.7, - topP: 0.8, - topK: 20, + temperature: 0.3, }; } diff --git a/packages/core/src/core/openaiContentGenerator/telemetryService.test.ts b/packages/core/src/core/openaiContentGenerator/telemetryService.test.ts deleted file mode 100644 index 6f0f8d09a..000000000 --- a/packages/core/src/core/openaiContentGenerator/telemetryService.test.ts +++ /dev/null @@ -1,1306 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import type { RequestContext } from './telemetryService.js'; -import { DefaultTelemetryService } from './telemetryService.js'; -import type { Config } from '../../config/config.js'; -import { logApiError, logApiResponse } from '../../telemetry/loggers.js'; -import { ApiErrorEvent, ApiResponseEvent } from '../../telemetry/types.js'; -import { openaiLogger } from '../../utils/openaiLogger.js'; -import type { GenerateContentResponse } from '@google/genai'; -import type OpenAI from 'openai'; - -// Mock dependencies -vi.mock('../../telemetry/loggers.js'); -vi.mock('../../utils/openaiLogger.js'); - -// Extended error interface for testing -interface ExtendedError extends Error { - requestID?: string; - type?: string; - code?: string; -} - -describe('DefaultTelemetryService', () => { - let mockConfig: Config; - let telemetryService: DefaultTelemetryService; - let mockRequestContext: RequestContext; - - beforeEach(() => { - // Create mock config - mockConfig = { - getSessionId: vi.fn().mockReturnValue('test-session-id'), - } as unknown as Config; - - // Create mock request context - mockRequestContext = { - userPromptId: 'test-prompt-id', - model: 'test-model', - authType: 'test-auth', - startTime: Date.now(), - duration: 1000, - isStreaming: false, - }; - - // Clear all mocks - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('constructor', () => { - it('should create instance with default OpenAI logging disabled', () => { - const service = new DefaultTelemetryService(mockConfig); - expect(service).toBeInstanceOf(DefaultTelemetryService); - }); - - it('should create instance with OpenAI logging enabled when specified', () => { - const service = new DefaultTelemetryService(mockConfig, true); - expect(service).toBeInstanceOf(DefaultTelemetryService); - }); - }); - - describe('logSuccess', () => { - beforeEach(() => { - telemetryService = new DefaultTelemetryService(mockConfig, false); - }); - - it('should log API response event with complete response data', async () => { - const mockResponse: GenerateContentResponse = { - responseId: 'test-response-id', - usageMetadata: { - promptTokenCount: 100, - candidatesTokenCount: 50, - totalTokenCount: 150, - cachedContentTokenCount: 10, - thoughtsTokenCount: 5, - toolUsePromptTokenCount: 20, - }, - } as GenerateContentResponse; - - await telemetryService.logSuccess(mockRequestContext, mockResponse); - - expect(logApiResponse).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - response_id: 'test-response-id', - model: 'test-model', - duration_ms: 1000, - prompt_id: 'test-prompt-id', - auth_type: 'test-auth', - input_token_count: 100, - output_token_count: 50, - total_token_count: 150, - cached_content_token_count: 10, - thoughts_token_count: 5, - tool_token_count: 20, - }), - ); - }); - - it('should handle response without responseId', async () => { - const mockResponse: GenerateContentResponse = { - usageMetadata: { - promptTokenCount: 100, - candidatesTokenCount: 50, - totalTokenCount: 150, - }, - } as GenerateContentResponse; - - await telemetryService.logSuccess(mockRequestContext, mockResponse); - - expect(logApiResponse).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - response_id: 'unknown', - model: 'test-model', - duration_ms: 1000, - prompt_id: 'test-prompt-id', - auth_type: 'test-auth', - }), - ); - }); - - it('should handle response without usage metadata', async () => { - const mockResponse: GenerateContentResponse = { - responseId: 'test-response-id', - } as GenerateContentResponse; - - await telemetryService.logSuccess(mockRequestContext, mockResponse); - - expect(logApiResponse).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - response_id: 'test-response-id', - model: 'test-model', - duration_ms: 1000, - prompt_id: 'test-prompt-id', - auth_type: 'test-auth', - }), - ); - }); - - it('should not log OpenAI interaction when logging is disabled', async () => { - const mockResponse: GenerateContentResponse = { - responseId: 'test-response-id', - } as GenerateContentResponse; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - const mockOpenAIResponse = { - id: 'test-id', - choices: [{ message: { content: 'response' } }], - } as OpenAI.Chat.ChatCompletion; - - await telemetryService.logSuccess( - mockRequestContext, - mockResponse, - mockOpenAIRequest, - mockOpenAIResponse, - ); - - expect(openaiLogger.logInteraction).not.toHaveBeenCalled(); - }); - - it('should log OpenAI interaction when logging is enabled', async () => { - telemetryService = new DefaultTelemetryService(mockConfig, true); - - const mockResponse: GenerateContentResponse = { - responseId: 'test-response-id', - } as GenerateContentResponse; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - const mockOpenAIResponse = { - id: 'test-id', - choices: [{ message: { content: 'response' } }], - } as OpenAI.Chat.ChatCompletion; - - await telemetryService.logSuccess( - mockRequestContext, - mockResponse, - mockOpenAIRequest, - mockOpenAIResponse, - ); - - expect(openaiLogger.logInteraction).toHaveBeenCalledWith( - mockOpenAIRequest, - mockOpenAIResponse, - ); - }); - - it('should not log OpenAI interaction when request or response is missing', async () => { - telemetryService = new DefaultTelemetryService(mockConfig, true); - - const mockResponse: GenerateContentResponse = { - responseId: 'test-response-id', - } as GenerateContentResponse; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - // Test with missing OpenAI response - await telemetryService.logSuccess( - mockRequestContext, - mockResponse, - mockOpenAIRequest, - undefined, - ); - - expect(openaiLogger.logInteraction).not.toHaveBeenCalled(); - - // Test with missing OpenAI request - await telemetryService.logSuccess( - mockRequestContext, - mockResponse, - undefined, - {} as OpenAI.Chat.ChatCompletion, - ); - - expect(openaiLogger.logInteraction).not.toHaveBeenCalled(); - }); - }); - - describe('logError', () => { - beforeEach(() => { - telemetryService = new DefaultTelemetryService(mockConfig, false); - }); - - it('should log API error event with Error instance', async () => { - const error = new Error('Test error message') as ExtendedError; - error.requestID = 'test-request-id'; - error.type = 'TestError'; - error.code = 'TEST_CODE'; - - await telemetryService.logError(mockRequestContext, error); - - expect(logApiError).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - response_id: 'test-request-id', - model: 'test-model', - error: 'Test error message', - duration_ms: 1000, - prompt_id: 'test-prompt-id', - auth_type: 'test-auth', - error_type: 'TestError', - status_code: 'TEST_CODE', - }), - ); - }); - - it('should handle error without requestID', async () => { - const error = new Error('Test error message'); - - await telemetryService.logError(mockRequestContext, error); - - expect(logApiError).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - response_id: 'unknown', - model: 'test-model', - error: 'Test error message', - duration_ms: 1000, - prompt_id: 'test-prompt-id', - auth_type: 'test-auth', - }), - ); - }); - - it('should handle non-Error objects', async () => { - const error = 'String error message'; - - await telemetryService.logError(mockRequestContext, error); - - expect(logApiError).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - response_id: 'unknown', - model: 'test-model', - error: 'String error message', - duration_ms: 1000, - prompt_id: 'test-prompt-id', - auth_type: 'test-auth', - }), - ); - }); - - it('should handle null/undefined errors', async () => { - await telemetryService.logError(mockRequestContext, null); - - expect(logApiError).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - error: 'null', - }), - ); - - await telemetryService.logError(mockRequestContext, undefined); - - expect(logApiError).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - error: 'undefined', - }), - ); - }); - - it('should not log OpenAI interaction when logging is disabled', async () => { - const error = new Error('Test error'); - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - await telemetryService.logError( - mockRequestContext, - error, - mockOpenAIRequest, - ); - - expect(openaiLogger.logInteraction).not.toHaveBeenCalled(); - }); - - it('should log OpenAI interaction when logging is enabled', async () => { - telemetryService = new DefaultTelemetryService(mockConfig, true); - - const error = new Error('Test error'); - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - await telemetryService.logError( - mockRequestContext, - error, - mockOpenAIRequest, - ); - - expect(openaiLogger.logInteraction).toHaveBeenCalledWith( - mockOpenAIRequest, - undefined, - error, - ); - }); - - it('should not log OpenAI interaction when request is missing', async () => { - telemetryService = new DefaultTelemetryService(mockConfig, true); - - const error = new Error('Test error'); - - await telemetryService.logError(mockRequestContext, error, undefined); - - expect(openaiLogger.logInteraction).not.toHaveBeenCalled(); - }); - }); - - describe('logStreamingSuccess', () => { - beforeEach(() => { - telemetryService = new DefaultTelemetryService(mockConfig, false); - }); - - it('should log streaming success with multiple responses', async () => { - const mockResponses: GenerateContentResponse[] = [ - { - responseId: 'response-1', - usageMetadata: { - promptTokenCount: 50, - candidatesTokenCount: 25, - totalTokenCount: 75, - }, - } as GenerateContentResponse, - { - responseId: 'response-2', - usageMetadata: { - promptTokenCount: 100, - candidatesTokenCount: 50, - totalTokenCount: 150, - cachedContentTokenCount: 10, - thoughtsTokenCount: 5, - toolUsePromptTokenCount: 20, - }, - } as GenerateContentResponse, - { - responseId: 'response-3', - } as GenerateContentResponse, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - ); - - expect(logApiResponse).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - response_id: 'response-3', - model: 'test-model', - duration_ms: 1000, - prompt_id: 'test-prompt-id', - auth_type: 'test-auth', - // Should use usage metadata from response-2 (last one with metadata) - input_token_count: 100, - output_token_count: 50, - total_token_count: 150, - cached_content_token_count: 10, - thoughts_token_count: 5, - tool_token_count: 20, - }), - ); - }); - - it('should handle empty responses array', async () => { - const mockResponses: GenerateContentResponse[] = []; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - ); - - expect(logApiResponse).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - response_id: 'unknown', - model: 'test-model', - duration_ms: 1000, - prompt_id: 'test-prompt-id', - auth_type: 'test-auth', - }), - ); - }); - - it('should handle responses without usage metadata', async () => { - const mockResponses: GenerateContentResponse[] = [ - { - responseId: 'response-1', - } as GenerateContentResponse, - { - responseId: 'response-2', - } as GenerateContentResponse, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - ); - - expect(logApiResponse).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - response_id: 'response-2', - model: 'test-model', - duration_ms: 1000, - prompt_id: 'test-prompt-id', - auth_type: 'test-auth', - }), - ); - }); - - it('should use the last response with usage metadata', async () => { - const mockResponses: GenerateContentResponse[] = [ - { - responseId: 'response-1', - usageMetadata: { - promptTokenCount: 50, - candidatesTokenCount: 25, - totalTokenCount: 75, - }, - } as GenerateContentResponse, - { - responseId: 'response-2', - } as GenerateContentResponse, - { - responseId: 'response-3', - usageMetadata: { - promptTokenCount: 100, - candidatesTokenCount: 50, - totalTokenCount: 150, - }, - } as GenerateContentResponse, - { - responseId: 'response-4', - } as GenerateContentResponse, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - ); - - expect(logApiResponse).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - response_id: 'response-4', - // Should use usage metadata from response-3 (last one with metadata) - input_token_count: 100, - output_token_count: 50, - total_token_count: 150, - }), - ); - }); - - it('should not log OpenAI interaction when logging is disabled', async () => { - const mockResponses: GenerateContentResponse[] = [ - { responseId: 'response-1' } as GenerateContentResponse, - ]; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - const mockOpenAIChunks = [ - { - id: 'test-id', - choices: [{ delta: { content: 'response' } }], - } as OpenAI.Chat.ChatCompletionChunk, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - mockOpenAIRequest, - mockOpenAIChunks, - ); - - expect(openaiLogger.logInteraction).not.toHaveBeenCalled(); - }); - - it('should log OpenAI interaction when logging is enabled', async () => { - telemetryService = new DefaultTelemetryService(mockConfig, true); - - const mockResponses: GenerateContentResponse[] = [ - { responseId: 'response-1' } as GenerateContentResponse, - ]; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - const mockOpenAIChunks = [ - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { - content: 'Hello', - reasoning_content: 'thinking ', - }, - finish_reason: null, - }, - ], - } as unknown as OpenAI.Chat.ChatCompletionChunk, - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { - content: ' world', - reasoning_content: 'more', - }, - finish_reason: 'stop', - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, - } as unknown as OpenAI.Chat.ChatCompletionChunk, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - mockOpenAIRequest, - mockOpenAIChunks, - ); - - expect(openaiLogger.logInteraction).toHaveBeenCalledWith( - mockOpenAIRequest, - expect.objectContaining({ - id: 'test-id', - object: 'chat.completion', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - message: expect.objectContaining({ - role: 'assistant', - content: 'Hello world', - reasoning_content: 'thinking more', - }), - finish_reason: 'stop', - logprobs: null, - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, - }), - ); - }); - - it('should not log OpenAI interaction when request or chunks are missing', async () => { - telemetryService = new DefaultTelemetryService(mockConfig, true); - - const mockResponses: GenerateContentResponse[] = [ - { responseId: 'response-1' } as GenerateContentResponse, - ]; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - // Test with missing OpenAI chunks - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - mockOpenAIRequest, - undefined, - ); - - expect(openaiLogger.logInteraction).not.toHaveBeenCalled(); - - // Test with missing OpenAI request - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - undefined, - [] as OpenAI.Chat.ChatCompletionChunk[], - ); - - expect(openaiLogger.logInteraction).not.toHaveBeenCalled(); - - // Test with empty chunks array - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - mockOpenAIRequest, - [], - ); - - expect(openaiLogger.logInteraction).not.toHaveBeenCalled(); - }); - }); - - describe('RequestContext interface', () => { - it('should have all required properties', () => { - const context: RequestContext = { - userPromptId: 'test-prompt-id', - model: 'test-model', - authType: 'test-auth', - startTime: Date.now(), - duration: 1000, - isStreaming: false, - }; - - expect(context.userPromptId).toBe('test-prompt-id'); - expect(context.model).toBe('test-model'); - expect(context.authType).toBe('test-auth'); - expect(typeof context.startTime).toBe('number'); - expect(context.duration).toBe(1000); - expect(context.isStreaming).toBe(false); - }); - - it('should support streaming context', () => { - const context: RequestContext = { - userPromptId: 'test-prompt-id', - model: 'test-model', - authType: 'test-auth', - startTime: Date.now(), - duration: 1000, - isStreaming: true, - }; - - expect(context.isStreaming).toBe(true); - }); - }); - - describe('combineOpenAIChunksForLogging', () => { - beforeEach(() => { - telemetryService = new DefaultTelemetryService(mockConfig, true); - }); - - it('should combine simple text chunks correctly', async () => { - const mockResponses: GenerateContentResponse[] = [ - { responseId: 'response-1' } as GenerateContentResponse, - ]; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - const mockOpenAIChunks = [ - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { - content: 'Hello', - reasoning_content: 'thinking ', - }, - finish_reason: null, - }, - ], - } as unknown as OpenAI.Chat.ChatCompletionChunk, - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { - content: ' world!', - reasoning_content: 'more', - }, - finish_reason: 'stop', - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, - } as unknown as OpenAI.Chat.ChatCompletionChunk, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - mockOpenAIRequest, - mockOpenAIChunks, - ); - - expect(openaiLogger.logInteraction).toHaveBeenCalledWith( - mockOpenAIRequest, - expect.objectContaining({ - choices: [ - expect.objectContaining({ - message: expect.objectContaining({ - content: 'Hello world!', - reasoning_content: 'thinking more', - }), - }), - ], - }), - ); - }); - - it('should combine tool call chunks correctly', async () => { - const mockResponses: GenerateContentResponse[] = [ - { responseId: 'response-1' } as GenerateContentResponse, - ]; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - const mockOpenAIChunks = [ - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { - tool_calls: [ - { - index: 0, - id: 'call_123', - type: 'function', - function: { name: 'get_weather', arguments: '' }, - }, - ], - }, - finish_reason: null, - }, - ], - } as OpenAI.Chat.ChatCompletionChunk, - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { - tool_calls: [ - { - index: 0, - function: { arguments: '{"location": "' }, - }, - ], - }, - finish_reason: null, - }, - ], - } as OpenAI.Chat.ChatCompletionChunk, - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { - tool_calls: [ - { - index: 0, - function: { arguments: 'New York"}' }, - }, - ], - }, - finish_reason: 'tool_calls', - }, - ], - usage: { - prompt_tokens: 15, - completion_tokens: 8, - total_tokens: 23, - }, - } as OpenAI.Chat.ChatCompletionChunk, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - mockOpenAIRequest, - mockOpenAIChunks, - ); - - expect(openaiLogger.logInteraction).toHaveBeenCalledWith( - mockOpenAIRequest, - expect.objectContaining({ - id: 'test-id', - object: 'chat.completion', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - message: { - role: 'assistant', - content: null, - refusal: null, - tool_calls: [ - { - id: 'call_123', - type: 'function', - function: { - name: 'get_weather', - arguments: '{"location": "New York"}', - }, - }, - ], - }, - finish_reason: 'tool_calls', - logprobs: null, - }, - ], - usage: { - prompt_tokens: 15, - completion_tokens: 8, - total_tokens: 23, - }, - }), - ); - }); - - it('should handle mixed content and tool calls', async () => { - const mockResponses: GenerateContentResponse[] = [ - { responseId: 'response-1' } as GenerateContentResponse, - ]; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - const mockOpenAIChunks = [ - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { content: 'Let me check the weather. ' }, - finish_reason: null, - }, - ], - } as OpenAI.Chat.ChatCompletionChunk, - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { - tool_calls: [ - { - index: 0, - id: 'call_456', - type: 'function', - function: { - name: 'get_weather', - arguments: '{"location": "Paris"}', - }, - }, - ], - }, - finish_reason: 'tool_calls', - }, - ], - usage: { - prompt_tokens: 20, - completion_tokens: 12, - total_tokens: 32, - }, - } as OpenAI.Chat.ChatCompletionChunk, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - mockOpenAIRequest, - mockOpenAIChunks, - ); - - expect(openaiLogger.logInteraction).toHaveBeenCalledWith( - mockOpenAIRequest, - expect.objectContaining({ - choices: [ - { - index: 0, - message: { - role: 'assistant', - content: 'Let me check the weather. ', - refusal: null, - tool_calls: [ - { - id: 'call_456', - type: 'function', - function: { - name: 'get_weather', - arguments: '{"location": "Paris"}', - }, - }, - ], - }, - finish_reason: 'tool_calls', - logprobs: null, - }, - ], - }), - ); - }); - - it('should handle chunks with no content or tool calls', async () => { - const mockResponses: GenerateContentResponse[] = [ - { responseId: 'response-1' } as GenerateContentResponse, - ]; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - const mockOpenAIChunks = [ - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: {}, - finish_reason: null, - }, - ], - } as OpenAI.Chat.ChatCompletionChunk, - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: {}, - finish_reason: 'stop', - }, - ], - usage: { - prompt_tokens: 5, - completion_tokens: 0, - total_tokens: 5, - }, - } as OpenAI.Chat.ChatCompletionChunk, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - mockOpenAIRequest, - mockOpenAIChunks, - ); - - expect(openaiLogger.logInteraction).toHaveBeenCalledWith( - mockOpenAIRequest, - expect.objectContaining({ - choices: [ - { - index: 0, - message: { - role: 'assistant', - content: null, - refusal: null, - }, - finish_reason: 'stop', - logprobs: null, - }, - ], - }), - ); - }); - - it('should use default values when usage is missing', async () => { - const mockResponses: GenerateContentResponse[] = [ - { responseId: 'response-1' } as GenerateContentResponse, - ]; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - const mockOpenAIChunks = [ - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { content: 'Hello' }, - finish_reason: 'stop', - }, - ], - } as OpenAI.Chat.ChatCompletionChunk, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - mockOpenAIRequest, - mockOpenAIChunks, - ); - - expect(openaiLogger.logInteraction).toHaveBeenCalledWith( - mockOpenAIRequest, - expect.objectContaining({ - usage: { - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - }, - }), - ); - }); - - it('should use default finish_reason when missing', async () => { - const mockResponses: GenerateContentResponse[] = [ - { responseId: 'response-1' } as GenerateContentResponse, - ]; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - const mockOpenAIChunks = [ - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { content: 'Hello' }, - finish_reason: null, - }, - ], - } as OpenAI.Chat.ChatCompletionChunk, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - mockOpenAIRequest, - mockOpenAIChunks, - ); - - expect(openaiLogger.logInteraction).toHaveBeenCalledWith( - mockOpenAIRequest, - expect.objectContaining({ - choices: [ - { - index: 0, - message: expect.any(Object), - finish_reason: 'stop', - logprobs: null, - }, - ], - }), - ); - }); - - it('should filter out empty tool calls', async () => { - const mockResponses: GenerateContentResponse[] = [ - { responseId: 'response-1' } as GenerateContentResponse, - ]; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - const mockOpenAIChunks = [ - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { - tool_calls: [ - { - index: 0, - id: '', // Empty ID should be filtered out - type: 'function', - function: { name: 'test', arguments: '{}' }, - }, - { - index: 1, - id: 'call_valid', - type: 'function', - function: { name: 'valid_call', arguments: '{}' }, - }, - ], - }, - finish_reason: 'tool_calls', - }, - ], - } as OpenAI.Chat.ChatCompletionChunk, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - mockOpenAIRequest, - mockOpenAIChunks, - ); - - expect(openaiLogger.logInteraction).toHaveBeenCalledWith( - mockOpenAIRequest, - expect.objectContaining({ - choices: [ - { - index: 0, - message: { - role: 'assistant', - content: null, - refusal: null, - tool_calls: [ - { - id: 'call_valid', - type: 'function', - function: { - name: 'valid_call', - arguments: '{}', - }, - }, - ], - }, - finish_reason: 'tool_calls', - logprobs: null, - }, - ], - }), - ); - }); - }); - - describe('integration with telemetry events', () => { - beforeEach(() => { - telemetryService = new DefaultTelemetryService(mockConfig, false); - }); - - it('should create ApiResponseEvent with correct structure', async () => { - const mockResponse: GenerateContentResponse = { - responseId: 'test-response-id', - usageMetadata: { - promptTokenCount: 100, - candidatesTokenCount: 50, - totalTokenCount: 150, - }, - } as GenerateContentResponse; - - await telemetryService.logSuccess(mockRequestContext, mockResponse); - - expect(logApiResponse).toHaveBeenCalledWith( - mockConfig, - expect.any(ApiResponseEvent), - ); - - const mockLogApiResponse = vi.mocked(logApiResponse); - const callArgs = mockLogApiResponse.mock.calls[0]; - const event = callArgs[1] as ApiResponseEvent; - - expect(event['event.name']).toBe('api_response'); - expect(event['event.timestamp']).toBeDefined(); - expect(event.response_id).toBe('test-response-id'); - expect(event.model).toBe('test-model'); - expect(event.duration_ms).toBe(1000); - expect(event.prompt_id).toBe('test-prompt-id'); - expect(event.auth_type).toBe('test-auth'); - }); - - it('should create ApiErrorEvent with correct structure', async () => { - const error = new Error('Test error message') as ExtendedError; - error.requestID = 'test-request-id'; - error.type = 'TestError'; - error.code = 'TEST_CODE'; - - await telemetryService.logError(mockRequestContext, error); - - expect(logApiError).toHaveBeenCalledWith( - mockConfig, - expect.any(ApiErrorEvent), - ); - - const mockLogApiError = vi.mocked(logApiError); - const callArgs = mockLogApiError.mock.calls[0]; - const event = callArgs[1] as ApiErrorEvent; - - expect(event['event.name']).toBe('api_error'); - expect(event['event.timestamp']).toBeDefined(); - expect(event.response_id).toBe('test-request-id'); - expect(event.model).toBe('test-model'); - expect(event.error).toBe('Test error message'); - expect(event.duration_ms).toBe(1000); - expect(event.prompt_id).toBe('test-prompt-id'); - expect(event.auth_type).toBe('test-auth'); - expect(event.error_type).toBe('TestError'); - expect(event.status_code).toBe('TEST_CODE'); - }); - }); -}); diff --git a/packages/core/src/core/openaiContentGenerator/telemetryService.ts b/packages/core/src/core/openaiContentGenerator/telemetryService.ts deleted file mode 100644 index 66a96ad07..000000000 --- a/packages/core/src/core/openaiContentGenerator/telemetryService.ts +++ /dev/null @@ -1,275 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { Config } from '../../config/config.js'; -import { logApiError, logApiResponse } from '../../telemetry/loggers.js'; -import { ApiErrorEvent, ApiResponseEvent } from '../../telemetry/types.js'; -import { OpenAILogger } from '../../utils/openaiLogger.js'; -import type { GenerateContentResponse } from '@google/genai'; -import type OpenAI from 'openai'; -import type { ExtendedCompletionChunkDelta } from './converter.js'; - -export interface RequestContext { - userPromptId: string; - model: string; - authType: string; - startTime: number; - duration: number; - isStreaming: boolean; -} - -export interface TelemetryService { - logSuccess( - context: RequestContext, - response: GenerateContentResponse, - openaiRequest?: OpenAI.Chat.ChatCompletionCreateParams, - openaiResponse?: OpenAI.Chat.ChatCompletion, - ): Promise; - - logError( - context: RequestContext, - error: unknown, - openaiRequest?: OpenAI.Chat.ChatCompletionCreateParams, - ): Promise; - - logStreamingSuccess( - context: RequestContext, - responses: GenerateContentResponse[], - openaiRequest?: OpenAI.Chat.ChatCompletionCreateParams, - openaiChunks?: OpenAI.Chat.ChatCompletionChunk[], - ): Promise; -} - -export class DefaultTelemetryService implements TelemetryService { - private logger: OpenAILogger; - - constructor( - private config: Config, - private enableOpenAILogging: boolean = false, - openAILoggingDir?: string, - ) { - // Always create a new logger instance to ensure correct working directory - // If no custom directory is provided, undefined will use the default path - this.logger = new OpenAILogger(openAILoggingDir); - } - - async logSuccess( - context: RequestContext, - response: GenerateContentResponse, - openaiRequest?: OpenAI.Chat.ChatCompletionCreateParams, - openaiResponse?: OpenAI.Chat.ChatCompletion, - ): Promise { - // Log API response event for UI telemetry - const responseEvent = new ApiResponseEvent( - response.responseId || 'unknown', - context.model, - context.duration, - context.userPromptId, - context.authType, - response.usageMetadata, - ); - - logApiResponse(this.config, responseEvent); - - // Log interaction if enabled - if (this.enableOpenAILogging && openaiRequest && openaiResponse) { - await this.logger.logInteraction(openaiRequest, openaiResponse); - } - } - - async logError( - context: RequestContext, - error: unknown, - openaiRequest?: OpenAI.Chat.ChatCompletionCreateParams, - ): Promise { - const errorMessage = error instanceof Error ? error.message : String(error); - - // Log API error event for UI telemetry - const errorEvent = new ApiErrorEvent( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (error as any)?.requestID || 'unknown', - context.model, - errorMessage, - context.duration, - context.userPromptId, - context.authType, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (error as any)?.type, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (error as any)?.code, - ); - logApiError(this.config, errorEvent); - - // Log error interaction if enabled - if (this.enableOpenAILogging && openaiRequest) { - await this.logger.logInteraction( - openaiRequest, - undefined, - error as Error, - ); - } - } - - async logStreamingSuccess( - context: RequestContext, - responses: GenerateContentResponse[], - openaiRequest?: OpenAI.Chat.ChatCompletionCreateParams, - openaiChunks?: OpenAI.Chat.ChatCompletionChunk[], - ): Promise { - // Get final usage metadata from the last response that has it - const finalUsageMetadata = responses - .slice() - .reverse() - .find((r) => r.usageMetadata)?.usageMetadata; - - // Log API response event for UI telemetry - const responseEvent = new ApiResponseEvent( - responses[responses.length - 1]?.responseId || 'unknown', - context.model, - context.duration, - context.userPromptId, - context.authType, - finalUsageMetadata, - ); - - logApiResponse(this.config, responseEvent); - - // Log interaction if enabled - combine chunks only when needed - if ( - this.enableOpenAILogging && - openaiRequest && - openaiChunks && - openaiChunks.length > 0 - ) { - const combinedResponse = this.combineOpenAIChunksForLogging(openaiChunks); - await this.logger.logInteraction(openaiRequest, combinedResponse); - } - } - - /** - * Combine OpenAI chunks for logging purposes - * This method consolidates all OpenAI stream chunks into a single ChatCompletion response - * for telemetry and logging purposes, avoiding unnecessary format conversions - */ - private combineOpenAIChunksForLogging( - chunks: OpenAI.Chat.ChatCompletionChunk[], - ): OpenAI.Chat.ChatCompletion { - if (chunks.length === 0) { - throw new Error('No chunks to combine'); - } - - const firstChunk = chunks[0]; - - // Combine all content from chunks - let combinedContent = ''; - const toolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[] = []; - let finishReason: - | 'stop' - | 'length' - | 'tool_calls' - | 'content_filter' - | 'function_call' - | null = null; - let combinedReasoning = ''; - let usage: - | { - prompt_tokens: number; - completion_tokens: number; - total_tokens: number; - } - | undefined; - - for (const chunk of chunks) { - const choice = chunk.choices?.[0]; - if (choice) { - // Combine reasoning content - const reasoningContent = (choice.delta as ExtendedCompletionChunkDelta) - ?.reasoning_content; - if (reasoningContent) { - combinedReasoning += reasoningContent; - } - // Combine text content - if (choice.delta?.content) { - combinedContent += choice.delta.content; - } - - // Collect tool calls - if (choice.delta?.tool_calls) { - for (const toolCall of choice.delta.tool_calls) { - if (toolCall.index !== undefined) { - if (!toolCalls[toolCall.index]) { - toolCalls[toolCall.index] = { - id: toolCall.id || '', - type: toolCall.type || 'function', - function: { name: '', arguments: '' }, - }; - } - - if (toolCall.function?.name) { - toolCalls[toolCall.index].function.name += - toolCall.function.name; - } - if (toolCall.function?.arguments) { - toolCalls[toolCall.index].function.arguments += - toolCall.function.arguments; - } - } - } - } - - // Get finish reason from the last chunk - if (choice.finish_reason) { - finishReason = choice.finish_reason; - } - } - - // Get usage from the last chunk that has it - if (chunk.usage) { - usage = chunk.usage; - } - } - - // Create the combined ChatCompletion response - const message: OpenAI.Chat.ChatCompletionMessage = { - role: 'assistant', - content: combinedContent || null, - refusal: null, - }; - if (combinedReasoning) { - // Attach reasoning content if any thought tokens were streamed - (message as { reasoning_content?: string }).reasoning_content = - combinedReasoning; - } - - // Add tool calls if any - if (toolCalls.length > 0) { - message.tool_calls = toolCalls.filter((tc) => tc.id); // Filter out empty tool calls - } - - const combinedResponse: OpenAI.Chat.ChatCompletion = { - id: firstChunk.id, - object: 'chat.completion', - created: firstChunk.created, - model: firstChunk.model, - choices: [ - { - index: 0, - message, - finish_reason: finishReason || 'stop', - logprobs: null, - }, - ], - usage: usage || { - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - }, - system_fingerprint: firstChunk.system_fingerprint, - }; - - return combinedResponse; - } -} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f8e096867..56680403b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -80,6 +80,9 @@ export * from './tools/tool-registry.js'; // Export subagents (Phase 1) export * from './subagents/index.js'; +// Export skills +export * from './skills/index.js'; + // Export prompt logic export * from './prompts/mcp-prompts.js'; @@ -101,6 +104,7 @@ export * from './tools/mcp-client-manager.js'; export * from './tools/mcp-tool.js'; export * from './tools/sdk-control-client-transport.js'; export * from './tools/task.js'; +export * from './tools/skill.js'; export * from './tools/todoWrite.js'; export * from './tools/exitPlanMode.js'; diff --git a/packages/core/src/mcp/constants.ts b/packages/core/src/mcp/constants.ts new file mode 100644 index 000000000..a352fc60b --- /dev/null +++ b/packages/core/src/mcp/constants.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * OAuth client name used for MCP dynamic client registration. + * This name must match the allowlist on MCP servers like Figma. + */ +export const MCP_OAUTH_CLIENT_NAME = 'Gemini CLI MCP Client'; + +/** + * OAuth client name for service account impersonation provider. + */ +export const MCP_SA_IMPERSONATION_CLIENT_NAME = + 'Gemini CLI (Service Account Impersonation)'; + +/** + * Port for OAuth redirect callback server. + */ +export const OAUTH_REDIRECT_PORT = 7777; + +/** + * Path for OAuth redirect callback. + */ +export const OAUTH_REDIRECT_PATH = '/oauth/callback'; diff --git a/packages/core/src/mcp/google-auth-provider.ts b/packages/core/src/mcp/google-auth-provider.ts index d76115622..8c858714c 100644 --- a/packages/core/src/mcp/google-auth-provider.ts +++ b/packages/core/src/mcp/google-auth-provider.ts @@ -13,6 +13,7 @@ import type { } from '@modelcontextprotocol/sdk/shared/auth.js'; import { GoogleAuth } from 'google-auth-library'; import type { MCPServerConfig } from '../config/config.js'; +import { MCP_OAUTH_CLIENT_NAME } from './constants.js'; const ALLOWED_HOSTS = [/^.+\.googleapis\.com$/, /^(.*\.)?luci\.app$/]; @@ -22,7 +23,7 @@ export class GoogleCredentialProvider implements OAuthClientProvider { // Properties required by OAuthClientProvider, with no-op values readonly redirectUrl = ''; readonly clientMetadata: OAuthClientMetadata = { - client_name: 'Gemini CLI (Google ADC)', + client_name: MCP_OAUTH_CLIENT_NAME, redirect_uris: [], grant_types: [], response_types: [], diff --git a/packages/core/src/mcp/oauth-provider.ts b/packages/core/src/mcp/oauth-provider.ts index 44f9d9c71..2b657f352 100644 --- a/packages/core/src/mcp/oauth-provider.ts +++ b/packages/core/src/mcp/oauth-provider.ts @@ -13,6 +13,11 @@ import type { OAuthToken } from './token-storage/types.js'; import { MCPOAuthTokenStorage } from './oauth-token-storage.js'; import { getErrorMessage } from '../utils/errors.js'; import { OAuthUtils } from './oauth-utils.js'; +import { + MCP_OAUTH_CLIENT_NAME, + OAUTH_REDIRECT_PORT, + OAUTH_REDIRECT_PATH, +} from './constants.js'; export const OAUTH_DISPLAY_MESSAGE_EVENT = 'oauth-display-message' as const; @@ -89,8 +94,6 @@ interface PKCEParams { state: string; } -const REDIRECT_PORT = 7777; -const REDIRECT_PATH = '/oauth/callback'; const HTTP_OK = 200; /** @@ -115,10 +118,11 @@ export class MCPOAuthProvider { config: MCPOAuthConfig, ): Promise { const redirectUri = - config.redirectUri || `http://localhost:${REDIRECT_PORT}${REDIRECT_PATH}`; + config.redirectUri || + `http://localhost:${OAUTH_REDIRECT_PORT}${OAUTH_REDIRECT_PATH}`; const registrationRequest: OAuthClientRegistrationRequest = { - client_name: 'Gemini CLI (Google ADC)', + client_name: MCP_OAUTH_CLIENT_NAME, redirect_uris: [redirectUri], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], @@ -192,9 +196,12 @@ export class MCPOAuthProvider { const server = http.createServer( async (req: http.IncomingMessage, res: http.ServerResponse) => { try { - const url = new URL(req.url!, `http://localhost:${REDIRECT_PORT}`); + const url = new URL( + req.url!, + `http://localhost:${OAUTH_REDIRECT_PORT}`, + ); - if (url.pathname !== REDIRECT_PATH) { + if (url.pathname !== OAUTH_REDIRECT_PATH) { res.writeHead(404); res.end('Not found'); return; @@ -257,8 +264,10 @@ export class MCPOAuthProvider { ); server.on('error', reject); - server.listen(REDIRECT_PORT, () => { - console.log(`OAuth callback server listening on port ${REDIRECT_PORT}`); + server.listen(OAUTH_REDIRECT_PORT, () => { + console.log( + `OAuth callback server listening on port ${OAUTH_REDIRECT_PORT}`, + ); }); // Timeout after 5 minutes @@ -286,7 +295,8 @@ export class MCPOAuthProvider { mcpServerUrl?: string, ): string { const redirectUri = - config.redirectUri || `http://localhost:${REDIRECT_PORT}${REDIRECT_PATH}`; + config.redirectUri || + `http://localhost:${OAUTH_REDIRECT_PORT}${OAUTH_REDIRECT_PATH}`; const params = new URLSearchParams({ client_id: config.clientId!, @@ -343,7 +353,8 @@ export class MCPOAuthProvider { mcpServerUrl?: string, ): Promise { const redirectUri = - config.redirectUri || `http://localhost:${REDIRECT_PORT}${REDIRECT_PATH}`; + config.redirectUri || + `http://localhost:${OAUTH_REDIRECT_PORT}${OAUTH_REDIRECT_PATH}`; const params = new URLSearchParams({ grant_type: 'authorization_code', diff --git a/packages/core/src/mcp/sa-impersonation-provider.ts b/packages/core/src/mcp/sa-impersonation-provider.ts index e3336693d..def86591e 100644 --- a/packages/core/src/mcp/sa-impersonation-provider.ts +++ b/packages/core/src/mcp/sa-impersonation-provider.ts @@ -13,6 +13,7 @@ import type { import { GoogleAuth } from 'google-auth-library'; import type { MCPServerConfig } from '../config/config.js'; import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; +import { MCP_SA_IMPERSONATION_CLIENT_NAME } from './constants.js'; const fiveMinBufferMs = 5 * 60 * 1000; @@ -32,7 +33,7 @@ export class ServiceAccountImpersonationProvider // Properties required by OAuthClientProvider, with no-op values readonly redirectUrl = ''; readonly clientMetadata: OAuthClientMetadata = { - client_name: 'Gemini CLI (Service Account Impersonation)', + client_name: MCP_SA_IMPERSONATION_CLIENT_NAME, redirect_uris: [], grant_types: [], response_types: [], diff --git a/packages/core/src/skills/index.ts b/packages/core/src/skills/index.ts new file mode 100644 index 000000000..94d5869f9 --- /dev/null +++ b/packages/core/src/skills/index.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Skills feature implementation + * + * This module provides the foundation for the skills feature, which allows + * users to define reusable skill configurations that can be loaded by the + * model via a dedicated Skills tool. + * + * Skills are stored as directories in `.qwen/skills/` (project-level) or + * `~/.qwen/skills/` (user-level), with each directory containing a SKILL.md + * file with YAML frontmatter for metadata. + */ + +// Core types and interfaces +export type { + SkillConfig, + SkillLevel, + SkillValidationResult, + ListSkillsOptions, + SkillErrorCode, +} from './types.js'; + +export { SkillError } from './types.js'; + +// Main management class +export { SkillManager } from './skill-manager.js'; diff --git a/packages/core/src/skills/skill-manager.test.ts b/packages/core/src/skills/skill-manager.test.ts new file mode 100644 index 000000000..076816f86 --- /dev/null +++ b/packages/core/src/skills/skill-manager.test.ts @@ -0,0 +1,463 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import { SkillManager } from './skill-manager.js'; +import { type SkillConfig, SkillError } from './types.js'; +import type { Config } from '../config/config.js'; +import { makeFakeConfig } from '../test-utils/config.js'; + +// Mock file system operations +vi.mock('fs/promises'); +vi.mock('os'); + +// Mock yaml parser - use vi.hoisted for proper hoisting +const mockParseYaml = vi.hoisted(() => vi.fn()); + +vi.mock('../utils/yaml-parser.js', () => ({ + parse: mockParseYaml, + stringify: vi.fn(), +})); + +describe('SkillManager', () => { + let manager: SkillManager; + let mockConfig: Config; + + beforeEach(() => { + // Create mock Config object using test utility + mockConfig = makeFakeConfig({}); + + // Mock the project root method + vi.spyOn(mockConfig, 'getProjectRoot').mockReturnValue('/test/project'); + + // Mock os.homedir + vi.mocked(os.homedir).mockReturnValue('/home/user'); + + // Reset and setup mocks + vi.clearAllMocks(); + + // Setup yaml parser mocks with sophisticated behavior + mockParseYaml.mockImplementation((yamlString: string) => { + // Handle different test cases based on YAML content + if (yamlString.includes('allowedTools:')) { + return { + name: 'test-skill', + description: 'A test skill', + allowedTools: ['read_file', 'write_file'], + }; + } + if (yamlString.includes('name: skill1')) { + return { name: 'skill1', description: 'First skill' }; + } + if (yamlString.includes('name: skill2')) { + return { name: 'skill2', description: 'Second skill' }; + } + if (yamlString.includes('name: skill3')) { + return { name: 'skill3', description: 'Third skill' }; + } + if (!yamlString.includes('name:')) { + return { description: 'A test skill' }; // Missing name case + } + if (!yamlString.includes('description:')) { + return { name: 'test-skill' }; // Missing description case + } + // Default case + return { + name: 'test-skill', + description: 'A test skill', + }; + }); + + manager = new SkillManager(mockConfig); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const validSkillConfig: SkillConfig = { + name: 'test-skill', + description: 'A test skill', + level: 'project', + filePath: '/test/project/.qwen/skills/test-skill/SKILL.md', + body: 'You are a helpful assistant with this skill.', + }; + + const validMarkdown = `--- +name: test-skill +description: A test skill +--- + +You are a helpful assistant with this skill. +`; + + describe('parseSkillContent', () => { + it('should parse valid markdown content', () => { + const config = manager.parseSkillContent( + validMarkdown, + validSkillConfig.filePath, + 'project', + ); + + expect(config.name).toBe('test-skill'); + expect(config.description).toBe('A test skill'); + expect(config.body).toBe('You are a helpful assistant with this skill.'); + expect(config.level).toBe('project'); + expect(config.filePath).toBe(validSkillConfig.filePath); + }); + + it('should parse content with allowedTools', () => { + const markdownWithTools = `--- +name: test-skill +description: A test skill +allowedTools: + - read_file + - write_file +--- + +You are a helpful assistant with this skill. +`; + + const config = manager.parseSkillContent( + markdownWithTools, + validSkillConfig.filePath, + 'project', + ); + + expect(config.allowedTools).toEqual(['read_file', 'write_file']); + }); + + it('should determine level from file path', () => { + const projectPath = '/test/project/.qwen/skills/test-skill/SKILL.md'; + const userPath = '/home/user/.qwen/skills/test-skill/SKILL.md'; + + const projectConfig = manager.parseSkillContent( + validMarkdown, + projectPath, + 'project', + ); + const userConfig = manager.parseSkillContent( + validMarkdown, + userPath, + 'user', + ); + + expect(projectConfig.level).toBe('project'); + expect(userConfig.level).toBe('user'); + }); + + it('should throw error for invalid frontmatter format', () => { + const invalidMarkdown = `No frontmatter here +Just content`; + + expect(() => + manager.parseSkillContent( + invalidMarkdown, + validSkillConfig.filePath, + 'project', + ), + ).toThrow(SkillError); + }); + + it('should throw error for missing name', () => { + const markdownWithoutName = `--- +description: A test skill +--- + +You are a helpful assistant. +`; + + expect(() => + manager.parseSkillContent( + markdownWithoutName, + validSkillConfig.filePath, + 'project', + ), + ).toThrow(SkillError); + }); + + it('should throw error for missing description', () => { + const markdownWithoutDescription = `--- +name: test-skill +--- + +You are a helpful assistant. +`; + + expect(() => + manager.parseSkillContent( + markdownWithoutDescription, + validSkillConfig.filePath, + 'project', + ), + ).toThrow(SkillError); + }); + }); + + describe('validateConfig', () => { + it('should validate valid configuration', () => { + const result = manager.validateConfig(validSkillConfig); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should report error for missing name', () => { + const invalidConfig = { ...validSkillConfig, name: '' }; + const result = manager.validateConfig(invalidConfig); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain('"name" cannot be empty'); + }); + + it('should report error for missing description', () => { + const invalidConfig = { ...validSkillConfig, description: '' }; + const result = manager.validateConfig(invalidConfig); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain('"description" cannot be empty'); + }); + + it('should report error for invalid allowedTools type', () => { + const invalidConfig = { + ...validSkillConfig, + allowedTools: 'not-an-array' as unknown as string[], + }; + const result = manager.validateConfig(invalidConfig); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain('"allowedTools" must be an array'); + }); + + it('should warn for empty body', () => { + const configWithEmptyBody = { ...validSkillConfig, body: '' }; + const result = manager.validateConfig(configWithEmptyBody); + + expect(result.isValid).toBe(true); // Still valid + expect(result.warnings).toContain('Skill body is empty'); + }); + }); + + describe('loadSkill', () => { + it('should load skill from project level first', async () => { + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'test-skill', isDirectory: () => true, isFile: () => false }, + ] as unknown as Awaited>); + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readFile).mockResolvedValue(validMarkdown); + + const config = await manager.loadSkill('test-skill'); + + expect(config).toBeDefined(); + expect(config!.name).toBe('test-skill'); + }); + + it('should fall back to user level if project level fails', async () => { + vi.mocked(fs.readdir) + .mockRejectedValueOnce(new Error('Project dir not found')) // project level fails + .mockResolvedValueOnce([ + { name: 'test-skill', isDirectory: () => true, isFile: () => false }, + ] as unknown as Awaited>); // user level succeeds + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readFile).mockResolvedValue(validMarkdown); + + const config = await manager.loadSkill('test-skill'); + + expect(config).toBeDefined(); + expect(config!.name).toBe('test-skill'); + }); + + it('should return null if not found at either level', async () => { + vi.mocked(fs.readdir).mockRejectedValue(new Error('Directory not found')); + + const config = await manager.loadSkill('nonexistent'); + + expect(config).toBeNull(); + }); + }); + + describe('loadSkillForRuntime', () => { + it('should load skill for runtime', async () => { + vi.mocked(fs.readdir).mockResolvedValueOnce([ + { name: 'test-skill', isDirectory: () => true, isFile: () => false }, + ] as unknown as Awaited>); + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readFile).mockResolvedValue(validMarkdown); // SKILL.md + + const config = await manager.loadSkillForRuntime('test-skill'); + + expect(config).toBeDefined(); + expect(config!.name).toBe('test-skill'); + }); + + it('should return null if skill not found', async () => { + vi.mocked(fs.readdir).mockRejectedValue(new Error('Directory not found')); + + const config = await manager.loadSkillForRuntime('nonexistent'); + + expect(config).toBeNull(); + }); + }); + + describe('listSkills', () => { + beforeEach(() => { + // Mock directory listing for skills directories (with Dirent objects) + vi.mocked(fs.readdir) + .mockResolvedValueOnce([ + { name: 'skill1', isDirectory: () => true, isFile: () => false }, + { name: 'skill2', isDirectory: () => true, isFile: () => false }, + { + name: 'not-a-dir.txt', + isDirectory: () => false, + isFile: () => true, + }, + ] as unknown as Awaited>) + .mockResolvedValueOnce([ + { name: 'skill3', isDirectory: () => true, isFile: () => false }, + { name: 'skill1', isDirectory: () => true, isFile: () => false }, + ] as unknown as Awaited>); + + vi.mocked(fs.access).mockResolvedValue(undefined); + + // Mock file reading for valid skills + vi.mocked(fs.readFile).mockImplementation((filePath) => { + const pathStr = String(filePath); + if (pathStr.includes('skill1')) { + return Promise.resolve(`--- +name: skill1 +description: First skill +--- +Skill 1 content`); + } else if (pathStr.includes('skill2')) { + return Promise.resolve(`--- +name: skill2 +description: Second skill +--- +Skill 2 content`); + } else if (pathStr.includes('skill3')) { + return Promise.resolve(`--- +name: skill3 +description: Third skill +--- +Skill 3 content`); + } + return Promise.reject(new Error('File not found')); + }); + }); + + it('should list skills from both levels', async () => { + const skills = await manager.listSkills(); + + expect(skills).toHaveLength(3); // skill1 (project takes precedence), skill2, skill3 + expect(skills.map((s) => s.name).sort()).toEqual([ + 'skill1', + 'skill2', + 'skill3', + ]); + }); + + it('should prioritize project level over user level', async () => { + const skills = await manager.listSkills(); + const skill1 = skills.find((s) => s.name === 'skill1'); + + expect(skill1!.level).toBe('project'); + }); + + it('should filter by level', async () => { + const projectSkills = await manager.listSkills({ + level: 'project', + }); + + expect(projectSkills).toHaveLength(2); // skill1, skill2 + expect(projectSkills.every((s) => s.level === 'project')).toBe(true); + }); + + it('should handle empty directories', async () => { + vi.mocked(fs.readdir).mockReset(); + vi.mocked(fs.readdir).mockResolvedValue( + [] as unknown as Awaited>, + ); + + const skills = await manager.listSkills({ force: true }); + + expect(skills).toHaveLength(0); + }); + + it('should handle directory read errors', async () => { + vi.mocked(fs.readdir).mockReset(); + vi.mocked(fs.readdir).mockRejectedValue(new Error('Directory not found')); + + const skills = await manager.listSkills({ force: true }); + + expect(skills).toHaveLength(0); + }); + }); + + describe('getSkillsBaseDir', () => { + it('should return project-level base dir', () => { + const baseDir = manager.getSkillsBaseDir('project'); + + expect(baseDir).toBe(path.join('/test/project', '.qwen', 'skills')); + }); + + it('should return user-level base dir', () => { + const baseDir = manager.getSkillsBaseDir('user'); + + expect(baseDir).toBe(path.join('/home/user', '.qwen', 'skills')); + }); + }); + + describe('change listeners', () => { + it('should notify listeners when cache is refreshed', async () => { + const listener = vi.fn(); + manager.addChangeListener(listener); + + vi.mocked(fs.readdir).mockResolvedValue( + [] as unknown as Awaited>, + ); + + await manager.refreshCache(); + + expect(listener).toHaveBeenCalled(); + }); + + it('should remove listener when cleanup function is called', async () => { + const listener = vi.fn(); + const removeListener = manager.addChangeListener(listener); + + removeListener(); + + vi.mocked(fs.readdir).mockResolvedValue( + [] as unknown as Awaited>, + ); + + await manager.refreshCache(); + + expect(listener).not.toHaveBeenCalled(); + }); + }); + + describe('parse errors', () => { + it('should track parse errors', async () => { + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'bad-skill', isDirectory: () => true, isFile: () => false }, + ] as unknown as Awaited>); + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readFile).mockResolvedValue( + 'invalid content without frontmatter', + ); + + await manager.listSkills({ force: true }); + + const errors = manager.getParseErrors(); + expect(errors.size).toBeGreaterThan(0); + }); + }); +}); diff --git a/packages/core/src/skills/skill-manager.ts b/packages/core/src/skills/skill-manager.ts new file mode 100644 index 000000000..77cec15fd --- /dev/null +++ b/packages/core/src/skills/skill-manager.ts @@ -0,0 +1,452 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import { parse as parseYaml } from '../utils/yaml-parser.js'; +import type { + SkillConfig, + SkillLevel, + ListSkillsOptions, + SkillValidationResult, +} from './types.js'; +import { SkillError, SkillErrorCode } from './types.js'; +import type { Config } from '../config/config.js'; + +const QWEN_CONFIG_DIR = '.qwen'; +const SKILLS_CONFIG_DIR = 'skills'; +const SKILL_MANIFEST_FILE = 'SKILL.md'; + +/** + * Manages skill configurations stored as directories containing SKILL.md files. + * Provides discovery, parsing, validation, and caching for skills. + */ +export class SkillManager { + private skillsCache: Map | null = null; + private readonly changeListeners: Set<() => void> = new Set(); + private parseErrors: Map = new Map(); + + constructor(private readonly config: Config) {} + + /** + * Adds a listener that will be called when skills change. + * @returns A function to remove the listener. + */ + addChangeListener(listener: () => void): () => void { + this.changeListeners.add(listener); + return () => { + this.changeListeners.delete(listener); + }; + } + + /** + * Notifies all registered change listeners. + */ + private notifyChangeListeners(): void { + for (const listener of this.changeListeners) { + try { + listener(); + } catch (error) { + console.warn('Skill change listener threw an error:', error); + } + } + } + + /** + * Gets any parse errors that occurred during skill loading. + * @returns Map of skill paths to their parse errors. + */ + getParseErrors(): Map { + return new Map(this.parseErrors); + } + + /** + * Lists all available skills. + * + * @param options - Filtering options + * @returns Array of skill configurations + */ + async listSkills(options: ListSkillsOptions = {}): Promise { + const skills: SkillConfig[] = []; + const seenNames = new Set(); + + const levelsToCheck: SkillLevel[] = options.level + ? [options.level] + : ['project', 'user']; + + // Check if we should use cache or force refresh + const shouldUseCache = !options.force && this.skillsCache !== null; + + // Initialize cache if it doesn't exist or we're forcing a refresh + if (!shouldUseCache) { + await this.refreshCache(); + } + + // Collect skills from each level (project takes precedence over user) + for (const level of levelsToCheck) { + const levelSkills = this.skillsCache?.get(level) || []; + + for (const skill of levelSkills) { + // Skip if we've already seen this name (precedence: project > user) + if (seenNames.has(skill.name)) { + continue; + } + + skills.push(skill); + seenNames.add(skill.name); + } + } + + // Sort by name for consistent ordering + skills.sort((a, b) => a.name.localeCompare(b.name)); + + return skills; + } + + /** + * Loads a skill configuration by name. + * If level is specified, only searches that level. + * If level is omitted, searches project-level first, then user-level. + * + * @param name - Name of the skill to load + * @param level - Optional level to limit search to + * @returns SkillConfig or null if not found + */ + async loadSkill( + name: string, + level?: SkillLevel, + ): Promise { + if (level) { + return this.findSkillByNameAtLevel(name, level); + } + + // Try project level first + const projectSkill = await this.findSkillByNameAtLevel(name, 'project'); + if (projectSkill) { + return projectSkill; + } + + // Try user level + return this.findSkillByNameAtLevel(name, 'user'); + } + + /** + * Loads a skill with its full content, ready for runtime use. + * This includes loading additional files from the skill directory. + * + * @param name - Name of the skill to load + * @param level - Optional level to limit search to + * @returns SkillConfig or null if not found + */ + async loadSkillForRuntime( + name: string, + level?: SkillLevel, + ): Promise { + const skill = await this.loadSkill(name, level); + if (!skill) { + return null; + } + + return skill; + } + + /** + * Validates a skill configuration. + * + * @param config - Configuration to validate + * @returns Validation result + */ + validateConfig(config: Partial): SkillValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + // Check required fields + if (typeof config.name !== 'string') { + errors.push('Missing or invalid "name" field'); + } else if (config.name.trim() === '') { + errors.push('"name" cannot be empty'); + } + + if (typeof config.description !== 'string') { + errors.push('Missing or invalid "description" field'); + } else if (config.description.trim() === '') { + errors.push('"description" cannot be empty'); + } + + // Validate allowedTools if present + if (config.allowedTools !== undefined) { + if (!Array.isArray(config.allowedTools)) { + errors.push('"allowedTools" must be an array'); + } else { + for (const tool of config.allowedTools) { + if (typeof tool !== 'string') { + errors.push('"allowedTools" must contain only strings'); + break; + } + } + } + } + + // Warn if body is empty + if (!config.body || config.body.trim() === '') { + warnings.push('Skill body is empty'); + } + + return { + isValid: errors.length === 0, + errors, + warnings, + }; + } + + /** + * Refreshes the skills cache by loading all skills from disk. + */ + async refreshCache(): Promise { + const skillsCache = new Map(); + this.parseErrors.clear(); + + const levels: SkillLevel[] = ['project', 'user']; + + for (const level of levels) { + const levelSkills = await this.listSkillsAtLevel(level); + skillsCache.set(level, levelSkills); + } + + this.skillsCache = skillsCache; + this.notifyChangeListeners(); + } + + /** + * Parses a SKILL.md file and returns the configuration. + * + * @param filePath - Path to the SKILL.md file + * @param level - Storage level + * @returns SkillConfig + * @throws SkillError if parsing fails + */ + parseSkillFile(filePath: string, level: SkillLevel): Promise { + return this.parseSkillFileInternal(filePath, level); + } + + /** + * Internal implementation of skill file parsing. + */ + private async parseSkillFileInternal( + filePath: string, + level: SkillLevel, + ): Promise { + let content: string; + + try { + content = await fs.readFile(filePath, 'utf8'); + } catch (error) { + const skillError = new SkillError( + `Failed to read skill file: ${error instanceof Error ? error.message : 'Unknown error'}`, + SkillErrorCode.FILE_ERROR, + ); + this.parseErrors.set(filePath, skillError); + throw skillError; + } + + return this.parseSkillContent(content, filePath, level); + } + + /** + * Parses skill content from a string. + * + * @param content - File content + * @param filePath - File path for error reporting + * @param level - Storage level + * @returns SkillConfig + * @throws SkillError if parsing fails + */ + parseSkillContent( + content: string, + filePath: string, + level: SkillLevel, + ): SkillConfig { + try { + // Split frontmatter and content + const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/; + const match = content.match(frontmatterRegex); + + if (!match) { + throw new Error('Invalid format: missing YAML frontmatter'); + } + + const [, frontmatterYaml, body] = match; + + // Parse YAML frontmatter + const frontmatter = parseYaml(frontmatterYaml) as Record; + + // Extract required fields + const nameRaw = frontmatter['name']; + const descriptionRaw = frontmatter['description']; + + if (nameRaw == null || nameRaw === '') { + throw new Error('Missing "name" in frontmatter'); + } + + if (descriptionRaw == null || descriptionRaw === '') { + throw new Error('Missing "description" in frontmatter'); + } + + // Convert to strings + const name = String(nameRaw); + const description = String(descriptionRaw); + + // Extract optional fields + const allowedToolsRaw = frontmatter['allowedTools'] as + | unknown[] + | undefined; + let allowedTools: string[] | undefined; + + if (allowedToolsRaw !== undefined) { + if (Array.isArray(allowedToolsRaw)) { + allowedTools = allowedToolsRaw.map(String); + } else { + throw new Error('"allowedTools" must be an array'); + } + } + + const config: SkillConfig = { + name, + description, + allowedTools, + level, + filePath, + body: body.trim(), + }; + + // Validate the parsed configuration + const validation = this.validateConfig(config); + if (!validation.isValid) { + throw new Error(`Validation failed: ${validation.errors.join(', ')}`); + } + + return config; + } catch (error) { + const skillError = new SkillError( + `Failed to parse skill file: ${error instanceof Error ? error.message : 'Unknown error'}`, + SkillErrorCode.PARSE_ERROR, + ); + this.parseErrors.set(filePath, skillError); + throw skillError; + } + } + + /** + * Gets the base directory for skills at a specific level. + * + * @param level - Storage level + * @returns Absolute directory path + */ + getSkillsBaseDir(level: SkillLevel): string { + const baseDir = + level === 'project' + ? path.join( + this.config.getProjectRoot(), + QWEN_CONFIG_DIR, + SKILLS_CONFIG_DIR, + ) + : path.join(os.homedir(), QWEN_CONFIG_DIR, SKILLS_CONFIG_DIR); + + return baseDir; + } + + /** + * Lists skills at a specific level. + * + * @param level - Storage level to scan + * @returns Array of skill configurations + */ + private async listSkillsAtLevel(level: SkillLevel): Promise { + const projectRoot = this.config.getProjectRoot(); + const homeDir = os.homedir(); + const isHomeDirectory = path.resolve(projectRoot) === path.resolve(homeDir); + + // If project level is requested but project root is same as home directory, + // return empty array to avoid conflicts between project and global skills + if (level === 'project' && isHomeDirectory) { + return []; + } + + const baseDir = this.getSkillsBaseDir(level); + + try { + const entries = await fs.readdir(baseDir, { withFileTypes: true }); + const skills: SkillConfig[] = []; + + for (const entry of entries) { + // Only process directories (each skill is a directory) + if (!entry.isDirectory()) continue; + + const skillDir = path.join(baseDir, entry.name); + const skillManifest = path.join(skillDir, SKILL_MANIFEST_FILE); + + try { + // Check if SKILL.md exists + await fs.access(skillManifest); + + const config = await this.parseSkillFileInternal( + skillManifest, + level, + ); + skills.push(config); + } catch (error) { + // Skip directories without valid SKILL.md + if (error instanceof SkillError) { + // Parse error was already recorded + console.warn( + `Failed to parse skill at ${skillDir}: ${error.message}`, + ); + } + continue; + } + } + + return skills; + } catch (_error) { + // Directory doesn't exist or can't be read + return []; + } + } + + /** + * Finds a skill by name at a specific level. + * + * @param name - Name of the skill to find + * @param level - Storage level to search + * @returns SkillConfig or null if not found + */ + private async findSkillByNameAtLevel( + name: string, + level: SkillLevel, + ): Promise { + await this.ensureLevelCache(level); + + const levelSkills = this.skillsCache?.get(level) || []; + + // Find the skill with matching name + return levelSkills.find((skill) => skill.name === name) || null; + } + + /** + * Ensures the cache is populated for a specific level without loading other levels. + */ + private async ensureLevelCache(level: SkillLevel): Promise { + if (!this.skillsCache) { + this.skillsCache = new Map(); + } + + if (!this.skillsCache.has(level)) { + const levelSkills = await this.listSkillsAtLevel(level); + this.skillsCache.set(level, levelSkills); + } + } +} diff --git a/packages/core/src/skills/types.ts b/packages/core/src/skills/types.ts new file mode 100644 index 000000000..75dfe014a --- /dev/null +++ b/packages/core/src/skills/types.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Represents the storage level for a skill configuration. + * - 'project': Stored in `.qwen/skills/` within the project directory + * - 'user': Stored in `~/.qwen/skills/` in the user's home directory + */ +export type SkillLevel = 'project' | 'user'; + +/** + * Core configuration for a skill as stored in SKILL.md files. + * Each skill directory contains a SKILL.md file with YAML frontmatter + * containing metadata, followed by markdown content describing the skill. + */ +export interface SkillConfig { + /** Unique name identifier for the skill */ + name: string; + + /** Human-readable description of what this skill provides */ + description: string; + + /** + * Optional list of tool names that this skill is allowed to use. + * For v1, this is informational only (no gating). + */ + allowedTools?: string[]; + + /** + * Storage level - determines where the configuration file is stored + */ + level: SkillLevel; + + /** + * Absolute path to the skill directory containing SKILL.md + */ + filePath: string; + + /** + * The markdown body content from SKILL.md (after the frontmatter) + */ + body: string; +} + +/** + * Runtime configuration for a skill when it's being actively used. + * Extends SkillConfig with additional runtime-specific fields. + */ +export type SkillRuntimeConfig = SkillConfig; + +/** + * Result of a validation operation on a skill configuration. + */ +export interface SkillValidationResult { + /** Whether the configuration is valid */ + isValid: boolean; + + /** Array of error messages if validation failed */ + errors: string[]; + + /** Array of warning messages (non-blocking issues) */ + warnings: string[]; +} + +/** + * Options for listing skills. + */ +export interface ListSkillsOptions { + /** Filter by storage level */ + level?: SkillLevel; + + /** Force refresh from disk, bypassing cache. Defaults to false. */ + force?: boolean; +} + +/** + * Error thrown when a skill operation fails. + */ +export class SkillError extends Error { + constructor( + message: string, + readonly code: SkillErrorCode, + readonly skillName?: string, + ) { + super(message); + this.name = 'SkillError'; + } +} + +/** + * Error codes for skill operations. + */ +export const SkillErrorCode = { + NOT_FOUND: 'NOT_FOUND', + INVALID_CONFIG: 'INVALID_CONFIG', + INVALID_NAME: 'INVALID_NAME', + FILE_ERROR: 'FILE_ERROR', + PARSE_ERROR: 'PARSE_ERROR', +} as const; + +export type SkillErrorCode = + (typeof SkillErrorCode)[keyof typeof SkillErrorCode]; diff --git a/packages/core/src/telemetry/constants.ts b/packages/core/src/telemetry/constants.ts index bc6546379..acbf56025 100644 --- a/packages/core/src/telemetry/constants.ts +++ b/packages/core/src/telemetry/constants.ts @@ -33,6 +33,7 @@ export const EVENT_MALFORMED_JSON_RESPONSE = export const EVENT_FILE_OPERATION = 'qwen-code.file_operation'; export const EVENT_MODEL_SLASH_COMMAND = 'qwen-code.slash_command.model'; export const EVENT_SUBAGENT_EXECUTION = 'qwen-code.subagent_execution'; +export const EVENT_SKILL_LAUNCH = 'qwen-code.skill_launch'; export const EVENT_AUTH = 'qwen-code.auth'; // Performance Events diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts index 9adb2d32c..d0feb0202 100644 --- a/packages/core/src/telemetry/index.ts +++ b/packages/core/src/telemetry/index.ts @@ -44,6 +44,7 @@ export { logRipgrepFallback, logNextSpeakerCheck, logAuth, + logSkillLaunch, } from './loggers.js'; export type { SlashCommandEvent, ChatCompressionEvent } from './types.js'; export { @@ -63,6 +64,7 @@ export { RipgrepFallbackEvent, NextSpeakerCheckEvent, AuthEvent, + SkillLaunchEvent, } from './types.js'; export { makeSlashCommandEvent, makeChatCompressionEvent } from './types.js'; export type { TelemetryEvent } from './types.js'; diff --git a/packages/core/src/telemetry/loggers.test.ts b/packages/core/src/telemetry/loggers.test.ts index ab226068b..ab026304a 100644 --- a/packages/core/src/telemetry/loggers.test.ts +++ b/packages/core/src/telemetry/loggers.test.ts @@ -200,6 +200,8 @@ describe('loggers', () => { mcp_tools: undefined, mcp_tools_count: undefined, output_format: 'json', + skills: undefined, + subagents: undefined, }, }); }); @@ -262,7 +264,7 @@ describe('loggers', () => { 'event.timestamp': '2025-01-01T00:00:00.000Z', prompt_length: 11, prompt_id: 'prompt-id-9', - auth_type: 'gemini-api-key', + auth_type: 'gemini', }, }); }); @@ -331,7 +333,7 @@ describe('loggers', () => { total_token_count: 0, response_text: 'test-response', prompt_id: 'prompt-id-1', - auth_type: 'gemini-api-key', + auth_type: 'gemini', }, }); diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index 127f0dd92..16b4dc5a7 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -37,6 +37,7 @@ import { EVENT_MALFORMED_JSON_RESPONSE, EVENT_INVALID_CHUNK, EVENT_AUTH, + EVENT_SKILL_LAUNCH, } from './constants.js'; import { recordApiErrorMetrics, @@ -84,6 +85,7 @@ import type { MalformedJsonResponseEvent, InvalidChunkEvent, AuthEvent, + SkillLaunchEvent, } from './types.js'; import type { UiEvent } from './uiTelemetry.js'; import { uiTelemetryService } from './uiTelemetry.js'; @@ -123,6 +125,8 @@ export function logStartSession( mcp_tools: event.mcp_tools, mcp_tools_count: event.mcp_tools_count, output_format: event.output_format, + skills: event.skills, + subagents: event.subagents, }; const logger = logs.getLogger(SERVICE_NAME); @@ -865,3 +869,21 @@ export function logAuth(config: Config, event: AuthEvent): void { }; logger.emit(logRecord); } + +export function logSkillLaunch(config: Config, event: SkillLaunchEvent): void { + if (!isTelemetrySdkInitialized()) return; + + const attributes: LogAttributes = { + ...getCommonAttributes(config), + ...event, + 'event.name': EVENT_SKILL_LAUNCH, + 'event.timestamp': new Date().toISOString(), + }; + + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: `Skill launch: ${event.skill_name}. Success: ${event.success}.`, + attributes, + }; + logger.emit(logRecord); +} diff --git a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts index f0fb94f18..e63511df8 100644 --- a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts +++ b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts @@ -38,6 +38,7 @@ import type { ModelSlashCommandEvent, ExtensionDisableEvent, AuthEvent, + SkillLaunchEvent, RipgrepFallbackEvent, EndSessionEvent, } from '../types.js'; @@ -391,6 +392,8 @@ export class QwenLogger { telemetry_enabled: event.telemetry_enabled, telemetry_log_user_prompts_enabled: event.telemetry_log_user_prompts_enabled, + skills: event.skills, + subagents: event.subagents, }, }); @@ -827,6 +830,18 @@ export class QwenLogger { this.flushIfNeeded(); } + logSkillLaunchEvent(event: SkillLaunchEvent): void { + const rumEvent = this.createActionEvent('misc', 'skill_launch', { + properties: { + skill_name: event.skill_name, + success: event.success ? 1 : 0, + }, + }); + + this.enqueueLogEvent(rumEvent); + this.flushIfNeeded(); + } + logChatCompressionEvent(event: ChatCompressionEvent): void { const rumEvent = this.createActionEvent('misc', 'chat_compression', { properties: { diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index cfe4a2a0f..4158c9053 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -18,6 +18,9 @@ import { import type { FileOperation } from './metrics.js'; export { ToolCallDecision }; import type { OutputFormat } from '../output/types.js'; +import { ToolNames } from '../tools/tool-names.js'; +import type { SkillTool } from '../tools/skill.js'; +import type { TaskTool } from '../tools/task.js'; export interface BaseTelemetryEvent { 'event.name': string; @@ -47,6 +50,8 @@ export class StartSessionEvent implements BaseTelemetryEvent { mcp_tools_count?: number; mcp_tools?: string; output_format: OutputFormat; + skills?: string; + subagents?: string; constructor(config: Config) { const generatorConfig = config.getContentGeneratorConfig(); @@ -79,6 +84,7 @@ export class StartSessionEvent implements BaseTelemetryEvent { config.getFileFilteringRespectGitIgnore(); this.mcp_servers_count = mcpServers ? Object.keys(mcpServers).length : 0; this.output_format = config.getOutputFormat(); + if (toolRegistry) { const mcpTools = toolRegistry .getAllTools() @@ -87,6 +93,22 @@ export class StartSessionEvent implements BaseTelemetryEvent { this.mcp_tools = mcpTools .map((tool) => (tool as DiscoveredMCPTool).name) .join(','); + + const skillTool = toolRegistry.getTool(ToolNames.SKILL) as + | SkillTool + | undefined; + const skillNames = skillTool?.getAvailableSkillNames?.(); + if (skillNames && skillNames.length > 0) { + this.skills = skillNames.join(','); + } + + const taskTool = toolRegistry.getTool(ToolNames.TASK) as + | TaskTool + | undefined; + const subagentNames = taskTool?.getAvailableSubagentNames?.(); + if (subagentNames && subagentNames.length > 0) { + this.subagents = subagentNames.join(','); + } } } } @@ -721,6 +743,20 @@ export class AuthEvent implements BaseTelemetryEvent { } } +export class SkillLaunchEvent implements BaseTelemetryEvent { + 'event.name': 'skill_launch'; + 'event.timestamp': string; + skill_name: string; + success: boolean; + + constructor(skill_name: string, success: boolean) { + this['event.name'] = 'skill_launch'; + this['event.timestamp'] = new Date().toISOString(); + this.skill_name = skill_name; + this.success = success; + } +} + export type TelemetryEvent = | StartSessionEvent | EndSessionEvent @@ -749,7 +785,8 @@ export type TelemetryEvent = | ExtensionUninstallEvent | ToolOutputTruncatedEvent | ModelSlashCommandEvent - | AuthEvent; + | AuthEvent + | SkillLaunchEvent; export class ExtensionDisableEvent implements BaseTelemetryEvent { 'event.name': 'extension_disable'; diff --git a/packages/core/src/tools/ls.test.ts b/packages/core/src/tools/ls.test.ts index 8eea72cb7..3a3f3a0d9 100644 --- a/packages/core/src/tools/ls.test.ts +++ b/packages/core/src/tools/ls.test.ts @@ -31,6 +31,8 @@ describe('LSTool', () => { tempSecondaryDir, ]); + const userSkillsBase = path.join(os.homedir(), '.qwen', 'skills'); + mockConfig = { getTargetDir: () => tempRootDir, getWorkspaceContext: () => mockWorkspaceContext, @@ -39,6 +41,9 @@ describe('LSTool', () => { respectGitIgnore: true, respectQwenIgnore: true, }), + storage: { + getUserSkillsDir: () => userSkillsBase, + }, } as unknown as Config; lsTool = new LSTool(mockConfig); @@ -288,7 +293,7 @@ describe('LSTool', () => { }; const invocation = lsTool.build(params); const description = invocation.getDescription(); - const expected = path.relative(tempRootDir, params.path); + const expected = path.resolve(params.path); expect(description).toBe(expected); }); }); diff --git a/packages/core/src/tools/ls.ts b/packages/core/src/tools/ls.ts index 2aefe4439..6310a6827 100644 --- a/packages/core/src/tools/ls.ts +++ b/packages/core/src/tools/ls.ts @@ -9,6 +9,7 @@ import path from 'node:path'; import type { ToolInvocation, ToolResult } from './tools.js'; import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; import { makeRelative, shortenPath } from '../utils/paths.js'; +import { isSubpath } from '../utils/paths.js'; import type { Config } from '../config/config.js'; import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js'; import { ToolErrorType } from './tool-error.js'; @@ -311,8 +312,14 @@ export class LSTool extends BaseDeclarativeTool { return `Path must be absolute: ${params.path}`; } + const userSkillsBase = this.config.storage.getUserSkillsDir(); + const isUnderUserSkills = isSubpath(userSkillsBase, params.path); + const workspaceContext = this.config.getWorkspaceContext(); - if (!workspaceContext.isPathWithinWorkspace(params.path)) { + if ( + !workspaceContext.isPathWithinWorkspace(params.path) && + !isUnderUserSkills + ) { const directories = workspaceContext.getDirectories(); return `Path must be within one of the workspace directories: ${directories.join( ', ', diff --git a/packages/core/src/tools/read-file.test.ts b/packages/core/src/tools/read-file.test.ts index aaca99236..01568eed9 100644 --- a/packages/core/src/tools/read-file.test.ts +++ b/packages/core/src/tools/read-file.test.ts @@ -40,6 +40,7 @@ describe('ReadFileTool', () => { getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir), storage: { getProjectTempDir: () => path.join(tempRootDir, '.temp'), + getUserSkillsDir: () => path.join(os.homedir(), '.qwen', 'skills'), }, getTruncateToolOutputThreshold: () => 2500, getTruncateToolOutputLines: () => 500, diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts index e4b41c237..6bd0ddb64 100644 --- a/packages/core/src/tools/read-file.ts +++ b/packages/core/src/tools/read-file.ts @@ -20,6 +20,7 @@ import { FileOperation } from '../telemetry/metrics.js'; import { getProgrammingLanguage } from '../telemetry/telemetry-utils.js'; import { logFileOperation } from '../telemetry/loggers.js'; import { FileOperationEvent } from '../telemetry/types.js'; +import { isSubpath } from '../utils/paths.js'; /** * Parameters for the ReadFile tool @@ -183,15 +184,20 @@ export class ReadFileTool extends BaseDeclarativeTool< const workspaceContext = this.config.getWorkspaceContext(); const projectTempDir = this.config.storage.getProjectTempDir(); + const userSkillsDir = this.config.storage.getUserSkillsDir(); const resolvedFilePath = path.resolve(filePath); - const resolvedProjectTempDir = path.resolve(projectTempDir); - const isWithinTempDir = - resolvedFilePath.startsWith(resolvedProjectTempDir + path.sep) || - resolvedFilePath === resolvedProjectTempDir; + const isWithinTempDir = isSubpath(projectTempDir, resolvedFilePath); + const isWithinUserSkills = isSubpath(userSkillsDir, resolvedFilePath); - if (!workspaceContext.isPathWithinWorkspace(filePath) && !isWithinTempDir) { + if ( + !workspaceContext.isPathWithinWorkspace(filePath) && + !isWithinTempDir && + !isWithinUserSkills + ) { const directories = workspaceContext.getDirectories(); - return `File path must be within one of the workspace directories: ${directories.join(', ')} or within the project temp directory: ${projectTempDir}`; + return `File path must be within one of the workspace directories: ${directories.join( + ', ', + )} or within the project temp directory: ${projectTempDir}`; } if (params.offset !== undefined && params.offset < 0) { return 'Offset must be a non-negative number'; diff --git a/packages/core/src/tools/skill.test.ts b/packages/core/src/tools/skill.test.ts new file mode 100644 index 000000000..da0e9a195 --- /dev/null +++ b/packages/core/src/tools/skill.test.ts @@ -0,0 +1,442 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { SkillTool, type SkillParams } from './skill.js'; +import type { PartListUnion } from '@google/genai'; +import type { ToolResultDisplay } from './tools.js'; +import type { Config } from '../config/config.js'; +import { SkillManager } from '../skills/skill-manager.js'; +import type { SkillConfig } from '../skills/types.js'; +import { partToString } from '../utils/partUtils.js'; + +// Type for accessing protected methods in tests +type SkillToolWithProtectedMethods = SkillTool & { + createInvocation: (params: SkillParams) => { + execute: ( + signal?: AbortSignal, + updateOutput?: (output: ToolResultDisplay) => void, + ) => Promise<{ + llmContent: PartListUnion; + returnDisplay: ToolResultDisplay; + }>; + getDescription: () => string; + shouldConfirmExecute: () => Promise; + }; +}; + +// Mock dependencies +vi.mock('../skills/skill-manager.js'); +vi.mock('../telemetry/index.js', () => ({ + logSkillLaunch: vi.fn(), + SkillLaunchEvent: class { + constructor( + public skill_name: string, + public success: boolean, + ) {} + }, +})); + +const MockedSkillManager = vi.mocked(SkillManager); + +describe('SkillTool', () => { + let config: Config; + let skillTool: SkillTool; + let mockSkillManager: SkillManager; + let changeListeners: Array<() => void>; + + const mockSkills: SkillConfig[] = [ + { + name: 'code-review', + description: 'Specialized skill for reviewing code quality', + level: 'project', + filePath: '/project/.qwen/skills/code-review/SKILL.md', + body: 'Review code for quality and best practices.', + }, + { + name: 'testing', + description: 'Skill for writing and running tests', + level: 'user', + filePath: '/home/user/.qwen/skills/testing/SKILL.md', + body: 'Help write comprehensive tests.', + allowedTools: ['read_file', 'write_file', 'shell'], + }, + ]; + + beforeEach(async () => { + // Setup fake timers + vi.useFakeTimers(); + + // Create mock config + config = { + getProjectRoot: vi.fn().mockReturnValue('/test/project'), + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getSkillManager: vi.fn(), + getGeminiClient: vi.fn().mockReturnValue(undefined), + } as unknown as Config; + + changeListeners = []; + + // Setup SkillManager mock + mockSkillManager = { + listSkills: vi.fn().mockResolvedValue(mockSkills), + loadSkill: vi.fn(), + loadSkillForRuntime: vi.fn(), + addChangeListener: vi.fn((listener: () => void) => { + changeListeners.push(listener); + return () => { + const index = changeListeners.indexOf(listener); + if (index >= 0) { + changeListeners.splice(index, 1); + } + }; + }), + getParseErrors: vi.fn().mockReturnValue(new Map()), + } as unknown as SkillManager; + + MockedSkillManager.mockImplementation(() => mockSkillManager); + + // Make config return the mock SkillManager + vi.mocked(config.getSkillManager).mockReturnValue(mockSkillManager); + + // Create SkillTool instance + skillTool = new SkillTool(config); + + // Allow async initialization to complete + await vi.runAllTimersAsync(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + describe('initialization', () => { + it('should initialize with correct name and properties', () => { + expect(skillTool.name).toBe('skill'); + expect(skillTool.displayName).toBe('Skill'); + expect(skillTool.kind).toBe('read'); + }); + + it('should load available skills during initialization', () => { + expect(mockSkillManager.listSkills).toHaveBeenCalled(); + }); + + it('should subscribe to skill manager changes', () => { + expect(mockSkillManager.addChangeListener).toHaveBeenCalledTimes(1); + }); + + it('should update description with available skills', () => { + expect(skillTool.description).toContain('code-review'); + expect(skillTool.description).toContain( + 'Specialized skill for reviewing code quality', + ); + expect(skillTool.description).toContain('testing'); + expect(skillTool.description).toContain( + 'Skill for writing and running tests', + ); + }); + + it('should handle empty skills list gracefully', async () => { + vi.mocked(mockSkillManager.listSkills).mockResolvedValue([]); + + const emptySkillTool = new SkillTool(config); + await vi.runAllTimersAsync(); + + expect(emptySkillTool.description).toContain( + 'No skills are currently configured', + ); + }); + + it('should handle skill loading errors gracefully', async () => { + vi.mocked(mockSkillManager.listSkills).mockRejectedValue( + new Error('Loading failed'), + ); + + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + new SkillTool(config); + await vi.runAllTimersAsync(); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to load skills for Skills tool:', + expect.any(Error), + ); + consoleSpy.mockRestore(); + }); + }); + + describe('schema generation', () => { + it('should expose static schema without dynamic enums', () => { + const schema = skillTool.schema; + const properties = schema.parametersJsonSchema as { + properties: { + skill: { + type: string; + description: string; + enum?: string[]; + }; + }; + }; + expect(properties.properties.skill.type).toBe('string'); + expect(properties.properties.skill.description).toBe( + 'The skill name (no arguments). E.g., "pdf" or "xlsx"', + ); + expect(properties.properties.skill.enum).toBeUndefined(); + }); + + it('should keep schema static even when no skills available', async () => { + vi.mocked(mockSkillManager.listSkills).mockResolvedValue([]); + + const emptySkillTool = new SkillTool(config); + await vi.runAllTimersAsync(); + + const schema = emptySkillTool.schema; + const properties = schema.parametersJsonSchema as { + properties: { + skill: { + type: string; + description: string; + enum?: string[]; + }; + }; + }; + expect(properties.properties.skill.type).toBe('string'); + expect(properties.properties.skill.description).toBe( + 'The skill name (no arguments). E.g., "pdf" or "xlsx"', + ); + expect(properties.properties.skill.enum).toBeUndefined(); + }); + }); + + describe('validateToolParams', () => { + it('should validate valid parameters', () => { + const result = skillTool.validateToolParams({ skill: 'code-review' }); + expect(result).toBeNull(); + }); + + it('should reject empty skill', () => { + const result = skillTool.validateToolParams({ skill: '' }); + expect(result).toBe('Parameter "skill" must be a non-empty string.'); + }); + + it('should reject non-existent skill', () => { + const result = skillTool.validateToolParams({ + skill: 'non-existent', + }); + expect(result).toBe( + 'Skill "non-existent" not found. Available skills: code-review, testing', + ); + }); + + it('should show appropriate message when no skills available', async () => { + vi.mocked(mockSkillManager.listSkills).mockResolvedValue([]); + + const emptySkillTool = new SkillTool(config); + await vi.runAllTimersAsync(); + + const result = emptySkillTool.validateToolParams({ + skill: 'non-existent', + }); + expect(result).toBe( + 'Skill "non-existent" not found. No skills are currently available.', + ); + }); + }); + + describe('refreshSkills', () => { + it('should refresh when change listener fires', async () => { + const newSkills: SkillConfig[] = [ + { + name: 'new-skill', + description: 'A brand new skill', + level: 'project', + filePath: '/project/.qwen/skills/new-skill/SKILL.md', + body: 'New skill content.', + }, + ]; + + vi.mocked(mockSkillManager.listSkills).mockResolvedValueOnce(newSkills); + + const listener = changeListeners[0]; + expect(listener).toBeDefined(); + + listener?.(); + await vi.runAllTimersAsync(); + + expect(skillTool.description).toContain('new-skill'); + expect(skillTool.description).toContain('A brand new skill'); + }); + + it('should refresh available skills and update description', async () => { + const newSkills: SkillConfig[] = [ + { + name: 'test-skill', + description: 'A test skill', + level: 'project', + filePath: '/project/.qwen/skills/test-skill/SKILL.md', + body: 'Test content.', + }, + ]; + + vi.mocked(mockSkillManager.listSkills).mockResolvedValue(newSkills); + + await skillTool.refreshSkills(); + + expect(skillTool.description).toContain('test-skill'); + expect(skillTool.description).toContain('A test skill'); + }); + }); + + describe('SkillToolInvocation', () => { + const mockRuntimeConfig: SkillConfig = { + ...mockSkills[0], + }; + + beforeEach(() => { + vi.mocked(mockSkillManager.loadSkillForRuntime).mockResolvedValue( + mockRuntimeConfig, + ); + }); + + it('should execute skill load successfully', async () => { + const params: SkillParams = { + skill: 'code-review', + }; + + const invocation = ( + skillTool as SkillToolWithProtectedMethods + ).createInvocation(params); + const result = await invocation.execute(); + + expect(mockSkillManager.loadSkillForRuntime).toHaveBeenCalledWith( + 'code-review', + ); + + const llmText = partToString(result.llmContent); + expect(llmText).toContain( + 'Base directory for this skill: /project/.qwen/skills/code-review', + ); + expect(llmText.trim()).toContain( + 'Review code for quality and best practices.', + ); + + expect(result.returnDisplay).toBe('Launching skill: code-review'); + }); + + it('should include allowedTools in result when present', async () => { + const skillWithTools: SkillConfig = { + ...mockSkills[1], + }; + vi.mocked(mockSkillManager.loadSkillForRuntime).mockResolvedValue( + skillWithTools, + ); + + const params: SkillParams = { + skill: 'testing', + }; + + const invocation = ( + skillTool as SkillToolWithProtectedMethods + ).createInvocation(params); + const result = await invocation.execute(); + + const llmText = partToString(result.llmContent); + expect(llmText).toContain('testing'); + // Base description is omitted from llmContent; ensure body is present. + expect(llmText).toContain('Help write comprehensive tests.'); + + expect(result.returnDisplay).toBe('Launching skill: testing'); + }); + + it('should handle skill not found error', async () => { + vi.mocked(mockSkillManager.loadSkillForRuntime).mockResolvedValue(null); + + const params: SkillParams = { + skill: 'non-existent', + }; + + const invocation = ( + skillTool as SkillToolWithProtectedMethods + ).createInvocation(params); + const result = await invocation.execute(); + + const llmText = partToString(result.llmContent); + expect(llmText).toContain('Skill "non-existent" not found'); + }); + + it('should handle execution errors gracefully', async () => { + vi.mocked(mockSkillManager.loadSkillForRuntime).mockRejectedValue( + new Error('Loading failed'), + ); + + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const params: SkillParams = { + skill: 'code-review', + }; + + const invocation = ( + skillTool as SkillToolWithProtectedMethods + ).createInvocation(params); + const result = await invocation.execute(); + + const llmText = partToString(result.llmContent); + expect(llmText).toContain('Failed to load skill'); + expect(llmText).toContain('Loading failed'); + + consoleSpy.mockRestore(); + }); + + it('should not require confirmation', async () => { + const params: SkillParams = { + skill: 'code-review', + }; + + const invocation = ( + skillTool as SkillToolWithProtectedMethods + ).createInvocation(params); + const shouldConfirm = await invocation.shouldConfirmExecute(); + + expect(shouldConfirm).toBe(false); + }); + + it('should provide correct description', () => { + const params: SkillParams = { + skill: 'code-review', + }; + + const invocation = ( + skillTool as SkillToolWithProtectedMethods + ).createInvocation(params); + const description = invocation.getDescription(); + + expect(description).toBe('Launching skill: "code-review"'); + }); + + it('should handle skill without additional files', async () => { + vi.mocked(mockSkillManager.loadSkillForRuntime).mockResolvedValue( + mockSkills[0], + ); + + const params: SkillParams = { + skill: 'code-review', + }; + + const invocation = ( + skillTool as SkillToolWithProtectedMethods + ).createInvocation(params); + const result = await invocation.execute(); + + const llmText = partToString(result.llmContent); + expect(llmText).not.toContain('## Additional Files'); + + expect(result.returnDisplay).toBe('Launching skill: code-review'); + }); + }); +}); diff --git a/packages/core/src/tools/skill.ts b/packages/core/src/tools/skill.ts new file mode 100644 index 000000000..d0a1fce69 --- /dev/null +++ b/packages/core/src/tools/skill.ts @@ -0,0 +1,264 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; +import { ToolNames, ToolDisplayNames } from './tool-names.js'; +import type { ToolResult, ToolResultDisplay } from './tools.js'; +import type { Config } from '../config/config.js'; +import type { SkillManager } from '../skills/skill-manager.js'; +import type { SkillConfig } from '../skills/types.js'; +import { logSkillLaunch, SkillLaunchEvent } from '../telemetry/index.js'; +import path from 'path'; + +export interface SkillParams { + skill: string; +} + +/** + * Skill tool that enables the model to access skill definitions. + * The tool dynamically loads available skills and includes them in its description + * for the model to choose from. + */ +export class SkillTool extends BaseDeclarativeTool { + static readonly Name: string = ToolNames.SKILL; + + private skillManager: SkillManager; + private availableSkills: SkillConfig[] = []; + + constructor(private readonly config: Config) { + // Initialize with a basic schema first + const initialSchema = { + type: 'object', + properties: { + skill: { + type: 'string', + description: 'The skill name (no arguments). E.g., "pdf" or "xlsx"', + }, + }, + required: ['skill'], + additionalProperties: false, + $schema: 'http://json-schema.org/draft-07/schema#', + }; + + super( + SkillTool.Name, + ToolDisplayNames.SKILL, + 'Execute a skill within the main conversation. Loading available skills...', // Initial description + Kind.Read, + initialSchema, + true, // isOutputMarkdown + false, // canUpdateOutput + ); + + this.skillManager = config.getSkillManager(); + this.skillManager.addChangeListener(() => { + void this.refreshSkills(); + }); + + // Initialize the tool asynchronously + this.refreshSkills(); + } + + /** + * Asynchronously initializes the tool by loading available skills + * and updating the description and schema. + */ + async refreshSkills(): Promise { + try { + this.availableSkills = await this.skillManager.listSkills(); + this.updateDescriptionAndSchema(); + } catch (error) { + console.warn('Failed to load skills for Skills tool:', error); + this.availableSkills = []; + this.updateDescriptionAndSchema(); + } finally { + // Update the client with the new tools + const geminiClient = this.config.getGeminiClient(); + if (geminiClient && geminiClient.isInitialized()) { + await geminiClient.setTools(); + } + } + } + + /** + * Updates the tool's description and schema based on available skills. + */ + private updateDescriptionAndSchema(): void { + let skillDescriptions = ''; + if (this.availableSkills.length === 0) { + skillDescriptions = + 'No skills are currently configured. Skills can be created by adding directories with SKILL.md files to .qwen/skills/ or ~/.qwen/skills/.'; + } else { + skillDescriptions = this.availableSkills + .map( + (skill) => ` + +${skill.name} + + +${skill.description} (${skill.level}) + + +${skill.level} + +`, + ) + .join('\n'); + } + + const baseDescription = `Execute a skill within the main conversation + + +When users ask you to perform tasks, check if any of the available skills below can help complete the task more effectively. Skills provide specialized capabilities and domain knowledge. + +How to invoke: +- Use this tool with the skill name only (no arguments) +- Examples: + - \`skill: "pdf"\` - invoke the pdf skill + - \`skill: "xlsx"\` - invoke the xlsx skill + - \`skill: "ms-office-suite:pdf"\` - invoke using fully qualified name + +Important: +- When a skill is relevant, you must invoke this tool IMMEDIATELY as your first action +- NEVER just announce or mention a skill in your text response without actually calling this tool +- This is a BLOCKING REQUIREMENT: invoke the relevant Skill tool BEFORE generating any other response about the task +- Only use skills listed in below +- Do not invoke a skill that is already running +- Do not use this tool for built-in CLI commands (like /help, /clear, etc.) + + + +${skillDescriptions} + +`; + // Update description using object property assignment + (this as { description: string }).description = baseDescription; + } + + override validateToolParams(params: SkillParams): string | null { + // Validate required fields + if ( + !params.skill || + typeof params.skill !== 'string' || + params.skill.trim() === '' + ) { + return 'Parameter "skill" must be a non-empty string.'; + } + + // Validate that the skill exists + const skillExists = this.availableSkills.some( + (skill) => skill.name === params.skill, + ); + + if (!skillExists) { + const availableNames = this.availableSkills.map((s) => s.name); + if (availableNames.length === 0) { + return `Skill "${params.skill}" not found. No skills are currently available.`; + } + return `Skill "${params.skill}" not found. Available skills: ${availableNames.join(', ')}`; + } + + return null; + } + + protected createInvocation(params: SkillParams) { + return new SkillToolInvocation(this.config, this.skillManager, params); + } + + getAvailableSkillNames(): string[] { + return this.availableSkills.map((skill) => skill.name); + } +} + +class SkillToolInvocation extends BaseToolInvocation { + constructor( + private readonly config: Config, + private readonly skillManager: SkillManager, + params: SkillParams, + ) { + super(params); + } + + getDescription(): string { + return `Launching skill: "${this.params.skill}"`; + } + + override async shouldConfirmExecute(): Promise { + // Skill loading is a read-only operation, no confirmation needed + return false; + } + + async execute( + _signal?: AbortSignal, + _updateOutput?: (output: ToolResultDisplay) => void, + ): Promise { + try { + // Load the skill with runtime config (includes additional files) + const skill = await this.skillManager.loadSkillForRuntime( + this.params.skill, + ); + + if (!skill) { + // Log failed skill launch + logSkillLaunch( + this.config, + new SkillLaunchEvent(this.params.skill, false), + ); + + // Get parse errors if any + const parseErrors = this.skillManager.getParseErrors(); + const errorMessages: string[] = []; + + for (const [filePath, error] of parseErrors) { + if (filePath.includes(this.params.skill)) { + errorMessages.push(`Parse error at ${filePath}: ${error.message}`); + } + } + + const errorDetail = + errorMessages.length > 0 + ? `\nErrors:\n${errorMessages.join('\n')}` + : ''; + + return { + llmContent: `Skill "${this.params.skill}" not found.${errorDetail}`, + returnDisplay: `Skill "${this.params.skill}" not found.${errorDetail}`, + }; + } + + // Log successful skill launch + logSkillLaunch( + this.config, + new SkillLaunchEvent(this.params.skill, true), + ); + + const baseDir = path.dirname(skill.filePath); + + // Build markdown content for LLM (show base dir, then body) + const llmContent = `Base directory for this skill: ${baseDir}\n\n${skill.body}\n`; + + return { + llmContent: [{ text: llmContent }], + returnDisplay: `Launching skill: ${skill.name}`, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + console.error(`[SkillsTool] Error launching skill: ${errorMessage}`); + + // Log failed skill launch + logSkillLaunch( + this.config, + new SkillLaunchEvent(this.params.skill, false), + ); + + return { + llmContent: `Failed to load skill "${this.params.skill}": ${errorMessage}`, + returnDisplay: `Failed to load skill "${this.params.skill}": ${errorMessage}`, + }; + } + } +} diff --git a/packages/core/src/tools/task.ts b/packages/core/src/tools/task.ts index 67f03f5f2..e8fd64d57 100644 --- a/packages/core/src/tools/task.ts +++ b/packages/core/src/tools/task.ts @@ -252,6 +252,10 @@ assistant: "I'm going to use the Task tool to launch the with the greeting-respo protected createInvocation(params: TaskParams) { return new TaskToolInvocation(this.config, this.subagentManager, params); } + + getAvailableSubagentNames(): string[] { + return this.availableSubagents.map((subagent) => subagent.name); + } } class TaskToolInvocation extends BaseToolInvocation { diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index 22be9c1b3..8cd1de541 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -20,6 +20,7 @@ export const ToolNames = { TODO_WRITE: 'todo_write', MEMORY: 'save_memory', TASK: 'task', + SKILL: 'skill', EXIT_PLAN_MODE: 'exit_plan_mode', WEB_FETCH: 'web_fetch', WEB_SEARCH: 'web_search', @@ -42,6 +43,7 @@ export const ToolDisplayNames = { TODO_WRITE: 'TodoWrite', MEMORY: 'SaveMemory', TASK: 'Task', + SKILL: 'Skill', EXIT_PLAN_MODE: 'ExitPlanMode', WEB_FETCH: 'WebFetch', WEB_SEARCH: 'WebSearch', diff --git a/packages/core/src/utils/paths.ts b/packages/core/src/utils/paths.ts index 0bdfaf832..a4c220107 100644 --- a/packages/core/src/utils/paths.ts +++ b/packages/core/src/utils/paths.ts @@ -120,6 +120,10 @@ export function makeRelative( const resolvedTargetPath = path.resolve(targetPath); const resolvedRootDirectory = path.resolve(rootDirectory); + if (!isSubpath(resolvedRootDirectory, resolvedTargetPath)) { + return resolvedTargetPath; + } + const relativePath = path.relative(resolvedRootDirectory, resolvedTargetPath); // If the paths are the same, path.relative returns '', return '.' instead diff --git a/packages/sdk-typescript/package.json b/packages/sdk-typescript/package.json index 31c0c9e87..0c82f138d 100644 --- a/packages/sdk-typescript/package.json +++ b/packages/sdk-typescript/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/sdk", - "version": "0.6.0", + "version": "0.1.0", "description": "TypeScript SDK for programmatic access to qwen-code CLI", "main": "./dist/index.cjs", "module": "./dist/index.mjs", diff --git a/packages/sdk-typescript/src/utils/cliPath.ts b/packages/sdk-typescript/src/utils/cliPath.ts index 4f0319634..49c145251 100644 --- a/packages/sdk-typescript/src/utils/cliPath.ts +++ b/packages/sdk-typescript/src/utils/cliPath.ts @@ -150,48 +150,49 @@ export function parseExecutableSpec(executableSpec?: string): { } // Check for runtime prefix (e.g., 'bun:/path/to/cli.js') + // Use whitelist mechanism: only treat as runtime spec if prefix matches supported runtimes + const supportedRuntimes = ['node', 'bun', 'tsx', 'deno']; const runtimeMatch = executableSpec.match(/^([^:]+):(.+)$/); + if (runtimeMatch) { const [, runtime, filePath] = runtimeMatch; - if (!runtime || !filePath) { - throw new Error(`Invalid runtime specification: '${executableSpec}'`); + + // Only process as runtime specification if it matches a supported runtime + if (runtime && supportedRuntimes.includes(runtime)) { + if (!filePath) { + throw new Error(`Invalid runtime specification: '${executableSpec}'`); + } + + if (!validateRuntimeAvailability(runtime)) { + throw new Error( + `Runtime '${runtime}' is not available on this system. Please install it first.`, + ); + } + + const resolvedPath = path.resolve(filePath); + + if (!fs.existsSync(resolvedPath)) { + throw new Error( + `Executable file not found at '${resolvedPath}' for runtime '${runtime}'. ` + + 'Please check the file path and ensure the file exists.', + ); + } + + if (!validateFileExtensionForRuntime(resolvedPath, runtime)) { + const ext = path.extname(resolvedPath); + throw new Error( + `File extension '${ext}' is not compatible with runtime '${runtime}'. ` + + `Expected extensions for ${runtime}: ${getExpectedExtensions(runtime).join(', ')}`, + ); + } + + return { + runtime, + executablePath: resolvedPath, + isExplicitRuntime: true, + }; } - - const supportedRuntimes = ['node', 'bun', 'tsx', 'deno']; - if (!supportedRuntimes.includes(runtime)) { - throw new Error( - `Unsupported runtime '${runtime}'. Supported runtimes: ${supportedRuntimes.join(', ')}`, - ); - } - - if (!validateRuntimeAvailability(runtime)) { - throw new Error( - `Runtime '${runtime}' is not available on this system. Please install it first.`, - ); - } - - const resolvedPath = path.resolve(filePath); - - if (!fs.existsSync(resolvedPath)) { - throw new Error( - `Executable file not found at '${resolvedPath}' for runtime '${runtime}'. ` + - 'Please check the file path and ensure the file exists.', - ); - } - - if (!validateFileExtensionForRuntime(resolvedPath, runtime)) { - const ext = path.extname(resolvedPath); - throw new Error( - `File extension '${ext}' is not compatible with runtime '${runtime}'. ` + - `Expected extensions for ${runtime}: ${getExpectedExtensions(runtime).join(', ')}`, - ); - } - - return { - runtime, - executablePath: resolvedPath, - isExplicitRuntime: true, - }; + // If not a supported runtime, fall through to treat as file path (e.g., Windows paths like 'D:\path\to\cli.js') } // Check if it's a command name (no path separators) or a file path diff --git a/packages/sdk-typescript/test/unit/cliPath.test.ts b/packages/sdk-typescript/test/unit/cliPath.test.ts index c4253175f..70d8cc378 100644 --- a/packages/sdk-typescript/test/unit/cliPath.test.ts +++ b/packages/sdk-typescript/test/unit/cliPath.test.ts @@ -125,12 +125,43 @@ describe('CLI Path Utilities', () => { }); }); - it('should throw for invalid runtime prefix format', () => { + it('should treat non-whitelisted runtime prefixes as command names', () => { + // With whitelist approach, 'invalid:format' is not recognized as a runtime spec + // so it's treated as a command name, which fails validation due to the colon expect(() => parseExecutableSpec('invalid:format')).toThrow( - 'Unsupported runtime', + 'Invalid command name', ); }); + it('should treat Windows drive letters as file paths, not runtime specs', () => { + mockFs.existsSync.mockReturnValue(true); + + // Test various Windows drive letters + const windowsPaths = [ + 'C:\\path\\to\\cli.js', + 'D:\\path\\to\\cli.js', + 'E:\\Users\\dev\\qwen\\cli.js', + ]; + + for (const winPath of windowsPaths) { + const result = parseExecutableSpec(winPath); + + expect(result.isExplicitRuntime).toBe(false); + expect(result.runtime).toBeUndefined(); + expect(result.executablePath).toBe(path.resolve(winPath)); + } + }); + + it('should handle Windows paths with forward slashes', () => { + mockFs.existsSync.mockReturnValue(true); + + const result = parseExecutableSpec('C:/path/to/cli.js'); + + expect(result.isExplicitRuntime).toBe(false); + expect(result.runtime).toBeUndefined(); + expect(result.executablePath).toBe(path.resolve('C:/path/to/cli.js')); + }); + it('should throw when runtime-prefixed file does not exist', () => { mockFs.existsSync.mockReturnValue(false); @@ -453,6 +484,41 @@ describe('CLI Path Utilities', () => { originalInput: `bun:${bundlePath}`, }); }); + + it('should handle Windows paths with drive letters', () => { + const windowsPath = 'D:\\path\\to\\cli.js'; + const result = prepareSpawnInfo(windowsPath); + + expect(result).toEqual({ + command: process.execPath, + args: [path.resolve(windowsPath)], + type: 'node', + originalInput: windowsPath, + }); + }); + + it('should handle Windows paths with TypeScript files', () => { + const windowsPath = 'C:\\Users\\dev\\qwen\\index.ts'; + const result = prepareSpawnInfo(windowsPath); + + expect(result).toEqual({ + command: 'tsx', + args: [path.resolve(windowsPath)], + type: 'tsx', + originalInput: windowsPath, + }); + }); + + it('should not confuse Windows drive letters with runtime prefixes', () => { + // Ensure 'D:' is not treated as a runtime specification + const windowsPath = 'D:\\workspace\\project\\cli.js'; + const result = prepareSpawnInfo(windowsPath); + + // Should use node runtime based on .js extension, not treat 'D' as runtime + expect(result.type).toBe('node'); + expect(result.command).toBe(process.execPath); + expect(result.args).toEqual([path.resolve(windowsPath)]); + }); }); describe('error cases', () => { @@ -472,21 +538,39 @@ describe('CLI Path Utilities', () => { ); }); - it('should provide helpful error for invalid runtime specification', () => { + it('should treat non-whitelisted runtime prefixes as command names', () => { + // With whitelist approach, 'invalid:spec' is not recognized as a runtime spec + // so it's treated as a command name, which fails validation due to the colon expect(() => prepareSpawnInfo('invalid:spec')).toThrow( - 'Unsupported runtime', + 'Invalid command name', + ); + }); + + it('should handle Windows paths correctly even when file is missing', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => prepareSpawnInfo('D:\\missing\\cli.js')).toThrow( + 'Executable file not found at', + ); + // Should not throw 'Invalid command name' error (which would happen if 'D:' was treated as invalid command) + expect(() => prepareSpawnInfo('D:\\missing\\cli.js')).not.toThrow( + 'Invalid command name', ); }); }); describe('comprehensive validation', () => { describe('runtime validation', () => { - it('should reject unsupported runtimes', () => { - expect(() => - parseExecutableSpec('unsupported:/path/to/file.js'), - ).toThrow( - "Unsupported runtime 'unsupported'. Supported runtimes: node, bun, tsx, deno", - ); + it('should treat unsupported runtime prefixes as file paths', () => { + mockFs.existsSync.mockReturnValue(true); + + // With whitelist approach, 'unsupported:' is not recognized as a runtime spec + // so 'unsupported:/path/to/file.js' is treated as a file path + const result = parseExecutableSpec('unsupported:/path/to/file.js'); + + // Should be treated as a file path, not a runtime specification + expect(result.isExplicitRuntime).toBe(false); + expect(result.runtime).toBeUndefined(); }); it('should validate runtime availability for explicit runtime specs', () => {