mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-21 16:26:20 +00:00
Compare commits
21 Commits
feat/cli-w
...
feat/multi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ec3ec2c38 | ||
|
|
ae9ba8be18 | ||
|
|
ed12c50693 | ||
|
|
1f5206cd54 | ||
|
|
19bbd22109 | ||
|
|
3ece0e3c3c | ||
|
|
fb3a95e874 | ||
|
|
b9a0d904de | ||
|
|
7d9917b2c9 | ||
|
|
7dd56d0861 | ||
|
|
6eb16c0bcf | ||
|
|
7fa1dcb0e6 | ||
|
|
3c68a9a5f6 | ||
|
|
bdfeec24fb | ||
|
|
03f12bfa3f | ||
|
|
c14ddab6fe | ||
|
|
35c865968f | ||
|
|
fa8b5a7762 | ||
|
|
9adad2f369 | ||
|
|
a8ccd7b6fb | ||
|
|
5d20848577 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -12,7 +12,7 @@
|
||||
!.gemini/config.yaml
|
||||
!.gemini/commands/
|
||||
|
||||
# Note: .gemini-clipboard/ is NOT in gitignore so Gemini can access pasted images
|
||||
# Note: .qwen-clipboard/ is NOT in gitignore so Gemini can access pasted images
|
||||
|
||||
# Dependency directory
|
||||
node_modules
|
||||
|
||||
@@ -202,7 +202,7 @@ This is the most critical stage where files are moved and transformed into their
|
||||
- Copies README.md and LICENSE to dist/
|
||||
- Copies locales folder for internationalization
|
||||
- Creates a clean package.json for distribution with only necessary dependencies
|
||||
- Includes runtime dependencies like tiktoken
|
||||
- Keeps distribution dependencies minimal (no bundled runtime deps)
|
||||
- Maintains optional dependencies for node-pty
|
||||
|
||||
2. The JavaScript Bundle is Created:
|
||||
|
||||
@@ -33,7 +33,6 @@ const external = [
|
||||
'@lydell/node-pty-linux-x64',
|
||||
'@lydell/node-pty-win32-arm64',
|
||||
'@lydell/node-pty-win32-x64',
|
||||
'tiktoken',
|
||||
];
|
||||
|
||||
esbuild
|
||||
|
||||
20
package-lock.json
generated
20
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.7.1",
|
||||
"version": "0.8.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.7.1",
|
||||
"version": "0.8.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -15682,12 +15682,6 @@
|
||||
"tslib": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/tiktoken": {
|
||||
"version": "1.0.22",
|
||||
"resolved": "https://registry.npmjs.org/tiktoken/-/tiktoken-1.0.22.tgz",
|
||||
"integrity": "sha512-PKvy1rVF1RibfF3JlXBSP0Jrcw2uq3yXdgcEXtKTYn3QJ/cBRBHDnrJ5jHky+MENZ6DIPwNUGWpkVx+7joCpNA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -17310,7 +17304,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.7.1",
|
||||
"version": "0.8.0",
|
||||
"dependencies": {
|
||||
"@google/genai": "1.30.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
@@ -17947,7 +17941,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.7.1",
|
||||
"version": "0.8.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.36.1",
|
||||
@@ -17990,7 +17984,6 @@
|
||||
"shell-quote": "^1.8.3",
|
||||
"simple-git": "^3.28.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"tiktoken": "^1.0.21",
|
||||
"undici": "^6.22.0",
|
||||
"uuid": "^9.0.1",
|
||||
"ws": "^8.18.0"
|
||||
@@ -18592,7 +18585,6 @@
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"tiktoken": "^1.0.21",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -21408,7 +21400,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@qwen-code/qwen-code-test-utils",
|
||||
"version": "0.7.1",
|
||||
"version": "0.8.0",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
@@ -21420,7 +21412,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "qwen-code-vscode-ide-companion",
|
||||
"version": "0.7.1",
|
||||
"version": "0.8.0",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.7.1",
|
||||
"version": "0.8.0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -13,7 +13,7 @@
|
||||
"url": "git+https://github.com/QwenLM/qwen-code.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.1"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.8.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.7.1",
|
||||
"version": "0.8.0",
|
||||
"description": "Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -33,19 +33,20 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.1"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.8.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "1.30.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@qwen-code/qwen-code-core": "file:../core",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@qwen-code/qwen-code-core": "file:../core",
|
||||
"@types/update-notifier": "^6.0.8",
|
||||
"ansi-regex": "^6.2.2",
|
||||
"command-exists": "^1.2.9",
|
||||
"comment-json": "^4.2.5",
|
||||
"diff": "^7.0.0",
|
||||
"dotenv": "^17.1.0",
|
||||
"extract-zip": "^2.0.1",
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^10.5.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
@@ -65,7 +66,6 @@
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"tar": "^7.5.2",
|
||||
"undici": "^6.22.0",
|
||||
"extract-zip": "^2.0.1",
|
||||
"update-notifier": "^7.3.1",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"yargs": "^17.7.2",
|
||||
@@ -74,6 +74,7 @@
|
||||
"devDependencies": {
|
||||
"@babel/runtime": "^7.27.6",
|
||||
"@google/gemini-cli-test-utils": "file:../test-utils",
|
||||
"@qwen-code/qwen-code-test-utils": "file:../test-utils",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/archiver": "^6.0.3",
|
||||
"@types/command-exists": "^1.2.3",
|
||||
@@ -92,8 +93,7 @@
|
||||
"pretty-format": "^30.0.2",
|
||||
"react-dom": "^19.1.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^3.1.1",
|
||||
"@qwen-code/qwen-code-test-utils": "file:../test-utils"
|
||||
"vitest": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { z } from 'zod';
|
||||
import * as schema from './schema.js';
|
||||
import { ACP_ERROR_CODES } from './errorCodes.js';
|
||||
export * from './schema.js';
|
||||
|
||||
import type { WritableStream, ReadableStream } from 'node:stream/web';
|
||||
@@ -349,27 +350,51 @@ export class RequestError extends Error {
|
||||
}
|
||||
|
||||
static parseError(details?: string): RequestError {
|
||||
return new RequestError(-32700, 'Parse error', details);
|
||||
return new RequestError(
|
||||
ACP_ERROR_CODES.PARSE_ERROR,
|
||||
'Parse error',
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
static invalidRequest(details?: string): RequestError {
|
||||
return new RequestError(-32600, 'Invalid request', details);
|
||||
return new RequestError(
|
||||
ACP_ERROR_CODES.INVALID_REQUEST,
|
||||
'Invalid request',
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
static methodNotFound(details?: string): RequestError {
|
||||
return new RequestError(-32601, 'Method not found', details);
|
||||
return new RequestError(
|
||||
ACP_ERROR_CODES.METHOD_NOT_FOUND,
|
||||
'Method not found',
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
static invalidParams(details?: string): RequestError {
|
||||
return new RequestError(-32602, 'Invalid params', details);
|
||||
return new RequestError(
|
||||
ACP_ERROR_CODES.INVALID_PARAMS,
|
||||
'Invalid params',
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
static internalError(details?: string): RequestError {
|
||||
return new RequestError(-32603, 'Internal error', details);
|
||||
return new RequestError(
|
||||
ACP_ERROR_CODES.INTERNAL_ERROR,
|
||||
'Internal error',
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
static authRequired(details?: string): RequestError {
|
||||
return new RequestError(-32000, 'Authentication required', details);
|
||||
return new RequestError(
|
||||
ACP_ERROR_CODES.AUTH_REQUIRED,
|
||||
'Authentication required',
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
toResult<T>(): Result<T> {
|
||||
|
||||
25
packages/cli/src/acp-integration/errorCodes.ts
Normal file
25
packages/cli/src/acp-integration/errorCodes.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export const ACP_ERROR_CODES = {
|
||||
// Parse error: invalid JSON received by server.
|
||||
PARSE_ERROR: -32700,
|
||||
// Invalid request: JSON is not a valid Request object.
|
||||
INVALID_REQUEST: -32600,
|
||||
// Method not found: method does not exist or is unavailable.
|
||||
METHOD_NOT_FOUND: -32601,
|
||||
// Invalid params: invalid method parameter(s).
|
||||
INVALID_PARAMS: -32602,
|
||||
// Internal error: implementation-defined server error.
|
||||
INTERNAL_ERROR: -32603,
|
||||
// Authentication required: must authenticate before operation.
|
||||
AUTH_REQUIRED: -32000,
|
||||
// Resource not found: e.g. missing file.
|
||||
RESOURCE_NOT_FOUND: -32002,
|
||||
} as const;
|
||||
|
||||
export type AcpErrorCode =
|
||||
(typeof ACP_ERROR_CODES)[keyof typeof ACP_ERROR_CODES];
|
||||
@@ -7,6 +7,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { FileSystemService } from '@qwen-code/qwen-code-core';
|
||||
import { AcpFileSystemService } from './filesystem.js';
|
||||
import { ACP_ERROR_CODES } from '../errorCodes.js';
|
||||
|
||||
const createFallback = (): FileSystemService => ({
|
||||
readTextFile: vi.fn(),
|
||||
@@ -16,11 +17,13 @@ const createFallback = (): FileSystemService => ({
|
||||
|
||||
describe('AcpFileSystemService', () => {
|
||||
describe('readTextFile ENOENT handling', () => {
|
||||
it('parses path from ACP ENOENT message (quoted)', async () => {
|
||||
it('converts RESOURCE_NOT_FOUND error to ENOENT', async () => {
|
||||
const resourceNotFoundError = {
|
||||
code: ACP_ERROR_CODES.RESOURCE_NOT_FOUND,
|
||||
message: 'File not found',
|
||||
};
|
||||
const client = {
|
||||
readTextFile: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ content: 'ERROR: ENOENT: "/remote/file.txt"' }),
|
||||
readTextFile: vi.fn().mockRejectedValue(resourceNotFoundError),
|
||||
} as unknown as import('../acp.js').Client;
|
||||
|
||||
const svc = new AcpFileSystemService(
|
||||
@@ -30,15 +33,20 @@ describe('AcpFileSystemService', () => {
|
||||
createFallback(),
|
||||
);
|
||||
|
||||
await expect(svc.readTextFile('/local/file.txt')).rejects.toMatchObject({
|
||||
await expect(svc.readTextFile('/some/file.txt')).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
path: '/remote/file.txt',
|
||||
errno: -2,
|
||||
path: '/some/file.txt',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to requested path when none provided', async () => {
|
||||
it('re-throws other errors unchanged', async () => {
|
||||
const otherError = {
|
||||
code: ACP_ERROR_CODES.INTERNAL_ERROR,
|
||||
message: 'Internal error',
|
||||
};
|
||||
const client = {
|
||||
readTextFile: vi.fn().mockResolvedValue({ content: 'ERROR: ENOENT:' }),
|
||||
readTextFile: vi.fn().mockRejectedValue(otherError),
|
||||
} as unknown as import('../acp.js').Client;
|
||||
|
||||
const svc = new AcpFileSystemService(
|
||||
@@ -48,12 +56,34 @@ describe('AcpFileSystemService', () => {
|
||||
createFallback(),
|
||||
);
|
||||
|
||||
await expect(
|
||||
svc.readTextFile('/fallback/path.txt'),
|
||||
).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
path: '/fallback/path.txt',
|
||||
await expect(svc.readTextFile('/some/file.txt')).rejects.toMatchObject({
|
||||
code: ACP_ERROR_CODES.INTERNAL_ERROR,
|
||||
message: 'Internal error',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses fallback when readTextFile capability is disabled', async () => {
|
||||
const client = {
|
||||
readTextFile: vi.fn(),
|
||||
} as unknown as import('../acp.js').Client;
|
||||
|
||||
const fallback = createFallback();
|
||||
(fallback.readTextFile as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
'fallback content',
|
||||
);
|
||||
|
||||
const svc = new AcpFileSystemService(
|
||||
client,
|
||||
'session-3',
|
||||
{ readTextFile: false, writeTextFile: true },
|
||||
fallback,
|
||||
);
|
||||
|
||||
const result = await svc.readTextFile('/some/file.txt');
|
||||
|
||||
expect(result).toBe('fallback content');
|
||||
expect(fallback.readTextFile).toHaveBeenCalledWith('/some/file.txt');
|
||||
expect(client.readTextFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import type { FileSystemService } from '@qwen-code/qwen-code-core';
|
||||
import type * as acp from '../acp.js';
|
||||
import { ACP_ERROR_CODES } from '../errorCodes.js';
|
||||
|
||||
/**
|
||||
* ACP client-based implementation of FileSystemService
|
||||
@@ -23,25 +24,31 @@ export class AcpFileSystemService implements FileSystemService {
|
||||
return this.fallback.readTextFile(filePath);
|
||||
}
|
||||
|
||||
const response = await this.client.readTextFile({
|
||||
path: filePath,
|
||||
sessionId: this.sessionId,
|
||||
line: null,
|
||||
limit: null,
|
||||
});
|
||||
let response: { content: string };
|
||||
try {
|
||||
response = await this.client.readTextFile({
|
||||
path: filePath,
|
||||
sessionId: this.sessionId,
|
||||
line: null,
|
||||
limit: null,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorCode =
|
||||
typeof error === 'object' && error !== null && 'code' in error
|
||||
? (error as { code?: unknown }).code
|
||||
: undefined;
|
||||
|
||||
if (response.content.startsWith('ERROR: ENOENT:')) {
|
||||
// Treat ACP error strings as structured ENOENT errors without
|
||||
// assuming a specific platform format.
|
||||
const match = /^ERROR:\s*ENOENT:\s*(?<path>.*)$/i.exec(response.content);
|
||||
const err = new Error(response.content) as NodeJS.ErrnoException;
|
||||
err.code = 'ENOENT';
|
||||
err.errno = -2;
|
||||
const rawPath = match?.groups?.['path']?.trim();
|
||||
err['path'] = rawPath
|
||||
? rawPath.replace(/^['"]|['"]$/g, '') || filePath
|
||||
: filePath;
|
||||
throw err;
|
||||
if (errorCode === ACP_ERROR_CODES.RESOURCE_NOT_FOUND) {
|
||||
const err = new Error(
|
||||
`File not found: ${filePath}`,
|
||||
) as NodeJS.ErrnoException;
|
||||
err.code = 'ENOENT';
|
||||
err.errno = -2;
|
||||
err.path = filePath;
|
||||
throw err;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response.content;
|
||||
|
||||
@@ -38,6 +38,7 @@ export const ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE = [
|
||||
'init',
|
||||
'summary',
|
||||
'compress',
|
||||
'bug',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -38,6 +38,12 @@ export const Colors: ColorsTheme = {
|
||||
get AccentRed() {
|
||||
return themeManager.getActiveTheme().colors.AccentRed;
|
||||
},
|
||||
get AccentYellowDim() {
|
||||
return themeManager.getActiveTheme().colors.AccentYellowDim;
|
||||
},
|
||||
get AccentRedDim() {
|
||||
return themeManager.getActiveTheme().colors.AccentRedDim;
|
||||
},
|
||||
get DiffAdded() {
|
||||
return themeManager.getActiveTheme().colors.DiffAdded;
|
||||
},
|
||||
|
||||
@@ -376,7 +376,7 @@ describe('InputPrompt', () => {
|
||||
it('should handle Ctrl+V when clipboard has an image', async () => {
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
|
||||
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(
|
||||
'/test/.gemini-clipboard/clipboard-123.png',
|
||||
'/test/.qwen-clipboard/clipboard-123.png',
|
||||
);
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
@@ -436,7 +436,7 @@ describe('InputPrompt', () => {
|
||||
it('should insert image path at cursor position with proper spacing', async () => {
|
||||
const imagePath = path.join(
|
||||
'test',
|
||||
'.gemini-clipboard',
|
||||
'.qwen-clipboard',
|
||||
'clipboard-456.png',
|
||||
);
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
|
||||
|
||||
@@ -749,10 +749,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
statusColor = theme.ui.symbol;
|
||||
statusText = t('Shell mode');
|
||||
} else if (showYoloStyling) {
|
||||
statusColor = theme.status.error;
|
||||
statusColor = theme.status.errorDim;
|
||||
statusText = t('YOLO mode');
|
||||
} else if (showAutoAcceptStyling) {
|
||||
statusColor = theme.status.warning;
|
||||
statusColor = theme.status.warningDim;
|
||||
statusText = t('Accepting edits');
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ const ansiLightColors: ColorsTheme = {
|
||||
AccentGreen: 'green',
|
||||
AccentYellow: 'orange',
|
||||
AccentRed: 'red',
|
||||
AccentYellowDim: 'orange',
|
||||
AccentRedDim: 'red',
|
||||
DiffAdded: '#E5F2E5',
|
||||
DiffRemoved: '#FFE5E5',
|
||||
Comment: 'gray',
|
||||
|
||||
@@ -18,6 +18,8 @@ const ansiColors: ColorsTheme = {
|
||||
AccentGreen: 'green',
|
||||
AccentYellow: 'yellow',
|
||||
AccentRed: 'red',
|
||||
AccentYellowDim: 'yellow',
|
||||
AccentRedDim: 'red',
|
||||
DiffAdded: '#003300',
|
||||
DiffRemoved: '#4D0000',
|
||||
Comment: 'gray',
|
||||
|
||||
@@ -17,6 +17,8 @@ const atomOneDarkColors: ColorsTheme = {
|
||||
AccentGreen: '#98c379',
|
||||
AccentYellow: '#e6c07b',
|
||||
AccentRed: '#e06c75',
|
||||
AccentYellowDim: '#8B7530',
|
||||
AccentRedDim: '#8B3A4A',
|
||||
DiffAdded: '#39544E',
|
||||
DiffRemoved: '#562B2F',
|
||||
Comment: '#5c6370',
|
||||
|
||||
@@ -17,6 +17,8 @@ const ayuLightColors: ColorsTheme = {
|
||||
AccentGreen: '#86b300',
|
||||
AccentYellow: '#f2ae49',
|
||||
AccentRed: '#f07171',
|
||||
AccentYellowDim: '#8B7000',
|
||||
AccentRedDim: '#993333',
|
||||
DiffAdded: '#C6EAD8',
|
||||
DiffRemoved: '#FFCCCC',
|
||||
Comment: '#ABADB1',
|
||||
|
||||
@@ -17,6 +17,8 @@ const ayuDarkColors: ColorsTheme = {
|
||||
AccentGreen: '#AAD94C',
|
||||
AccentYellow: '#FFB454',
|
||||
AccentRed: '#F26D78',
|
||||
AccentYellowDim: '#8B7530',
|
||||
AccentRedDim: '#8B3A4A',
|
||||
DiffAdded: '#293022',
|
||||
DiffRemoved: '#3D1215',
|
||||
Comment: '#646A71',
|
||||
|
||||
@@ -17,6 +17,8 @@ const draculaColors: ColorsTheme = {
|
||||
AccentGreen: '#50fa7b',
|
||||
AccentYellow: '#fff783',
|
||||
AccentRed: '#ff5555',
|
||||
AccentYellowDim: '#8B7530',
|
||||
AccentRedDim: '#8B3A4A',
|
||||
DiffAdded: '#11431d',
|
||||
DiffRemoved: '#6e1818',
|
||||
Comment: '#6272a4',
|
||||
|
||||
@@ -17,6 +17,8 @@ const githubDarkColors: ColorsTheme = {
|
||||
AccentGreen: '#85E89D',
|
||||
AccentYellow: '#FFAB70',
|
||||
AccentRed: '#F97583',
|
||||
AccentYellowDim: '#8B7530',
|
||||
AccentRedDim: '#8B3A4A',
|
||||
DiffAdded: '#3C4636',
|
||||
DiffRemoved: '#502125',
|
||||
Comment: '#6A737D',
|
||||
|
||||
@@ -17,6 +17,8 @@ const githubLightColors: ColorsTheme = {
|
||||
AccentGreen: '#008080',
|
||||
AccentYellow: '#990073',
|
||||
AccentRed: '#d14',
|
||||
AccentYellowDim: '#8B7000',
|
||||
AccentRedDim: '#993333',
|
||||
DiffAdded: '#C6EAD8',
|
||||
DiffRemoved: '#FFCCCC',
|
||||
Comment: '#998',
|
||||
|
||||
@@ -17,6 +17,8 @@ const googleCodeColors: ColorsTheme = {
|
||||
AccentGreen: '#080',
|
||||
AccentYellow: '#660',
|
||||
AccentRed: '#800',
|
||||
AccentYellowDim: '#8B7000',
|
||||
AccentRedDim: '#993333',
|
||||
DiffAdded: '#C6EAD8',
|
||||
DiffRemoved: '#FEDEDE',
|
||||
Comment: '#5f6368',
|
||||
|
||||
@@ -19,6 +19,8 @@ const noColorColorsTheme: ColorsTheme = {
|
||||
AccentGreen: '',
|
||||
AccentYellow: '',
|
||||
AccentRed: '',
|
||||
AccentYellowDim: '',
|
||||
AccentRedDim: '',
|
||||
DiffAdded: '',
|
||||
DiffRemoved: '',
|
||||
Comment: '',
|
||||
@@ -52,6 +54,8 @@ const noColorSemanticColors: SemanticColors = {
|
||||
error: '',
|
||||
success: '',
|
||||
warning: '',
|
||||
errorDim: '',
|
||||
warningDim: '',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ const qwenDarkColors: ColorsTheme = {
|
||||
AccentGreen: '#AAD94C',
|
||||
AccentYellow: '#FFD700',
|
||||
AccentRed: '#F26D78',
|
||||
AccentYellowDim: '#8B7530',
|
||||
AccentRedDim: '#8B3A4A',
|
||||
DiffAdded: '#AAD94C',
|
||||
DiffRemoved: '#F26D78',
|
||||
Comment: '#646A71',
|
||||
|
||||
@@ -18,6 +18,8 @@ const qwenLightColors: ColorsTheme = {
|
||||
AccentGreen: '#86b300',
|
||||
AccentYellow: '#f2ae49',
|
||||
AccentRed: '#f07171',
|
||||
AccentYellowDim: '#8B7000',
|
||||
AccentRedDim: '#993333',
|
||||
DiffAdded: '#86b300',
|
||||
DiffRemoved: '#f07171',
|
||||
Comment: '#ABADB1',
|
||||
|
||||
@@ -33,6 +33,9 @@ export interface SemanticColors {
|
||||
error: string;
|
||||
success: string;
|
||||
warning: string;
|
||||
// Dim variants for less intense UI elements
|
||||
errorDim: string;
|
||||
warningDim: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,6 +66,8 @@ export const lightSemanticColors: SemanticColors = {
|
||||
error: lightTheme.AccentRed,
|
||||
success: lightTheme.AccentGreen,
|
||||
warning: lightTheme.AccentYellow,
|
||||
errorDim: lightTheme.AccentRedDim,
|
||||
warningDim: lightTheme.AccentYellowDim,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -93,6 +98,8 @@ export const darkSemanticColors: SemanticColors = {
|
||||
error: darkTheme.AccentRed,
|
||||
success: darkTheme.AccentGreen,
|
||||
warning: darkTheme.AccentYellow,
|
||||
errorDim: darkTheme.AccentRedDim,
|
||||
warningDim: darkTheme.AccentYellowDim,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -123,5 +130,7 @@ export const ansiSemanticColors: SemanticColors = {
|
||||
error: ansiTheme.AccentRed,
|
||||
success: ansiTheme.AccentGreen,
|
||||
warning: ansiTheme.AccentYellow,
|
||||
errorDim: ansiTheme.AccentRedDim,
|
||||
warningDim: ansiTheme.AccentYellowDim,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -22,6 +22,8 @@ const shadesOfPurpleColors: ColorsTheme = {
|
||||
AccentGreen: '#A5FF90', // Strings and many others
|
||||
AccentYellow: '#fad000', // Title, main yellow
|
||||
AccentRed: '#ff628c', // Error/deletion accent
|
||||
AccentYellowDim: '#8B7530',
|
||||
AccentRedDim: '#8B3A4A',
|
||||
DiffAdded: '#383E45',
|
||||
DiffRemoved: '#572244',
|
||||
Comment: '#B362FF', // Comment color (same as AccentPurple)
|
||||
|
||||
@@ -21,6 +21,9 @@ export interface ColorsTheme {
|
||||
AccentGreen: string;
|
||||
AccentYellow: string;
|
||||
AccentRed: string;
|
||||
// Dim variants for less intense UI elements
|
||||
AccentYellowDim: string;
|
||||
AccentRedDim: string;
|
||||
DiffAdded: string;
|
||||
DiffRemoved: string;
|
||||
Comment: string;
|
||||
@@ -58,6 +61,8 @@ export interface CustomTheme {
|
||||
error?: string;
|
||||
success?: string;
|
||||
warning?: string;
|
||||
errorDim?: string;
|
||||
warningDim?: string;
|
||||
};
|
||||
|
||||
// Legacy properties (all optional)
|
||||
@@ -70,6 +75,8 @@ export interface CustomTheme {
|
||||
AccentGreen?: string;
|
||||
AccentYellow?: string;
|
||||
AccentRed?: string;
|
||||
AccentYellowDim?: string;
|
||||
AccentRedDim?: string;
|
||||
DiffAdded?: string;
|
||||
DiffRemoved?: string;
|
||||
Comment?: string;
|
||||
@@ -88,6 +95,8 @@ export const lightTheme: ColorsTheme = {
|
||||
AccentGreen: '#3CA84B',
|
||||
AccentYellow: '#D5A40A',
|
||||
AccentRed: '#DD4C4C',
|
||||
AccentYellowDim: '#8B7000',
|
||||
AccentRedDim: '#993333',
|
||||
DiffAdded: '#C6EAD8',
|
||||
DiffRemoved: '#FFCCCC',
|
||||
Comment: '#008000',
|
||||
@@ -106,6 +115,8 @@ export const darkTheme: ColorsTheme = {
|
||||
AccentGreen: '#A6E3A1',
|
||||
AccentYellow: '#F9E2AF',
|
||||
AccentRed: '#F38BA8',
|
||||
AccentYellowDim: '#8B7530',
|
||||
AccentRedDim: '#8B3A4A',
|
||||
DiffAdded: '#28350B',
|
||||
DiffRemoved: '#430000',
|
||||
Comment: '#6C7086',
|
||||
@@ -124,6 +135,8 @@ export const ansiTheme: ColorsTheme = {
|
||||
AccentGreen: 'green',
|
||||
AccentYellow: 'yellow',
|
||||
AccentRed: 'red',
|
||||
AccentYellowDim: 'yellow',
|
||||
AccentRedDim: 'red',
|
||||
DiffAdded: 'green',
|
||||
DiffRemoved: 'red',
|
||||
Comment: 'gray',
|
||||
@@ -182,6 +195,8 @@ export class Theme {
|
||||
error: this.colors.AccentRed,
|
||||
success: this.colors.AccentGreen,
|
||||
warning: this.colors.AccentYellow,
|
||||
errorDim: this.colors.AccentRedDim,
|
||||
warningDim: this.colors.AccentYellowDim,
|
||||
},
|
||||
};
|
||||
this._colorMap = Object.freeze(this._buildColorMap(rawMappings)); // Build and freeze the map
|
||||
@@ -261,6 +276,10 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
|
||||
AccentGreen: customTheme.status?.success ?? customTheme.AccentGreen ?? '',
|
||||
AccentYellow: customTheme.status?.warning ?? customTheme.AccentYellow ?? '',
|
||||
AccentRed: customTheme.status?.error ?? customTheme.AccentRed ?? '',
|
||||
AccentYellowDim:
|
||||
customTheme.status?.warningDim ?? customTheme.AccentYellowDim ?? '',
|
||||
AccentRedDim:
|
||||
customTheme.status?.errorDim ?? customTheme.AccentRedDim ?? '',
|
||||
DiffAdded:
|
||||
customTheme.background?.diff?.added ?? customTheme.DiffAdded ?? '',
|
||||
DiffRemoved:
|
||||
@@ -435,6 +454,8 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
|
||||
error: customTheme.status?.error ?? colors.AccentRed,
|
||||
success: customTheme.status?.success ?? colors.AccentGreen,
|
||||
warning: customTheme.status?.warning ?? colors.AccentYellow,
|
||||
errorDim: customTheme.status?.errorDim ?? colors.AccentRedDim,
|
||||
warningDim: customTheme.status?.warningDim ?? colors.AccentYellowDim,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ const xcodeColors: ColorsTheme = {
|
||||
AccentGreen: '#007400',
|
||||
AccentYellow: '#836C28',
|
||||
AccentRed: '#c41a16',
|
||||
AccentYellowDim: '#8B7000',
|
||||
AccentRedDim: '#993333',
|
||||
DiffAdded: '#C6EAD8',
|
||||
DiffRemoved: '#FEDEDE',
|
||||
Comment: '#007400',
|
||||
|
||||
@@ -52,7 +52,7 @@ export async function saveClipboardImage(
|
||||
// Create a temporary directory for clipboard images within the target directory
|
||||
// This avoids security restrictions on paths outside the target directory
|
||||
const baseDir = targetDir || process.cwd();
|
||||
const tempDir = path.join(baseDir, '.gemini-clipboard');
|
||||
const tempDir = path.join(baseDir, '.qwen-clipboard');
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
|
||||
// Generate a unique filename with timestamp
|
||||
@@ -130,7 +130,7 @@ export async function cleanupOldClipboardImages(
|
||||
): Promise<void> {
|
||||
try {
|
||||
const baseDir = targetDir || process.cwd();
|
||||
const tempDir = path.join(baseDir, '.gemini-clipboard');
|
||||
const tempDir = path.join(baseDir, '.qwen-clipboard');
|
||||
const files = await fs.readdir(tempDir);
|
||||
const oneHourAgo = Date.now() - 60 * 60 * 1000;
|
||||
|
||||
|
||||
@@ -241,9 +241,12 @@ describe('handleAutoUpdate', () => {
|
||||
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
'npm i -g @qwen-code/qwen-code@nightly',
|
||||
expect.stringMatching(/^(bash|cmd\.exe)$/),
|
||||
expect.arrayContaining([
|
||||
expect.stringMatching(/^(-c|\/c)$/),
|
||||
'npm i -g @qwen-code/qwen-code@nightly',
|
||||
]),
|
||||
{
|
||||
shell: true,
|
||||
stdio: 'pipe',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { HistoryItem } from '../ui/types.js';
|
||||
import { MessageType } from '../ui/types.js';
|
||||
import { spawnWrapper } from './spawnWrapper.js';
|
||||
import type { spawn } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
|
||||
export function handleAutoUpdate(
|
||||
info: UpdateObject | null,
|
||||
@@ -53,7 +54,10 @@ export function handleAutoUpdate(
|
||||
'@latest',
|
||||
isNightly ? '@nightly' : `@${info.update.latest}`,
|
||||
);
|
||||
const updateProcess = spawnFn(updateCommand, { stdio: 'pipe', shell: true });
|
||||
const isWindows = os.platform() === 'win32';
|
||||
const shell = isWindows ? 'cmd.exe' : 'bash';
|
||||
const shellArgs = isWindows ? ['/c', updateCommand] : ['-c', updateCommand];
|
||||
const updateProcess = spawnFn(shell, shellArgs, { stdio: 'pipe' });
|
||||
let errorOutput = '';
|
||||
updateProcess.stderr.on('data', (data) => {
|
||||
errorOutput += data.toString();
|
||||
|
||||
@@ -291,9 +291,10 @@ export async function start_sandbox(
|
||||
sandboxEnv['NO_PROXY'] = noProxy;
|
||||
sandboxEnv['no_proxy'] = noProxy;
|
||||
}
|
||||
proxyProcess = spawn(proxyCommand, {
|
||||
// Note: CodeQL flags this as js/shell-command-injection-from-environment.
|
||||
// This is intentional - CLI tool executes user-provided proxy commands.
|
||||
proxyProcess = spawn('bash', ['-c', proxyCommand], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
shell: true,
|
||||
detached: true,
|
||||
});
|
||||
// install handlers to stop proxy on exit/signal
|
||||
@@ -781,9 +782,15 @@ export async function start_sandbox(
|
||||
if (proxyCommand) {
|
||||
// run proxyCommand in its own container
|
||||
const proxyContainerCommand = `${config.command} run --rm --init ${userFlag} --name ${SANDBOX_PROXY_NAME} --network ${SANDBOX_PROXY_NAME} -p 8877:8877 -v ${process.cwd()}:${workdir} --workdir ${workdir} ${image} ${proxyCommand}`;
|
||||
proxyProcess = spawn(proxyContainerCommand, {
|
||||
const isWindows = os.platform() === 'win32';
|
||||
const proxyShell = isWindows ? 'cmd.exe' : 'bash';
|
||||
const proxyShellArgs = isWindows
|
||||
? ['/c', proxyContainerCommand]
|
||||
: ['-c', proxyContainerCommand];
|
||||
// Note: CodeQL flags this as js/shell-command-injection-from-environment.
|
||||
// This is intentional - CLI tool executes user-provided proxy commands in container.
|
||||
proxyProcess = spawn(proxyShell, proxyShellArgs, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
shell: true,
|
||||
detached: true,
|
||||
});
|
||||
// install handlers to stop proxy on exit/signal
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.7.1",
|
||||
"version": "0.8.0",
|
||||
"description": "Qwen Code Core",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -63,7 +63,6 @@
|
||||
"shell-quote": "^1.8.3",
|
||||
"simple-git": "^3.28.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"tiktoken": "^1.0.21",
|
||||
"undici": "^6.22.0",
|
||||
"uuid": "^9.0.1",
|
||||
"ws": "^8.18.0"
|
||||
|
||||
@@ -19,9 +19,7 @@ const mockTokenizer = {
|
||||
};
|
||||
|
||||
vi.mock('../../utils/request-tokenizer/index.js', () => ({
|
||||
getDefaultTokenizer: vi.fn(() => mockTokenizer),
|
||||
DefaultRequestTokenizer: vi.fn(() => mockTokenizer),
|
||||
disposeDefaultTokenizer: vi.fn(),
|
||||
RequestTokenEstimator: vi.fn(() => mockTokenizer),
|
||||
}));
|
||||
|
||||
type AnthropicCreateArgs = [unknown, { signal?: AbortSignal }?];
|
||||
@@ -352,9 +350,7 @@ describe('AnthropicContentGenerator', () => {
|
||||
};
|
||||
|
||||
const result = await generator.countTokens(request);
|
||||
expect(mockTokenizer.calculateTokens).toHaveBeenCalledWith(request, {
|
||||
textEncoding: 'cl100k_base',
|
||||
});
|
||||
expect(mockTokenizer.calculateTokens).toHaveBeenCalledWith(request);
|
||||
expect(result.totalTokens).toBe(50);
|
||||
});
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ type MessageCreateParamsNonStreaming =
|
||||
Anthropic.MessageCreateParamsNonStreaming;
|
||||
type MessageCreateParamsStreaming = Anthropic.MessageCreateParamsStreaming;
|
||||
type RawMessageStreamEvent = Anthropic.RawMessageStreamEvent;
|
||||
import { getDefaultTokenizer } from '../../utils/request-tokenizer/index.js';
|
||||
import { RequestTokenEstimator } from '../../utils/request-tokenizer/index.js';
|
||||
import { safeJsonParse } from '../../utils/safeJsonParse.js';
|
||||
import { AnthropicContentConverter } from './converter.js';
|
||||
|
||||
@@ -105,10 +105,8 @@ export class AnthropicContentGenerator implements ContentGenerator {
|
||||
request: CountTokensParameters,
|
||||
): Promise<CountTokensResponse> {
|
||||
try {
|
||||
const tokenizer = getDefaultTokenizer();
|
||||
const result = await tokenizer.calculateTokens(request, {
|
||||
textEncoding: 'cl100k_base',
|
||||
});
|
||||
const estimator = new RequestTokenEstimator();
|
||||
const result = await estimator.calculateTokens(request);
|
||||
|
||||
return {
|
||||
totalTokens: result.totalTokens,
|
||||
|
||||
@@ -208,6 +208,238 @@ describe('AnthropicContentConverter', () => {
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('converts function response with inlineData image parts into tool_result with images', () => {
|
||||
const { messages } = converter.convertGeminiRequestToAnthropic({
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call-1',
|
||||
name: 'Read',
|
||||
response: { output: 'Image content' },
|
||||
parts: [
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: 'base64encodeddata',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'call-1',
|
||||
content: [
|
||||
{ type: 'text', text: 'Image content' },
|
||||
{
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'image/png',
|
||||
data: 'base64encodeddata',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders non-image inlineData as a text block (avoids invalid image media_type)', () => {
|
||||
const { messages } = converter.convertGeminiRequestToAnthropic({
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call-1',
|
||||
name: 'Read',
|
||||
response: { output: 'Audio content' },
|
||||
parts: [
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'audio/mpeg',
|
||||
data: 'base64encodedaudiodata',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]?.role).toBe('user');
|
||||
|
||||
const toolResult = messages[0]?.content?.[0] as {
|
||||
type: string;
|
||||
content: Array<{ type: string; text?: string }>;
|
||||
};
|
||||
expect(toolResult.type).toBe('tool_result');
|
||||
expect(Array.isArray(toolResult.content)).toBe(true);
|
||||
expect(toolResult.content[0]).toEqual({
|
||||
type: 'text',
|
||||
text: 'Audio content',
|
||||
});
|
||||
expect(toolResult.content[1]?.type).toBe('text');
|
||||
expect(toolResult.content[1]?.text).toContain(
|
||||
'Unsupported inline media type for Anthropic',
|
||||
);
|
||||
expect(toolResult.content[1]?.text).toContain('audio/mpeg');
|
||||
});
|
||||
|
||||
it('converts fileData with PDF into document block', () => {
|
||||
const { messages } = converter.convertGeminiRequestToAnthropic({
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call-1',
|
||||
name: 'Read',
|
||||
response: { output: 'PDF content' },
|
||||
parts: [
|
||||
{
|
||||
fileData: {
|
||||
mimeType: 'application/pdf',
|
||||
fileUri: 'pdfbase64data',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'call-1',
|
||||
content: [
|
||||
{ type: 'text', text: 'PDF content' },
|
||||
{
|
||||
type: 'document',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'application/pdf',
|
||||
data: 'pdfbase64data',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('associates each image with its preceding functionResponse', () => {
|
||||
const { messages } = converter.convertGeminiRequestToAnthropic({
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
// Tool 1 with image 1
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call-1',
|
||||
name: 'Read',
|
||||
response: { output: 'File 1' },
|
||||
parts: [
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: 'image1data',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// Tool 2 with image 2
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call-2',
|
||||
name: 'Read',
|
||||
response: { output: 'File 2' },
|
||||
parts: [
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'image/jpeg',
|
||||
data: 'image2data',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Multiple tool_result blocks are emitted in order
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toEqual({
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'call-1',
|
||||
content: [
|
||||
{ type: 'text', text: 'File 1' },
|
||||
{
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'image/png',
|
||||
data: 'image1data',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'call-2',
|
||||
content: [
|
||||
{ type: 'text', text: 'File 2' },
|
||||
{
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'image/jpeg',
|
||||
data: 'image2data',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertGeminiToolsToAnthropic', () => {
|
||||
|
||||
@@ -10,7 +10,6 @@ import type {
|
||||
Content,
|
||||
ContentListUnion,
|
||||
ContentUnion,
|
||||
FunctionCall,
|
||||
FunctionResponse,
|
||||
GenerateContentParameters,
|
||||
Part,
|
||||
@@ -30,15 +29,6 @@ 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;
|
||||
@@ -228,127 +218,161 @@ export class AnthropicContentConverter {
|
||||
}
|
||||
|
||||
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<string, unknown>) || {},
|
||||
}),
|
||||
);
|
||||
|
||||
const textBlocks: AnthropicContentBlockParam[] = parsed.contentParts.map(
|
||||
(text) => ({
|
||||
type: 'text' as const,
|
||||
text,
|
||||
}),
|
||||
);
|
||||
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: [...thinkingBlocks, ...textBlocks, ...toolUses],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = content.parts || [];
|
||||
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[] = [];
|
||||
const contentBlocks: AnthropicContentBlockParam[] = [];
|
||||
let toolCallIndex = 0;
|
||||
|
||||
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:
|
||||
contentBlocks.push({ type: 'text', text: part });
|
||||
continue;
|
||||
}
|
||||
|
||||
if ('text' in part && 'thought' in part && part.thought) {
|
||||
if (role === 'assistant') {
|
||||
const thinkingBlock: unknown = {
|
||||
type: 'thinking',
|
||||
thinking: part.text || '',
|
||||
};
|
||||
if (
|
||||
'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);
|
||||
) {
|
||||
(thinkingBlock as { signature?: string }).signature =
|
||||
part.thoughtSignature;
|
||||
}
|
||||
contentBlocks.push(thinkingBlock as AnthropicContentBlockParam);
|
||||
}
|
||||
}
|
||||
|
||||
if ('text' in part && part.text && !('thought' in part && part.thought)) {
|
||||
contentBlocks.push({ type: 'text', text: part.text });
|
||||
}
|
||||
|
||||
const mediaBlock = this.createMediaBlockFromPart(part);
|
||||
if (mediaBlock) {
|
||||
contentBlocks.push(mediaBlock);
|
||||
}
|
||||
|
||||
if ('functionCall' in part && part.functionCall) {
|
||||
if (role === 'assistant') {
|
||||
contentBlocks.push({
|
||||
type: 'tool_use',
|
||||
id: part.functionCall.id || `tool_${toolCallIndex}`,
|
||||
name: part.functionCall.name || '',
|
||||
input: (part.functionCall.args as Record<string, unknown>) || {},
|
||||
});
|
||||
toolCallIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (part.functionResponse) {
|
||||
const toolResultBlock = this.createToolResultBlock(
|
||||
part.functionResponse,
|
||||
);
|
||||
if (toolResultBlock && role === 'user') {
|
||||
contentBlocks.push(toolResultBlock);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (contentBlocks.length > 0) {
|
||||
messages.push({ role, content: contentBlocks });
|
||||
}
|
||||
}
|
||||
|
||||
private createToolResultBlock(
|
||||
response: FunctionResponse,
|
||||
): Anthropic.ToolResultBlockParam | null {
|
||||
const textContent = this.extractFunctionResponseContent(response.response);
|
||||
|
||||
type ToolResultContent = Anthropic.ToolResultBlockParam['content'];
|
||||
const partBlocks: AnthropicContentBlockParam[] = [];
|
||||
|
||||
for (const part of response.parts || []) {
|
||||
const block = this.createMediaBlockFromPart(part);
|
||||
if (block) {
|
||||
partBlocks.push(block);
|
||||
}
|
||||
}
|
||||
|
||||
let content: ToolResultContent;
|
||||
if (partBlocks.length > 0) {
|
||||
const blocks: AnthropicContentBlockParam[] = [];
|
||||
if (textContent) {
|
||||
blocks.push({ type: 'text', text: textContent });
|
||||
}
|
||||
blocks.push(...partBlocks);
|
||||
content = blocks as unknown as ToolResultContent;
|
||||
} else {
|
||||
content = textContent;
|
||||
}
|
||||
|
||||
return {
|
||||
thoughtParts,
|
||||
contentParts,
|
||||
functionCalls,
|
||||
functionResponses,
|
||||
type: 'tool_result',
|
||||
tool_use_id: response.id || '',
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
private createMediaBlockFromPart(
|
||||
part: Part,
|
||||
): AnthropicContentBlockParam | null {
|
||||
if (part.inlineData?.mimeType && part.inlineData?.data) {
|
||||
if (!this.isSupportedAnthropicImageMimeType(part.inlineData.mimeType)) {
|
||||
const displayName = part.inlineData.displayName ?? '';
|
||||
return {
|
||||
type: 'text',
|
||||
text: `Unsupported inline media type for Anthropic: ${part.inlineData.mimeType}${displayName}.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: part.inlineData.mimeType as
|
||||
| 'image/jpeg'
|
||||
| 'image/png'
|
||||
| 'image/gif'
|
||||
| 'image/webp',
|
||||
data: part.inlineData.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (part.fileData?.mimeType && part.fileData?.fileUri) {
|
||||
if (part.fileData.mimeType !== 'application/pdf') {
|
||||
const displayName = part.fileData.displayName ?? '';
|
||||
return {
|
||||
type: 'text',
|
||||
text: `Unsupported file media for Anthropic: ${part.fileData.mimeType}${displayName}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'document',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: part.fileData.mimeType as 'application/pdf',
|
||||
data: part.fileData.fileUri,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private isSupportedAnthropicImageMimeType(
|
||||
mimeType: string,
|
||||
): mimeType is 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp' {
|
||||
return (
|
||||
mimeType === 'image/jpeg' ||
|
||||
mimeType === 'image/png' ||
|
||||
mimeType === 'image/gif' ||
|
||||
mimeType === 'image/webp'
|
||||
);
|
||||
}
|
||||
|
||||
private extractTextFromContentUnion(contentUnion: unknown): string {
|
||||
if (typeof contentUnion === 'string') {
|
||||
return contentUnion;
|
||||
|
||||
@@ -153,6 +153,26 @@ vi.mock('../telemetry/loggers.js', () => ({
|
||||
logNextSpeakerCheck: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock RequestTokenizer to use simple character-based estimation
|
||||
vi.mock('../utils/request-tokenizer/requestTokenizer.js', () => ({
|
||||
RequestTokenizer: class {
|
||||
async calculateTokens(request: { contents: unknown }) {
|
||||
// Simple estimation: count characters in JSON and divide by 4
|
||||
const totalChars = JSON.stringify(request.contents).length;
|
||||
return {
|
||||
totalTokens: Math.floor(totalChars / 4),
|
||||
breakdown: {
|
||||
textTokens: Math.floor(totalChars / 4),
|
||||
imageTokens: 0,
|
||||
audioTokens: 0,
|
||||
otherTokens: 0,
|
||||
},
|
||||
processingTime: 0,
|
||||
};
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* Array.fromAsync ponyfill, which will be available in es 2024.
|
||||
*
|
||||
@@ -417,6 +437,12 @@ describe('Gemini Client (client.ts)', () => {
|
||||
] as Content[],
|
||||
originalTokenCount = 1000,
|
||||
summaryText = 'This is a summary.',
|
||||
// Token counts returned in usageMetadata to simulate what the API would return
|
||||
// Default values ensure successful compression:
|
||||
// newTokenCount = originalTokenCount - (compressionInputTokenCount - 1000) + compressionOutputTokenCount
|
||||
// = 1000 - (1600 - 1000) + 50 = 1000 - 600 + 50 = 450 (< 1000, success)
|
||||
compressionInputTokenCount = 1600,
|
||||
compressionOutputTokenCount = 50,
|
||||
} = {}) {
|
||||
const mockOriginalChat: Partial<GeminiChat> = {
|
||||
getHistory: vi.fn((_curated?: boolean) => chatHistory),
|
||||
@@ -438,6 +464,12 @@ describe('Gemini Client (client.ts)', () => {
|
||||
},
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: compressionInputTokenCount,
|
||||
candidatesTokenCount: compressionOutputTokenCount,
|
||||
totalTokenCount:
|
||||
compressionInputTokenCount + compressionOutputTokenCount,
|
||||
},
|
||||
} as unknown as GenerateContentResponse);
|
||||
|
||||
// Calculate what the new history will be
|
||||
@@ -477,11 +509,13 @@ describe('Gemini Client (client.ts)', () => {
|
||||
.fn()
|
||||
.mockResolvedValue(mockNewChat as GeminiChat);
|
||||
|
||||
const totalChars = newCompressedHistory.reduce(
|
||||
(total, content) => total + JSON.stringify(content).length,
|
||||
// New token count formula: originalTokenCount - (compressionInputTokenCount - 1000) + compressionOutputTokenCount
|
||||
const estimatedNewTokenCount = Math.max(
|
||||
0,
|
||||
originalTokenCount -
|
||||
(compressionInputTokenCount - 1000) +
|
||||
compressionOutputTokenCount,
|
||||
);
|
||||
const estimatedNewTokenCount = Math.floor(totalChars / 4);
|
||||
|
||||
return {
|
||||
client,
|
||||
@@ -493,49 +527,58 @@ describe('Gemini Client (client.ts)', () => {
|
||||
|
||||
describe('when compression inflates the token count', () => {
|
||||
it('allows compression to be forced/manual after a failure', async () => {
|
||||
// Call 1 (Fails): Setup with a long summary to inflate tokens
|
||||
// Call 1 (Fails): Setup with token counts that will inflate
|
||||
// newTokenCount = originalTokenCount - (compressionInputTokenCount - 1000) + compressionOutputTokenCount
|
||||
// = 100 - (1010 - 1000) + 200 = 100 - 10 + 200 = 290 > 100 (inflation)
|
||||
const longSummary = 'long summary '.repeat(100);
|
||||
const { client, estimatedNewTokenCount: inflatedTokenCount } = setup({
|
||||
originalTokenCount: 100,
|
||||
summaryText: longSummary,
|
||||
compressionInputTokenCount: 1010,
|
||||
compressionOutputTokenCount: 200,
|
||||
});
|
||||
expect(inflatedTokenCount).toBeGreaterThan(100); // Ensure setup is correct
|
||||
|
||||
await client.tryCompressChat('prompt-id-4', false); // Fails
|
||||
|
||||
// Call 2 (Forced): Re-setup with a short summary
|
||||
// Call 2 (Forced): Re-setup with token counts that will compress
|
||||
// newTokenCount = 100 - (1100 - 1000) + 50 = 100 - 100 + 50 = 50 <= 100 (compression)
|
||||
const shortSummary = 'short';
|
||||
const { estimatedNewTokenCount: compressedTokenCount } = setup({
|
||||
originalTokenCount: 100,
|
||||
summaryText: shortSummary,
|
||||
compressionInputTokenCount: 1100,
|
||||
compressionOutputTokenCount: 50,
|
||||
});
|
||||
expect(compressedTokenCount).toBeLessThanOrEqual(100); // Ensure setup is correct
|
||||
|
||||
const result = await client.tryCompressChat('prompt-id-4', true); // Forced
|
||||
|
||||
expect(result).toEqual({
|
||||
compressionStatus: CompressionStatus.COMPRESSED,
|
||||
newTokenCount: compressedTokenCount,
|
||||
originalTokenCount: 100,
|
||||
});
|
||||
expect(result.compressionStatus).toBe(CompressionStatus.COMPRESSED);
|
||||
expect(result.originalTokenCount).toBe(100);
|
||||
// newTokenCount might be clamped to originalTokenCount due to tolerance logic
|
||||
expect(result.newTokenCount).toBeLessThanOrEqual(100);
|
||||
});
|
||||
|
||||
it('yields the result even if the compression inflated the tokens', async () => {
|
||||
// newTokenCount = 100 - (1010 - 1000) + 200 = 100 - 10 + 200 = 290 > 100 (inflation)
|
||||
const longSummary = 'long summary '.repeat(100);
|
||||
const { client, estimatedNewTokenCount } = setup({
|
||||
originalTokenCount: 100,
|
||||
summaryText: longSummary,
|
||||
compressionInputTokenCount: 1010,
|
||||
compressionOutputTokenCount: 200,
|
||||
});
|
||||
expect(estimatedNewTokenCount).toBeGreaterThan(100); // Ensure setup is correct
|
||||
|
||||
const result = await client.tryCompressChat('prompt-id-4', false);
|
||||
|
||||
expect(result).toEqual({
|
||||
compressionStatus:
|
||||
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
|
||||
newTokenCount: estimatedNewTokenCount,
|
||||
originalTokenCount: 100,
|
||||
});
|
||||
expect(result.compressionStatus).toBe(
|
||||
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
|
||||
);
|
||||
expect(result.originalTokenCount).toBe(100);
|
||||
// The newTokenCount should be higher than original since compression failed due to inflation
|
||||
expect(result.newTokenCount).toBeGreaterThan(100);
|
||||
// IMPORTANT: The change in client.ts means setLastPromptTokenCount is NOT called on failure
|
||||
expect(
|
||||
uiTelemetryService.setLastPromptTokenCount,
|
||||
@@ -543,10 +586,13 @@ describe('Gemini Client (client.ts)', () => {
|
||||
});
|
||||
|
||||
it('does not manipulate the source chat', async () => {
|
||||
// newTokenCount = 100 - (1010 - 1000) + 200 = 100 - 10 + 200 = 290 > 100 (inflation)
|
||||
const longSummary = 'long summary '.repeat(100);
|
||||
const { client, mockOriginalChat, estimatedNewTokenCount } = setup({
|
||||
originalTokenCount: 100,
|
||||
summaryText: longSummary,
|
||||
compressionInputTokenCount: 1010,
|
||||
compressionOutputTokenCount: 200,
|
||||
});
|
||||
expect(estimatedNewTokenCount).toBeGreaterThan(100); // Ensure setup is correct
|
||||
|
||||
@@ -557,10 +603,13 @@ describe('Gemini Client (client.ts)', () => {
|
||||
});
|
||||
|
||||
it('will not attempt to compress context after a failure', async () => {
|
||||
// newTokenCount = 100 - (1010 - 1000) + 200 = 100 - 10 + 200 = 290 > 100 (inflation)
|
||||
const longSummary = 'long summary '.repeat(100);
|
||||
const { client, estimatedNewTokenCount } = setup({
|
||||
originalTokenCount: 100,
|
||||
summaryText: longSummary,
|
||||
compressionInputTokenCount: 1010,
|
||||
compressionOutputTokenCount: 200,
|
||||
});
|
||||
expect(estimatedNewTokenCount).toBeGreaterThan(100); // Ensure setup is correct
|
||||
|
||||
@@ -631,6 +680,7 @@ describe('Gemini Client (client.ts)', () => {
|
||||
);
|
||||
|
||||
// Mock the summary response from the chat
|
||||
// newTokenCount = 501 - (1400 - 1000) + 50 = 501 - 400 + 50 = 151 <= 501 (success)
|
||||
const summaryText = 'This is a summary.';
|
||||
mockGenerateContentFn.mockResolvedValue({
|
||||
candidates: [
|
||||
@@ -641,6 +691,11 @@ describe('Gemini Client (client.ts)', () => {
|
||||
},
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 1400,
|
||||
candidatesTokenCount: 50,
|
||||
totalTokenCount: 1450,
|
||||
},
|
||||
} as unknown as GenerateContentResponse);
|
||||
|
||||
// Mock startChat to complete the compression flow
|
||||
@@ -719,13 +774,8 @@ describe('Gemini Client (client.ts)', () => {
|
||||
.fn()
|
||||
.mockResolvedValue(mockNewChat as GeminiChat);
|
||||
|
||||
const totalChars = newCompressedHistory.reduce(
|
||||
(total, content) => total + JSON.stringify(content).length,
|
||||
0,
|
||||
);
|
||||
const newTokenCount = Math.floor(totalChars / 4);
|
||||
|
||||
// Mock the summary response from the chat
|
||||
// newTokenCount = 501 - (1400 - 1000) + 50 = 501 - 400 + 50 = 151 <= 501 (success)
|
||||
mockGenerateContentFn.mockResolvedValue({
|
||||
candidates: [
|
||||
{
|
||||
@@ -735,6 +785,11 @@ describe('Gemini Client (client.ts)', () => {
|
||||
},
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 1400,
|
||||
candidatesTokenCount: 50,
|
||||
totalTokenCount: 1450,
|
||||
},
|
||||
} as unknown as GenerateContentResponse);
|
||||
|
||||
const initialChat = client.getChat();
|
||||
@@ -744,12 +799,11 @@ describe('Gemini Client (client.ts)', () => {
|
||||
expect(tokenLimit).toHaveBeenCalled();
|
||||
expect(mockGenerateContentFn).toHaveBeenCalled();
|
||||
|
||||
// Assert that summarization happened and returned the correct stats
|
||||
expect(result).toEqual({
|
||||
compressionStatus: CompressionStatus.COMPRESSED,
|
||||
originalTokenCount,
|
||||
newTokenCount,
|
||||
});
|
||||
// Assert that summarization happened
|
||||
expect(result.compressionStatus).toBe(CompressionStatus.COMPRESSED);
|
||||
expect(result.originalTokenCount).toBe(originalTokenCount);
|
||||
// newTokenCount might be clamped to originalTokenCount due to tolerance logic
|
||||
expect(result.newTokenCount).toBeLessThanOrEqual(originalTokenCount);
|
||||
|
||||
// Assert that the chat was reset
|
||||
expect(newChat).not.toBe(initialChat);
|
||||
@@ -809,13 +863,8 @@ describe('Gemini Client (client.ts)', () => {
|
||||
.fn()
|
||||
.mockResolvedValue(mockNewChat as GeminiChat);
|
||||
|
||||
const totalChars = newCompressedHistory.reduce(
|
||||
(total, content) => total + JSON.stringify(content).length,
|
||||
0,
|
||||
);
|
||||
const newTokenCount = Math.floor(totalChars / 4);
|
||||
|
||||
// Mock the summary response from the chat
|
||||
// newTokenCount = 700 - (1500 - 1000) + 50 = 700 - 500 + 50 = 250 <= 700 (success)
|
||||
mockGenerateContentFn.mockResolvedValue({
|
||||
candidates: [
|
||||
{
|
||||
@@ -825,6 +874,11 @@ describe('Gemini Client (client.ts)', () => {
|
||||
},
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 1500,
|
||||
candidatesTokenCount: 50,
|
||||
totalTokenCount: 1550,
|
||||
},
|
||||
} as unknown as GenerateContentResponse);
|
||||
|
||||
const initialChat = client.getChat();
|
||||
@@ -834,12 +888,11 @@ describe('Gemini Client (client.ts)', () => {
|
||||
expect(tokenLimit).toHaveBeenCalled();
|
||||
expect(mockGenerateContentFn).toHaveBeenCalled();
|
||||
|
||||
// Assert that summarization happened and returned the correct stats
|
||||
expect(result).toEqual({
|
||||
compressionStatus: CompressionStatus.COMPRESSED,
|
||||
originalTokenCount,
|
||||
newTokenCount,
|
||||
});
|
||||
// Assert that summarization happened
|
||||
expect(result.compressionStatus).toBe(CompressionStatus.COMPRESSED);
|
||||
expect(result.originalTokenCount).toBe(originalTokenCount);
|
||||
// newTokenCount might be clamped to originalTokenCount due to tolerance logic
|
||||
expect(result.newTokenCount).toBeLessThanOrEqual(originalTokenCount);
|
||||
// Assert that the chat was reset
|
||||
expect(newChat).not.toBe(initialChat);
|
||||
|
||||
@@ -887,13 +940,8 @@ describe('Gemini Client (client.ts)', () => {
|
||||
.fn()
|
||||
.mockResolvedValue(mockNewChat as GeminiChat);
|
||||
|
||||
const totalChars = newCompressedHistory.reduce(
|
||||
(total, content) => total + JSON.stringify(content).length,
|
||||
0,
|
||||
);
|
||||
const newTokenCount = Math.floor(totalChars / 4);
|
||||
|
||||
// Mock the summary response from the chat
|
||||
// newTokenCount = 100 - (1060 - 1000) + 20 = 100 - 60 + 20 = 60 <= 100 (success)
|
||||
mockGenerateContentFn.mockResolvedValue({
|
||||
candidates: [
|
||||
{
|
||||
@@ -903,6 +951,11 @@ describe('Gemini Client (client.ts)', () => {
|
||||
},
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 1060,
|
||||
candidatesTokenCount: 20,
|
||||
totalTokenCount: 1080,
|
||||
},
|
||||
} as unknown as GenerateContentResponse);
|
||||
|
||||
const initialChat = client.getChat();
|
||||
@@ -911,11 +964,10 @@ describe('Gemini Client (client.ts)', () => {
|
||||
|
||||
expect(mockGenerateContentFn).toHaveBeenCalled();
|
||||
|
||||
expect(result).toEqual({
|
||||
compressionStatus: CompressionStatus.COMPRESSED,
|
||||
originalTokenCount,
|
||||
newTokenCount,
|
||||
});
|
||||
expect(result.compressionStatus).toBe(CompressionStatus.COMPRESSED);
|
||||
expect(result.originalTokenCount).toBe(originalTokenCount);
|
||||
// newTokenCount might be clamped to originalTokenCount due to tolerance logic
|
||||
expect(result.newTokenCount).toBeLessThanOrEqual(originalTokenCount);
|
||||
|
||||
// Assert that the chat was reset
|
||||
expect(newChat).not.toBe(initialChat);
|
||||
|
||||
@@ -441,47 +441,19 @@ export class GeminiClient {
|
||||
yield { type: GeminiEventType.ChatCompressed, value: compressed };
|
||||
}
|
||||
|
||||
// Check session token limit after compression using accurate token counting
|
||||
// Check session token limit after compression.
|
||||
// `lastPromptTokenCount` is treated as authoritative for the (possibly compressed) history;
|
||||
const sessionTokenLimit = this.config.getSessionTokenLimit();
|
||||
if (sessionTokenLimit > 0) {
|
||||
// Get all the content that would be sent in an API call
|
||||
const currentHistory = this.getChat().getHistory(true);
|
||||
const userMemory = this.config.getUserMemory();
|
||||
const systemPrompt = getCoreSystemPrompt(
|
||||
userMemory,
|
||||
this.config.getModel(),
|
||||
);
|
||||
const initialHistory = await getInitialChatHistory(this.config);
|
||||
|
||||
// Create a mock request content to count total tokens
|
||||
const mockRequestContent = [
|
||||
{
|
||||
role: 'system' as const,
|
||||
parts: [{ text: systemPrompt }],
|
||||
},
|
||||
...initialHistory,
|
||||
...currentHistory,
|
||||
];
|
||||
|
||||
// Use the improved countTokens method for accurate counting
|
||||
const { totalTokens: totalRequestTokens } = await this.config
|
||||
.getContentGenerator()
|
||||
.countTokens({
|
||||
model: this.config.getModel(),
|
||||
contents: mockRequestContent,
|
||||
});
|
||||
|
||||
if (
|
||||
totalRequestTokens !== undefined &&
|
||||
totalRequestTokens > sessionTokenLimit
|
||||
) {
|
||||
const lastPromptTokenCount = uiTelemetryService.getLastPromptTokenCount();
|
||||
if (lastPromptTokenCount > sessionTokenLimit) {
|
||||
yield {
|
||||
type: GeminiEventType.SessionTokenLimitExceeded,
|
||||
value: {
|
||||
currentTokens: totalRequestTokens,
|
||||
currentTokens: lastPromptTokenCount,
|
||||
limit: sessionTokenLimit,
|
||||
message:
|
||||
`Session token limit exceeded: ${totalRequestTokens} tokens > ${sessionTokenLimit} limit. ` +
|
||||
`Session token limit exceeded: ${lastPromptTokenCount} tokens > ${sessionTokenLimit} limit. ` +
|
||||
'Please start a new session or increase the sessionTokenLimit in your settings.json.',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -800,11 +800,11 @@ describe('convertToFunctionResponse', () => {
|
||||
name: toolName,
|
||||
id: callId,
|
||||
response: {
|
||||
output: 'Binary content of type image/png was processed.',
|
||||
output: '',
|
||||
},
|
||||
parts: [{ inlineData: { mimeType: 'image/png', data: 'base64...' } }],
|
||||
},
|
||||
},
|
||||
llmContent,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -819,11 +819,15 @@ describe('convertToFunctionResponse', () => {
|
||||
name: toolName,
|
||||
id: callId,
|
||||
response: {
|
||||
output: 'Binary content of type application/pdf was processed.',
|
||||
output: '',
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
fileData: { mimeType: 'application/pdf', fileUri: 'gs://...' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
llmContent,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -857,11 +861,13 @@ describe('convertToFunctionResponse', () => {
|
||||
name: toolName,
|
||||
id: callId,
|
||||
response: {
|
||||
output: 'Binary content of type image/gif was processed.',
|
||||
output: '',
|
||||
},
|
||||
parts: [
|
||||
{ inlineData: { mimeType: 'image/gif', data: 'gifdata...' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
...llmContent,
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -30,7 +30,12 @@ import {
|
||||
ToolOutputTruncatedEvent,
|
||||
InputFormat,
|
||||
} from '../index.js';
|
||||
import type { Part, PartListUnion } from '@google/genai';
|
||||
import type {
|
||||
FunctionResponse,
|
||||
FunctionResponsePart,
|
||||
Part,
|
||||
PartListUnion,
|
||||
} from '@google/genai';
|
||||
import { getResponseTextFromParts } from '../utils/generateContentResponseUtilities.js';
|
||||
import type { ModifyContext } from '../tools/modifiable-tool.js';
|
||||
import {
|
||||
@@ -151,13 +156,17 @@ function createFunctionResponsePart(
|
||||
callId: string,
|
||||
toolName: string,
|
||||
output: string,
|
||||
mediaParts?: FunctionResponsePart[],
|
||||
): Part {
|
||||
const functionResponse: FunctionResponse = {
|
||||
id: callId,
|
||||
name: toolName,
|
||||
response: { output },
|
||||
...(mediaParts && mediaParts.length > 0 ? { parts: mediaParts } : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
functionResponse: {
|
||||
id: callId,
|
||||
name: toolName,
|
||||
response: { output },
|
||||
},
|
||||
functionResponse,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -198,16 +207,21 @@ export function convertToFunctionResponse(
|
||||
}
|
||||
|
||||
if (contentToProcess.inlineData || contentToProcess.fileData) {
|
||||
const mimeType =
|
||||
contentToProcess.inlineData?.mimeType ||
|
||||
contentToProcess.fileData?.mimeType ||
|
||||
'unknown';
|
||||
const mediaParts: FunctionResponsePart[] = [];
|
||||
if (contentToProcess.inlineData) {
|
||||
mediaParts.push({ inlineData: contentToProcess.inlineData });
|
||||
}
|
||||
if (contentToProcess.fileData) {
|
||||
mediaParts.push({ fileData: contentToProcess.fileData });
|
||||
}
|
||||
|
||||
const functionResponse = createFunctionResponsePart(
|
||||
callId,
|
||||
toolName,
|
||||
`Binary content of type ${mimeType} was processed.`,
|
||||
'',
|
||||
mediaParts,
|
||||
);
|
||||
return [functionResponse, contentToProcess];
|
||||
return [functionResponse];
|
||||
}
|
||||
|
||||
if (contentToProcess.text !== undefined) {
|
||||
|
||||
@@ -708,7 +708,7 @@ describe('GeminiChat', () => {
|
||||
|
||||
// Verify that token counting is called when usageMetadata is present
|
||||
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledWith(
|
||||
42,
|
||||
57,
|
||||
);
|
||||
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledTimes(
|
||||
1,
|
||||
|
||||
@@ -529,10 +529,10 @@ export class GeminiChat {
|
||||
// Collect token usage for consolidated recording
|
||||
if (chunk.usageMetadata) {
|
||||
usageMetadata = chunk.usageMetadata;
|
||||
if (chunk.usageMetadata.promptTokenCount !== undefined) {
|
||||
uiTelemetryService.setLastPromptTokenCount(
|
||||
chunk.usageMetadata.promptTokenCount,
|
||||
);
|
||||
const lastPromptTokenCount =
|
||||
usageMetadata.totalTokenCount ?? usageMetadata.promptTokenCount;
|
||||
if (lastPromptTokenCount) {
|
||||
uiTelemetryService.setLastPromptTokenCount(lastPromptTokenCount);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -309,11 +309,13 @@ describe('executeToolCall', () => {
|
||||
name: 'testTool',
|
||||
id: 'call6',
|
||||
response: {
|
||||
output: 'Binary content of type image/png was processed.',
|
||||
output: '',
|
||||
},
|
||||
parts: [
|
||||
{ inlineData: { mimeType: 'image/png', data: 'base64data' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
imageDataPart,
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,7 +122,13 @@ describe('OpenAIContentConverter', () => {
|
||||
const toolMessage = messages.find((message) => message.role === 'tool');
|
||||
|
||||
expect(toolMessage).toBeDefined();
|
||||
expect(toolMessage?.content).toBe('Raw output text');
|
||||
expect(Array.isArray(toolMessage?.content)).toBe(true);
|
||||
const contentArray = toolMessage?.content as Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
}>;
|
||||
expect(contentArray[0].type).toBe('text');
|
||||
expect(contentArray[0].text).toBe('Raw output text');
|
||||
});
|
||||
|
||||
it('should prioritize error field when present', () => {
|
||||
@@ -134,7 +140,13 @@ describe('OpenAIContentConverter', () => {
|
||||
const toolMessage = messages.find((message) => message.role === 'tool');
|
||||
|
||||
expect(toolMessage).toBeDefined();
|
||||
expect(toolMessage?.content).toBe('Command failed');
|
||||
expect(Array.isArray(toolMessage?.content)).toBe(true);
|
||||
const contentArray = toolMessage?.content as Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
}>;
|
||||
expect(contentArray[0].type).toBe('text');
|
||||
expect(contentArray[0].text).toBe('Command failed');
|
||||
});
|
||||
|
||||
it('should stringify non-string responses', () => {
|
||||
@@ -146,7 +158,318 @@ describe('OpenAIContentConverter', () => {
|
||||
const toolMessage = messages.find((message) => message.role === 'tool');
|
||||
|
||||
expect(toolMessage).toBeDefined();
|
||||
expect(toolMessage?.content).toBe('{"data":{"value":42}}');
|
||||
expect(Array.isArray(toolMessage?.content)).toBe(true);
|
||||
const contentArray = toolMessage?.content as Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
}>;
|
||||
expect(contentArray[0].type).toBe('text');
|
||||
expect(contentArray[0].text).toBe('{"data":{"value":42}}');
|
||||
});
|
||||
|
||||
it('should convert function responses with inlineData to tool message with embedded image_url', () => {
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
id: 'call_1',
|
||||
name: 'Read',
|
||||
args: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call_1',
|
||||
name: 'Read',
|
||||
response: { output: 'Image content' },
|
||||
parts: [
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: 'base64encodedimagedata',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const messages = converter.convertGeminiRequestToOpenAI(request);
|
||||
|
||||
// Should have tool message with both text and image content
|
||||
const toolMessage = messages.find((message) => message.role === 'tool');
|
||||
expect(toolMessage).toBeDefined();
|
||||
expect((toolMessage as { tool_call_id?: string }).tool_call_id).toBe(
|
||||
'call_1',
|
||||
);
|
||||
expect(Array.isArray(toolMessage?.content)).toBe(true);
|
||||
const contentArray = toolMessage?.content as Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
image_url?: { url: string };
|
||||
}>;
|
||||
expect(contentArray).toHaveLength(2);
|
||||
expect(contentArray[0].type).toBe('text');
|
||||
expect(contentArray[0].text).toBe('Image content');
|
||||
expect(contentArray[1].type).toBe('image_url');
|
||||
expect(contentArray[1].image_url?.url).toBe(
|
||||
'data:image/png;base64,base64encodedimagedata',
|
||||
);
|
||||
|
||||
// No separate user message should be created
|
||||
const userMessage = messages.find((message) => message.role === 'user');
|
||||
expect(userMessage).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should convert function responses with fileData to tool message with embedded input_file', () => {
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
id: 'call_1',
|
||||
name: 'Read',
|
||||
args: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call_1',
|
||||
name: 'Read',
|
||||
response: { output: 'File content' },
|
||||
parts: [
|
||||
{
|
||||
fileData: {
|
||||
mimeType: 'image/jpeg',
|
||||
fileUri: 'base64imagedata',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const messages = converter.convertGeminiRequestToOpenAI(request);
|
||||
|
||||
// Should have tool message with both text and file content
|
||||
const toolMessage = messages.find((message) => message.role === 'tool');
|
||||
expect(toolMessage).toBeDefined();
|
||||
expect(Array.isArray(toolMessage?.content)).toBe(true);
|
||||
const contentArray = toolMessage?.content as Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
file?: { filename: string; file_data: string };
|
||||
}>;
|
||||
expect(contentArray).toHaveLength(2);
|
||||
expect(contentArray[0].type).toBe('text');
|
||||
expect(contentArray[0].text).toBe('File content');
|
||||
expect(contentArray[1].type).toBe('file');
|
||||
expect(contentArray[1].file?.filename).toBe('file'); // Default filename when displayName not provided
|
||||
expect(contentArray[1].file?.file_data).toBe(
|
||||
'data:image/jpeg;base64,base64imagedata',
|
||||
);
|
||||
|
||||
// No separate user message should be created
|
||||
const userMessage = messages.find((message) => message.role === 'user');
|
||||
expect(userMessage).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should convert PDF fileData to tool message with embedded input_file', () => {
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
id: 'call_1',
|
||||
name: 'Read',
|
||||
args: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call_1',
|
||||
name: 'Read',
|
||||
response: { output: 'PDF content' },
|
||||
parts: [
|
||||
{
|
||||
fileData: {
|
||||
mimeType: 'application/pdf',
|
||||
fileUri: 'base64pdfdata',
|
||||
displayName: 'document.pdf',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const messages = converter.convertGeminiRequestToOpenAI(request);
|
||||
|
||||
// Should have tool message with both text and file content
|
||||
const toolMessage = messages.find((message) => message.role === 'tool');
|
||||
expect(toolMessage).toBeDefined();
|
||||
expect(Array.isArray(toolMessage?.content)).toBe(true);
|
||||
const contentArray = toolMessage?.content as Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
file?: { filename: string; file_data: string };
|
||||
}>;
|
||||
expect(contentArray).toHaveLength(2);
|
||||
expect(contentArray[0].type).toBe('text');
|
||||
expect(contentArray[0].text).toBe('PDF content');
|
||||
expect(contentArray[1].type).toBe('file');
|
||||
expect(contentArray[1].file?.filename).toBe('document.pdf');
|
||||
expect(contentArray[1].file?.file_data).toBe(
|
||||
'data:application/pdf;base64,base64pdfdata',
|
||||
);
|
||||
|
||||
// No separate user message should be created
|
||||
const userMessage = messages.find((message) => message.role === 'user');
|
||||
expect(userMessage).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should convert audio parts to tool message with embedded input_audio', () => {
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
id: 'call_1',
|
||||
name: 'Record',
|
||||
args: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call_1',
|
||||
name: 'Record',
|
||||
response: { output: 'Audio recorded' },
|
||||
parts: [
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'audio/wav',
|
||||
data: 'audiobase64data',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const messages = converter.convertGeminiRequestToOpenAI(request);
|
||||
|
||||
// Should have tool message with both text and audio content
|
||||
const toolMessage = messages.find((message) => message.role === 'tool');
|
||||
expect(toolMessage).toBeDefined();
|
||||
expect(Array.isArray(toolMessage?.content)).toBe(true);
|
||||
const contentArray = toolMessage?.content as Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
input_audio?: { data: string; format: string };
|
||||
}>;
|
||||
expect(contentArray).toHaveLength(2);
|
||||
expect(contentArray[0].type).toBe('text');
|
||||
expect(contentArray[0].text).toBe('Audio recorded');
|
||||
expect(contentArray[1].type).toBe('input_audio');
|
||||
expect(contentArray[1].input_audio?.data).toBe('audiobase64data');
|
||||
expect(contentArray[1].input_audio?.format).toBe('wav');
|
||||
|
||||
// No separate user message should be created
|
||||
const userMessage = messages.find((message) => message.role === 'user');
|
||||
expect(userMessage).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should create tool message with text-only content when no media parts', () => {
|
||||
const request = createRequestWithFunctionResponse({
|
||||
output: 'Plain text output',
|
||||
});
|
||||
|
||||
const messages = converter.convertGeminiRequestToOpenAI(request);
|
||||
const toolMessage = messages.find((message) => message.role === 'tool');
|
||||
|
||||
expect(toolMessage).toBeDefined();
|
||||
expect(Array.isArray(toolMessage?.content)).toBe(true);
|
||||
const contentArray = toolMessage?.content as Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
}>;
|
||||
expect(contentArray).toHaveLength(1);
|
||||
expect(contentArray[0].type).toBe('text');
|
||||
expect(contentArray[0].text).toBe('Plain text output');
|
||||
|
||||
// No user message should be created when there's no media
|
||||
const userMessage = messages.find((message) => message.role === 'user');
|
||||
expect(userMessage).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should skip empty function responses with no media and no text', () => {
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call_1',
|
||||
name: 'Empty',
|
||||
response: { output: '' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const messages = converter.convertGeminiRequestToOpenAI(request);
|
||||
|
||||
// Should have no messages for empty response
|
||||
expect(messages).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -180,6 +503,35 @@ describe('OpenAIContentConverter', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should convert reasoning to a thought part for non-streaming responses', () => {
|
||||
const response = converter.convertOpenAIResponseToGemini({
|
||||
object: 'chat.completion',
|
||||
id: 'chatcmpl-2',
|
||||
created: 123,
|
||||
model: 'gpt-test',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'final answer',
|
||||
reasoning: 'chain-of-thought',
|
||||
},
|
||||
finish_reason: 'stop',
|
||||
logprobs: null,
|
||||
},
|
||||
],
|
||||
} as unknown as OpenAI.Chat.ChatCompletion);
|
||||
|
||||
const parts = response.candidates?.[0]?.content?.parts;
|
||||
expect(parts?.[0]).toEqual(
|
||||
expect.objectContaining({ thought: true, text: 'chain-of-thought' }),
|
||||
);
|
||||
expect(parts?.[1]).toEqual(
|
||||
expect.objectContaining({ text: 'final answer' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should convert streaming reasoning_content delta to a thought part', () => {
|
||||
const chunk = converter.convertOpenAIChunkToGemini({
|
||||
object: 'chat.completion.chunk',
|
||||
@@ -208,6 +560,34 @@ describe('OpenAIContentConverter', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should convert streaming reasoning delta to a thought part', () => {
|
||||
const chunk = converter.convertOpenAIChunkToGemini({
|
||||
object: 'chat.completion.chunk',
|
||||
id: 'chunk-1b',
|
||||
created: 456,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
content: 'visible text',
|
||||
reasoning: 'thinking...',
|
||||
},
|
||||
finish_reason: 'stop',
|
||||
logprobs: null,
|
||||
},
|
||||
],
|
||||
model: 'gpt-test',
|
||||
} as unknown as OpenAI.Chat.ChatCompletionChunk);
|
||||
|
||||
const parts = chunk.candidates?.[0]?.content?.parts;
|
||||
expect(parts?.[0]).toEqual(
|
||||
expect.objectContaining({ thought: true, text: 'thinking...' }),
|
||||
);
|
||||
expect(parts?.[1]).toEqual(
|
||||
expect.objectContaining({ text: 'visible text' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw when streaming chunk has no delta', () => {
|
||||
const chunk = converter.convertOpenAIChunkToGemini({
|
||||
object: 'chat.completion.chunk',
|
||||
@@ -584,11 +964,7 @@ describe('OpenAIContentConverter', () => {
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].role).toBe('assistant');
|
||||
const content = messages[0]
|
||||
.content as OpenAI.Chat.ChatCompletionContentPart[];
|
||||
expect(content).toHaveLength(2);
|
||||
expect(content[0]).toEqual({ type: 'text', text: 'First part' });
|
||||
expect(content[1]).toEqual({ type: 'text', text: 'Second part' });
|
||||
expect(messages[0].content).toBe('First partSecond part');
|
||||
});
|
||||
|
||||
it('should merge multiple consecutive assistant messages', () => {
|
||||
@@ -614,9 +990,7 @@ describe('OpenAIContentConverter', () => {
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].role).toBe('assistant');
|
||||
const content = messages[0]
|
||||
.content as OpenAI.Chat.ChatCompletionContentPart[];
|
||||
expect(content).toHaveLength(3);
|
||||
expect(messages[0].content).toBe('Part 1Part 2Part 3');
|
||||
});
|
||||
|
||||
it('should merge tool_calls from consecutive assistant messages', () => {
|
||||
@@ -674,7 +1048,9 @@ describe('OpenAIContentConverter', () => {
|
||||
],
|
||||
};
|
||||
|
||||
const messages = converter.convertGeminiRequestToOpenAI(request);
|
||||
const messages = converter.convertGeminiRequestToOpenAI(request, {
|
||||
cleanOrphanToolCalls: false,
|
||||
});
|
||||
|
||||
// Should have: assistant (tool_call_1), tool (result_1), assistant (tool_call_2), tool (result_2)
|
||||
expect(messages).toHaveLength(4);
|
||||
@@ -729,10 +1105,7 @@ describe('OpenAIContentConverter', () => {
|
||||
const messages = converter.convertGeminiRequestToOpenAI(request);
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
const content = messages[0]
|
||||
.content as OpenAI.Chat.ChatCompletionContentPart[];
|
||||
expect(Array.isArray(content)).toBe(true);
|
||||
expect(content).toHaveLength(2);
|
||||
expect(messages[0].content).toBe('Text partAnother text');
|
||||
});
|
||||
|
||||
it('should merge empty content correctly', () => {
|
||||
@@ -758,11 +1131,7 @@ describe('OpenAIContentConverter', () => {
|
||||
|
||||
// Empty messages should be filtered out
|
||||
expect(messages).toHaveLength(1);
|
||||
const content = messages[0]
|
||||
.content as OpenAI.Chat.ChatCompletionContentPart[];
|
||||
expect(content).toHaveLength(2);
|
||||
expect(content[0]).toEqual({ type: 'text', text: 'First' });
|
||||
expect(content[1]).toEqual({ type: 'text', text: 'Second' });
|
||||
expect(messages[0].content).toBe('FirstSecond');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
Tool,
|
||||
ToolListUnion,
|
||||
CallableTool,
|
||||
FunctionCall,
|
||||
FunctionResponse,
|
||||
ContentListUnion,
|
||||
ContentUnion,
|
||||
@@ -47,11 +46,13 @@ type ExtendedChatCompletionMessageParam =
|
||||
export interface ExtendedCompletionMessage
|
||||
extends OpenAI.Chat.ChatCompletionMessage {
|
||||
reasoning_content?: string | null;
|
||||
reasoning?: string | null;
|
||||
}
|
||||
|
||||
export interface ExtendedCompletionChunkDelta
|
||||
extends OpenAI.Chat.ChatCompletionChunk.Choice.Delta {
|
||||
reasoning_content?: string | null;
|
||||
reasoning?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,21 +64,17 @@ export interface ToolCallAccumulator {
|
||||
arguments: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed parts from Gemini content, categorized by type
|
||||
*/
|
||||
interface ParsedParts {
|
||||
thoughtParts: string[];
|
||||
contentParts: string[];
|
||||
functionCalls: FunctionCall[];
|
||||
functionResponses: FunctionResponse[];
|
||||
mediaParts: Array<{
|
||||
type: 'image' | 'audio' | 'file';
|
||||
data: string;
|
||||
mimeType: string;
|
||||
fileUri?: string;
|
||||
}>;
|
||||
}
|
||||
type OpenAIContentPart =
|
||||
| OpenAI.Chat.ChatCompletionContentPartText
|
||||
| OpenAI.Chat.ChatCompletionContentPartImage
|
||||
| OpenAI.Chat.ChatCompletionContentPartInputAudio
|
||||
| {
|
||||
type: 'file';
|
||||
file: {
|
||||
filename: string;
|
||||
file_data: string;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Converter class for transforming data between Gemini and OpenAI formats
|
||||
@@ -271,28 +268,48 @@ export class OpenAIContentConverter {
|
||||
): OpenAI.Chat.ChatCompletion {
|
||||
const candidate = response.candidates?.[0];
|
||||
const parts = (candidate?.content?.parts || []) as Part[];
|
||||
const parsedParts = this.parseParts(parts);
|
||||
|
||||
// Parse parts inline
|
||||
const thoughtParts: string[] = [];
|
||||
const contentParts: string[] = [];
|
||||
const toolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[] = [];
|
||||
let toolCallIndex = 0;
|
||||
|
||||
for (const part of parts) {
|
||||
if (typeof part === 'string') {
|
||||
contentParts.push(part);
|
||||
} else if ('text' in part && part.text) {
|
||||
if ('thought' in part && part.thought) {
|
||||
thoughtParts.push(part.text);
|
||||
} else {
|
||||
contentParts.push(part.text);
|
||||
}
|
||||
} else if ('functionCall' in part && part.functionCall) {
|
||||
toolCalls.push({
|
||||
id: part.functionCall.id || `call_${toolCallIndex}`,
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: part.functionCall.name || '',
|
||||
arguments: JSON.stringify(part.functionCall.args || {}),
|
||||
},
|
||||
});
|
||||
toolCallIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const message: ExtendedCompletionMessage = {
|
||||
role: 'assistant',
|
||||
content: parsedParts.contentParts.join('') || null,
|
||||
content: contentParts.join('') || null,
|
||||
refusal: null,
|
||||
};
|
||||
|
||||
const reasoningContent = parsedParts.thoughtParts.join('');
|
||||
const reasoningContent = 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 || {}),
|
||||
},
|
||||
}));
|
||||
if (toolCalls.length > 0) {
|
||||
message.tool_calls = toolCalls;
|
||||
}
|
||||
|
||||
const finishReason = this.mapGeminiFinishReasonToOpenAI(
|
||||
@@ -390,40 +407,82 @@ export class OpenAIContentConverter {
|
||||
}
|
||||
|
||||
if (!this.isContentObject(content)) return;
|
||||
const parts = content.parts || [];
|
||||
const role = content.role === 'model' ? 'assistant' : 'user';
|
||||
|
||||
const parsedParts = this.parseParts(content.parts || []);
|
||||
const contentParts: OpenAIContentPart[] = [];
|
||||
const reasoningParts: string[] = [];
|
||||
const toolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[] = [];
|
||||
let toolCallIndex = 0;
|
||||
|
||||
// Handle function responses (tool results) first
|
||||
if (parsedParts.functionResponses.length > 0) {
|
||||
for (const funcResponse of parsedParts.functionResponses) {
|
||||
messages.push({
|
||||
role: 'tool' as const,
|
||||
tool_call_id: funcResponse.id || '',
|
||||
content: this.extractFunctionResponseContent(funcResponse.response),
|
||||
});
|
||||
for (const part of parts) {
|
||||
if (typeof part === 'string') {
|
||||
contentParts.push({ type: 'text' as const, text: part });
|
||||
continue;
|
||||
}
|
||||
|
||||
if ('text' in part && 'thought' in part && part.thought) {
|
||||
if (role === 'assistant' && part.text) {
|
||||
reasoningParts.push(part.text);
|
||||
}
|
||||
}
|
||||
|
||||
if ('text' in part && part.text && !('thought' in part && part.thought)) {
|
||||
contentParts.push({ type: 'text' as const, text: part.text });
|
||||
}
|
||||
|
||||
const mediaPart = this.createMediaContentPart(part);
|
||||
if (mediaPart && role === 'user') {
|
||||
contentParts.push(mediaPart);
|
||||
}
|
||||
|
||||
if ('functionCall' in part && part.functionCall && role === 'assistant') {
|
||||
toolCalls.push({
|
||||
id: part.functionCall.id || `call_${toolCallIndex}`,
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: part.functionCall.name || '',
|
||||
arguments: JSON.stringify(part.functionCall.args || {}),
|
||||
},
|
||||
});
|
||||
toolCallIndex += 1;
|
||||
}
|
||||
|
||||
if (part.functionResponse && role === 'user') {
|
||||
// Create tool message for the function response (with embedded media)
|
||||
const toolMessage = this.createToolMessage(part.functionResponse);
|
||||
if (toolMessage) {
|
||||
messages.push(toolMessage);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle model messages with function calls
|
||||
if (content.role === 'model' && parsedParts.functionCalls.length > 0) {
|
||||
const toolCalls = parsedParts.functionCalls.map((fc, index) => ({
|
||||
id: fc.id || `call_${index}`,
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: fc.name || '',
|
||||
arguments: JSON.stringify(fc.args || {}),
|
||||
},
|
||||
}));
|
||||
if (role === 'assistant') {
|
||||
if (
|
||||
contentParts.length === 0 &&
|
||||
toolCalls.length === 0 &&
|
||||
reasoningParts.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const assistantTextContent = contentParts
|
||||
.filter(
|
||||
(part): part is OpenAI.Chat.ChatCompletionContentPartText =>
|
||||
part.type === 'text',
|
||||
)
|
||||
.map((part) => part.text)
|
||||
.join('');
|
||||
const assistantMessage: ExtendedChatCompletionAssistantMessageParam = {
|
||||
role: 'assistant' as const,
|
||||
content: parsedParts.contentParts.join('') || null,
|
||||
tool_calls: toolCalls,
|
||||
role: 'assistant',
|
||||
content: assistantTextContent || null,
|
||||
};
|
||||
|
||||
// Only include reasoning_content if it has actual content
|
||||
const reasoningContent = parsedParts.thoughtParts.join('');
|
||||
if (toolCalls.length > 0) {
|
||||
assistantMessage.tool_calls = toolCalls;
|
||||
}
|
||||
|
||||
const reasoningContent = reasoningParts.join('');
|
||||
if (reasoningContent) {
|
||||
assistantMessage.reasoning_content = reasoningContent;
|
||||
}
|
||||
@@ -432,79 +491,15 @@ export class OpenAIContentConverter {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle regular messages with multimodal content
|
||||
const role = content.role === 'model' ? 'assistant' : 'user';
|
||||
const openAIMessage = this.createMultimodalMessage(role, parsedParts);
|
||||
|
||||
if (openAIMessage) {
|
||||
messages.push(openAIMessage);
|
||||
if (contentParts.length > 0) {
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content:
|
||||
contentParts as unknown as OpenAI.Chat.ChatCompletionContentPart[],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Gemini parts into categorized components
|
||||
*/
|
||||
private parseParts(parts: Part[]): ParsedParts {
|
||||
const thoughtParts: string[] = [];
|
||||
const contentParts: string[] = [];
|
||||
const functionCalls: FunctionCall[] = [];
|
||||
const functionResponses: FunctionResponse[] = [];
|
||||
const mediaParts: Array<{
|
||||
type: 'image' | 'audio' | 'file';
|
||||
data: string;
|
||||
mimeType: string;
|
||||
fileUri?: string;
|
||||
}> = [];
|
||||
|
||||
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 &&
|
||||
part.text &&
|
||||
'thought' in part &&
|
||||
part.thought
|
||||
) {
|
||||
thoughtParts.push(part.text);
|
||||
} else if ('functionCall' in part && part.functionCall) {
|
||||
functionCalls.push(part.functionCall);
|
||||
} else if ('functionResponse' in part && part.functionResponse) {
|
||||
functionResponses.push(part.functionResponse);
|
||||
} else if ('inlineData' in part && part.inlineData) {
|
||||
const { data, mimeType } = part.inlineData;
|
||||
if (data && mimeType) {
|
||||
const mediaType = this.getMediaType(mimeType);
|
||||
mediaParts.push({ type: mediaType, data, mimeType });
|
||||
}
|
||||
} else if ('fileData' in part && part.fileData) {
|
||||
const { fileUri, mimeType } = part.fileData;
|
||||
if (fileUri && mimeType) {
|
||||
const mediaType = this.getMediaType(mimeType);
|
||||
mediaParts.push({
|
||||
type: mediaType,
|
||||
data: '',
|
||||
mimeType,
|
||||
fileUri,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
thoughtParts,
|
||||
contentParts,
|
||||
functionCalls,
|
||||
functionResponses,
|
||||
mediaParts,
|
||||
};
|
||||
}
|
||||
|
||||
private extractFunctionResponseContent(response: unknown): string {
|
||||
if (response === null || response === undefined) {
|
||||
return '';
|
||||
@@ -535,6 +530,96 @@ export class OpenAIContentConverter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a tool message from function response (with embedded media parts)
|
||||
*/
|
||||
private createToolMessage(
|
||||
response: FunctionResponse,
|
||||
): OpenAI.Chat.ChatCompletionToolMessageParam | null {
|
||||
const textContent = this.extractFunctionResponseContent(response.response);
|
||||
const contentParts: OpenAIContentPart[] = [];
|
||||
|
||||
// Add text content first if present
|
||||
if (textContent) {
|
||||
contentParts.push({ type: 'text' as const, text: textContent });
|
||||
}
|
||||
|
||||
// Add media parts from function response
|
||||
for (const part of response.parts || []) {
|
||||
const mediaPart = this.createMediaContentPart(part);
|
||||
if (mediaPart) {
|
||||
contentParts.push(mediaPart);
|
||||
}
|
||||
}
|
||||
|
||||
// Tool messages require content, so skip if empty
|
||||
if (contentParts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cast to OpenAI type - some OpenAI-compatible APIs support richer content in tool messages
|
||||
return {
|
||||
role: 'tool' as const,
|
||||
tool_call_id: response.id || '',
|
||||
content: contentParts as unknown as
|
||||
| string
|
||||
| OpenAI.Chat.ChatCompletionContentPartText[],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create OpenAI media content part from Gemini part
|
||||
*/
|
||||
private createMediaContentPart(part: Part): OpenAIContentPart | null {
|
||||
if (part.inlineData?.mimeType && part.inlineData?.data) {
|
||||
const mediaType = this.getMediaType(part.inlineData.mimeType);
|
||||
if (mediaType === 'image') {
|
||||
const dataUrl = `data:${part.inlineData.mimeType};base64,${part.inlineData.data}`;
|
||||
return {
|
||||
type: 'image_url' as const,
|
||||
image_url: { url: dataUrl },
|
||||
};
|
||||
}
|
||||
if (mediaType === 'audio') {
|
||||
const format = this.getAudioFormat(part.inlineData.mimeType);
|
||||
if (format) {
|
||||
return {
|
||||
type: 'input_audio' as const,
|
||||
input_audio: {
|
||||
data: part.inlineData.data,
|
||||
format,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (part.fileData?.mimeType && part.fileData?.fileUri) {
|
||||
const filename = part.fileData.displayName || 'file';
|
||||
const fileUri = part.fileData.fileUri;
|
||||
|
||||
if (fileUri.startsWith('data:')) {
|
||||
return {
|
||||
type: 'file' as const,
|
||||
file: {
|
||||
filename,
|
||||
file_data: fileUri,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'file' as const,
|
||||
file: {
|
||||
filename,
|
||||
file_data: `data:${part.fileData.mimeType};base64,${fileUri}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine media type from MIME type
|
||||
*/
|
||||
@@ -544,85 +629,6 @@ export class OpenAIContentConverter {
|
||||
return 'file';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multimodal OpenAI message from parsed parts
|
||||
*/
|
||||
private createMultimodalMessage(
|
||||
role: 'user' | 'assistant',
|
||||
parsedParts: Pick<
|
||||
ParsedParts,
|
||||
'contentParts' | 'mediaParts' | 'thoughtParts'
|
||||
>,
|
||||
): ExtendedChatCompletionMessageParam | null {
|
||||
const { contentParts, mediaParts, thoughtParts } = parsedParts;
|
||||
const reasoningContent = thoughtParts.join('');
|
||||
const content = contentParts.map((text) => ({
|
||||
type: 'text' as const,
|
||||
text,
|
||||
}));
|
||||
|
||||
// If no media parts, return simple text message
|
||||
if (mediaParts.length === 0) {
|
||||
if (content.length === 0) return null;
|
||||
const message: ExtendedChatCompletionMessageParam = { role, content };
|
||||
// Only include reasoning_content if it has actual content
|
||||
if (reasoningContent) {
|
||||
(
|
||||
message as ExtendedChatCompletionAssistantMessageParam
|
||||
).reasoning_content = reasoningContent;
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
// For assistant messages with media, convert to text only
|
||||
// since OpenAI assistant messages don't support media content arrays
|
||||
if (role === 'assistant') {
|
||||
return content.length > 0
|
||||
? { role: 'assistant' as const, content }
|
||||
: null;
|
||||
}
|
||||
|
||||
const contentArray: OpenAI.Chat.ChatCompletionContentPart[] = [...content];
|
||||
|
||||
// Add media content
|
||||
for (const mediaPart of mediaParts) {
|
||||
if (mediaPart.type === 'image') {
|
||||
if (mediaPart.fileUri) {
|
||||
// For file URIs, use the URI directly
|
||||
contentArray.push({
|
||||
type: 'image_url' as const,
|
||||
image_url: { url: mediaPart.fileUri },
|
||||
});
|
||||
} else if (mediaPart.data) {
|
||||
// For inline data, create data URL
|
||||
const dataUrl = `data:${mediaPart.mimeType};base64,${mediaPart.data}`;
|
||||
contentArray.push({
|
||||
type: 'image_url' as const,
|
||||
image_url: { url: dataUrl },
|
||||
});
|
||||
}
|
||||
} else if (mediaPart.type === 'audio' && mediaPart.data) {
|
||||
// Convert audio format from MIME type
|
||||
const format = this.getAudioFormat(mediaPart.mimeType);
|
||||
if (format) {
|
||||
contentArray.push({
|
||||
type: 'input_audio' as const,
|
||||
input_audio: {
|
||||
data: mediaPart.data,
|
||||
format: format as 'wav' | 'mp3',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
// Note: File type is not directly supported in OpenAI's current API
|
||||
// Could be extended in the future or handled as text description
|
||||
}
|
||||
|
||||
return contentArray.length > 0
|
||||
? { role: 'user' as const, content: contentArray }
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert MIME type to OpenAI audio format
|
||||
*/
|
||||
@@ -693,8 +699,9 @@ export class OpenAIContentConverter {
|
||||
const parts: Part[] = [];
|
||||
|
||||
// Handle reasoning content (thoughts)
|
||||
const reasoningText = (choice.message as ExtendedCompletionMessage)
|
||||
.reasoning_content;
|
||||
const reasoningText =
|
||||
(choice.message as ExtendedCompletionMessage).reasoning_content ??
|
||||
(choice.message as ExtendedCompletionMessage).reasoning;
|
||||
if (reasoningText) {
|
||||
parts.push({ text: reasoningText, thought: true });
|
||||
}
|
||||
@@ -798,8 +805,9 @@ export class OpenAIContentConverter {
|
||||
if (choice) {
|
||||
const parts: Part[] = [];
|
||||
|
||||
const reasoningText = (choice.delta as ExtendedCompletionChunkDelta)
|
||||
?.reasoning_content;
|
||||
const reasoningText =
|
||||
(choice.delta as ExtendedCompletionChunkDelta)?.reasoning_content ??
|
||||
(choice.delta as ExtendedCompletionChunkDelta)?.reasoning;
|
||||
if (reasoningText) {
|
||||
parts.push({ text: reasoningText, thought: true });
|
||||
}
|
||||
@@ -1130,6 +1138,10 @@ export class OpenAIContentConverter {
|
||||
|
||||
// If the last message is also an assistant message, merge them
|
||||
if (lastMessage.role === 'assistant') {
|
||||
const lastToolCalls =
|
||||
'tool_calls' in lastMessage ? lastMessage.tool_calls || [] : [];
|
||||
const currentToolCalls =
|
||||
'tool_calls' in message ? message.tool_calls || [] : [];
|
||||
// Combine content
|
||||
const lastContent = lastMessage.content;
|
||||
const currentContent = message.content;
|
||||
@@ -1171,10 +1183,6 @@ export class OpenAIContentConverter {
|
||||
}
|
||||
|
||||
// Combine tool calls
|
||||
const lastToolCalls =
|
||||
'tool_calls' in lastMessage ? lastMessage.tool_calls || [] : [];
|
||||
const currentToolCalls =
|
||||
'tool_calls' in message ? message.tool_calls || [] : [];
|
||||
const combinedToolCalls = [...lastToolCalls, ...currentToolCalls];
|
||||
|
||||
// Update the last message with combined data
|
||||
|
||||
@@ -22,17 +22,7 @@ const mockTokenizer = {
|
||||
};
|
||||
|
||||
vi.mock('../../../utils/request-tokenizer/index.js', () => ({
|
||||
getDefaultTokenizer: vi.fn(() => mockTokenizer),
|
||||
DefaultRequestTokenizer: vi.fn(() => mockTokenizer),
|
||||
disposeDefaultTokenizer: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock tiktoken as well for completeness
|
||||
vi.mock('tiktoken', () => ({
|
||||
get_encoding: vi.fn(() => ({
|
||||
encode: vi.fn(() => new Array(50)), // Mock 50 tokens
|
||||
free: vi.fn(),
|
||||
})),
|
||||
RequestTokenEstimator: vi.fn(() => mockTokenizer),
|
||||
}));
|
||||
|
||||
// Now import the modules that depend on the mocked modules
|
||||
@@ -134,7 +124,7 @@ describe('OpenAIContentGenerator (Refactored)', () => {
|
||||
});
|
||||
|
||||
describe('countTokens', () => {
|
||||
it('should count tokens using tiktoken', async () => {
|
||||
it('should count tokens using character-based estimation', async () => {
|
||||
const request: CountTokensParameters = {
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello world' }] }],
|
||||
model: 'gpt-4',
|
||||
@@ -142,26 +132,27 @@ describe('OpenAIContentGenerator (Refactored)', () => {
|
||||
|
||||
const result = await generator.countTokens(request);
|
||||
|
||||
expect(result.totalTokens).toBe(50); // Mocked value
|
||||
// 'Hello world' = 11 ASCII chars
|
||||
// 11 / 4 = 2.75 -> ceil = 3 tokens
|
||||
expect(result.totalTokens).toBe(3);
|
||||
});
|
||||
|
||||
it('should fall back to character approximation if tiktoken fails', async () => {
|
||||
// Mock tiktoken to throw error
|
||||
vi.doMock('tiktoken', () => ({
|
||||
get_encoding: vi.fn().mockImplementation(() => {
|
||||
throw new Error('Tiktoken failed');
|
||||
}),
|
||||
}));
|
||||
|
||||
it('should handle multimodal content', async () => {
|
||||
const request: CountTokensParameters = {
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello world' }] }],
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'Hello' }, { text: ' world' }],
|
||||
},
|
||||
],
|
||||
model: 'gpt-4',
|
||||
};
|
||||
|
||||
const result = await generator.countTokens(request);
|
||||
|
||||
// Should use character approximation (content length / 4)
|
||||
expect(result.totalTokens).toBeGreaterThan(0);
|
||||
// Parts are combined for estimation:
|
||||
// 'Hello world' = 11 ASCII chars -> 11/4 = 2.75 -> ceil = 3 tokens
|
||||
expect(result.totalTokens).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
import type { PipelineConfig } from './pipeline.js';
|
||||
import { ContentGenerationPipeline } from './pipeline.js';
|
||||
import { EnhancedErrorHandler } from './errorHandler.js';
|
||||
import { getDefaultTokenizer } from '../../utils/request-tokenizer/index.js';
|
||||
import { RequestTokenEstimator } from '../../utils/request-tokenizer/index.js';
|
||||
import type { ContentGeneratorConfig } from '../contentGenerator.js';
|
||||
import { isAbortError } from '../../utils/errors.js';
|
||||
|
||||
@@ -80,11 +80,9 @@ export class OpenAIContentGenerator implements ContentGenerator {
|
||||
request: CountTokensParameters,
|
||||
): Promise<CountTokensResponse> {
|
||||
try {
|
||||
// Use the new high-performance request tokenizer
|
||||
const tokenizer = getDefaultTokenizer();
|
||||
const result = await tokenizer.calculateTokens(request, {
|
||||
textEncoding: 'cl100k_base', // Use GPT-4 encoding for consistency
|
||||
});
|
||||
// Use the request token estimator (character-based).
|
||||
const estimator = new RequestTokenEstimator();
|
||||
const result = await estimator.calculateTokens(request);
|
||||
|
||||
return {
|
||||
totalTokens: result.totalTokens,
|
||||
|
||||
@@ -320,13 +320,15 @@ export class ContentGenerationPipeline {
|
||||
'frequency_penalty',
|
||||
'frequencyPenalty',
|
||||
),
|
||||
...this.buildReasoningConfig(),
|
||||
...this.buildReasoningConfig(request),
|
||||
};
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
private buildReasoningConfig(): Record<string, unknown> {
|
||||
private buildReasoningConfig(
|
||||
request: GenerateContentParameters,
|
||||
): Record<string, unknown> {
|
||||
// Reasoning configuration for OpenAI-compatible endpoints is highly fragmented.
|
||||
// For example, across common providers and models:
|
||||
//
|
||||
@@ -336,13 +338,21 @@ export class ContentGenerationPipeline {
|
||||
// - gpt-5.x series — thinking is enabled by default; can be disabled via `reasoning.effort`
|
||||
// - qwen3 series — model-dependent; can be manually disabled via `extra_body.enable_thinking`
|
||||
//
|
||||
// Given this inconsistency, we choose not to set any reasoning config here and
|
||||
// instead rely on each model’s default behavior.
|
||||
// Given this inconsistency, we avoid mapping values and only pass through the
|
||||
// configured reasoning object when explicitly enabled. This keeps provider- and
|
||||
// model-specific semantics intact while honoring request-level opt-out.
|
||||
|
||||
// We plan to introduce provider- and model-specific settings to enable more
|
||||
// fine-grained control over reasoning configuration.
|
||||
if (request.config?.thinkingConfig?.includeThoughts === false) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {};
|
||||
const reasoning = this.contentGeneratorConfig.reasoning;
|
||||
|
||||
if (reasoning === false || reasoning === undefined) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return { reasoning };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -608,7 +608,7 @@ describe('DashScopeOpenAICompatibleProvider', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should add empty text item with cache control if last item is not text for streaming requests', () => {
|
||||
it('should add cache control to last item even if not text for streaming requests', () => {
|
||||
const requestWithNonTextLast: OpenAI.Chat.ChatCompletionCreateParams = {
|
||||
model: 'qwen-max',
|
||||
stream: true, // This will trigger cache control on last message
|
||||
@@ -633,12 +633,12 @@ describe('DashScopeOpenAICompatibleProvider', () => {
|
||||
|
||||
const content = result.messages[0]
|
||||
.content as OpenAI.Chat.ChatCompletionContentPart[];
|
||||
expect(content).toHaveLength(3);
|
||||
expect(content).toHaveLength(2);
|
||||
|
||||
// Should add empty text item with cache control
|
||||
expect(content[2]).toEqual({
|
||||
type: 'text',
|
||||
text: '',
|
||||
// Cache control should be added to the last item (image)
|
||||
expect(content[1]).toEqual({
|
||||
type: 'image_url',
|
||||
image_url: { url: 'https://example.com/image.jpg' },
|
||||
cache_control: { type: 'ephemeral' },
|
||||
});
|
||||
});
|
||||
@@ -709,13 +709,8 @@ describe('DashScopeOpenAICompatibleProvider', () => {
|
||||
|
||||
const content = result.messages[0]
|
||||
.content as OpenAI.Chat.ChatCompletionContentPart[];
|
||||
expect(content).toEqual([
|
||||
{
|
||||
type: 'text',
|
||||
text: '',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
]);
|
||||
// Empty content array should remain empty
|
||||
expect(content).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -257,31 +257,15 @@ export class DashScopeOpenAICompatibleProvider
|
||||
contentArray: ChatCompletionContentPartWithCache[],
|
||||
): ChatCompletionContentPartWithCache[] {
|
||||
if (contentArray.length === 0) {
|
||||
return [
|
||||
{
|
||||
type: 'text',
|
||||
text: '',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
} as ChatCompletionContentPartTextWithCache,
|
||||
];
|
||||
return contentArray;
|
||||
}
|
||||
|
||||
// Add cache_control to the last text item
|
||||
const lastItem = contentArray[contentArray.length - 1];
|
||||
|
||||
if (lastItem.type === 'text') {
|
||||
// Add cache_control to the last text item
|
||||
contentArray[contentArray.length - 1] = {
|
||||
...lastItem,
|
||||
cache_control: { type: 'ephemeral' },
|
||||
} as ChatCompletionContentPartTextWithCache;
|
||||
} else {
|
||||
// If the last item is not text, add a new text item with cache_control
|
||||
contentArray.push({
|
||||
type: 'text',
|
||||
text: '',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
} as ChatCompletionContentPartTextWithCache);
|
||||
}
|
||||
contentArray[contentArray.length - 1] = {
|
||||
...lastItem,
|
||||
cache_control: { type: 'ephemeral' },
|
||||
} as ChatCompletionContentPartTextWithCache;
|
||||
|
||||
return contentArray;
|
||||
}
|
||||
|
||||
@@ -102,16 +102,14 @@ export const QWEN_OAUTH_ALLOWED_MODELS = [
|
||||
export const QWEN_OAUTH_MODELS: ModelConfig[] = [
|
||||
{
|
||||
id: 'coder-model',
|
||||
name: 'Qwen Coder',
|
||||
description:
|
||||
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)',
|
||||
name: 'coder-model',
|
||||
description: 'The latest Qwen Coder model from Alibaba Cloud ModelStudio',
|
||||
capabilities: { vision: false },
|
||||
},
|
||||
{
|
||||
id: 'vision-model',
|
||||
name: 'Qwen Vision',
|
||||
description:
|
||||
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)',
|
||||
name: 'vision-model',
|
||||
description: 'The latest Qwen Vision model from Alibaba Cloud ModelStudio',
|
||||
capabilities: { vision: true },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -15,13 +15,11 @@ import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
|
||||
import { tokenLimit } from '../core/tokenLimits.js';
|
||||
import type { GeminiChat } from '../core/geminiChat.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { getInitialChatHistory } from '../utils/environmentContext.js';
|
||||
import type { ContentGenerator } from '../core/contentGenerator.js';
|
||||
|
||||
vi.mock('../telemetry/uiTelemetry.js');
|
||||
vi.mock('../core/tokenLimits.js');
|
||||
vi.mock('../telemetry/loggers.js');
|
||||
vi.mock('../utils/environmentContext.js');
|
||||
|
||||
describe('findCompressSplitPoint', () => {
|
||||
it('should throw an error for non-positive numbers', () => {
|
||||
@@ -122,9 +120,6 @@ describe('ChatCompressionService', () => {
|
||||
|
||||
vi.mocked(tokenLimit).mockReturnValue(1000);
|
||||
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(500);
|
||||
vi.mocked(getInitialChatHistory).mockImplementation(
|
||||
async (_config, extraHistory) => extraHistory || [],
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -241,6 +236,7 @@ describe('ChatCompressionService', () => {
|
||||
vi.mocked(mockChat.getHistory).mockReturnValue(history);
|
||||
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(800);
|
||||
vi.mocked(tokenLimit).mockReturnValue(1000);
|
||||
// newTokenCount = 800 - (1600 - 1000) + 50 = 800 - 600 + 50 = 250 <= 800 (success)
|
||||
const mockGenerateContent = vi.fn().mockResolvedValue({
|
||||
candidates: [
|
||||
{
|
||||
@@ -249,6 +245,11 @@ describe('ChatCompressionService', () => {
|
||||
},
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 1600,
|
||||
candidatesTokenCount: 50,
|
||||
totalTokenCount: 1650,
|
||||
},
|
||||
} as unknown as GenerateContentResponse);
|
||||
vi.mocked(mockConfig.getContentGenerator).mockReturnValue({
|
||||
generateContent: mockGenerateContent,
|
||||
@@ -264,6 +265,7 @@ describe('ChatCompressionService', () => {
|
||||
);
|
||||
|
||||
expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED);
|
||||
expect(result.info.newTokenCount).toBe(250); // 800 - (1600 - 1000) + 50
|
||||
expect(result.newHistory).not.toBeNull();
|
||||
expect(result.newHistory![0].parts![0].text).toBe('Summary');
|
||||
expect(mockGenerateContent).toHaveBeenCalled();
|
||||
@@ -280,6 +282,7 @@ describe('ChatCompressionService', () => {
|
||||
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(100);
|
||||
vi.mocked(tokenLimit).mockReturnValue(1000);
|
||||
|
||||
// newTokenCount = 100 - (1100 - 1000) + 50 = 100 - 100 + 50 = 50 <= 100 (success)
|
||||
const mockGenerateContent = vi.fn().mockResolvedValue({
|
||||
candidates: [
|
||||
{
|
||||
@@ -288,6 +291,11 @@ describe('ChatCompressionService', () => {
|
||||
},
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 1100,
|
||||
candidatesTokenCount: 50,
|
||||
totalTokenCount: 1150,
|
||||
},
|
||||
} as unknown as GenerateContentResponse);
|
||||
vi.mocked(mockConfig.getContentGenerator).mockReturnValue({
|
||||
generateContent: mockGenerateContent,
|
||||
@@ -315,15 +323,19 @@ describe('ChatCompressionService', () => {
|
||||
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(10);
|
||||
vi.mocked(tokenLimit).mockReturnValue(1000);
|
||||
|
||||
const longSummary = 'a'.repeat(1000); // Long summary to inflate token count
|
||||
const mockGenerateContent = vi.fn().mockResolvedValue({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: longSummary }],
|
||||
parts: [{ text: 'Summary' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 1,
|
||||
candidatesTokenCount: 20,
|
||||
totalTokenCount: 21,
|
||||
},
|
||||
} as unknown as GenerateContentResponse);
|
||||
vi.mocked(mockConfig.getContentGenerator).mockReturnValue({
|
||||
generateContent: mockGenerateContent,
|
||||
@@ -344,6 +356,48 @@ describe('ChatCompressionService', () => {
|
||||
expect(result.newHistory).toBeNull();
|
||||
});
|
||||
|
||||
it('should return FAILED if usage metadata is missing', async () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'msg1' }] },
|
||||
{ role: 'model', parts: [{ text: 'msg2' }] },
|
||||
{ role: 'user', parts: [{ text: 'msg3' }] },
|
||||
{ role: 'model', parts: [{ text: 'msg4' }] },
|
||||
];
|
||||
vi.mocked(mockChat.getHistory).mockReturnValue(history);
|
||||
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(800);
|
||||
vi.mocked(tokenLimit).mockReturnValue(1000);
|
||||
|
||||
const mockGenerateContent = vi.fn().mockResolvedValue({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'Summary' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
// No usageMetadata -> keep original token count
|
||||
} as unknown as GenerateContentResponse);
|
||||
vi.mocked(mockConfig.getContentGenerator).mockReturnValue({
|
||||
generateContent: mockGenerateContent,
|
||||
} as unknown as ContentGenerator);
|
||||
|
||||
const result = await service.compress(
|
||||
mockChat,
|
||||
mockPromptId,
|
||||
false,
|
||||
mockModel,
|
||||
mockConfig,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.info.compressionStatus).toBe(
|
||||
CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR,
|
||||
);
|
||||
expect(result.info.originalTokenCount).toBe(800);
|
||||
expect(result.info.newTokenCount).toBe(800);
|
||||
expect(result.newHistory).toBeNull();
|
||||
});
|
||||
|
||||
it('should return FAILED if summary is empty string', async () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'msg1' }] },
|
||||
|
||||
@@ -14,7 +14,6 @@ import { getCompressionPrompt } from '../core/prompts.js';
|
||||
import { getResponseText } from '../utils/partUtils.js';
|
||||
import { logChatCompression } from '../telemetry/loggers.js';
|
||||
import { makeChatCompressionEvent } from '../telemetry/types.js';
|
||||
import { getInitialChatHistory } from '../utils/environmentContext.js';
|
||||
|
||||
/**
|
||||
* Threshold for compression token count as a fraction of the model's token limit.
|
||||
@@ -163,9 +162,25 @@ export class ChatCompressionService {
|
||||
);
|
||||
const summary = getResponseText(summaryResponse) ?? '';
|
||||
const isSummaryEmpty = !summary || summary.trim().length === 0;
|
||||
const compressionUsageMetadata = summaryResponse.usageMetadata;
|
||||
const compressionInputTokenCount =
|
||||
compressionUsageMetadata?.promptTokenCount;
|
||||
let compressionOutputTokenCount =
|
||||
compressionUsageMetadata?.candidatesTokenCount;
|
||||
if (
|
||||
compressionOutputTokenCount === undefined &&
|
||||
typeof compressionUsageMetadata?.totalTokenCount === 'number' &&
|
||||
typeof compressionInputTokenCount === 'number'
|
||||
) {
|
||||
compressionOutputTokenCount = Math.max(
|
||||
0,
|
||||
compressionUsageMetadata.totalTokenCount - compressionInputTokenCount,
|
||||
);
|
||||
}
|
||||
|
||||
let newTokenCount = originalTokenCount;
|
||||
let extraHistory: Content[] = [];
|
||||
let canCalculateNewTokenCount = false;
|
||||
|
||||
if (!isSummaryEmpty) {
|
||||
extraHistory = [
|
||||
@@ -180,16 +195,26 @@ export class ChatCompressionService {
|
||||
...historyToKeep,
|
||||
];
|
||||
|
||||
// Use a shared utility to construct the initial history for an accurate token count.
|
||||
const fullNewHistory = await getInitialChatHistory(config, extraHistory);
|
||||
|
||||
// Estimate token count 1 token ≈ 4 characters
|
||||
newTokenCount = Math.floor(
|
||||
fullNewHistory.reduce(
|
||||
(total, content) => total + JSON.stringify(content).length,
|
||||
// Best-effort token math using *only* model-reported token counts.
|
||||
//
|
||||
// Note: compressionInputTokenCount includes the compression prompt and
|
||||
// the extra "reason in your scratchpad" instruction(approx. 1000 tokens), and
|
||||
// compressionOutputTokenCount may include non-persisted tokens (thoughts).
|
||||
// We accept these inaccuracies to avoid local token estimation.
|
||||
if (
|
||||
typeof compressionInputTokenCount === 'number' &&
|
||||
compressionInputTokenCount > 0 &&
|
||||
typeof compressionOutputTokenCount === 'number' &&
|
||||
compressionOutputTokenCount > 0
|
||||
) {
|
||||
canCalculateNewTokenCount = true;
|
||||
newTokenCount = Math.max(
|
||||
0,
|
||||
) / 4,
|
||||
);
|
||||
originalTokenCount -
|
||||
(compressionInputTokenCount - 1000) +
|
||||
compressionOutputTokenCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logChatCompression(
|
||||
@@ -197,6 +222,8 @@ export class ChatCompressionService {
|
||||
makeChatCompressionEvent({
|
||||
tokens_before: originalTokenCount,
|
||||
tokens_after: newTokenCount,
|
||||
compression_input_token_count: compressionInputTokenCount,
|
||||
compression_output_token_count: compressionOutputTokenCount,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -209,6 +236,16 @@ export class ChatCompressionService {
|
||||
compressionStatus: CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY,
|
||||
},
|
||||
};
|
||||
} else if (!canCalculateNewTokenCount) {
|
||||
return {
|
||||
newHistory: null,
|
||||
info: {
|
||||
originalTokenCount,
|
||||
newTokenCount: originalTokenCount,
|
||||
compressionStatus:
|
||||
CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR,
|
||||
},
|
||||
};
|
||||
} else if (newTokenCount > originalTokenCount) {
|
||||
return {
|
||||
newHistory: null,
|
||||
|
||||
@@ -580,9 +580,11 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
});
|
||||
|
||||
expect(mockCpSpawn).toHaveBeenCalledWith(
|
||||
'ls -l',
|
||||
[],
|
||||
expect.objectContaining({ shell: 'bash' }),
|
||||
'bash',
|
||||
['-c', 'ls -l'],
|
||||
expect.objectContaining({
|
||||
detached: true,
|
||||
}),
|
||||
);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.signal).toBeNull();
|
||||
@@ -825,10 +827,9 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
);
|
||||
|
||||
expect(mockCpSpawn).toHaveBeenCalledWith(
|
||||
'dir "foo bar"',
|
||||
[],
|
||||
'cmd.exe',
|
||||
['/c', 'dir "foo bar"'],
|
||||
expect.objectContaining({
|
||||
shell: true,
|
||||
detached: false,
|
||||
windowsHide: true,
|
||||
}),
|
||||
@@ -840,10 +841,9 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
await simulateExecution('ls "foo bar"', (cp) => cp.emit('exit', 0, null));
|
||||
|
||||
expect(mockCpSpawn).toHaveBeenCalledWith(
|
||||
'ls "foo bar"',
|
||||
[],
|
||||
'bash',
|
||||
['-c', 'ls "foo bar"'],
|
||||
expect.objectContaining({
|
||||
shell: 'bash',
|
||||
detached: true,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -223,12 +223,17 @@ export class ShellExecutionService {
|
||||
): ShellExecutionHandle {
|
||||
try {
|
||||
const isWindows = os.platform() === 'win32';
|
||||
const shell = isWindows ? 'cmd.exe' : 'bash';
|
||||
const shellArgs = isWindows
|
||||
? ['/c', commandToExecute]
|
||||
: ['-c', commandToExecute];
|
||||
|
||||
const child = cpSpawn(commandToExecute, [], {
|
||||
// Note: CodeQL flags this as js/shell-command-injection-from-environment.
|
||||
// This is intentional - CLI tool executes user-provided shell commands.
|
||||
const child = cpSpawn(shell, shellArgs, {
|
||||
cwd,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsVerbatimArguments: true,
|
||||
shell: isWindows ? true : 'bash',
|
||||
windowsVerbatimArguments: isWindows,
|
||||
detached: !isWindows,
|
||||
windowsHide: isWindows,
|
||||
env: {
|
||||
|
||||
@@ -439,17 +439,27 @@ export interface ChatCompressionEvent extends BaseTelemetryEvent {
|
||||
'event.timestamp': string;
|
||||
tokens_before: number;
|
||||
tokens_after: number;
|
||||
compression_input_token_count?: number;
|
||||
compression_output_token_count?: number;
|
||||
}
|
||||
|
||||
export function makeChatCompressionEvent({
|
||||
tokens_before,
|
||||
tokens_after,
|
||||
compression_input_token_count,
|
||||
compression_output_token_count,
|
||||
}: Omit<ChatCompressionEvent, CommonFields>): ChatCompressionEvent {
|
||||
return {
|
||||
'event.name': 'chat_compression',
|
||||
'event.timestamp': new Date().toISOString(),
|
||||
tokens_before,
|
||||
tokens_after,
|
||||
...(compression_input_token_count !== undefined
|
||||
? { compression_input_token_count }
|
||||
: {}),
|
||||
...(compression_output_token_count !== undefined
|
||||
? { compression_output_token_count }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -283,6 +283,7 @@ describe('ReadFileTool', () => {
|
||||
inlineData: {
|
||||
data: pngHeader.toString('base64'),
|
||||
mimeType: 'image/png',
|
||||
displayName: 'image.png',
|
||||
},
|
||||
});
|
||||
expect(result.returnDisplay).toBe('Read image file: image.png');
|
||||
@@ -301,9 +302,10 @@ describe('ReadFileTool', () => {
|
||||
|
||||
const result = await invocation.execute(abortSignal);
|
||||
expect(result.llmContent).toEqual({
|
||||
inlineData: {
|
||||
data: pdfHeader.toString('base64'),
|
||||
fileData: {
|
||||
fileUri: pdfHeader.toString('base64'),
|
||||
mimeType: 'application/pdf',
|
||||
displayName: 'document.pdf',
|
||||
},
|
||||
});
|
||||
expect(result.returnDisplay).toBe('Read pdf file: document.pdf');
|
||||
|
||||
@@ -383,6 +383,7 @@ describe('ReadManyFilesTool', () => {
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
]).toString('base64'),
|
||||
mimeType: 'image/png',
|
||||
displayName: 'image.png',
|
||||
},
|
||||
},
|
||||
'\n--- End of content ---',
|
||||
@@ -407,6 +408,7 @@ describe('ReadManyFilesTool', () => {
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
]).toString('base64'),
|
||||
mimeType: 'image/png',
|
||||
displayName: 'myExactImage.png',
|
||||
},
|
||||
},
|
||||
'\n--- End of content ---',
|
||||
@@ -434,32 +436,34 @@ describe('ReadManyFilesTool', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should include PDF files as inlineData parts if explicitly requested by extension', async () => {
|
||||
it('should include PDF files as fileData parts if explicitly requested by extension', async () => {
|
||||
createBinaryFile('important.pdf', Buffer.from('%PDF-1.4...'));
|
||||
const params = { paths: ['*.pdf'] }; // Explicitly requesting .pdf files
|
||||
const invocation = tool.build(params);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
expect(result.llmContent).toEqual([
|
||||
{
|
||||
inlineData: {
|
||||
data: Buffer.from('%PDF-1.4...').toString('base64'),
|
||||
fileData: {
|
||||
fileUri: Buffer.from('%PDF-1.4...').toString('base64'),
|
||||
mimeType: 'application/pdf',
|
||||
displayName: 'important.pdf',
|
||||
},
|
||||
},
|
||||
'\n--- End of content ---',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should include PDF files as inlineData parts if explicitly requested by name', async () => {
|
||||
it('should include PDF files as fileData parts if explicitly requested by name', async () => {
|
||||
createBinaryFile('report-final.pdf', Buffer.from('%PDF-1.4...'));
|
||||
const params = { paths: ['report-final.pdf'] };
|
||||
const invocation = tool.build(params);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
expect(result.llmContent).toEqual([
|
||||
{
|
||||
inlineData: {
|
||||
data: Buffer.from('%PDF-1.4...').toString('base64'),
|
||||
fileData: {
|
||||
fileUri: Buffer.from('%PDF-1.4...').toString('base64'),
|
||||
mimeType: 'application/pdf',
|
||||
displayName: 'report-final.pdf',
|
||||
},
|
||||
},
|
||||
'\n--- End of content ---',
|
||||
|
||||
@@ -731,6 +731,10 @@ describe('fileUtils', () => {
|
||||
expect(
|
||||
(result.llmContent as { inlineData: { data: string } }).inlineData.data,
|
||||
).toBe(fakePngData.toString('base64'));
|
||||
expect(
|
||||
(result.llmContent as { inlineData: { displayName?: string } })
|
||||
.inlineData.displayName,
|
||||
).toBe('image.png');
|
||||
expect(result.returnDisplay).toContain('Read image file: image.png');
|
||||
});
|
||||
|
||||
@@ -743,15 +747,20 @@ describe('fileUtils', () => {
|
||||
mockConfig,
|
||||
);
|
||||
expect(
|
||||
(result.llmContent as { inlineData: unknown }).inlineData,
|
||||
(result.llmContent as { fileData: unknown }).fileData,
|
||||
).toBeDefined();
|
||||
expect(
|
||||
(result.llmContent as { inlineData: { mimeType: string } }).inlineData
|
||||
(result.llmContent as { fileData: { mimeType: string } }).fileData
|
||||
.mimeType,
|
||||
).toBe('application/pdf');
|
||||
expect(
|
||||
(result.llmContent as { inlineData: { data: string } }).inlineData.data,
|
||||
(result.llmContent as { fileData: { fileUri: string } }).fileData
|
||||
.fileUri,
|
||||
).toBe(fakePdfData.toString('base64'));
|
||||
expect(
|
||||
(result.llmContent as { fileData: { displayName?: string } }).fileData
|
||||
.displayName,
|
||||
).toBe('document.pdf');
|
||||
expect(result.returnDisplay).toContain('Read pdf file: document.pdf');
|
||||
});
|
||||
|
||||
|
||||
@@ -351,6 +351,7 @@ export async function processSingleFileContent(
|
||||
.relative(rootDirectory, filePath)
|
||||
.replace(/\\/g, '/');
|
||||
|
||||
const displayName = path.basename(filePath);
|
||||
switch (fileType) {
|
||||
case 'binary': {
|
||||
return {
|
||||
@@ -456,7 +457,6 @@ export async function processSingleFileContent(
|
||||
};
|
||||
}
|
||||
case 'image':
|
||||
case 'pdf':
|
||||
case 'audio':
|
||||
case 'video': {
|
||||
const contentBuffer = await fs.promises.readFile(filePath);
|
||||
@@ -466,6 +466,21 @@ export async function processSingleFileContent(
|
||||
inlineData: {
|
||||
data: base64Data,
|
||||
mimeType: mime.getType(filePath) || 'application/octet-stream',
|
||||
displayName,
|
||||
},
|
||||
},
|
||||
returnDisplay: `Read ${fileType} file: ${relativePathForDisplay}`,
|
||||
};
|
||||
}
|
||||
case 'pdf': {
|
||||
const contentBuffer = await fs.promises.readFile(filePath);
|
||||
const base64Data = contentBuffer.toString('base64');
|
||||
return {
|
||||
llmContent: {
|
||||
fileData: {
|
||||
fileUri: base64Data,
|
||||
mimeType: mime.getType(filePath) || 'application/octet-stream',
|
||||
displayName,
|
||||
},
|
||||
},
|
||||
returnDisplay: `Read ${fileType} file: ${relativePathForDisplay}`,
|
||||
|
||||
@@ -113,6 +113,7 @@ describe('readPathFromWorkspace', () => {
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: imageData.toString('base64'),
|
||||
displayName: 'image.png',
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -263,6 +264,7 @@ describe('readPathFromWorkspace', () => {
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: imageData.toString('base64'),
|
||||
displayName: 'photo.png',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,37 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export { DefaultRequestTokenizer } from './requestTokenizer.js';
|
||||
import { DefaultRequestTokenizer } from './requestTokenizer.js';
|
||||
export { RequestTokenizer as RequestTokenEstimator } from './requestTokenizer.js';
|
||||
export { TextTokenizer } from './textTokenizer.js';
|
||||
export { ImageTokenizer } from './imageTokenizer.js';
|
||||
|
||||
export type {
|
||||
RequestTokenizer,
|
||||
TokenizerConfig,
|
||||
TokenCalculationResult,
|
||||
ImageMetadata,
|
||||
} from './types.js';
|
||||
|
||||
// Singleton instance for convenient usage
|
||||
let defaultTokenizer: DefaultRequestTokenizer | null = null;
|
||||
|
||||
/**
|
||||
* Get the default request tokenizer instance
|
||||
*/
|
||||
export function getDefaultTokenizer(): DefaultRequestTokenizer {
|
||||
if (!defaultTokenizer) {
|
||||
defaultTokenizer = new DefaultRequestTokenizer();
|
||||
}
|
||||
return defaultTokenizer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of the default tokenizer instance
|
||||
*/
|
||||
export async function disposeDefaultTokenizer(): Promise<void> {
|
||||
if (defaultTokenizer) {
|
||||
await defaultTokenizer.dispose();
|
||||
defaultTokenizer = null;
|
||||
}
|
||||
}
|
||||
export type { TokenCalculationResult, ImageMetadata } from './types.js';
|
||||
|
||||
@@ -4,19 +4,15 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { DefaultRequestTokenizer } from './requestTokenizer.js';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { RequestTokenizer } from './requestTokenizer.js';
|
||||
import type { CountTokensParameters } from '@google/genai';
|
||||
|
||||
describe('DefaultRequestTokenizer', () => {
|
||||
let tokenizer: DefaultRequestTokenizer;
|
||||
describe('RequestTokenEstimator', () => {
|
||||
let tokenizer: RequestTokenizer;
|
||||
|
||||
beforeEach(() => {
|
||||
tokenizer = new DefaultRequestTokenizer();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await tokenizer.dispose();
|
||||
tokenizer = new RequestTokenizer();
|
||||
});
|
||||
|
||||
describe('text token calculation', () => {
|
||||
@@ -221,25 +217,7 @@ describe('DefaultRequestTokenizer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('configuration', () => {
|
||||
it('should use custom text encoding', async () => {
|
||||
const request: CountTokensParameters = {
|
||||
model: 'test-model',
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'Test text for encoding' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = await tokenizer.calculateTokens(request, {
|
||||
textEncoding: 'cl100k_base',
|
||||
});
|
||||
|
||||
expect(result.totalTokens).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
describe('images', () => {
|
||||
it('should process multiple images serially', async () => {
|
||||
const pngBase64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU77yQAAAABJRU5ErkJggg==';
|
||||
|
||||
@@ -10,18 +10,14 @@ import type {
|
||||
Part,
|
||||
PartUnion,
|
||||
} from '@google/genai';
|
||||
import type {
|
||||
RequestTokenizer,
|
||||
TokenizerConfig,
|
||||
TokenCalculationResult,
|
||||
} from './types.js';
|
||||
import type { TokenCalculationResult } from './types.js';
|
||||
import { TextTokenizer } from './textTokenizer.js';
|
||||
import { ImageTokenizer } from './imageTokenizer.js';
|
||||
|
||||
/**
|
||||
* Simple request tokenizer that handles text and image content serially
|
||||
* Simple request token estimator that handles text and image content serially
|
||||
*/
|
||||
export class DefaultRequestTokenizer implements RequestTokenizer {
|
||||
export class RequestTokenizer {
|
||||
private textTokenizer: TextTokenizer;
|
||||
private imageTokenizer: ImageTokenizer;
|
||||
|
||||
@@ -35,15 +31,9 @@ export class DefaultRequestTokenizer implements RequestTokenizer {
|
||||
*/
|
||||
async calculateTokens(
|
||||
request: CountTokensParameters,
|
||||
config: TokenizerConfig = {},
|
||||
): Promise<TokenCalculationResult> {
|
||||
const startTime = performance.now();
|
||||
|
||||
// Apply configuration
|
||||
if (config.textEncoding) {
|
||||
this.textTokenizer = new TextTokenizer(config.textEncoding);
|
||||
}
|
||||
|
||||
try {
|
||||
// Process request content and group by type
|
||||
const { textContents, imageContents, audioContents, otherContents } =
|
||||
@@ -112,9 +102,8 @@ export class DefaultRequestTokenizer implements RequestTokenizer {
|
||||
if (textContents.length === 0) return 0;
|
||||
|
||||
try {
|
||||
const tokenCounts =
|
||||
await this.textTokenizer.calculateTokensBatch(textContents);
|
||||
return tokenCounts.reduce((sum, count) => sum + count, 0);
|
||||
// Avoid per-part rounding inflation by estimating once on the combined text.
|
||||
return await this.textTokenizer.calculateTokens(textContents.join(''));
|
||||
} catch (error) {
|
||||
console.warn('Error calculating text tokens:', error);
|
||||
// Fallback: character-based estimation
|
||||
@@ -177,10 +166,8 @@ export class DefaultRequestTokenizer implements RequestTokenizer {
|
||||
if (otherContents.length === 0) return 0;
|
||||
|
||||
try {
|
||||
// Treat other content as text for token calculation
|
||||
const tokenCounts =
|
||||
await this.textTokenizer.calculateTokensBatch(otherContents);
|
||||
return tokenCounts.reduce((sum, count) => sum + count, 0);
|
||||
// Treat other content as text, and avoid per-item rounding inflation.
|
||||
return await this.textTokenizer.calculateTokens(otherContents.join(''));
|
||||
} catch (error) {
|
||||
console.warn('Error calculating other content tokens:', error);
|
||||
// Fallback: character-based estimation
|
||||
@@ -264,7 +251,18 @@ export class DefaultRequestTokenizer implements RequestTokenizer {
|
||||
otherContents,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Some request shapes (e.g. CountTokensParameters) allow passing parts directly
|
||||
// instead of wrapping them in a { parts: [...] } Content object.
|
||||
this.processPart(
|
||||
content as Part | string,
|
||||
textContents,
|
||||
imageContents,
|
||||
audioContents,
|
||||
otherContents,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -326,16 +324,4 @@ export class DefaultRequestTokenizer implements RequestTokenizer {
|
||||
console.warn('Failed to serialize unknown part type:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of resources
|
||||
*/
|
||||
async dispose(): Promise<void> {
|
||||
try {
|
||||
// Dispose of tokenizers
|
||||
this.textTokenizer.dispose();
|
||||
} catch (error) {
|
||||
console.warn('Error disposing request tokenizer:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,36 +4,14 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { TextTokenizer } from './textTokenizer.js';
|
||||
|
||||
// Mock tiktoken at the top level with hoisted functions
|
||||
const mockEncode = vi.hoisted(() => vi.fn());
|
||||
const mockFree = vi.hoisted(() => vi.fn());
|
||||
const mockGetEncoding = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('tiktoken', () => ({
|
||||
get_encoding: mockGetEncoding,
|
||||
}));
|
||||
|
||||
describe('TextTokenizer', () => {
|
||||
let tokenizer: TextTokenizer;
|
||||
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
// Default mock implementation
|
||||
mockGetEncoding.mockReturnValue({
|
||||
encode: mockEncode,
|
||||
free: mockFree,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
tokenizer?.dispose();
|
||||
tokenizer = new TextTokenizer();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
@@ -42,17 +20,14 @@ describe('TextTokenizer', () => {
|
||||
expect(tokenizer).toBeInstanceOf(TextTokenizer);
|
||||
});
|
||||
|
||||
it('should create tokenizer with custom encoding', () => {
|
||||
tokenizer = new TextTokenizer('gpt2');
|
||||
it('should create tokenizer with custom encoding (for backward compatibility)', () => {
|
||||
tokenizer = new TextTokenizer();
|
||||
expect(tokenizer).toBeInstanceOf(TextTokenizer);
|
||||
// Note: encoding name is accepted but not used
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateTokens', () => {
|
||||
beforeEach(() => {
|
||||
tokenizer = new TextTokenizer();
|
||||
});
|
||||
|
||||
it('should return 0 for empty text', async () => {
|
||||
const result = await tokenizer.calculateTokens('');
|
||||
expect(result).toBe(0);
|
||||
@@ -69,99 +44,77 @@ describe('TextTokenizer', () => {
|
||||
expect(result2).toBe(0);
|
||||
});
|
||||
|
||||
it('should calculate tokens using tiktoken when available', async () => {
|
||||
const testText = 'Hello, world!';
|
||||
const mockTokens = [1, 2, 3, 4, 5]; // 5 tokens
|
||||
mockEncode.mockReturnValue(mockTokens);
|
||||
|
||||
it('should calculate tokens using character-based estimation for ASCII text', async () => {
|
||||
const testText = 'Hello, world!'; // 13 ASCII chars
|
||||
const result = await tokenizer.calculateTokens(testText);
|
||||
// 13 / 4 = 3.25 -> ceil = 4
|
||||
expect(result).toBe(4);
|
||||
});
|
||||
|
||||
expect(mockGetEncoding).toHaveBeenCalledWith('cl100k_base');
|
||||
expect(mockEncode).toHaveBeenCalledWith(testText);
|
||||
it('should calculate tokens for code (ASCII)', async () => {
|
||||
const code = 'function test() { return 42; }'; // 30 ASCII chars
|
||||
const result = await tokenizer.calculateTokens(code);
|
||||
// 30 / 4 = 7.5 -> ceil = 8
|
||||
expect(result).toBe(8);
|
||||
});
|
||||
|
||||
it('should calculate tokens for non-ASCII text (CJK)', async () => {
|
||||
const unicodeText = '你好世界'; // 4 non-ASCII chars
|
||||
const result = await tokenizer.calculateTokens(unicodeText);
|
||||
// 4 * 1.1 = 4.4 -> ceil = 5
|
||||
expect(result).toBe(5);
|
||||
});
|
||||
|
||||
it('should use fallback calculation when tiktoken fails to load', async () => {
|
||||
mockGetEncoding.mockImplementation(() => {
|
||||
throw new Error('Failed to load tiktoken');
|
||||
});
|
||||
|
||||
const testText = 'Hello, world!'; // 13 characters
|
||||
const result = await tokenizer.calculateTokens(testText);
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Failed to load tiktoken with encoding cl100k_base:',
|
||||
expect.any(Error),
|
||||
);
|
||||
// Fallback: Math.ceil(13 / 4) = 4
|
||||
it('should calculate tokens for mixed ASCII and non-ASCII text', async () => {
|
||||
const mixedText = 'Hello 世界'; // 6 ASCII + 2 non-ASCII
|
||||
const result = await tokenizer.calculateTokens(mixedText);
|
||||
// (6 / 4) + (2 * 1.1) = 1.5 + 2.2 = 3.7 -> ceil = 4
|
||||
expect(result).toBe(4);
|
||||
});
|
||||
|
||||
it('should use fallback calculation when encoding fails', async () => {
|
||||
mockEncode.mockImplementation(() => {
|
||||
throw new Error('Encoding failed');
|
||||
});
|
||||
|
||||
const testText = 'Hello, world!'; // 13 characters
|
||||
const result = await tokenizer.calculateTokens(testText);
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Error encoding text with tiktoken:',
|
||||
expect.any(Error),
|
||||
);
|
||||
// Fallback: Math.ceil(13 / 4) = 4
|
||||
expect(result).toBe(4);
|
||||
it('should calculate tokens for emoji', async () => {
|
||||
const emojiText = '🌍'; // 2 UTF-16 code units (non-ASCII)
|
||||
const result = await tokenizer.calculateTokens(emojiText);
|
||||
// 2 * 1.1 = 2.2 -> ceil = 3
|
||||
expect(result).toBe(3);
|
||||
});
|
||||
|
||||
it('should handle very long text', async () => {
|
||||
const longText = 'a'.repeat(10000);
|
||||
const mockTokens = new Array(2500); // 2500 tokens
|
||||
mockEncode.mockReturnValue(mockTokens);
|
||||
|
||||
const longText = 'a'.repeat(10000); // 10000 ASCII chars
|
||||
const result = await tokenizer.calculateTokens(longText);
|
||||
|
||||
// 10000 / 4 = 2500 -> ceil = 2500
|
||||
expect(result).toBe(2500);
|
||||
});
|
||||
|
||||
it('should handle unicode characters', async () => {
|
||||
const unicodeText = '你好世界 🌍';
|
||||
const mockTokens = [1, 2, 3, 4, 5, 6];
|
||||
mockEncode.mockReturnValue(mockTokens);
|
||||
|
||||
const result = await tokenizer.calculateTokens(unicodeText);
|
||||
|
||||
expect(result).toBe(6);
|
||||
it('should handle text with only whitespace', async () => {
|
||||
const whitespaceText = ' \n\t '; // 7 ASCII chars
|
||||
const result = await tokenizer.calculateTokens(whitespaceText);
|
||||
// 7 / 4 = 1.75 -> ceil = 2
|
||||
expect(result).toBe(2);
|
||||
});
|
||||
|
||||
it('should use custom encoding when specified', async () => {
|
||||
tokenizer = new TextTokenizer('gpt2');
|
||||
const testText = 'Hello, world!';
|
||||
const mockTokens = [1, 2, 3];
|
||||
mockEncode.mockReturnValue(mockTokens);
|
||||
it('should handle special characters and symbols', async () => {
|
||||
const specialText = '!@#$%^&*()_+-=[]{}|;:,.<>?'; // 26 ASCII chars
|
||||
const result = await tokenizer.calculateTokens(specialText);
|
||||
// 26 / 4 = 6.5 -> ceil = 7
|
||||
expect(result).toBe(7);
|
||||
});
|
||||
|
||||
const result = await tokenizer.calculateTokens(testText);
|
||||
|
||||
expect(mockGetEncoding).toHaveBeenCalledWith('gpt2');
|
||||
expect(result).toBe(3);
|
||||
it('should handle very short text', async () => {
|
||||
const result = await tokenizer.calculateTokens('a');
|
||||
// 1 / 4 = 0.25 -> ceil = 1
|
||||
expect(result).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateTokensBatch', () => {
|
||||
beforeEach(() => {
|
||||
tokenizer = new TextTokenizer();
|
||||
});
|
||||
|
||||
it('should process multiple texts and return token counts', async () => {
|
||||
const texts = ['Hello', 'world', 'test'];
|
||||
mockEncode
|
||||
.mockReturnValueOnce([1, 2]) // 2 tokens for 'Hello'
|
||||
.mockReturnValueOnce([3, 4, 5]) // 3 tokens for 'world'
|
||||
.mockReturnValueOnce([6]); // 1 token for 'test'
|
||||
|
||||
const result = await tokenizer.calculateTokensBatch(texts);
|
||||
|
||||
expect(result).toEqual([2, 3, 1]);
|
||||
expect(mockEncode).toHaveBeenCalledTimes(3);
|
||||
// 'Hello' = 5 / 4 = 1.25 -> ceil = 2
|
||||
// 'world' = 5 / 4 = 1.25 -> ceil = 2
|
||||
// 'test' = 4 / 4 = 1 -> ceil = 1
|
||||
expect(result).toEqual([2, 2, 1]);
|
||||
});
|
||||
|
||||
it('should handle empty array', async () => {
|
||||
@@ -171,177 +124,156 @@ describe('TextTokenizer', () => {
|
||||
|
||||
it('should handle array with empty strings', async () => {
|
||||
const texts = ['', 'hello', ''];
|
||||
mockEncode.mockReturnValue([1, 2, 3]); // Only called for 'hello'
|
||||
|
||||
const result = await tokenizer.calculateTokensBatch(texts);
|
||||
|
||||
expect(result).toEqual([0, 3, 0]);
|
||||
expect(mockEncode).toHaveBeenCalledTimes(1);
|
||||
expect(mockEncode).toHaveBeenCalledWith('hello');
|
||||
// '' = 0
|
||||
// 'hello' = 5 / 4 = 1.25 -> ceil = 2
|
||||
// '' = 0
|
||||
expect(result).toEqual([0, 2, 0]);
|
||||
});
|
||||
|
||||
it('should use fallback calculation when tiktoken fails to load', async () => {
|
||||
mockGetEncoding.mockImplementation(() => {
|
||||
throw new Error('Failed to load tiktoken');
|
||||
});
|
||||
|
||||
const texts = ['Hello', 'world']; // 5 and 5 characters
|
||||
it('should handle mixed ASCII and non-ASCII texts', async () => {
|
||||
const texts = ['Hello', '世界', 'Hello 世界'];
|
||||
const result = await tokenizer.calculateTokensBatch(texts);
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Failed to load tiktoken with encoding cl100k_base:',
|
||||
expect.any(Error),
|
||||
);
|
||||
// Fallback: Math.ceil(5/4) = 2 for both
|
||||
expect(result).toEqual([2, 2]);
|
||||
});
|
||||
|
||||
it('should use fallback calculation when encoding fails during batch processing', async () => {
|
||||
mockEncode.mockImplementation(() => {
|
||||
throw new Error('Encoding failed');
|
||||
});
|
||||
|
||||
const texts = ['Hello', 'world']; // 5 and 5 characters
|
||||
const result = await tokenizer.calculateTokensBatch(texts);
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Error encoding texts with tiktoken:',
|
||||
expect.any(Error),
|
||||
);
|
||||
// Fallback: Math.ceil(5/4) = 2 for both
|
||||
expect(result).toEqual([2, 2]);
|
||||
// 'Hello' = 5 / 4 = 1.25 -> ceil = 2
|
||||
// '世界' = 2 * 1.1 = 2.2 -> ceil = 3
|
||||
// 'Hello 世界' = (6/4) + (2*1.1) = 1.5 + 2.2 = 3.7 -> ceil = 4
|
||||
expect(result).toEqual([2, 3, 4]);
|
||||
});
|
||||
|
||||
it('should handle null and undefined values in batch', async () => {
|
||||
const texts = [null, 'hello', undefined, 'world'] as unknown as string[];
|
||||
mockEncode
|
||||
.mockReturnValueOnce([1, 2, 3]) // 3 tokens for 'hello'
|
||||
.mockReturnValueOnce([4, 5]); // 2 tokens for 'world'
|
||||
|
||||
const result = await tokenizer.calculateTokensBatch(texts);
|
||||
// null = 0
|
||||
// 'hello' = 5 / 4 = 1.25 -> ceil = 2
|
||||
// undefined = 0
|
||||
// 'world' = 5 / 4 = 1.25 -> ceil = 2
|
||||
expect(result).toEqual([0, 2, 0, 2]);
|
||||
});
|
||||
|
||||
expect(result).toEqual([0, 3, 0, 2]);
|
||||
it('should process large batches efficiently', async () => {
|
||||
const texts = Array.from({ length: 1000 }, (_, i) => `text${i}`);
|
||||
const result = await tokenizer.calculateTokensBatch(texts);
|
||||
expect(result).toHaveLength(1000);
|
||||
// Verify results are reasonable
|
||||
result.forEach((count) => {
|
||||
expect(count).toBeGreaterThan(0);
|
||||
expect(count).toBeLessThan(10); // 'textNNN' should be less than 10 tokens
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('dispose', () => {
|
||||
beforeEach(() => {
|
||||
tokenizer = new TextTokenizer();
|
||||
describe('backward compatibility', () => {
|
||||
it('should accept encoding parameter in constructor', () => {
|
||||
const tokenizer1 = new TextTokenizer();
|
||||
const tokenizer2 = new TextTokenizer();
|
||||
const tokenizer3 = new TextTokenizer();
|
||||
|
||||
expect(tokenizer1).toBeInstanceOf(TextTokenizer);
|
||||
expect(tokenizer2).toBeInstanceOf(TextTokenizer);
|
||||
expect(tokenizer3).toBeInstanceOf(TextTokenizer);
|
||||
});
|
||||
|
||||
it('should free tiktoken encoding when disposing', async () => {
|
||||
// Initialize the encoding by calling calculateTokens
|
||||
await tokenizer.calculateTokens('test');
|
||||
it('should produce same results regardless of encoding parameter', async () => {
|
||||
const text = 'Hello, world!';
|
||||
const tokenizer1 = new TextTokenizer();
|
||||
const tokenizer2 = new TextTokenizer();
|
||||
const tokenizer3 = new TextTokenizer();
|
||||
|
||||
tokenizer.dispose();
|
||||
const result1 = await tokenizer1.calculateTokens(text);
|
||||
const result2 = await tokenizer2.calculateTokens(text);
|
||||
const result3 = await tokenizer3.calculateTokens(text);
|
||||
|
||||
expect(mockFree).toHaveBeenCalled();
|
||||
// All should use character-based estimation, ignoring encoding parameter
|
||||
expect(result1).toBe(result2);
|
||||
expect(result2).toBe(result3);
|
||||
expect(result1).toBe(4); // 13 / 4 = 3.25 -> ceil = 4
|
||||
});
|
||||
|
||||
it('should handle disposal when encoding is not initialized', () => {
|
||||
expect(() => tokenizer.dispose()).not.toThrow();
|
||||
expect(mockFree).not.toHaveBeenCalled();
|
||||
it('should maintain async interface for calculateTokens', async () => {
|
||||
const result = tokenizer.calculateTokens('test');
|
||||
expect(result).toBeInstanceOf(Promise);
|
||||
await expect(result).resolves.toBe(1);
|
||||
});
|
||||
|
||||
it('should handle disposal when encoding is null', async () => {
|
||||
// Force encoding to be null by making tiktoken fail
|
||||
mockGetEncoding.mockImplementation(() => {
|
||||
throw new Error('Failed to load');
|
||||
});
|
||||
|
||||
await tokenizer.calculateTokens('test');
|
||||
|
||||
expect(() => tokenizer.dispose()).not.toThrow();
|
||||
expect(mockFree).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle errors during disposal gracefully', async () => {
|
||||
await tokenizer.calculateTokens('test');
|
||||
|
||||
mockFree.mockImplementation(() => {
|
||||
throw new Error('Free failed');
|
||||
});
|
||||
|
||||
tokenizer.dispose();
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Error freeing tiktoken encoding:',
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow multiple calls to dispose', async () => {
|
||||
await tokenizer.calculateTokens('test');
|
||||
|
||||
tokenizer.dispose();
|
||||
tokenizer.dispose(); // Second call should not throw
|
||||
|
||||
expect(mockFree).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lazy initialization', () => {
|
||||
beforeEach(() => {
|
||||
tokenizer = new TextTokenizer();
|
||||
});
|
||||
|
||||
it('should not initialize tiktoken until first use', () => {
|
||||
expect(mockGetEncoding).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should initialize tiktoken on first calculateTokens call', async () => {
|
||||
await tokenizer.calculateTokens('test');
|
||||
expect(mockGetEncoding).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not reinitialize tiktoken on subsequent calls', async () => {
|
||||
await tokenizer.calculateTokens('test1');
|
||||
await tokenizer.calculateTokens('test2');
|
||||
|
||||
expect(mockGetEncoding).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should initialize tiktoken on first calculateTokensBatch call', async () => {
|
||||
await tokenizer.calculateTokensBatch(['test']);
|
||||
expect(mockGetEncoding).toHaveBeenCalledTimes(1);
|
||||
it('should maintain async interface for calculateTokensBatch', async () => {
|
||||
const result = tokenizer.calculateTokensBatch(['test']);
|
||||
expect(result).toBeInstanceOf(Promise);
|
||||
await expect(result).resolves.toEqual([1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
beforeEach(() => {
|
||||
tokenizer = new TextTokenizer();
|
||||
});
|
||||
|
||||
it('should handle very short text', async () => {
|
||||
const result = await tokenizer.calculateTokens('a');
|
||||
|
||||
if (mockGetEncoding.mock.calls.length > 0) {
|
||||
// If tiktoken was called, use its result
|
||||
expect(mockEncode).toHaveBeenCalledWith('a');
|
||||
} else {
|
||||
// If tiktoken failed, should use fallback: Math.ceil(1/4) = 1
|
||||
expect(result).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle text with only whitespace', async () => {
|
||||
const whitespaceText = ' \n\t ';
|
||||
const mockTokens = [1];
|
||||
mockEncode.mockReturnValue(mockTokens);
|
||||
|
||||
const result = await tokenizer.calculateTokens(whitespaceText);
|
||||
|
||||
it('should handle text with only newlines', async () => {
|
||||
const text = '\n\n\n'; // 3 ASCII chars
|
||||
const result = await tokenizer.calculateTokens(text);
|
||||
// 3 / 4 = 0.75 -> ceil = 1
|
||||
expect(result).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle special characters and symbols', async () => {
|
||||
const specialText = '!@#$%^&*()_+-=[]{}|;:,.<>?';
|
||||
const mockTokens = new Array(10);
|
||||
mockEncode.mockReturnValue(mockTokens);
|
||||
it('should handle text with tabs', async () => {
|
||||
const text = '\t\t\t\t'; // 4 ASCII chars
|
||||
const result = await tokenizer.calculateTokens(text);
|
||||
// 4 / 4 = 1 -> ceil = 1
|
||||
expect(result).toBe(1);
|
||||
});
|
||||
|
||||
const result = await tokenizer.calculateTokens(specialText);
|
||||
it('should handle surrogate pairs correctly', async () => {
|
||||
// Character outside BMP (Basic Multilingual Plane)
|
||||
const text = '𝕳𝖊𝖑𝖑𝖔'; // Mathematical bold letters (2 UTF-16 units each)
|
||||
const result = await tokenizer.calculateTokens(text);
|
||||
// Each character is 2 UTF-16 units, all non-ASCII
|
||||
// Total: 10 non-ASCII units
|
||||
// 10 * 1.1 = 11 -> ceil = 11
|
||||
expect(result).toBe(11);
|
||||
});
|
||||
|
||||
expect(result).toBe(10);
|
||||
it('should handle combining characters', async () => {
|
||||
// e + combining acute accent
|
||||
const text = 'e\u0301'; // 2 chars: 'e' (ASCII) + combining acute (non-ASCII)
|
||||
const result = await tokenizer.calculateTokens(text);
|
||||
// ASCII: 1 / 4 = 0.25
|
||||
// Non-ASCII: 1 * 1.1 = 1.1
|
||||
// Total: 0.25 + 1.1 = 1.35 -> ceil = 2
|
||||
expect(result).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle accented characters', async () => {
|
||||
const text = 'café'; // 'caf' = 3 ASCII, 'é' = 1 non-ASCII
|
||||
const result = await tokenizer.calculateTokens(text);
|
||||
// ASCII: 3 / 4 = 0.75
|
||||
// Non-ASCII: 1 * 1.1 = 1.1
|
||||
// Total: 0.75 + 1.1 = 1.85 -> ceil = 2
|
||||
expect(result).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle various unicode scripts', async () => {
|
||||
const cyrillic = 'Привет'; // 6 non-ASCII chars
|
||||
const arabic = 'مرحبا'; // 5 non-ASCII chars
|
||||
const japanese = 'こんにちは'; // 5 non-ASCII chars
|
||||
|
||||
const result1 = await tokenizer.calculateTokens(cyrillic);
|
||||
const result2 = await tokenizer.calculateTokens(arabic);
|
||||
const result3 = await tokenizer.calculateTokens(japanese);
|
||||
|
||||
// All should use 1.1 tokens per char
|
||||
expect(result1).toBe(7); // 6 * 1.1 = 6.6 -> ceil = 7
|
||||
expect(result2).toBe(6); // 5 * 1.1 = 5.5 -> ceil = 6
|
||||
expect(result3).toBe(6); // 5 * 1.1 = 5.5 -> ceil = 6
|
||||
});
|
||||
});
|
||||
|
||||
describe('large inputs', () => {
|
||||
it('should handle very long text', async () => {
|
||||
const longText = 'a'.repeat(200000); // 200k characters
|
||||
const result = await tokenizer.calculateTokens(longText);
|
||||
expect(result).toBe(50000); // 200000 / 4
|
||||
});
|
||||
|
||||
it('should handle large batches', async () => {
|
||||
const texts = Array.from({ length: 5000 }, () => 'Hello, world!');
|
||||
const result = await tokenizer.calculateTokensBatch(texts);
|
||||
expect(result).toHaveLength(5000);
|
||||
expect(result[0]).toBe(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,94 +4,55 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { TiktokenEncoding, Tiktoken } from 'tiktoken';
|
||||
import { get_encoding } from 'tiktoken';
|
||||
|
||||
/**
|
||||
* Text tokenizer for calculating text tokens using tiktoken
|
||||
* Text tokenizer for calculating text tokens using character-based estimation.
|
||||
*
|
||||
* Uses a lightweight character-based approach that is "good enough" for
|
||||
* guardrail features like sessionTokenLimit.
|
||||
*
|
||||
* Algorithm:
|
||||
* - ASCII characters: 0.25 tokens per char (4 chars = 1 token)
|
||||
* - Non-ASCII characters: 1.1 tokens per char (conservative for CJK, emoji, etc.)
|
||||
*/
|
||||
export class TextTokenizer {
|
||||
private encoding: Tiktoken | null = null;
|
||||
private encodingName: string;
|
||||
|
||||
constructor(encodingName: string = 'cl100k_base') {
|
||||
this.encodingName = encodingName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the tokenizer (lazy loading)
|
||||
*/
|
||||
private async ensureEncoding(): Promise<void> {
|
||||
if (this.encoding) return;
|
||||
|
||||
try {
|
||||
// Use type assertion since we know the encoding name is valid
|
||||
this.encoding = get_encoding(this.encodingName as TiktokenEncoding);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to load tiktoken with encoding ${this.encodingName}:`,
|
||||
error,
|
||||
);
|
||||
this.encoding = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate tokens for text content
|
||||
*
|
||||
* @param text - The text to estimate tokens for
|
||||
* @returns The estimated token count
|
||||
*/
|
||||
async calculateTokens(text: string): Promise<number> {
|
||||
if (!text) return 0;
|
||||
|
||||
await this.ensureEncoding();
|
||||
|
||||
if (this.encoding) {
|
||||
try {
|
||||
return this.encoding.encode(text).length;
|
||||
} catch (error) {
|
||||
console.warn('Error encoding text with tiktoken:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: rough approximation using character count
|
||||
// This is a conservative estimate: 1 token ≈ 4 characters for most languages
|
||||
return Math.ceil(text.length / 4);
|
||||
return this.calculateTokensSync(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate tokens for multiple text strings in parallel
|
||||
* Calculate tokens for multiple text strings
|
||||
*
|
||||
* @param texts - Array of text strings to estimate tokens for
|
||||
* @returns Array of token counts corresponding to each input text
|
||||
*/
|
||||
async calculateTokensBatch(texts: string[]): Promise<number[]> {
|
||||
await this.ensureEncoding();
|
||||
|
||||
if (this.encoding) {
|
||||
try {
|
||||
return texts.map((text) => {
|
||||
if (!text) return 0;
|
||||
// this.encoding may be null, add a null check to satisfy lint
|
||||
return this.encoding ? this.encoding.encode(text).length : 0;
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Error encoding texts with tiktoken:', error);
|
||||
// In case of error, return fallback estimation for all texts
|
||||
return texts.map((text) => Math.ceil((text || '').length / 4));
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for batch processing
|
||||
return texts.map((text) => Math.ceil((text || '').length / 4));
|
||||
return texts.map((text) => this.calculateTokensSync(text));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of resources
|
||||
*/
|
||||
dispose(): void {
|
||||
if (this.encoding) {
|
||||
try {
|
||||
this.encoding.free();
|
||||
} catch (error) {
|
||||
console.warn('Error freeing tiktoken encoding:', error);
|
||||
}
|
||||
this.encoding = null;
|
||||
private calculateTokensSync(text: string): number {
|
||||
if (!text || text.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let asciiChars = 0;
|
||||
let nonAsciiChars = 0;
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const charCode = text.charCodeAt(i);
|
||||
if (charCode < 128) {
|
||||
asciiChars++;
|
||||
} else {
|
||||
nonAsciiChars++;
|
||||
}
|
||||
}
|
||||
|
||||
const tokens = asciiChars / 4 + nonAsciiChars * 1.1;
|
||||
return Math.ceil(tokens);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CountTokensParameters } from '@google/genai';
|
||||
|
||||
/**
|
||||
* Token calculation result for different content types
|
||||
*/
|
||||
@@ -23,14 +21,6 @@ export interface TokenCalculationResult {
|
||||
processingTime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for token calculation
|
||||
*/
|
||||
export interface TokenizerConfig {
|
||||
/** Custom text tokenizer encoding (defaults to cl100k_base) */
|
||||
textEncoding?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Image metadata extracted from base64 data
|
||||
*/
|
||||
@@ -44,21 +34,3 @@ export interface ImageMetadata {
|
||||
/** Size of the base64 data in bytes */
|
||||
dataSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request tokenizer interface
|
||||
*/
|
||||
export interface RequestTokenizer {
|
||||
/**
|
||||
* Calculate tokens for a request
|
||||
*/
|
||||
calculateTokens(
|
||||
request: CountTokensParameters,
|
||||
config?: TokenizerConfig,
|
||||
): Promise<TokenCalculationResult>;
|
||||
|
||||
/**
|
||||
* Dispose of resources (worker threads, etc.)
|
||||
*/
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -46,8 +46,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"zod": "^3.25.0",
|
||||
"tiktoken": "^1.0.21"
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code-test-utils",
|
||||
"version": "0.7.1",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "qwen-code-vscode-ide-companion",
|
||||
"displayName": "Qwen Code Companion",
|
||||
"description": "Enable Qwen Code with direct access to your VS Code workspace.",
|
||||
"version": "0.7.1",
|
||||
"version": "0.8.0",
|
||||
"publisher": "qwenlm",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
@@ -23,3 +23,23 @@ export const CLIENT_METHODS = {
|
||||
session_request_permission: 'session/request_permission',
|
||||
session_update: 'session/update',
|
||||
} as const;
|
||||
|
||||
export const ACP_ERROR_CODES = {
|
||||
// Parse error: invalid JSON received by server.
|
||||
PARSE_ERROR: -32700,
|
||||
// Invalid request: JSON is not a valid Request object.
|
||||
INVALID_REQUEST: -32600,
|
||||
// Method not found: method does not exist or is unavailable.
|
||||
METHOD_NOT_FOUND: -32601,
|
||||
// Invalid params: invalid method parameter(s).
|
||||
INVALID_PARAMS: -32602,
|
||||
// Internal error: implementation-defined server error.
|
||||
INTERNAL_ERROR: -32603,
|
||||
// Authentication required: must authenticate before operation.
|
||||
AUTH_REQUIRED: -32000,
|
||||
// Resource not found: e.g. missing file.
|
||||
RESOURCE_NOT_FOUND: -32002,
|
||||
} as const;
|
||||
|
||||
export type AcpErrorCode =
|
||||
(typeof ACP_ERROR_CODES)[keyof typeof ACP_ERROR_CODES];
|
||||
|
||||
@@ -28,6 +28,7 @@ import * as os from 'node:os';
|
||||
import type { z } from 'zod';
|
||||
import type { DiffManager } from './diff-manager.js';
|
||||
import { OpenFilesManager } from './open-files-manager.js';
|
||||
import { ACP_ERROR_CODES } from './constants/acpSchema.js';
|
||||
|
||||
class CORSError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -264,7 +265,7 @@ export class IDEServer {
|
||||
res.status(400).json({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32000,
|
||||
code: ACP_ERROR_CODES.AUTH_REQUIRED,
|
||||
message:
|
||||
'Bad Request: No valid session ID provided for non-initialize request.',
|
||||
},
|
||||
@@ -283,7 +284,7 @@ export class IDEServer {
|
||||
res.status(500).json({
|
||||
jsonrpc: '2.0' as const,
|
||||
error: {
|
||||
code: -32603,
|
||||
code: ACP_ERROR_CODES.INTERNAL_ERROR,
|
||||
message: 'Internal server error',
|
||||
},
|
||||
id: null,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { JSONRPC_VERSION } from '../types/acpTypes.js';
|
||||
import { ACP_ERROR_CODES } from '../constants/acpSchema.js';
|
||||
import type {
|
||||
AcpMessage,
|
||||
AcpPermissionRequest,
|
||||
@@ -232,12 +233,34 @@ export class AcpConnection {
|
||||
})
|
||||
.catch((error) => {
|
||||
if ('id' in message && typeof message.id === 'number') {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'message' in error &&
|
||||
typeof (error as { message: unknown }).message === 'string'
|
||||
? (error as { message: string }).message
|
||||
: String(error);
|
||||
|
||||
let errorCode: number = ACP_ERROR_CODES.INTERNAL_ERROR;
|
||||
const errorCodeValue =
|
||||
typeof error === 'object' && error !== null && 'code' in error
|
||||
? (error as { code?: unknown }).code
|
||||
: undefined;
|
||||
|
||||
if (typeof errorCodeValue === 'number') {
|
||||
errorCode = errorCodeValue;
|
||||
} else if (errorCodeValue === 'ENOENT') {
|
||||
errorCode = ACP_ERROR_CODES.RESOURCE_NOT_FOUND;
|
||||
}
|
||||
|
||||
this.messageHandler.sendResponseMessage(this.child, {
|
||||
jsonrpc: JSONRPC_VERSION,
|
||||
id: message.id,
|
||||
error: {
|
||||
code: -32603,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
code: errorCode,
|
||||
message: errorMessage,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -66,6 +66,11 @@ export class AcpFileHandler {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[ACP] Failed to read file ${params.path}:`, errorMsg);
|
||||
|
||||
const nodeError = error as NodeJS.ErrnoException;
|
||||
if (nodeError?.code === 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new Error(`Failed to read file '${params.path}': ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { ACP_ERROR_CODES } from '../constants/acpSchema.js';
|
||||
|
||||
const AUTH_ERROR_PATTERNS = [
|
||||
'Authentication required', // Standard authentication request message
|
||||
'(code: -32000)', // RPC error code -32000 indicates authentication failure
|
||||
`(code: ${ACP_ERROR_CODES.AUTH_REQUIRED})`, // RPC error code indicates auth failure
|
||||
'Unauthorized', // HTTP unauthorized error
|
||||
'Invalid token', // Invalid token
|
||||
'Session expired', // Session expired
|
||||
|
||||
@@ -8,6 +8,9 @@ import * as vscode from 'vscode';
|
||||
import { BaseMessageHandler } from './BaseMessageHandler.js';
|
||||
import type { ChatMessage } from '../../services/qwenAgentManager.js';
|
||||
import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js';
|
||||
import { ACP_ERROR_CODES } from '../../constants/acpSchema.js';
|
||||
|
||||
const AUTH_REQUIRED_CODE_PATTERN = `(code: ${ACP_ERROR_CODES.AUTH_REQUIRED})`;
|
||||
|
||||
/**
|
||||
* Session message handler
|
||||
@@ -355,7 +358,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||
createErr instanceof Error ? createErr.message : String(createErr);
|
||||
if (
|
||||
errorMsg.includes('Authentication required') ||
|
||||
errorMsg.includes('(code: -32000)')
|
||||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN)
|
||||
) {
|
||||
await this.promptLogin(
|
||||
'Your login session has expired or is invalid. Please login again to continue using Qwen Code.',
|
||||
@@ -421,7 +424,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||
errorMsg.includes('Session not found') ||
|
||||
errorMsg.includes('No active ACP session') ||
|
||||
errorMsg.includes('Authentication required') ||
|
||||
errorMsg.includes('(code: -32000)') ||
|
||||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
|
||||
errorMsg.includes('Unauthorized') ||
|
||||
errorMsg.includes('Invalid token')
|
||||
) {
|
||||
@@ -512,7 +515,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||
// Check for authentication/session expiration errors
|
||||
if (
|
||||
errorMsg.includes('Authentication required') ||
|
||||
errorMsg.includes('(code: -32000)') ||
|
||||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
|
||||
errorMsg.includes('Unauthorized') ||
|
||||
errorMsg.includes('Invalid token') ||
|
||||
errorMsg.includes('No active ACP session')
|
||||
@@ -622,7 +625,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||
// Check for authentication/session expiration errors
|
||||
if (
|
||||
errorMsg.includes('Authentication required') ||
|
||||
errorMsg.includes('(code: -32000)') ||
|
||||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
|
||||
errorMsg.includes('Unauthorized') ||
|
||||
errorMsg.includes('Invalid token') ||
|
||||
errorMsg.includes('No active ACP session')
|
||||
@@ -682,7 +685,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||
// Check for authentication/session expiration errors in session creation
|
||||
if (
|
||||
createErrorMsg.includes('Authentication required') ||
|
||||
createErrorMsg.includes('(code: -32000)') ||
|
||||
createErrorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
|
||||
createErrorMsg.includes('Unauthorized') ||
|
||||
createErrorMsg.includes('Invalid token') ||
|
||||
createErrorMsg.includes('No active ACP session')
|
||||
@@ -722,7 +725,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||
// Check for authentication/session expiration errors
|
||||
if (
|
||||
errorMsg.includes('Authentication required') ||
|
||||
errorMsg.includes('(code: -32000)') ||
|
||||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
|
||||
errorMsg.includes('Unauthorized') ||
|
||||
errorMsg.includes('Invalid token') ||
|
||||
errorMsg.includes('No active ACP session')
|
||||
@@ -777,7 +780,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||
// Check for authentication/session expiration errors
|
||||
if (
|
||||
errorMsg.includes('Authentication required') ||
|
||||
errorMsg.includes('(code: -32000)') ||
|
||||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
|
||||
errorMsg.includes('Unauthorized') ||
|
||||
errorMsg.includes('Invalid token') ||
|
||||
errorMsg.includes('No active ACP session')
|
||||
@@ -827,7 +830,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||
// Check for authentication/session expiration errors
|
||||
if (
|
||||
errorMsg.includes('Authentication required') ||
|
||||
errorMsg.includes('(code: -32000)') ||
|
||||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
|
||||
errorMsg.includes('Unauthorized') ||
|
||||
errorMsg.includes('Invalid token') ||
|
||||
errorMsg.includes('No active ACP session')
|
||||
@@ -855,7 +858,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||
// Check for authentication/session expiration errors
|
||||
if (
|
||||
errorMsg.includes('Authentication required') ||
|
||||
errorMsg.includes('(code: -32000)') ||
|
||||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
|
||||
errorMsg.includes('Unauthorized') ||
|
||||
errorMsg.includes('Invalid token') ||
|
||||
errorMsg.includes('No active ACP session')
|
||||
@@ -961,7 +964,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||
// Check for authentication/session expiration errors
|
||||
if (
|
||||
errorMsg.includes('Authentication required') ||
|
||||
errorMsg.includes('(code: -32000)') ||
|
||||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
|
||||
errorMsg.includes('Unauthorized') ||
|
||||
errorMsg.includes('Invalid token') ||
|
||||
errorMsg.includes('No active ACP session')
|
||||
@@ -989,7 +992,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||
// Check for authentication/session expiration errors
|
||||
if (
|
||||
errorMsg.includes('Authentication required') ||
|
||||
errorMsg.includes('(code: -32000)') ||
|
||||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
|
||||
errorMsg.includes('Unauthorized') ||
|
||||
errorMsg.includes('Invalid token') ||
|
||||
errorMsg.includes('No active ACP session')
|
||||
|
||||
@@ -98,17 +98,6 @@ console.log('Creating package.json for distribution...');
|
||||
const rootPackageJson = JSON.parse(
|
||||
fs.readFileSync(path.join(rootDir, 'package.json'), 'utf-8'),
|
||||
);
|
||||
const corePackageJson = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(rootDir, 'packages', 'core', 'package.json'),
|
||||
'utf-8',
|
||||
),
|
||||
);
|
||||
|
||||
const runtimeDependencies = {};
|
||||
if (corePackageJson.dependencies?.tiktoken) {
|
||||
runtimeDependencies.tiktoken = corePackageJson.dependencies.tiktoken;
|
||||
}
|
||||
|
||||
// Create a clean package.json for the published package
|
||||
const distPackageJson = {
|
||||
@@ -124,7 +113,7 @@ const distPackageJson = {
|
||||
},
|
||||
files: ['cli.js', 'vendor', '*.sb', 'README.md', 'LICENSE', 'locales'],
|
||||
config: rootPackageJson.config,
|
||||
dependencies: runtimeDependencies,
|
||||
dependencies: {},
|
||||
optionalDependencies: {
|
||||
'@lydell/node-pty': '1.1.0',
|
||||
'@lydell/node-pty-darwin-arm64': '1.1.0',
|
||||
|
||||
Reference in New Issue
Block a user