diff --git a/package-lock.json b/package-lock.json index 711cf63d9..330b90e08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -134,6 +134,36 @@ "node": ">=6.0.0" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.36.3", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.36.3.tgz", + "integrity": "sha512-+c0mMLxL/17yFZ4P5+U6bTWiCSFZUKJddrv01ud2aFBWnTPLdRncYV76D3q1tqfnL7aCnhRtykFnoCFzvr4U3Q==", + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -3822,6 +3852,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", @@ -4820,7 +4860,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "dev": true, "license": "MIT", "dependencies": { "event-target-shim": "^5.0.0" @@ -4907,6 +4946,18 @@ "node": ">= 14" } }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -5478,7 +5529,6 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, "node_modules/atomically": { @@ -6437,7 +6487,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -7063,7 +7112,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -7576,7 +7624,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -8106,7 +8153,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -8652,7 +8698,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -8665,11 +8710,16 @@ "node": ">= 6" } }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, "node_modules/form-data/node_modules/mime-types": { "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -8678,6 +8728,28 @@ "node": ">= 0.6" } }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/formdata-node/node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -9262,7 +9334,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -9441,6 +9512,15 @@ "node": ">=16.17.0" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -11940,6 +12020,48 @@ "node": ">=10.5.0" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-pty": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz", @@ -17834,6 +17956,7 @@ "version": "0.6.0", "hasInstallScript": true, "dependencies": { + "@anthropic-ai/sdk": "^0.36.1", "@google/genai": "1.30.0", "@modelcontextprotocol/sdk": "^1.25.1", "@opentelemetry/api": "^1.9.0", diff --git a/packages/cli/src/config/auth.ts b/packages/cli/src/config/auth.ts index 7f87db455..9f5d50a07 100644 --- a/packages/cli/src/config/auth.ts +++ b/packages/cli/src/config/auth.ts @@ -26,6 +26,20 @@ export function validateAuthMethod(authMethod: string): string | null { return null; } + if (authMethod === AuthType.USE_ANTHROPIC) { + const hasApiKey = process.env['ANTHROPIC_API_KEY']; + if (!hasApiKey) { + return 'ANTHROPIC_API_KEY environment variable not found.'; + } + + const hasBaseUrl = process.env['ANTHROPIC_BASE_URL']; + if (!hasBaseUrl) { + return 'ANTHROPIC_BASE_URL environment variable not found.'; + } + + return null; + } + if (authMethod === AuthType.USE_GEMINI) { const hasApiKey = process.env['GEMINI_API_KEY']; if (!hasApiKey) { diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 6eb75c3b1..0b95f7857 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -2114,7 +2114,14 @@ describe('loadCliConfig model selection', () => { }); it('always prefers model from argvs', async () => { - process.argv = ['node', 'script.js', '--model', 'qwen3-coder-plus']; + process.argv = [ + 'node', + 'script.js', + '--auth-type', + 'openai', + '--model', + 'qwen3-coder-plus', + ]; const argv = await parseArguments({} as Settings); const config = await loadCliConfig( { @@ -2134,7 +2141,14 @@ describe('loadCliConfig model selection', () => { }); it('selects the model from argvs if provided', async () => { - process.argv = ['node', 'script.js', '--model', 'qwen3-coder-plus']; + process.argv = [ + 'node', + 'script.js', + '--auth-type', + 'openai', + '--model', + 'qwen3-coder-plus', + ]; const argv = await parseArguments({} as Settings); const config = await loadCliConfig( { diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 838d0c82b..7cd7d685a 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -468,6 +468,7 @@ export async function parseArguments(settings: Settings): Promise { type: 'string', choices: [ AuthType.USE_OPENAI, + AuthType.USE_ANTHROPIC, AuthType.QWEN_OAUTH, AuthType.USE_GEMINI, AuthType.USE_VERTEX_AI, @@ -876,11 +877,30 @@ export async function loadCliConfig( ); } + const selectedAuthType = + (argv.authType as AuthType | undefined) || + settings.security?.auth?.selectedType; + + const apiKey = + (selectedAuthType === AuthType.USE_OPENAI + ? argv.openaiApiKey || + process.env['OPENAI_API_KEY'] || + settings.security?.auth?.apiKey + : '') || ''; + const baseUrl = + (selectedAuthType === AuthType.USE_OPENAI + ? argv.openaiBaseUrl || + process.env['OPENAI_BASE_URL'] || + settings.security?.auth?.baseUrl + : '') || ''; const resolvedModel = argv.model || - process.env['OPENAI_MODEL'] || - process.env['QWEN_MODEL'] || - settings.model?.name; + (selectedAuthType === AuthType.USE_OPENAI + ? process.env['OPENAI_MODEL'] || + process.env['QWEN_MODEL'] || + settings.model?.name + : '') || + ''; const sandboxConfig = await loadSandboxConfig(settings, argv); const screenReader = @@ -967,23 +987,15 @@ export async function loadCliConfig( extensions: allExtensions, blockedMcpServers, noBrowser: !!process.env['NO_BROWSER'], - authType: - (argv.authType as AuthType | undefined) || - settings.security?.auth?.selectedType, + authType: selectedAuthType, inputFormat, outputFormat, includePartialMessages, generationConfig: { ...(settings.model?.generationConfig || {}), model: resolvedModel, - apiKey: - argv.openaiApiKey || - process.env['OPENAI_API_KEY'] || - settings.security?.auth?.apiKey, - baseUrl: - argv.openaiBaseUrl || - process.env['OPENAI_BASE_URL'] || - settings.security?.auth?.baseUrl, + apiKey, + baseUrl, enableOpenAILogging: (typeof argv.openaiLogging === 'undefined' ? settings.model?.enableOpenAILogging diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index ba569fe1b..c13f33c95 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -228,6 +228,7 @@ export const useAuthCommand = ( ![ AuthType.QWEN_OAUTH, AuthType.USE_OPENAI, + AuthType.USE_ANTHROPIC, AuthType.USE_GEMINI, AuthType.USE_VERTEX_AI, ].includes(defaultAuthType as AuthType) @@ -240,6 +241,7 @@ export const useAuthCommand = ( validValues: [ AuthType.QWEN_OAUTH, AuthType.USE_OPENAI, + AuthType.USE_ANTHROPIC, AuthType.USE_GEMINI, AuthType.USE_VERTEX_AI, ].join(', '), diff --git a/packages/cli/src/ui/models/availableModels.ts b/packages/cli/src/ui/models/availableModels.ts index 9a04101f0..d9c9eb725 100644 --- a/packages/cli/src/ui/models/availableModels.ts +++ b/packages/cli/src/ui/models/availableModels.ts @@ -60,6 +60,11 @@ export function getOpenAIAvailableModelFromEnv(): AvailableModel | null { return id ? { id, label: id } : null; } +export function getAnthropicAvailableModelFromEnv(): AvailableModel | null { + const id = process.env['ANTHROPIC_MODEL']?.trim(); + return id ? { id, label: id } : null; +} + export function getAvailableModelsForAuthType( authType: AuthType, ): AvailableModel[] { @@ -70,6 +75,10 @@ export function getAvailableModelsForAuthType( const openAIModel = getOpenAIAvailableModelFromEnv(); return openAIModel ? [openAIModel] : []; } + case AuthType.USE_ANTHROPIC: { + const anthropicModel = getAnthropicAvailableModelFromEnv(); + return anthropicModel ? [anthropicModel] : []; + } default: // For other auth types, return empty array for now // This can be expanded later according to the design doc diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts index 29d602725..f6b7ee392 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts @@ -20,6 +20,11 @@ const makeConfig = (tools: Record) => getToolRegistry: () => ({ getTool: (name: string) => tools[name], }), + getContentGenerator: () => ({ + // Default to showing full thinking content during resume unless explicitly + // summarized; tests don't care about summarized thinking behavior. + useSummarizedThinking: () => false, + }), }) as unknown as Config; describe('resumeHistoryUtils', () => { diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.ts index 3c69bfd47..686e87e2d 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.ts @@ -204,7 +204,11 @@ function convertToHistoryItems( const parts = record.message?.parts as Part[] | undefined; // Extract thought content - const thoughtText = extractThoughtTextFromParts(parts); + const thoughtText = !config + .getContentGenerator() + .useSummarizedThinking() + ? extractThoughtTextFromParts(parts) + : ''; // Extract text content (non-function-call, non-thought) const text = extractTextFromParts(parts); diff --git a/packages/cli/src/utils/systemInfo.ts b/packages/cli/src/utils/systemInfo.ts index 84927a951..5f067b3ae 100644 --- a/packages/cli/src/utils/systemInfo.ts +++ b/packages/cli/src/utils/systemInfo.ts @@ -153,7 +153,8 @@ export async function getExtendedSystemInfo( // Get base URL if using OpenAI auth const baseUrl = - baseInfo.selectedAuthType === AuthType.USE_OPENAI + baseInfo.selectedAuthType === AuthType.USE_OPENAI || + baseInfo.selectedAuthType === AuthType.USE_ANTHROPIC ? context.services.config?.getContentGeneratorConfig()?.baseUrl : undefined; diff --git a/packages/cli/src/validateNonInterActiveAuth.test.ts b/packages/cli/src/validateNonInterActiveAuth.test.ts index 867777b34..2997847d3 100644 --- a/packages/cli/src/validateNonInterActiveAuth.test.ts +++ b/packages/cli/src/validateNonInterActiveAuth.test.ts @@ -19,6 +19,9 @@ describe('validateNonInterActiveAuth', () => { let originalEnvVertexAi: string | undefined; let originalEnvGcp: string | undefined; let originalEnvOpenAiApiKey: string | undefined; + let originalEnvQwenOauth: string | undefined; + let originalEnvGoogleApiKey: string | undefined; + let originalEnvAnthropicApiKey: string | undefined; let consoleErrorSpy: ReturnType; let processExitSpy: ReturnType>; let refreshAuthMock: ReturnType; @@ -29,10 +32,16 @@ describe('validateNonInterActiveAuth', () => { originalEnvVertexAi = process.env['GOOGLE_GENAI_USE_VERTEXAI']; originalEnvGcp = process.env['GOOGLE_GENAI_USE_GCA']; originalEnvOpenAiApiKey = process.env['OPENAI_API_KEY']; + originalEnvQwenOauth = process.env['QWEN_OAUTH']; + originalEnvGoogleApiKey = process.env['GOOGLE_API_KEY']; + originalEnvAnthropicApiKey = process.env['ANTHROPIC_API_KEY']; delete process.env['GEMINI_API_KEY']; delete process.env['GOOGLE_GENAI_USE_VERTEXAI']; delete process.env['GOOGLE_GENAI_USE_GCA']; delete process.env['OPENAI_API_KEY']; + delete process.env['QWEN_OAUTH']; + delete process.env['GOOGLE_API_KEY']; + delete process.env['ANTHROPIC_API_KEY']; consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { throw new Error(`process.exit(${code}) called`); @@ -80,6 +89,21 @@ describe('validateNonInterActiveAuth', () => { } else { delete process.env['OPENAI_API_KEY']; } + if (originalEnvQwenOauth !== undefined) { + process.env['QWEN_OAUTH'] = originalEnvQwenOauth; + } else { + delete process.env['QWEN_OAUTH']; + } + if (originalEnvGoogleApiKey !== undefined) { + process.env['GOOGLE_API_KEY'] = originalEnvGoogleApiKey; + } else { + delete process.env['GOOGLE_API_KEY']; + } + if (originalEnvAnthropicApiKey !== undefined) { + process.env['ANTHROPIC_API_KEY'] = originalEnvAnthropicApiKey; + } else { + delete process.env['ANTHROPIC_API_KEY']; + } vi.restoreAllMocks(); }); diff --git a/packages/cli/src/validateNonInterActiveAuth.ts b/packages/cli/src/validateNonInterActiveAuth.ts index e89eaa0e3..be5425a97 100644 --- a/packages/cli/src/validateNonInterActiveAuth.ts +++ b/packages/cli/src/validateNonInterActiveAuth.ts @@ -27,6 +27,9 @@ function getAuthTypeFromEnv(): AuthType | undefined { if (process.env['GOOGLE_API_KEY']) { return AuthType.USE_VERTEX_AI; } + if (process.env['ANTHROPIC_API_KEY']) { + return AuthType.USE_ANTHROPIC; + } return undefined; } diff --git a/packages/core/package.json b/packages/core/package.json index 92d220bb5..de06c78bc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -23,6 +23,7 @@ "scripts/postinstall.js" ], "dependencies": { + "@anthropic-ai/sdk": "^0.36.1", "@google/genai": "1.30.0", "@modelcontextprotocol/sdk": "^1.25.1", "@opentelemetry/api": "^1.9.0", diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 4e241ca6f..1b163b9a6 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -16,7 +16,6 @@ import { QwenLogger, } from '../telemetry/index.js'; import type { ContentGeneratorConfig } from '../core/contentGenerator.js'; -import { DEFAULT_DASHSCOPE_BASE_URL } from '../core/openaiContentGenerator/constants.js'; import { AuthType, createContentGeneratorConfig, @@ -273,7 +272,7 @@ describe('Server Config (config.ts)', () => { authType, { model: MODEL, - baseUrl: DEFAULT_DASHSCOPE_BASE_URL, + baseUrl: undefined, }, ); // Verify that contentGeneratorConfig is updated diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index b91d59f54..34dbb4649 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -96,7 +96,6 @@ import { } from './constants.js'; import { DEFAULT_QWEN_EMBEDDING_MODEL, DEFAULT_QWEN_MODEL } from './models.js'; import { Storage } from './storage.js'; -import { DEFAULT_DASHSCOPE_BASE_URL } from '../core/openaiContentGenerator/constants.js'; import { ChatRecordingService } from '../services/chatRecordingService.js'; import { SessionService, @@ -574,7 +573,7 @@ export class Config { this._generationConfig = { model: params.model, ...(params.generationConfig || {}), - baseUrl: params.generationConfig?.baseUrl || DEFAULT_DASHSCOPE_BASE_URL, + baseUrl: params.generationConfig?.baseUrl, }; this.contentGeneratorConfig = this ._generationConfig as ContentGeneratorConfig; diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts new file mode 100644 index 000000000..48807511c --- /dev/null +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts @@ -0,0 +1,428 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import Anthropic from '@anthropic-ai/sdk'; +import type { + CountTokensParameters, + CountTokensResponse, + EmbedContentParameters, + EmbedContentResponse, + GenerateContentParameters, + GenerateContentResponseUsageMetadata, + Part, +} from '@google/genai'; +import { GenerateContentResponse } from '@google/genai'; +import type { Config } from '../../config/config.js'; +import type { + ContentGenerator, + ContentGeneratorConfig, +} from '../contentGenerator.js'; +type Message = Anthropic.Message; +type MessageCreateParamsNonStreaming = + Anthropic.MessageCreateParamsNonStreaming; +type MessageCreateParamsStreaming = Anthropic.MessageCreateParamsStreaming; +type RawMessageStreamEvent = Anthropic.RawMessageStreamEvent; +import { getDefaultTokenizer } from '../../utils/request-tokenizer/index.js'; +import { safeJsonParse } from '../../utils/safeJsonParse.js'; +import { AnthropicContentConverter } from './converter.js'; + +type StreamingBlockState = { + type: string; + id?: string; + name?: string; + inputJson: string; + signature: string; +}; + +type MessageCreateParamsWithThinking = MessageCreateParamsNonStreaming & { + thinking?: { type: 'enabled'; budget_tokens: number }; +}; + +export class AnthropicContentGenerator implements ContentGenerator { + private client: Anthropic; + private converter: AnthropicContentConverter; + + constructor( + private contentGeneratorConfig: ContentGeneratorConfig, + private cliConfig: Config, + ) { + const defaultHeaders = this.buildHeaders(); + const baseURL = contentGeneratorConfig.baseUrl; + + this.client = new Anthropic({ + apiKey: contentGeneratorConfig.apiKey, + baseURL, + timeout: contentGeneratorConfig.timeout, + maxRetries: contentGeneratorConfig.maxRetries, + defaultHeaders, + }); + + this.converter = new AnthropicContentConverter( + contentGeneratorConfig.model, + contentGeneratorConfig.schemaCompliance, + ); + } + + async generateContent( + request: GenerateContentParameters, + ): Promise { + const anthropicRequest = await this.buildRequest(request); + const response = (await this.client.messages.create(anthropicRequest, { + signal: request.config?.abortSignal, + })) as Message; + + return this.converter.convertAnthropicResponseToGemini(response); + } + + async generateContentStream( + request: GenerateContentParameters, + ): Promise> { + const anthropicRequest = await this.buildRequest(request); + const streamingRequest: MessageCreateParamsStreaming & { + thinking?: { type: 'enabled'; budget_tokens: number }; + } = { + ...anthropicRequest, + stream: true, + }; + + const stream = (await this.client.messages.create( + streamingRequest as MessageCreateParamsStreaming, + { + signal: request.config?.abortSignal, + }, + )) as AsyncIterable; + + return this.processStream(stream); + } + + async countTokens( + request: CountTokensParameters, + ): Promise { + try { + const tokenizer = getDefaultTokenizer(); + const result = await tokenizer.calculateTokens(request, { + textEncoding: 'cl100k_base', + }); + + return { + totalTokens: result.totalTokens, + }; + } catch (error) { + console.warn( + 'Failed to calculate tokens with tokenizer, ' + + 'falling back to simple method:', + error, + ); + + const content = JSON.stringify(request.contents); + const totalTokens = Math.ceil(content.length / 4); + return { + totalTokens, + }; + } + } + + async embedContent( + _request: EmbedContentParameters, + ): Promise { + throw new Error('Anthropic does not support embeddings.'); + } + + useSummarizedThinking(): boolean { + return false; + } + + private buildHeaders(): Record { + const version = this.cliConfig.getCliVersion() || 'unknown'; + const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; + return { + 'User-Agent': userAgent, + }; + } + + private async buildRequest( + request: GenerateContentParameters, + ): Promise { + const { system, messages } = + this.converter.convertGeminiRequestToAnthropic(request); + + const tools = request.config?.tools + ? await this.converter.convertGeminiToolsToAnthropic(request.config.tools) + : undefined; + + const sampling = this.buildSamplingParameters(request); + const thinking = this.buildThinkingConfig(request, sampling.max_tokens); + + return { + model: this.contentGeneratorConfig.model, + system, + messages, + tools, + ...sampling, + ...(thinking ? { thinking } : {}), + }; + } + + private buildSamplingParameters(request: GenerateContentParameters): { + max_tokens: number; + temperature?: number; + top_p?: number; + top_k?: number; + } { + const configSamplingParams = this.contentGeneratorConfig.samplingParams; + const requestConfig = request.config || {}; + + const getParam = ( + configKey: keyof NonNullable, + requestKey?: keyof NonNullable, + ): T | undefined => { + const configValue = configSamplingParams?.[configKey] as T | undefined; + const requestValue = requestKey + ? (requestConfig[requestKey] as T | undefined) + : undefined; + return configValue !== undefined ? configValue : requestValue; + }; + + const maxTokens = getParam('max_tokens', 'maxOutputTokens') ?? 8192; + + return { + max_tokens: maxTokens, + temperature: getParam('temperature', 'temperature') ?? 1, + top_p: getParam('top_p', 'topP'), + top_k: getParam('top_k', 'topK'), + }; + } + + private buildThinkingConfig( + request: GenerateContentParameters, + maxTokens: number, + ): { type: 'enabled'; budget_tokens: number } | undefined { + if (request.config?.thinkingConfig?.includeThoughts === false) { + return undefined; + } + + const effort = this.contentGeneratorConfig.reasoning?.effort; + const baseBudget = + effort === 'low' ? 1024 : effort === 'high' ? 4096 : 2048; + const budgetTokens = Math.min(baseBudget, Math.max(1, maxTokens)); + + return { + type: 'enabled', + budget_tokens: budgetTokens, + }; + } + + private async *processStream( + stream: AsyncIterable, + ): AsyncGenerator { + let messageId: string | undefined; + let model = this.contentGeneratorConfig.model; + let cachedTokens = 0; + let promptTokens = 0; + let completionTokens = 0; + let finishReason: string | undefined; + + const blocks = new Map(); + const collectedResponses: GenerateContentResponse[] = []; + + for await (const event of stream) { + switch (event.type) { + case 'message_start': { + messageId = event.message.id ?? messageId; + model = event.message.model ?? model; + cachedTokens = + event.message.usage?.cache_read_input_tokens ?? cachedTokens; + promptTokens = event.message.usage?.input_tokens ?? promptTokens; + break; + } + case 'content_block_start': { + const index = event.index ?? 0; + const type = String(event.content_block.type || 'text'); + const initialInput = + type === 'tool_use' && 'input' in event.content_block + ? JSON.stringify(event.content_block.input) + : ''; + blocks.set(index, { + type, + id: + 'id' in event.content_block ? event.content_block.id : undefined, + name: + 'name' in event.content_block + ? event.content_block.name + : undefined, + inputJson: initialInput !== '{}' ? initialInput : '', + signature: + type === 'thinking' && + 'signature' in event.content_block && + typeof event.content_block.signature === 'string' + ? event.content_block.signature + : '', + }); + break; + } + case 'content_block_delta': { + const index = event.index ?? 0; + const deltaType = (event.delta as { type?: string }).type || ''; + const blockState = blocks.get(index); + + if (deltaType === 'text_delta') { + const text = 'text' in event.delta ? event.delta.text : ''; + if (text) { + const chunk = this.buildGeminiChunk({ text }, messageId, model); + collectedResponses.push(chunk); + yield chunk; + } + } else if (deltaType === 'thinking_delta') { + const thinking = + (event.delta as { thinking?: string }).thinking || ''; + if (thinking) { + const chunk = this.buildGeminiChunk( + { text: thinking, thought: true }, + messageId, + model, + ); + collectedResponses.push(chunk); + yield chunk; + } + } else if (deltaType === 'signature_delta' && blockState) { + const signature = + (event.delta as { signature?: string }).signature || ''; + if (signature) { + blockState.signature += signature; + const chunk = this.buildGeminiChunk( + { thought: true, thoughtSignature: signature }, + messageId, + model, + ); + collectedResponses.push(chunk); + yield chunk; + } + } else if (deltaType === 'input_json_delta' && blockState) { + const jsonDelta = + (event.delta as { partial_json?: string }).partial_json || ''; + if (jsonDelta) { + blockState.inputJson += jsonDelta; + } + } + break; + } + case 'content_block_stop': { + const index = event.index ?? 0; + const blockState = blocks.get(index); + if (blockState?.type === 'tool_use') { + const args = safeJsonParse(blockState.inputJson || '{}', {}); + const chunk = this.buildGeminiChunk( + { + functionCall: { + id: blockState.id, + name: blockState.name, + args, + }, + }, + messageId, + model, + ); + collectedResponses.push(chunk); + yield chunk; + } + blocks.delete(index); + break; + } + case 'message_delta': { + const stopReasonValue = event.delta.stop_reason; + if (stopReasonValue) { + finishReason = stopReasonValue; + } + + if (event.usage?.output_tokens !== undefined) { + completionTokens = event.usage.output_tokens; + } + + if (finishReason || event.usage) { + const chunk = this.buildGeminiChunk( + undefined, + messageId, + model, + finishReason, + { + cachedContentTokenCount: cachedTokens, + promptTokenCount: cachedTokens + promptTokens, + candidatesTokenCount: completionTokens, + totalTokenCount: cachedTokens + promptTokens + completionTokens, + }, + ); + collectedResponses.push(chunk); + yield chunk; + } + break; + } + case 'message_stop': { + if (promptTokens || completionTokens) { + const chunk = this.buildGeminiChunk( + undefined, + messageId, + model, + finishReason, + { + cachedContentTokenCount: cachedTokens, + promptTokenCount: cachedTokens + promptTokens, + candidatesTokenCount: completionTokens, + totalTokenCount: cachedTokens + promptTokens + completionTokens, + }, + ); + collectedResponses.push(chunk); + yield chunk; + } + break; + } + default: + break; + } + } + } + + private buildGeminiChunk( + part?: { + text?: string; + thought?: boolean; + thoughtSignature?: string; + functionCall?: unknown; + }, + responseId?: string, + model?: string, + finishReason?: string, + usageMetadata?: GenerateContentResponseUsageMetadata, + ): GenerateContentResponse { + const response = new GenerateContentResponse(); + response.responseId = responseId; + response.createTime = Date.now().toString(); + response.modelVersion = model || this.contentGeneratorConfig.model; + response.promptFeedback = { safetyRatings: [] }; + + const candidateParts = part ? [part as unknown as Part] : []; + const mappedFinishReason = + finishReason !== undefined + ? this.converter.mapAnthropicFinishReasonToGemini(finishReason) + : undefined; + response.candidates = [ + { + content: { + parts: candidateParts, + role: 'model' as const, + }, + index: 0, + safetyRatings: [], + ...(mappedFinishReason ? { finishReason: mappedFinishReason } : {}), + }, + ]; + + if (usageMetadata) { + response.usageMetadata = usageMetadata; + } + + return response; + } +} diff --git a/packages/core/src/core/anthropicContentGenerator/converter.ts b/packages/core/src/core/anthropicContentGenerator/converter.ts new file mode 100644 index 000000000..554c122e8 --- /dev/null +++ b/packages/core/src/core/anthropicContentGenerator/converter.ts @@ -0,0 +1,441 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + Candidate, + CallableTool, + Content, + ContentListUnion, + ContentUnion, + FunctionCall, + FunctionResponse, + GenerateContentParameters, + Part, + PartUnion, + Tool, + ToolListUnion, +} from '@google/genai'; +import { FinishReason, GenerateContentResponse } from '@google/genai'; +import type Anthropic from '@anthropic-ai/sdk'; +import { safeJsonParse } from '../../utils/safeJsonParse.js'; +import { + convertSchema, + type SchemaComplianceMode, +} from '../../utils/schemaConverter.js'; + +type AnthropicMessageParam = Anthropic.MessageParam; +type AnthropicToolParam = Anthropic.Tool; +type AnthropicContentBlockParam = Anthropic.ContentBlockParam; + +type ThoughtPart = { text: string; signature?: string }; + +interface ParsedParts { + thoughtParts: ThoughtPart[]; + contentParts: string[]; + functionCalls: FunctionCall[]; + functionResponses: FunctionResponse[]; +} + +export class AnthropicContentConverter { + private model: string; + private schemaCompliance: SchemaComplianceMode; + + constructor(model: string, schemaCompliance: SchemaComplianceMode = 'auto') { + this.model = model; + this.schemaCompliance = schemaCompliance; + } + + convertGeminiRequestToAnthropic(request: GenerateContentParameters): { + system?: string; + messages: AnthropicMessageParam[]; + } { + const messages: AnthropicMessageParam[] = []; + + const system = this.extractTextFromContentUnion( + request.config?.systemInstruction, + ); + + this.processContents(request.contents, messages); + + return { + system: system || undefined, + messages, + }; + } + + async convertGeminiToolsToAnthropic( + geminiTools: ToolListUnion, + ): Promise { + const tools: AnthropicToolParam[] = []; + + for (const tool of geminiTools) { + let actualTool: Tool; + + if ('tool' in tool) { + actualTool = await (tool as CallableTool).tool(); + } else { + actualTool = tool as Tool; + } + + if (!actualTool.functionDeclarations) { + continue; + } + + for (const func of actualTool.functionDeclarations) { + if (!func.name) continue; + + let inputSchema: Record | undefined; + if (func.parametersJsonSchema) { + inputSchema = { + ...(func.parametersJsonSchema as Record), + }; + } else if (func.parameters) { + inputSchema = func.parameters as Record; + } + + if (!inputSchema) { + inputSchema = { type: 'object', properties: {} }; + } + + inputSchema = convertSchema(inputSchema, this.schemaCompliance); + if (typeof inputSchema['type'] !== 'string') { + inputSchema['type'] = 'object'; + } + + tools.push({ + name: func.name, + description: func.description, + input_schema: inputSchema as Anthropic.Tool.InputSchema, + }); + } + } + + return tools; + } + + convertAnthropicResponseToGemini( + response: Anthropic.Message, + ): GenerateContentResponse { + const geminiResponse = new GenerateContentResponse(); + const parts: Part[] = []; + + for (const block of response.content || []) { + const blockType = String((block as { type?: string })['type'] || ''); + if (blockType === 'text') { + const text = + typeof (block as { text?: string }).text === 'string' + ? (block as { text?: string }).text + : ''; + if (text) { + parts.push({ text }); + } + } else if (blockType === 'tool_use') { + const toolUse = block as { + id?: string; + name?: string; + input?: unknown; + }; + parts.push({ + functionCall: { + id: typeof toolUse.id === 'string' ? toolUse.id : undefined, + name: typeof toolUse.name === 'string' ? toolUse.name : undefined, + args: this.safeInputToArgs(toolUse.input), + }, + }); + } else if (blockType === 'thinking') { + const thinking = + typeof (block as { thinking?: string }).thinking === 'string' + ? (block as { thinking?: string }).thinking + : ''; + const signature = + typeof (block as { signature?: string }).signature === 'string' + ? (block as { signature?: string }).signature + : ''; + if (thinking || signature) { + parts.push({ + text: thinking, + thought: true, + thoughtSignature: signature || undefined, + }); + } + } else if (blockType === 'redacted_thinking') { + parts.push({ text: '', thought: true }); + } + } + + const candidate: Candidate = { + content: { + parts, + role: 'model' as const, + }, + index: 0, + safetyRatings: [], + }; + + const finishReason = this.mapAnthropicFinishReasonToGemini( + response.stop_reason, + ); + if (finishReason) { + candidate.finishReason = finishReason; + } + + geminiResponse.candidates = [candidate]; + geminiResponse.responseId = response.id; + geminiResponse.createTime = Date.now().toString(); + geminiResponse.modelVersion = response.model || this.model; + geminiResponse.promptFeedback = { safetyRatings: [] }; + + if (response.usage) { + const promptTokens = response.usage.input_tokens || 0; + const completionTokens = response.usage.output_tokens || 0; + geminiResponse.usageMetadata = { + promptTokenCount: promptTokens, + candidatesTokenCount: completionTokens, + totalTokenCount: promptTokens + completionTokens, + }; + } + + return geminiResponse; + } + + private processContents( + contents: ContentListUnion, + messages: AnthropicMessageParam[], + ): void { + if (Array.isArray(contents)) { + for (const content of contents) { + this.processContent(content, messages); + } + } else if (contents) { + this.processContent(contents, messages); + } + } + + private processContent( + content: ContentUnion | PartUnion, + messages: AnthropicMessageParam[], + ): void { + if (typeof content === 'string') { + messages.push({ + role: 'user', + content: [{ type: 'text', text: content }], + }); + return; + } + + if (!this.isContentObject(content)) return; + + const parsed = this.parseParts(content.parts || []); + + if (parsed.functionResponses.length > 0) { + for (const response of parsed.functionResponses) { + messages.push({ + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: response.id || '', + content: this.extractFunctionResponseContent(response.response), + }, + ], + }); + } + return; + } + + if (content.role === 'model' && parsed.functionCalls.length > 0) { + const thinkingBlocks: AnthropicContentBlockParam[] = + parsed.thoughtParts.map( + (part) => + ({ + type: 'thinking', + thinking: part.text, + signature: part.signature, + }) as unknown as AnthropicContentBlockParam, + ); + const toolUses: AnthropicContentBlockParam[] = parsed.functionCalls.map( + (call, index) => ({ + type: 'tool_use', + id: call.id || `tool_${index}`, + name: call.name || '', + input: (call.args as Record) || {}, + }), + ); + + const textBlocks: AnthropicContentBlockParam[] = parsed.contentParts.map( + (text) => ({ + type: 'text' as const, + text, + }), + ); + + messages.push({ + role: 'assistant', + content: [...thinkingBlocks, ...textBlocks, ...toolUses], + }); + return; + } + + const role = content.role === 'model' ? 'assistant' : 'user'; + const thinkingBlocks: AnthropicContentBlockParam[] = + role === 'assistant' + ? parsed.thoughtParts.map( + (part) => + ({ + type: 'thinking', + thinking: part.text, + signature: part.signature, + }) as unknown as AnthropicContentBlockParam, + ) + : []; + const textBlocks: AnthropicContentBlockParam[] = [ + ...thinkingBlocks, + ...parsed.contentParts.map((text) => ({ + type: 'text' as const, + text, + })), + ]; + if (textBlocks.length > 0) { + messages.push({ role, content: textBlocks }); + } + } + + private parseParts(parts: Part[]): ParsedParts { + const thoughtParts: ThoughtPart[] = []; + const contentParts: string[] = []; + const functionCalls: FunctionCall[] = []; + const functionResponses: FunctionResponse[] = []; + + for (const part of parts) { + if (typeof part === 'string') { + contentParts.push(part); + } else if ( + 'text' in part && + part.text && + !('thought' in part && part.thought) + ) { + contentParts.push(part.text); + } else if ('text' in part && 'thought' in part && part.thought) { + thoughtParts.push({ + text: part.text || '', + signature: + 'thoughtSignature' in part && + typeof part.thoughtSignature === 'string' + ? part.thoughtSignature + : undefined, + }); + } else if ('functionCall' in part && part.functionCall) { + functionCalls.push(part.functionCall); + } else if ('functionResponse' in part && part.functionResponse) { + functionResponses.push(part.functionResponse); + } + } + + return { + thoughtParts, + contentParts, + functionCalls, + functionResponses, + }; + } + + private extractTextFromContentUnion(contentUnion: unknown): string { + if (typeof contentUnion === 'string') { + return contentUnion; + } + + if (Array.isArray(contentUnion)) { + return contentUnion + .map((item) => this.extractTextFromContentUnion(item)) + .filter(Boolean) + .join('\n'); + } + + if (typeof contentUnion === 'object' && contentUnion !== null) { + if ('parts' in contentUnion) { + const content = contentUnion as Content; + return ( + content.parts + ?.map((part: Part) => { + if (typeof part === 'string') return part; + if ('text' in part) return part.text || ''; + return ''; + }) + .filter(Boolean) + .join('\n') || '' + ); + } + } + + return ''; + } + + private extractFunctionResponseContent(response: unknown): string { + if (response === null || response === undefined) { + return ''; + } + + if (typeof response === 'string') { + return response; + } + + if (typeof response === 'object') { + const responseObject = response as Record; + const output = responseObject['output']; + if (typeof output === 'string') { + return output; + } + + const error = responseObject['error']; + if (typeof error === 'string') { + return error; + } + } + + try { + const serialized = JSON.stringify(response); + return serialized ?? String(response); + } catch { + return String(response); + } + } + + private safeInputToArgs(input: unknown): Record { + if (input && typeof input === 'object') { + return input as Record; + } + if (typeof input === 'string') { + return safeJsonParse(input, {}); + } + return {}; + } + + mapAnthropicFinishReasonToGemini( + reason?: string | null, + ): FinishReason | undefined { + if (!reason) return undefined; + const mapping: Record = { + end_turn: FinishReason.STOP, + stop_sequence: FinishReason.STOP, + tool_use: FinishReason.STOP, + max_tokens: FinishReason.MAX_TOKENS, + content_filter: FinishReason.SAFETY, + }; + return mapping[reason] || FinishReason.FINISH_REASON_UNSPECIFIED; + } + + private isContentObject( + content: unknown, + ): content is { role: string; parts: Part[] } { + return ( + typeof content === 'object' && + content !== null && + 'role' in content && + 'parts' in content && + Array.isArray((content as Record)['parts']) + ); + } +} diff --git a/packages/core/src/core/anthropicContentGenerator/index.ts b/packages/core/src/core/anthropicContentGenerator/index.ts new file mode 100644 index 000000000..ce480790a --- /dev/null +++ b/packages/core/src/core/anthropicContentGenerator/index.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + ContentGenerator, + ContentGeneratorConfig, +} from '../contentGenerator.js'; +import type { Config } from '../../config/config.js'; +import { AnthropicContentGenerator } from './anthropicContentGenerator.js'; + +export { AnthropicContentGenerator } from './anthropicContentGenerator.js'; + +export function createAnthropicContentGenerator( + contentGeneratorConfig: ContentGeneratorConfig, + cliConfig: Config, +): ContentGenerator { + return new AnthropicContentGenerator(contentGeneratorConfig, cliConfig); +} diff --git a/packages/core/src/core/contentGenerator.test.ts b/packages/core/src/core/contentGenerator.test.ts index e70b4d13e..920c920ee 100644 --- a/packages/core/src/core/contentGenerator.test.ts +++ b/packages/core/src/core/contentGenerator.test.ts @@ -8,7 +8,7 @@ import { describe, it, expect, vi } from 'vitest'; import { createContentGenerator, AuthType } from './contentGenerator.js'; import { GoogleGenAI } from '@google/genai'; import type { Config } from '../config/config.js'; -import { LoggingContentGenerator } from './geminiContentGenerator/loggingContentGenerator.js'; +import { LoggingContentGenerator } from './loggingContentGenerator/loggingContentGenerator.js'; vi.mock('@google/genai'); diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 8ba85160c..113662450 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -14,6 +14,7 @@ import type { } from '@google/genai'; import { DEFAULT_QWEN_MODEL } from '../config/models.js'; import type { Config } from '../config/config.js'; +import { LoggingContentGenerator } from './loggingContentGenerator/loggingContentGenerator.js'; /** * Interface abstracting the core functionalities for generating content and counting tokens. @@ -37,10 +38,11 @@ export interface ContentGenerator { } export enum AuthType { - USE_GEMINI = 'gemini-api-key', - USE_VERTEX_AI = 'vertex-ai', USE_OPENAI = 'openai', QWEN_OAUTH = 'qwen-oauth', + USE_GEMINI = 'gemini', + USE_VERTEX_AI = 'vertex-ai', + USE_ANTHROPIC = 'anthropic', } export type ContentGeneratorConfig = { @@ -77,7 +79,7 @@ export function createContentGeneratorConfig( authType: AuthType | undefined, generationConfig?: Partial, ): ContentGeneratorConfig { - const newContentGeneratorConfig: Partial = { + let newContentGeneratorConfig: Partial = { ...(generationConfig || {}), authType, proxy: config?.getProxy(), @@ -94,6 +96,14 @@ export function createContentGeneratorConfig( } if (authType === AuthType.USE_OPENAI) { + newContentGeneratorConfig = { + ...newContentGeneratorConfig, + apiKey: newContentGeneratorConfig.apiKey || process.env['OPENAI_API_KEY'], + baseUrl: + newContentGeneratorConfig.baseUrl || process.env['OPENAI_BASE_URL'], + model: newContentGeneratorConfig.model || process.env['OPENAI_MODEL'], + }; + if (!newContentGeneratorConfig.apiKey) { throw new Error('OpenAI API key is required'); } @@ -104,10 +114,62 @@ export function createContentGeneratorConfig( } as ContentGeneratorConfig; } - return { - ...newContentGeneratorConfig, - model: newContentGeneratorConfig?.model || DEFAULT_QWEN_MODEL, - } as ContentGeneratorConfig; + if (authType === AuthType.USE_ANTHROPIC) { + newContentGeneratorConfig = { + ...newContentGeneratorConfig, + apiKey: + newContentGeneratorConfig.apiKey || process.env['ANTHROPIC_API_KEY'], + baseUrl: + newContentGeneratorConfig.baseUrl || process.env['ANTHROPIC_BASE_URL'], + model: newContentGeneratorConfig.model || process.env['ANTHROPIC_MODEL'], + }; + + if (!newContentGeneratorConfig.apiKey) { + throw new Error('ANTHROPIC_API_KEY environment variable not found.'); + } + + if (!newContentGeneratorConfig.baseUrl) { + throw new Error('ANTHROPIC_BASE_URL environment variable not found.'); + } + + if (!newContentGeneratorConfig.model) { + throw new Error('ANTHROPIC_MODEL environment variable not found.'); + } + } + + if (authType === AuthType.USE_GEMINI) { + newContentGeneratorConfig = { + ...newContentGeneratorConfig, + apiKey: newContentGeneratorConfig.apiKey || process.env['GEMINI_API_KEY'], + model: newContentGeneratorConfig.model || process.env['GEMINI_MODEL'], + }; + + if (!newContentGeneratorConfig.apiKey) { + throw new Error('GEMINI_API_KEY environment variable not found.'); + } + + if (!newContentGeneratorConfig.model) { + throw new Error('GEMINI_MODEL environment variable not found.'); + } + } + + if (authType === AuthType.USE_VERTEX_AI) { + newContentGeneratorConfig = { + ...newContentGeneratorConfig, + apiKey: newContentGeneratorConfig.apiKey || process.env['GOOGLE_API_KEY'], + model: newContentGeneratorConfig.model || process.env['GOOGLE_MODEL'], + }; + + if (!newContentGeneratorConfig.apiKey) { + throw new Error('Google API key is required'); + } + + if (!newContentGeneratorConfig.model) { + throw new Error('GOOGLE_MODEL environment variable not found.'); + } + } + + return newContentGeneratorConfig as ContentGeneratorConfig; } export async function createContentGenerator( @@ -115,16 +177,6 @@ export async function createContentGenerator( gcConfig: Config, isInitialAuth?: boolean, ): Promise { - if ( - config.authType === AuthType.USE_GEMINI || - config.authType === AuthType.USE_VERTEX_AI - ) { - const { createGeminiContentGenerator } = await import( - './geminiContentGenerator/index.js' - ); - return createGeminiContentGenerator(config, gcConfig); - } - if (config.authType === AuthType.USE_OPENAI) { if (!config.apiKey) { throw new Error('OpenAI API key is required'); @@ -136,7 +188,8 @@ export async function createContentGenerator( ); // Always use OpenAIContentGenerator, logging is controlled by enableOpenAILogging flag - return createOpenAIContentGenerator(config, gcConfig); + const generator = createOpenAIContentGenerator(config, gcConfig); + return new LoggingContentGenerator(generator, gcConfig); } if (config.authType === AuthType.QWEN_OAUTH) { @@ -157,7 +210,8 @@ export async function createContentGenerator( ); // Create the content generator with dynamic token management - return new QwenContentGenerator(qwenClient, config, gcConfig); + const generator = new QwenContentGenerator(qwenClient, config, gcConfig); + return new LoggingContentGenerator(generator, gcConfig); } catch (error) { throw new Error( `${error instanceof Error ? error.message : String(error)}`, @@ -165,6 +219,30 @@ export async function createContentGenerator( } } + if (config.authType === AuthType.USE_ANTHROPIC) { + if (!config.apiKey) { + throw new Error('Anthropic API key is required'); + } + + const { createAnthropicContentGenerator } = await import( + './anthropicContentGenerator/index.js' + ); + + const generator = createAnthropicContentGenerator(config, gcConfig); + return new LoggingContentGenerator(generator, gcConfig); + } + + if ( + config.authType === AuthType.USE_GEMINI || + config.authType === AuthType.USE_VERTEX_AI + ) { + const { createGeminiContentGenerator } = await import( + './geminiContentGenerator/index.js' + ); + const generator = createGeminiContentGenerator(config, gcConfig); + return new LoggingContentGenerator(generator, gcConfig); + } + throw new Error( `Error creating contentGenerator: Unsupported authType: ${config.authType}`, ); diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index b849fdbf3..39b732cd9 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -720,66 +720,6 @@ describe('GeminiChat', () => { ); }); - it('should handle summarized thinking by conditionally including thoughts in history', async () => { - // Case 1: useSummarizedThinking is true -> thoughts NOT in history - vi.mocked(mockContentGenerator.useSummarizedThinking).mockReturnValue( - true, - ); - const stream1 = (async function* () { - yield { - candidates: [ - { - content: { - role: 'model', - parts: [{ thought: true, text: 'T1' }, { text: 'A1' }], - }, - finishReason: 'STOP', - }, - ], - } as unknown as GenerateContentResponse; - })(); - vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue( - stream1, - ); - - const res1 = await chat.sendMessageStream('m1', { message: 'h1' }, 'p1'); - for await (const _ of res1); - - const history1 = chat.getHistory(); - expect(history1[1].parts).toEqual([{ text: 'A1' }]); - - // Case 2: useSummarizedThinking is false -> thoughts ARE in history - chat.clearHistory(); - vi.mocked(mockContentGenerator.useSummarizedThinking).mockReturnValue( - false, - ); - const stream2 = (async function* () { - yield { - candidates: [ - { - content: { - role: 'model', - parts: [{ thought: true, text: 'T2' }, { text: 'A2' }], - }, - finishReason: 'STOP', - }, - ], - } as unknown as GenerateContentResponse; - })(); - vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue( - stream2, - ); - - const res2 = await chat.sendMessageStream('m1', { message: 'h1' }, 'p2'); - for await (const _ of res2); - - const history2 = chat.getHistory(); - expect(history2[1].parts).toEqual([ - { text: 'T2', thought: true }, - { text: 'A2' }, - ]); - }); - it('should keep parts with thoughtSignature when consolidating history', async () => { const stream = (async function* () { yield { diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 8968a7eb7..99be49326 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -559,14 +559,26 @@ export class GeminiChat { yield chunk; // Yield every chunk to the UI immediately. } - let thoughtText = ''; - // Only include thoughts if not using summarized thinking. - if (!this.config.getContentGenerator().useSummarizedThinking()) { - thoughtText = allModelParts - .filter((part) => part.thought) - .map((part) => part.text) - .join('') - .trim(); + let thoughtContentPart: Part | undefined; + const thoughtText = allModelParts + .filter((part) => part.thought) + .map((part) => part.text) + .join('') + .trim(); + + if (thoughtText !== '') { + thoughtContentPart = { + text: thoughtText, + thought: true, + }; + + const thoughtSignature = + allModelParts.filter( + (part) => part.thoughtSignature && part.thought, + )?.[0]?.thoughtSignature || ''; + if (thoughtContentPart && thoughtSignature !== '') { + thoughtContentPart.thoughtSignature = thoughtSignature; + } } const contentParts = allModelParts.filter((part) => !part.thought); @@ -592,11 +604,11 @@ export class GeminiChat { .trim(); // Record assistant turn with raw Content and metadata - if (thoughtText || contentText || hasToolCall || usageMetadata) { + if (thoughtContentPart || contentText || hasToolCall || usageMetadata) { this.chatRecordingService?.recordAssistantTurn({ model, message: [ - ...(thoughtText ? [{ text: thoughtText, thought: true }] : []), + ...(thoughtContentPart ? [thoughtContentPart] : []), ...(contentText ? [{ text: contentText }] : []), ...(hasToolCall ? contentParts @@ -632,7 +644,7 @@ export class GeminiChat { this.history.push({ role: 'model', parts: [ - ...(thoughtText ? [{ text: thoughtText, thought: true }] : []), + ...(thoughtContentPart ? [thoughtContentPart] : []), ...consolidatedHistoryParts, ], }); diff --git a/packages/core/src/core/geminiContentGenerator/index.test.ts b/packages/core/src/core/geminiContentGenerator/index.test.ts index ac3f9f627..c7effd220 100644 --- a/packages/core/src/core/geminiContentGenerator/index.test.ts +++ b/packages/core/src/core/geminiContentGenerator/index.test.ts @@ -7,7 +7,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createGeminiContentGenerator } from './index.js'; import { GeminiContentGenerator } from './geminiContentGenerator.js'; -import { LoggingContentGenerator } from './loggingContentGenerator.js'; import type { Config } from '../../config/config.js'; import { AuthType } from '../contentGenerator.js'; @@ -15,10 +14,6 @@ vi.mock('./geminiContentGenerator.js', () => ({ GeminiContentGenerator: vi.fn().mockImplementation(() => ({})), })); -vi.mock('./loggingContentGenerator.js', () => ({ - LoggingContentGenerator: vi.fn().mockImplementation((wrapped) => wrapped), -})); - describe('createGeminiContentGenerator', () => { let mockConfig: Config; @@ -31,7 +26,7 @@ describe('createGeminiContentGenerator', () => { } as unknown as Config; }); - it('should create a GeminiContentGenerator wrapped in LoggingContentGenerator', () => { + it('should create a GeminiContentGenerator', () => { const config = { model: 'gemini-1.5-flash', apiKey: 'test-key', @@ -41,7 +36,6 @@ describe('createGeminiContentGenerator', () => { const generator = createGeminiContentGenerator(config, mockConfig); expect(GeminiContentGenerator).toHaveBeenCalled(); - expect(LoggingContentGenerator).toHaveBeenCalled(); expect(generator).toBeDefined(); }); }); diff --git a/packages/core/src/core/geminiContentGenerator/index.ts b/packages/core/src/core/geminiContentGenerator/index.ts index 60e74cbf4..4a615c0d8 100644 --- a/packages/core/src/core/geminiContentGenerator/index.ts +++ b/packages/core/src/core/geminiContentGenerator/index.ts @@ -11,10 +11,8 @@ import type { } from '../contentGenerator.js'; import type { Config } from '../../config/config.js'; import { InstallationManager } from '../../utils/installationManager.js'; -import { LoggingContentGenerator } from './loggingContentGenerator.js'; export { GeminiContentGenerator } from './geminiContentGenerator.js'; -export { LoggingContentGenerator } from './loggingContentGenerator.js'; /** * Create a Gemini content generator. @@ -51,5 +49,5 @@ export function createGeminiContentGenerator( config, ); - return new LoggingContentGenerator(geminiContentGenerator, gcConfig); + return geminiContentGenerator; } diff --git a/packages/core/src/core/geminiContentGenerator/loggingContentGenerator.ts b/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.ts similarity index 51% rename from packages/core/src/core/geminiContentGenerator/loggingContentGenerator.ts rename to packages/core/src/core/loggingContentGenerator/loggingContentGenerator.ts index 60d0fc247..34e9128a8 100644 --- a/packages/core/src/core/geminiContentGenerator/loggingContentGenerator.ts +++ b/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.ts @@ -4,20 +4,22 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { - Content, - CountTokensParameters, - CountTokensResponse, - EmbedContentParameters, - EmbedContentResponse, - GenerateContentParameters, - GenerateContentResponseUsageMetadata, +import { GenerateContentResponse, - ContentListUnion, - ContentUnion, - Part, - PartUnion, + type Content, + type CountTokensParameters, + type CountTokensResponse, + type EmbedContentParameters, + type EmbedContentResponse, + type GenerateContentParameters, + type GenerateContentResponseUsageMetadata, + type ContentListUnion, + type ContentUnion, + type Part, + type PartUnion, + type FinishReason, } from '@google/genai'; +import type OpenAI from 'openai'; import { ApiRequestEvent, ApiResponseEvent, @@ -31,6 +33,8 @@ import { } from '../../telemetry/loggers.js'; import type { ContentGenerator } from '../contentGenerator.js'; import { isStructuredError } from '../../utils/quotaErrorDetection.js'; +import { OpenAIContentConverter } from '../openaiContentGenerator/converter.js'; +import { OpenAILogger } from '../../utils/openaiLogger.js'; interface StructuredError { status: number; @@ -40,10 +44,19 @@ interface StructuredError { * A decorator that wraps a ContentGenerator to add logging to API calls. */ export class LoggingContentGenerator implements ContentGenerator { + private openaiLogger?: OpenAILogger; + private schemaCompliance?: 'auto' | 'openapi_30'; + constructor( private readonly wrapped: ContentGenerator, private readonly config: Config, - ) {} + ) { + const generatorConfig = this.config.getContentGeneratorConfig(); + if (generatorConfig?.enableOpenAILogging) { + this.openaiLogger = new OpenAILogger(generatorConfig.openAILoggingDir); + this.schemaCompliance = generatorConfig.schemaCompliance; + } + } getWrapped(): ContentGenerator { return this.wrapped; @@ -91,21 +104,31 @@ export class LoggingContentGenerator implements ContentGenerator { prompt_id: string, ): void { const errorMessage = error instanceof Error ? error.message : String(error); - const errorType = error instanceof Error ? error.name : 'unknown'; + const errorType = + (error as { type?: string })?.type || + (error instanceof Error ? error.name : 'unknown'); + const errorResponseId = + (error as { requestID?: string; request_id?: string })?.requestID || + (error as { requestID?: string; request_id?: string })?.request_id || + responseId; + const errorStatus = + (error as { code?: string | number; status?: number })?.code ?? + (error as { status?: number })?.status ?? + (isStructuredError(error) + ? (error as StructuredError).status + : undefined); logApiError( this.config, new ApiErrorEvent( - responseId, + errorResponseId, model, errorMessage, durationMs, prompt_id, this.config.getContentGeneratorConfig()?.authType, errorType, - isStructuredError(error) - ? (error as StructuredError).status - : undefined, + errorStatus, ), ); } @@ -116,6 +139,7 @@ export class LoggingContentGenerator implements ContentGenerator { ): Promise { const startTime = Date.now(); this.logApiRequest(this.toContents(req.contents), req.model, userPromptId); + const openaiRequest = await this.buildOpenAIRequestForLogging(req); try { const response = await this.wrapped.generateContent(req, userPromptId); const durationMs = Date.now() - startTime; @@ -127,10 +151,12 @@ export class LoggingContentGenerator implements ContentGenerator { response.usageMetadata, JSON.stringify(response), ); + await this.logOpenAIInteraction(openaiRequest, response); return response; } catch (error) { const durationMs = Date.now() - startTime; this._logApiError(undefined, durationMs, error, req.model, userPromptId); + await this.logOpenAIInteraction(openaiRequest, undefined, error); throw error; } } @@ -141,6 +167,7 @@ export class LoggingContentGenerator implements ContentGenerator { ): Promise> { const startTime = Date.now(); this.logApiRequest(this.toContents(req.contents), req.model, userPromptId); + const openaiRequest = await this.buildOpenAIRequestForLogging(req); let stream: AsyncGenerator; try { @@ -148,6 +175,7 @@ export class LoggingContentGenerator implements ContentGenerator { } catch (error) { const durationMs = Date.now() - startTime; this._logApiError(undefined, durationMs, error, req.model, userPromptId); + await this.logOpenAIInteraction(openaiRequest, undefined, error); throw error; } @@ -156,6 +184,7 @@ export class LoggingContentGenerator implements ContentGenerator { startTime, userPromptId, req.model, + openaiRequest, ); } @@ -164,6 +193,7 @@ export class LoggingContentGenerator implements ContentGenerator { startTime: number, userPromptId: string, model: string, + openaiRequest?: OpenAI.Chat.ChatCompletionCreateParams, ): AsyncGenerator { const responses: GenerateContentResponse[] = []; @@ -186,6 +216,9 @@ export class LoggingContentGenerator implements ContentGenerator { lastUsageMetadata, JSON.stringify(responses), ); + const consolidatedResponse = + this.consolidateGeminiResponsesForLogging(responses); + await this.logOpenAIInteraction(openaiRequest, consolidatedResponse); } catch (error) { const durationMs = Date.now() - startTime; this._logApiError( @@ -195,10 +228,182 @@ export class LoggingContentGenerator implements ContentGenerator { responses[0]?.modelVersion || model, userPromptId, ); + await this.logOpenAIInteraction(openaiRequest, undefined, error); throw error; } } + private async buildOpenAIRequestForLogging( + request: GenerateContentParameters, + ): Promise { + if (!this.openaiLogger) { + return undefined; + } + + const converter = new OpenAIContentConverter( + request.model, + this.schemaCompliance, + ); + const messages = converter.convertGeminiRequestToOpenAI(request, { + cleanOrphanToolCalls: false, + }); + + const openaiRequest: OpenAI.Chat.ChatCompletionCreateParams = { + model: request.model, + messages, + }; + + if (request.config?.tools) { + openaiRequest.tools = await converter.convertGeminiToolsToOpenAI( + request.config.tools, + ); + } + + if (request.config?.temperature !== undefined) { + openaiRequest.temperature = request.config.temperature; + } + if (request.config?.topP !== undefined) { + openaiRequest.top_p = request.config.topP; + } + if (request.config?.maxOutputTokens !== undefined) { + openaiRequest.max_tokens = request.config.maxOutputTokens; + } + if (request.config?.presencePenalty !== undefined) { + openaiRequest.presence_penalty = request.config.presencePenalty; + } + if (request.config?.frequencyPenalty !== undefined) { + openaiRequest.frequency_penalty = request.config.frequencyPenalty; + } + + return openaiRequest; + } + + private async logOpenAIInteraction( + openaiRequest: OpenAI.Chat.ChatCompletionCreateParams | undefined, + response?: GenerateContentResponse, + error?: unknown, + ): Promise { + if (!this.openaiLogger || !openaiRequest) { + return; + } + + const openaiResponse = response + ? this.convertGeminiResponseToOpenAIForLogging(response, openaiRequest) + : undefined; + + await this.openaiLogger.logInteraction( + openaiRequest, + openaiResponse, + error instanceof Error + ? error + : error + ? new Error(String(error)) + : undefined, + ); + } + + private convertGeminiResponseToOpenAIForLogging( + response: GenerateContentResponse, + openaiRequest: OpenAI.Chat.ChatCompletionCreateParams, + ): OpenAI.Chat.ChatCompletion { + const converter = new OpenAIContentConverter( + openaiRequest.model, + this.schemaCompliance, + ); + + return converter.convertGeminiResponseToOpenAI(response); + } + + private consolidateGeminiResponsesForLogging( + responses: GenerateContentResponse[], + ): GenerateContentResponse | undefined { + if (responses.length === 0) { + return undefined; + } + + const consolidated = new GenerateContentResponse(); + const combinedParts: Part[] = []; + const functionCallIndex = new Map(); + let finishReason: FinishReason | undefined; + let usageMetadata: GenerateContentResponseUsageMetadata | undefined; + + for (const response of responses) { + if (response.usageMetadata) { + usageMetadata = response.usageMetadata; + } + + const candidate = response.candidates?.[0]; + if (candidate?.finishReason) { + finishReason = candidate.finishReason; + } + + const parts = candidate?.content?.parts ?? []; + for (const part of parts as Part[]) { + if (typeof part === 'string') { + combinedParts.push({ text: part }); + continue; + } + + if ('text' in part) { + if (part.text) { + combinedParts.push({ + text: part.text, + ...(part.thought ? { thought: true } : {}), + ...(part.thoughtSignature + ? { thoughtSignature: part.thoughtSignature } + : {}), + }); + } + continue; + } + + if ('functionCall' in part && part.functionCall) { + const callKey = + part.functionCall.id || part.functionCall.name || 'tool_call'; + const existingIndex = functionCallIndex.get(callKey); + const functionPart = { functionCall: part.functionCall }; + if (existingIndex !== undefined) { + combinedParts[existingIndex] = functionPart; + } else { + functionCallIndex.set(callKey, combinedParts.length); + combinedParts.push(functionPart); + } + continue; + } + + if ('functionResponse' in part && part.functionResponse) { + combinedParts.push({ functionResponse: part.functionResponse }); + continue; + } + + combinedParts.push(part); + } + } + + const lastResponse = responses[responses.length - 1]; + const lastCandidate = lastResponse.candidates?.[0]; + + consolidated.responseId = lastResponse.responseId; + consolidated.createTime = lastResponse.createTime; + consolidated.modelVersion = lastResponse.modelVersion; + consolidated.promptFeedback = lastResponse.promptFeedback; + consolidated.usageMetadata = usageMetadata; + + consolidated.candidates = [ + { + content: { + role: lastCandidate?.content?.role || 'model', + parts: combinedParts, + }, + ...(finishReason ? { finishReason } : {}), + index: 0, + safetyRatings: lastCandidate?.safetyRatings || [], + }, + ]; + + return consolidated; + } + async countTokens(req: CountTokensParameters): Promise { return this.wrapped.countTokens(req); } diff --git a/packages/core/src/core/openaiContentGenerator/converter.ts b/packages/core/src/core/openaiContentGenerator/converter.ts index 8187746da..79bb43365 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.ts @@ -236,8 +236,9 @@ export class OpenAIContentConverter { */ convertGeminiRequestToOpenAI( request: GenerateContentParameters, + options: { cleanOrphanToolCalls: boolean } = { cleanOrphanToolCalls: true }, ): OpenAI.Chat.ChatCompletionMessageParam[] { - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = []; + let messages: OpenAI.Chat.ChatCompletionMessageParam[] = []; // Handle system instruction from config this.addSystemInstructionMessage(request, messages); @@ -246,11 +247,89 @@ export class OpenAIContentConverter { this.processContents(request.contents, messages); // Clean up orphaned tool calls and merge consecutive assistant messages - const cleanedMessages = this.cleanOrphanedToolCalls(messages); - const mergedMessages = - this.mergeConsecutiveAssistantMessages(cleanedMessages); + if (options.cleanOrphanToolCalls) { + messages = this.cleanOrphanedToolCalls(messages); + } + messages = this.mergeConsecutiveAssistantMessages(messages); - return mergedMessages; + return messages; + } + + /** + * Convert Gemini response to OpenAI completion format (for logging). + */ + convertGeminiResponseToOpenAI( + response: GenerateContentResponse, + ): OpenAI.Chat.ChatCompletion { + const candidate = response.candidates?.[0]; + const parts = (candidate?.content?.parts || []) as Part[]; + const parsedParts = this.parseParts(parts); + + const message: ExtendedCompletionMessage = { + role: 'assistant', + content: parsedParts.contentParts.join('') || null, + refusal: null, + }; + + const reasoningContent = parsedParts.thoughtParts.join(''); + if (reasoningContent) { + message.reasoning_content = reasoningContent; + } + + if (parsedParts.functionCalls.length > 0) { + message.tool_calls = parsedParts.functionCalls.map((call, index) => ({ + id: call.id || `call_${index}`, + type: 'function' as const, + function: { + name: call.name || '', + arguments: JSON.stringify(call.args || {}), + }, + })); + } + + const finishReason = this.mapGeminiFinishReasonToOpenAI( + candidate?.finishReason, + ); + + const usageMetadata = response.usageMetadata; + const usage: OpenAI.CompletionUsage = { + prompt_tokens: usageMetadata?.promptTokenCount || 0, + completion_tokens: usageMetadata?.candidatesTokenCount || 0, + total_tokens: usageMetadata?.totalTokenCount || 0, + }; + + if (usageMetadata?.cachedContentTokenCount !== undefined) { + ( + usage as OpenAI.CompletionUsage & { + prompt_tokens_details?: { cached_tokens?: number }; + } + ).prompt_tokens_details = { + cached_tokens: usageMetadata.cachedContentTokenCount, + }; + } + + const createdMs = response.createTime + ? Number(response.createTime) + : Date.now(); + const createdSeconds = Number.isFinite(createdMs) + ? Math.floor(createdMs / 1000) + : Math.floor(Date.now() / 1000); + + return { + id: response.responseId || `gemini-${Date.now()}`, + object: 'chat.completion', + created: createdSeconds, + model: response.modelVersion || this.model, + choices: [ + { + index: 0, + message, + finish_reason: finishReason, + logprobs: null, + }, + ], + usage, + }; } /** @@ -836,84 +915,6 @@ export class OpenAIContentConverter { return response; } - /** - * Convert Gemini response format to OpenAI chat completion format for logging - */ - convertGeminiResponseToOpenAI( - response: GenerateContentResponse, - ): OpenAI.Chat.ChatCompletion { - const candidate = response.candidates?.[0]; - const content = candidate?.content; - - let messageContent: string | null = null; - const toolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[] = []; - - if (content?.parts) { - const textParts: string[] = []; - - for (const part of content.parts) { - if ('text' in part && part.text) { - textParts.push(part.text); - } else if ('functionCall' in part && part.functionCall) { - toolCalls.push({ - id: part.functionCall.id || `call_${toolCalls.length}`, - type: 'function' as const, - function: { - name: part.functionCall.name || '', - arguments: JSON.stringify(part.functionCall.args || {}), - }, - }); - } - } - - messageContent = textParts.join('').trimEnd(); - } - - const choice: OpenAI.Chat.ChatCompletion.Choice = { - index: 0, - message: { - role: 'assistant', - content: messageContent, - refusal: null, - }, - finish_reason: this.mapGeminiFinishReasonToOpenAI( - candidate?.finishReason, - ) as OpenAI.Chat.ChatCompletion.Choice['finish_reason'], - logprobs: null, - }; - - if (toolCalls.length > 0) { - choice.message.tool_calls = toolCalls; - } - - const openaiResponse: OpenAI.Chat.ChatCompletion = { - id: response.responseId || `chatcmpl-${Date.now()}`, - object: 'chat.completion', - created: response.createTime - ? Number(response.createTime) - : Math.floor(Date.now() / 1000), - model: this.model, - choices: [choice], - }; - - // Add usage metadata if available - if (response.usageMetadata) { - openaiResponse.usage = { - prompt_tokens: response.usageMetadata.promptTokenCount || 0, - completion_tokens: response.usageMetadata.candidatesTokenCount || 0, - total_tokens: response.usageMetadata.totalTokenCount || 0, - }; - - if (response.usageMetadata.cachedContentTokenCount) { - openaiResponse.usage.prompt_tokens_details = { - cached_tokens: response.usageMetadata.cachedContentTokenCount, - }; - } - } - - return openaiResponse; - } - /** * Map OpenAI finish reasons to Gemini finish reasons */ @@ -931,29 +932,24 @@ export class OpenAIContentConverter { return mapping[openaiReason] || FinishReason.FINISH_REASON_UNSPECIFIED; } - /** - * Map Gemini finish reasons to OpenAI finish reasons - */ - private mapGeminiFinishReasonToOpenAI(geminiReason?: unknown): string { - if (!geminiReason) return 'stop'; + private mapGeminiFinishReasonToOpenAI( + geminiReason?: FinishReason, + ): 'stop' | 'length' | 'tool_calls' | 'content_filter' | 'function_call' { + if (!geminiReason) { + return 'stop'; + } switch (geminiReason) { - case 'STOP': - case 1: // FinishReason.STOP + case FinishReason.STOP: return 'stop'; - case 'MAX_TOKENS': - case 2: // FinishReason.MAX_TOKENS + case FinishReason.MAX_TOKENS: return 'length'; - case 'SAFETY': - case 3: // FinishReason.SAFETY + case FinishReason.SAFETY: return 'content_filter'; - case 'RECITATION': - case 4: // FinishReason.RECITATION - return 'content_filter'; - case 'OTHER': - case 5: // FinishReason.OTHER - return 'stop'; default: + if (geminiReason === ('RECITATION' as FinishReason)) { + return 'content_filter'; + } return 'stop'; } } diff --git a/packages/core/src/core/openaiContentGenerator/errorHandler.test.ts b/packages/core/src/core/openaiContentGenerator/errorHandler.test.ts index b54a9607e..e124d92a2 100644 --- a/packages/core/src/core/openaiContentGenerator/errorHandler.test.ts +++ b/packages/core/src/core/openaiContentGenerator/errorHandler.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { GenerateContentParameters } from '@google/genai'; import { EnhancedErrorHandler } from './errorHandler.js'; -import type { RequestContext } from './telemetryService.js'; +import type { RequestContext } from './errorHandler.js'; describe('EnhancedErrorHandler', () => { let errorHandler: EnhancedErrorHandler; diff --git a/packages/core/src/core/openaiContentGenerator/errorHandler.ts b/packages/core/src/core/openaiContentGenerator/errorHandler.ts index 9091116d3..fe74a87a9 100644 --- a/packages/core/src/core/openaiContentGenerator/errorHandler.ts +++ b/packages/core/src/core/openaiContentGenerator/errorHandler.ts @@ -5,7 +5,15 @@ */ import type { GenerateContentParameters } from '@google/genai'; -import type { RequestContext } from './telemetryService.js'; + +export interface RequestContext { + userPromptId: string; + model: string; + authType: string; + startTime: number; + duration: number; + isStreaming: boolean; +} export interface ErrorHandler { handle( diff --git a/packages/core/src/core/openaiContentGenerator/index.ts b/packages/core/src/core/openaiContentGenerator/index.ts index 8559258cb..fee32a049 100644 --- a/packages/core/src/core/openaiContentGenerator/index.ts +++ b/packages/core/src/core/openaiContentGenerator/index.ts @@ -91,11 +91,4 @@ export function determineProvider( return new DefaultOpenAICompatibleProvider(contentGeneratorConfig, cliConfig); } -// Services -export { - type TelemetryService, - type RequestContext, - DefaultTelemetryService, -} from './telemetryService.js'; - export { type ErrorHandler, EnhancedErrorHandler } from './errorHandler.js'; diff --git a/packages/core/src/core/openaiContentGenerator/openaiContentGenerator.ts b/packages/core/src/core/openaiContentGenerator/openaiContentGenerator.ts index 4dae3f19d..734ed6afb 100644 --- a/packages/core/src/core/openaiContentGenerator/openaiContentGenerator.ts +++ b/packages/core/src/core/openaiContentGenerator/openaiContentGenerator.ts @@ -11,7 +11,6 @@ import type { } from '@google/genai'; import type { PipelineConfig } from './pipeline.js'; import { ContentGenerationPipeline } from './pipeline.js'; -import { DefaultTelemetryService } from './telemetryService.js'; import { EnhancedErrorHandler } from './errorHandler.js'; import { getDefaultTokenizer } from '../../utils/request-tokenizer/index.js'; import type { ContentGeneratorConfig } from '../contentGenerator.js'; @@ -29,11 +28,6 @@ export class OpenAIContentGenerator implements ContentGenerator { cliConfig, provider, contentGeneratorConfig, - telemetryService: new DefaultTelemetryService( - cliConfig, - contentGeneratorConfig.enableOpenAILogging, - contentGeneratorConfig.openAILoggingDir, - ), errorHandler: new EnhancedErrorHandler( (error: unknown, request: GenerateContentParameters) => this.shouldSuppressErrorLogging(error, request), diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts index bccd489e9..93adcb090 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts @@ -15,7 +15,6 @@ import { OpenAIContentConverter } from './converter.js'; import type { Config } from '../../config/config.js'; import type { ContentGeneratorConfig, AuthType } from '../contentGenerator.js'; import type { OpenAICompatibleProvider } from './provider/index.js'; -import type { TelemetryService } from './telemetryService.js'; import type { ErrorHandler } from './errorHandler.js'; // Mock dependencies @@ -28,7 +27,6 @@ describe('ContentGenerationPipeline', () => { let mockProvider: OpenAICompatibleProvider; let mockClient: OpenAI; let mockConverter: OpenAIContentConverter; - let mockTelemetryService: TelemetryService; let mockErrorHandler: ErrorHandler; let mockContentGeneratorConfig: ContentGeneratorConfig; let mockCliConfig: Config; @@ -63,13 +61,6 @@ describe('ContentGenerationPipeline', () => { getDefaultGenerationConfig: vi.fn().mockReturnValue({}), }; - // Mock telemetry service - mockTelemetryService = { - logSuccess: vi.fn().mockResolvedValue(undefined), - logError: vi.fn().mockResolvedValue(undefined), - logStreamingSuccess: vi.fn().mockResolvedValue(undefined), - }; - // Mock error handler mockErrorHandler = { handle: vi.fn().mockImplementation((error: unknown) => { @@ -99,7 +90,6 @@ describe('ContentGenerationPipeline', () => { cliConfig: mockCliConfig, provider: mockProvider, contentGeneratorConfig: mockContentGeneratorConfig, - telemetryService: mockTelemetryService, errorHandler: mockErrorHandler, }; @@ -172,17 +162,6 @@ describe('ContentGenerationPipeline', () => { expect(mockConverter.convertOpenAIResponseToGemini).toHaveBeenCalledWith( mockOpenAIResponse, ); - expect(mockTelemetryService.logSuccess).toHaveBeenCalledWith( - expect.objectContaining({ - userPromptId, - model: 'test-model', - authType: 'openai', - isStreaming: false, - }), - mockGeminiResponse, - expect.any(Object), - mockOpenAIResponse, - ); }); it('should handle tools in request', async () => { @@ -268,16 +247,6 @@ describe('ContentGenerationPipeline', () => { 'API Error', ); - expect(mockTelemetryService.logError).toHaveBeenCalledWith( - expect.objectContaining({ - userPromptId, - model: 'test-model', - authType: 'openai', - isStreaming: false, - }), - testError, - expect.any(Object), - ); expect(mockErrorHandler.handle).toHaveBeenCalledWith( testError, expect.any(Object), @@ -376,17 +345,6 @@ describe('ContentGenerationPipeline', () => { signal: undefined, }), ); - expect(mockTelemetryService.logStreamingSuccess).toHaveBeenCalledWith( - expect.objectContaining({ - userPromptId, - model: 'test-model', - authType: 'openai', - isStreaming: true, - }), - [mockGeminiResponse1, mockGeminiResponse2], - expect.any(Object), - [mockChunk1, mockChunk2], - ); }); it('should filter empty responses', async () => { @@ -490,16 +448,6 @@ describe('ContentGenerationPipeline', () => { expect(results).toHaveLength(0); // No results due to error expect(mockConverter.resetStreamingToolCalls).toHaveBeenCalledTimes(2); // Once at start, once on error - expect(mockTelemetryService.logError).toHaveBeenCalledWith( - expect.objectContaining({ - userPromptId, - model: 'test-model', - authType: 'openai', - isStreaming: true, - }), - testError, - expect.any(Object), - ); expect(mockErrorHandler.handle).toHaveBeenCalledWith( testError, expect.any(Object), @@ -650,18 +598,6 @@ describe('ContentGenerationPipeline', () => { candidatesTokenCount: 20, totalTokenCount: 30, }); - - expect(mockTelemetryService.logStreamingSuccess).toHaveBeenCalledWith( - expect.objectContaining({ - userPromptId, - model: 'test-model', - authType: 'openai', - isStreaming: true, - }), - results, - expect.any(Object), - [mockChunk1, mockChunk2, mockChunk3], - ); }); it('should handle ideal case where last chunk has both finishReason and usageMetadata', async () => { @@ -853,18 +789,6 @@ describe('ContentGenerationPipeline', () => { candidatesTokenCount: 20, totalTokenCount: 30, }); - - expect(mockTelemetryService.logStreamingSuccess).toHaveBeenCalledWith( - expect.objectContaining({ - userPromptId, - model: 'test-model', - authType: 'openai', - isStreaming: true, - }), - results, - expect.any(Object), - [mockChunk1, mockChunk2, mockChunk3], - ); }); it('should handle providers that send finishReason and valid usage in same chunk', async () => { @@ -1118,19 +1042,6 @@ describe('ContentGenerationPipeline', () => { await pipeline.execute(request, userPromptId); // Assert - expect(mockTelemetryService.logSuccess).toHaveBeenCalledWith( - expect.objectContaining({ - userPromptId, - model: 'test-model', - authType: 'openai', - isStreaming: false, - startTime: expect.any(Number), - duration: expect.any(Number), - }), - expect.any(Object), - expect.any(Object), - expect.any(Object), - ); }); it('should create context with correct properties for streaming request', async () => { @@ -1173,19 +1084,6 @@ describe('ContentGenerationPipeline', () => { } // Assert - expect(mockTelemetryService.logStreamingSuccess).toHaveBeenCalledWith( - expect.objectContaining({ - userPromptId, - model: 'test-model', - authType: 'openai', - isStreaming: true, - startTime: expect.any(Number), - duration: expect.any(Number), - }), - expect.any(Array), - expect.any(Object), - expect.any(Array), - ); }); it('should collect all OpenAI chunks for logging even when Gemini responses are filtered', async () => { @@ -1329,22 +1227,6 @@ describe('ContentGenerationPipeline', () => { // Should only yield the final response (empty ones are filtered) expect(responses).toHaveLength(1); expect(responses[0]).toBe(finalGeminiResponse); - - // Verify telemetry was called with ALL OpenAI chunks, including the filtered ones - expect(mockTelemetryService.logStreamingSuccess).toHaveBeenCalledWith( - expect.objectContaining({ - model: 'test-model', - duration: expect.any(Number), - userPromptId: 'test-prompt-id', - authType: 'openai', - }), - [finalGeminiResponse], // Only the non-empty Gemini response - expect.objectContaining({ - model: 'test-model', - messages: [{ role: 'user', content: 'test' }], - }), - [partialToolCallChunk1, partialToolCallChunk2, finishChunk], // ALL OpenAI chunks - ); }); }); }); diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.ts b/packages/core/src/core/openaiContentGenerator/pipeline.ts index 8ebc4bfcc..09dba26d2 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.ts @@ -13,14 +13,12 @@ import type { Config } from '../../config/config.js'; import type { ContentGeneratorConfig } from '../contentGenerator.js'; import type { OpenAICompatibleProvider } from './provider/index.js'; import { OpenAIContentConverter } from './converter.js'; -import type { TelemetryService, RequestContext } from './telemetryService.js'; -import type { ErrorHandler } from './errorHandler.js'; +import type { ErrorHandler, RequestContext } from './errorHandler.js'; export interface PipelineConfig { cliConfig: Config; provider: OpenAICompatibleProvider; contentGeneratorConfig: ContentGeneratorConfig; - telemetryService: TelemetryService; errorHandler: ErrorHandler; } @@ -46,7 +44,7 @@ export class ContentGenerationPipeline { request, userPromptId, false, - async (openaiRequest, context) => { + async (openaiRequest) => { const openaiResponse = (await this.client.chat.completions.create( openaiRequest, { @@ -57,14 +55,6 @@ export class ContentGenerationPipeline { const geminiResponse = this.converter.convertOpenAIResponseToGemini(openaiResponse); - // Log success - await this.config.telemetryService.logSuccess( - context, - geminiResponse, - openaiRequest, - openaiResponse, - ); - return geminiResponse; }, ); @@ -88,12 +78,7 @@ export class ContentGenerationPipeline { )) as AsyncIterable; // Stage 2: Process stream with conversion and logging - return this.processStreamWithLogging( - stream, - context, - openaiRequest, - request, - ); + return this.processStreamWithLogging(stream, context, request); }, ); } @@ -110,11 +95,9 @@ export class ContentGenerationPipeline { private async *processStreamWithLogging( stream: AsyncIterable, context: RequestContext, - openaiRequest: OpenAI.Chat.ChatCompletionCreateParams, request: GenerateContentParameters, ): AsyncGenerator { const collectedGeminiResponses: GenerateContentResponse[] = []; - const collectedOpenAIChunks: OpenAI.Chat.ChatCompletionChunk[] = []; // Reset streaming tool calls to prevent data pollution from previous streams this.converter.resetStreamingToolCalls(); @@ -125,9 +108,6 @@ export class ContentGenerationPipeline { try { // Stage 2a: Convert and yield each chunk while preserving original for await (const chunk of stream) { - // Always collect OpenAI chunks for logging, regardless of Gemini conversion result - collectedOpenAIChunks.push(chunk); - const response = this.converter.convertOpenAIChunkToGemini(chunk); // Stage 2b: Filter empty responses to avoid downstream issues @@ -164,15 +144,8 @@ export class ContentGenerationPipeline { yield pendingFinishResponse; } - // Stage 2e: Stream completed successfully - perform logging with original OpenAI chunks + // Stage 2e: Stream completed successfully context.duration = Date.now() - context.startTime; - - await this.config.telemetryService.logStreamingSuccess( - context, - collectedGeminiResponses, - openaiRequest, - collectedOpenAIChunks, - ); } catch (error) { // Clear streaming tool calls on error to prevent data pollution this.converter.resetStreamingToolCalls(); @@ -369,13 +342,7 @@ export class ContentGenerationPipeline { return result; } catch (error) { // Use shared error handling logic - return await this.handleError( - error, - context, - request, - userPromptId, - isStreaming, - ); + return await this.handleError(error, context, request); } } @@ -387,37 +354,8 @@ export class ContentGenerationPipeline { error: unknown, context: RequestContext, request: GenerateContentParameters, - userPromptId?: string, - isStreaming?: boolean, ): Promise { context.duration = Date.now() - context.startTime; - - // Build request for logging (may fail, but we still want to log the error) - let openaiRequest: OpenAI.Chat.ChatCompletionCreateParams; - try { - if (userPromptId !== undefined && isStreaming !== undefined) { - openaiRequest = await this.buildRequest( - request, - userPromptId, - isStreaming, - ); - } else { - // For processStreamWithLogging, we don't have userPromptId/isStreaming, - // so create a minimal request - openaiRequest = { - model: this.contentGeneratorConfig.model, - messages: [], - }; - } - } catch (_buildError) { - // If we can't build the request, create a minimal one for logging - openaiRequest = { - model: this.contentGeneratorConfig.model, - messages: [], - }; - } - - await this.config.telemetryService.logError(context, error, openaiRequest); this.config.errorHandler.handle(error, context, request); } diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts index 4c3d7dfdf..1f1659967 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts @@ -39,7 +39,8 @@ export class DashScopeOpenAICompatibleProvider return ( authType === AuthType.QWEN_OAUTH || baseUrl === 'https://dashscope.aliyuncs.com/compatible-mode/v1' || - baseUrl === 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1' + baseUrl === 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1' || + !baseUrl ); } diff --git a/packages/core/src/core/openaiContentGenerator/telemetryService.test.ts b/packages/core/src/core/openaiContentGenerator/telemetryService.test.ts deleted file mode 100644 index 6f0f8d09a..000000000 --- a/packages/core/src/core/openaiContentGenerator/telemetryService.test.ts +++ /dev/null @@ -1,1306 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import type { RequestContext } from './telemetryService.js'; -import { DefaultTelemetryService } from './telemetryService.js'; -import type { Config } from '../../config/config.js'; -import { logApiError, logApiResponse } from '../../telemetry/loggers.js'; -import { ApiErrorEvent, ApiResponseEvent } from '../../telemetry/types.js'; -import { openaiLogger } from '../../utils/openaiLogger.js'; -import type { GenerateContentResponse } from '@google/genai'; -import type OpenAI from 'openai'; - -// Mock dependencies -vi.mock('../../telemetry/loggers.js'); -vi.mock('../../utils/openaiLogger.js'); - -// Extended error interface for testing -interface ExtendedError extends Error { - requestID?: string; - type?: string; - code?: string; -} - -describe('DefaultTelemetryService', () => { - let mockConfig: Config; - let telemetryService: DefaultTelemetryService; - let mockRequestContext: RequestContext; - - beforeEach(() => { - // Create mock config - mockConfig = { - getSessionId: vi.fn().mockReturnValue('test-session-id'), - } as unknown as Config; - - // Create mock request context - mockRequestContext = { - userPromptId: 'test-prompt-id', - model: 'test-model', - authType: 'test-auth', - startTime: Date.now(), - duration: 1000, - isStreaming: false, - }; - - // Clear all mocks - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('constructor', () => { - it('should create instance with default OpenAI logging disabled', () => { - const service = new DefaultTelemetryService(mockConfig); - expect(service).toBeInstanceOf(DefaultTelemetryService); - }); - - it('should create instance with OpenAI logging enabled when specified', () => { - const service = new DefaultTelemetryService(mockConfig, true); - expect(service).toBeInstanceOf(DefaultTelemetryService); - }); - }); - - describe('logSuccess', () => { - beforeEach(() => { - telemetryService = new DefaultTelemetryService(mockConfig, false); - }); - - it('should log API response event with complete response data', async () => { - const mockResponse: GenerateContentResponse = { - responseId: 'test-response-id', - usageMetadata: { - promptTokenCount: 100, - candidatesTokenCount: 50, - totalTokenCount: 150, - cachedContentTokenCount: 10, - thoughtsTokenCount: 5, - toolUsePromptTokenCount: 20, - }, - } as GenerateContentResponse; - - await telemetryService.logSuccess(mockRequestContext, mockResponse); - - expect(logApiResponse).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - response_id: 'test-response-id', - model: 'test-model', - duration_ms: 1000, - prompt_id: 'test-prompt-id', - auth_type: 'test-auth', - input_token_count: 100, - output_token_count: 50, - total_token_count: 150, - cached_content_token_count: 10, - thoughts_token_count: 5, - tool_token_count: 20, - }), - ); - }); - - it('should handle response without responseId', async () => { - const mockResponse: GenerateContentResponse = { - usageMetadata: { - promptTokenCount: 100, - candidatesTokenCount: 50, - totalTokenCount: 150, - }, - } as GenerateContentResponse; - - await telemetryService.logSuccess(mockRequestContext, mockResponse); - - expect(logApiResponse).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - response_id: 'unknown', - model: 'test-model', - duration_ms: 1000, - prompt_id: 'test-prompt-id', - auth_type: 'test-auth', - }), - ); - }); - - it('should handle response without usage metadata', async () => { - const mockResponse: GenerateContentResponse = { - responseId: 'test-response-id', - } as GenerateContentResponse; - - await telemetryService.logSuccess(mockRequestContext, mockResponse); - - expect(logApiResponse).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - response_id: 'test-response-id', - model: 'test-model', - duration_ms: 1000, - prompt_id: 'test-prompt-id', - auth_type: 'test-auth', - }), - ); - }); - - it('should not log OpenAI interaction when logging is disabled', async () => { - const mockResponse: GenerateContentResponse = { - responseId: 'test-response-id', - } as GenerateContentResponse; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - const mockOpenAIResponse = { - id: 'test-id', - choices: [{ message: { content: 'response' } }], - } as OpenAI.Chat.ChatCompletion; - - await telemetryService.logSuccess( - mockRequestContext, - mockResponse, - mockOpenAIRequest, - mockOpenAIResponse, - ); - - expect(openaiLogger.logInteraction).not.toHaveBeenCalled(); - }); - - it('should log OpenAI interaction when logging is enabled', async () => { - telemetryService = new DefaultTelemetryService(mockConfig, true); - - const mockResponse: GenerateContentResponse = { - responseId: 'test-response-id', - } as GenerateContentResponse; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - const mockOpenAIResponse = { - id: 'test-id', - choices: [{ message: { content: 'response' } }], - } as OpenAI.Chat.ChatCompletion; - - await telemetryService.logSuccess( - mockRequestContext, - mockResponse, - mockOpenAIRequest, - mockOpenAIResponse, - ); - - expect(openaiLogger.logInteraction).toHaveBeenCalledWith( - mockOpenAIRequest, - mockOpenAIResponse, - ); - }); - - it('should not log OpenAI interaction when request or response is missing', async () => { - telemetryService = new DefaultTelemetryService(mockConfig, true); - - const mockResponse: GenerateContentResponse = { - responseId: 'test-response-id', - } as GenerateContentResponse; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - // Test with missing OpenAI response - await telemetryService.logSuccess( - mockRequestContext, - mockResponse, - mockOpenAIRequest, - undefined, - ); - - expect(openaiLogger.logInteraction).not.toHaveBeenCalled(); - - // Test with missing OpenAI request - await telemetryService.logSuccess( - mockRequestContext, - mockResponse, - undefined, - {} as OpenAI.Chat.ChatCompletion, - ); - - expect(openaiLogger.logInteraction).not.toHaveBeenCalled(); - }); - }); - - describe('logError', () => { - beforeEach(() => { - telemetryService = new DefaultTelemetryService(mockConfig, false); - }); - - it('should log API error event with Error instance', async () => { - const error = new Error('Test error message') as ExtendedError; - error.requestID = 'test-request-id'; - error.type = 'TestError'; - error.code = 'TEST_CODE'; - - await telemetryService.logError(mockRequestContext, error); - - expect(logApiError).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - response_id: 'test-request-id', - model: 'test-model', - error: 'Test error message', - duration_ms: 1000, - prompt_id: 'test-prompt-id', - auth_type: 'test-auth', - error_type: 'TestError', - status_code: 'TEST_CODE', - }), - ); - }); - - it('should handle error without requestID', async () => { - const error = new Error('Test error message'); - - await telemetryService.logError(mockRequestContext, error); - - expect(logApiError).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - response_id: 'unknown', - model: 'test-model', - error: 'Test error message', - duration_ms: 1000, - prompt_id: 'test-prompt-id', - auth_type: 'test-auth', - }), - ); - }); - - it('should handle non-Error objects', async () => { - const error = 'String error message'; - - await telemetryService.logError(mockRequestContext, error); - - expect(logApiError).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - response_id: 'unknown', - model: 'test-model', - error: 'String error message', - duration_ms: 1000, - prompt_id: 'test-prompt-id', - auth_type: 'test-auth', - }), - ); - }); - - it('should handle null/undefined errors', async () => { - await telemetryService.logError(mockRequestContext, null); - - expect(logApiError).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - error: 'null', - }), - ); - - await telemetryService.logError(mockRequestContext, undefined); - - expect(logApiError).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - error: 'undefined', - }), - ); - }); - - it('should not log OpenAI interaction when logging is disabled', async () => { - const error = new Error('Test error'); - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - await telemetryService.logError( - mockRequestContext, - error, - mockOpenAIRequest, - ); - - expect(openaiLogger.logInteraction).not.toHaveBeenCalled(); - }); - - it('should log OpenAI interaction when logging is enabled', async () => { - telemetryService = new DefaultTelemetryService(mockConfig, true); - - const error = new Error('Test error'); - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - await telemetryService.logError( - mockRequestContext, - error, - mockOpenAIRequest, - ); - - expect(openaiLogger.logInteraction).toHaveBeenCalledWith( - mockOpenAIRequest, - undefined, - error, - ); - }); - - it('should not log OpenAI interaction when request is missing', async () => { - telemetryService = new DefaultTelemetryService(mockConfig, true); - - const error = new Error('Test error'); - - await telemetryService.logError(mockRequestContext, error, undefined); - - expect(openaiLogger.logInteraction).not.toHaveBeenCalled(); - }); - }); - - describe('logStreamingSuccess', () => { - beforeEach(() => { - telemetryService = new DefaultTelemetryService(mockConfig, false); - }); - - it('should log streaming success with multiple responses', async () => { - const mockResponses: GenerateContentResponse[] = [ - { - responseId: 'response-1', - usageMetadata: { - promptTokenCount: 50, - candidatesTokenCount: 25, - totalTokenCount: 75, - }, - } as GenerateContentResponse, - { - responseId: 'response-2', - usageMetadata: { - promptTokenCount: 100, - candidatesTokenCount: 50, - totalTokenCount: 150, - cachedContentTokenCount: 10, - thoughtsTokenCount: 5, - toolUsePromptTokenCount: 20, - }, - } as GenerateContentResponse, - { - responseId: 'response-3', - } as GenerateContentResponse, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - ); - - expect(logApiResponse).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - response_id: 'response-3', - model: 'test-model', - duration_ms: 1000, - prompt_id: 'test-prompt-id', - auth_type: 'test-auth', - // Should use usage metadata from response-2 (last one with metadata) - input_token_count: 100, - output_token_count: 50, - total_token_count: 150, - cached_content_token_count: 10, - thoughts_token_count: 5, - tool_token_count: 20, - }), - ); - }); - - it('should handle empty responses array', async () => { - const mockResponses: GenerateContentResponse[] = []; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - ); - - expect(logApiResponse).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - response_id: 'unknown', - model: 'test-model', - duration_ms: 1000, - prompt_id: 'test-prompt-id', - auth_type: 'test-auth', - }), - ); - }); - - it('should handle responses without usage metadata', async () => { - const mockResponses: GenerateContentResponse[] = [ - { - responseId: 'response-1', - } as GenerateContentResponse, - { - responseId: 'response-2', - } as GenerateContentResponse, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - ); - - expect(logApiResponse).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - response_id: 'response-2', - model: 'test-model', - duration_ms: 1000, - prompt_id: 'test-prompt-id', - auth_type: 'test-auth', - }), - ); - }); - - it('should use the last response with usage metadata', async () => { - const mockResponses: GenerateContentResponse[] = [ - { - responseId: 'response-1', - usageMetadata: { - promptTokenCount: 50, - candidatesTokenCount: 25, - totalTokenCount: 75, - }, - } as GenerateContentResponse, - { - responseId: 'response-2', - } as GenerateContentResponse, - { - responseId: 'response-3', - usageMetadata: { - promptTokenCount: 100, - candidatesTokenCount: 50, - totalTokenCount: 150, - }, - } as GenerateContentResponse, - { - responseId: 'response-4', - } as GenerateContentResponse, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - ); - - expect(logApiResponse).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - response_id: 'response-4', - // Should use usage metadata from response-3 (last one with metadata) - input_token_count: 100, - output_token_count: 50, - total_token_count: 150, - }), - ); - }); - - it('should not log OpenAI interaction when logging is disabled', async () => { - const mockResponses: GenerateContentResponse[] = [ - { responseId: 'response-1' } as GenerateContentResponse, - ]; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - const mockOpenAIChunks = [ - { - id: 'test-id', - choices: [{ delta: { content: 'response' } }], - } as OpenAI.Chat.ChatCompletionChunk, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - mockOpenAIRequest, - mockOpenAIChunks, - ); - - expect(openaiLogger.logInteraction).not.toHaveBeenCalled(); - }); - - it('should log OpenAI interaction when logging is enabled', async () => { - telemetryService = new DefaultTelemetryService(mockConfig, true); - - const mockResponses: GenerateContentResponse[] = [ - { responseId: 'response-1' } as GenerateContentResponse, - ]; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - const mockOpenAIChunks = [ - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { - content: 'Hello', - reasoning_content: 'thinking ', - }, - finish_reason: null, - }, - ], - } as unknown as OpenAI.Chat.ChatCompletionChunk, - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { - content: ' world', - reasoning_content: 'more', - }, - finish_reason: 'stop', - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, - } as unknown as OpenAI.Chat.ChatCompletionChunk, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - mockOpenAIRequest, - mockOpenAIChunks, - ); - - expect(openaiLogger.logInteraction).toHaveBeenCalledWith( - mockOpenAIRequest, - expect.objectContaining({ - id: 'test-id', - object: 'chat.completion', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - message: expect.objectContaining({ - role: 'assistant', - content: 'Hello world', - reasoning_content: 'thinking more', - }), - finish_reason: 'stop', - logprobs: null, - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, - }), - ); - }); - - it('should not log OpenAI interaction when request or chunks are missing', async () => { - telemetryService = new DefaultTelemetryService(mockConfig, true); - - const mockResponses: GenerateContentResponse[] = [ - { responseId: 'response-1' } as GenerateContentResponse, - ]; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - // Test with missing OpenAI chunks - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - mockOpenAIRequest, - undefined, - ); - - expect(openaiLogger.logInteraction).not.toHaveBeenCalled(); - - // Test with missing OpenAI request - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - undefined, - [] as OpenAI.Chat.ChatCompletionChunk[], - ); - - expect(openaiLogger.logInteraction).not.toHaveBeenCalled(); - - // Test with empty chunks array - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - mockOpenAIRequest, - [], - ); - - expect(openaiLogger.logInteraction).not.toHaveBeenCalled(); - }); - }); - - describe('RequestContext interface', () => { - it('should have all required properties', () => { - const context: RequestContext = { - userPromptId: 'test-prompt-id', - model: 'test-model', - authType: 'test-auth', - startTime: Date.now(), - duration: 1000, - isStreaming: false, - }; - - expect(context.userPromptId).toBe('test-prompt-id'); - expect(context.model).toBe('test-model'); - expect(context.authType).toBe('test-auth'); - expect(typeof context.startTime).toBe('number'); - expect(context.duration).toBe(1000); - expect(context.isStreaming).toBe(false); - }); - - it('should support streaming context', () => { - const context: RequestContext = { - userPromptId: 'test-prompt-id', - model: 'test-model', - authType: 'test-auth', - startTime: Date.now(), - duration: 1000, - isStreaming: true, - }; - - expect(context.isStreaming).toBe(true); - }); - }); - - describe('combineOpenAIChunksForLogging', () => { - beforeEach(() => { - telemetryService = new DefaultTelemetryService(mockConfig, true); - }); - - it('should combine simple text chunks correctly', async () => { - const mockResponses: GenerateContentResponse[] = [ - { responseId: 'response-1' } as GenerateContentResponse, - ]; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - const mockOpenAIChunks = [ - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { - content: 'Hello', - reasoning_content: 'thinking ', - }, - finish_reason: null, - }, - ], - } as unknown as OpenAI.Chat.ChatCompletionChunk, - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { - content: ' world!', - reasoning_content: 'more', - }, - finish_reason: 'stop', - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, - } as unknown as OpenAI.Chat.ChatCompletionChunk, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - mockOpenAIRequest, - mockOpenAIChunks, - ); - - expect(openaiLogger.logInteraction).toHaveBeenCalledWith( - mockOpenAIRequest, - expect.objectContaining({ - choices: [ - expect.objectContaining({ - message: expect.objectContaining({ - content: 'Hello world!', - reasoning_content: 'thinking more', - }), - }), - ], - }), - ); - }); - - it('should combine tool call chunks correctly', async () => { - const mockResponses: GenerateContentResponse[] = [ - { responseId: 'response-1' } as GenerateContentResponse, - ]; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - const mockOpenAIChunks = [ - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { - tool_calls: [ - { - index: 0, - id: 'call_123', - type: 'function', - function: { name: 'get_weather', arguments: '' }, - }, - ], - }, - finish_reason: null, - }, - ], - } as OpenAI.Chat.ChatCompletionChunk, - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { - tool_calls: [ - { - index: 0, - function: { arguments: '{"location": "' }, - }, - ], - }, - finish_reason: null, - }, - ], - } as OpenAI.Chat.ChatCompletionChunk, - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { - tool_calls: [ - { - index: 0, - function: { arguments: 'New York"}' }, - }, - ], - }, - finish_reason: 'tool_calls', - }, - ], - usage: { - prompt_tokens: 15, - completion_tokens: 8, - total_tokens: 23, - }, - } as OpenAI.Chat.ChatCompletionChunk, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - mockOpenAIRequest, - mockOpenAIChunks, - ); - - expect(openaiLogger.logInteraction).toHaveBeenCalledWith( - mockOpenAIRequest, - expect.objectContaining({ - id: 'test-id', - object: 'chat.completion', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - message: { - role: 'assistant', - content: null, - refusal: null, - tool_calls: [ - { - id: 'call_123', - type: 'function', - function: { - name: 'get_weather', - arguments: '{"location": "New York"}', - }, - }, - ], - }, - finish_reason: 'tool_calls', - logprobs: null, - }, - ], - usage: { - prompt_tokens: 15, - completion_tokens: 8, - total_tokens: 23, - }, - }), - ); - }); - - it('should handle mixed content and tool calls', async () => { - const mockResponses: GenerateContentResponse[] = [ - { responseId: 'response-1' } as GenerateContentResponse, - ]; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - const mockOpenAIChunks = [ - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { content: 'Let me check the weather. ' }, - finish_reason: null, - }, - ], - } as OpenAI.Chat.ChatCompletionChunk, - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { - tool_calls: [ - { - index: 0, - id: 'call_456', - type: 'function', - function: { - name: 'get_weather', - arguments: '{"location": "Paris"}', - }, - }, - ], - }, - finish_reason: 'tool_calls', - }, - ], - usage: { - prompt_tokens: 20, - completion_tokens: 12, - total_tokens: 32, - }, - } as OpenAI.Chat.ChatCompletionChunk, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - mockOpenAIRequest, - mockOpenAIChunks, - ); - - expect(openaiLogger.logInteraction).toHaveBeenCalledWith( - mockOpenAIRequest, - expect.objectContaining({ - choices: [ - { - index: 0, - message: { - role: 'assistant', - content: 'Let me check the weather. ', - refusal: null, - tool_calls: [ - { - id: 'call_456', - type: 'function', - function: { - name: 'get_weather', - arguments: '{"location": "Paris"}', - }, - }, - ], - }, - finish_reason: 'tool_calls', - logprobs: null, - }, - ], - }), - ); - }); - - it('should handle chunks with no content or tool calls', async () => { - const mockResponses: GenerateContentResponse[] = [ - { responseId: 'response-1' } as GenerateContentResponse, - ]; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - const mockOpenAIChunks = [ - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: {}, - finish_reason: null, - }, - ], - } as OpenAI.Chat.ChatCompletionChunk, - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: {}, - finish_reason: 'stop', - }, - ], - usage: { - prompt_tokens: 5, - completion_tokens: 0, - total_tokens: 5, - }, - } as OpenAI.Chat.ChatCompletionChunk, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - mockOpenAIRequest, - mockOpenAIChunks, - ); - - expect(openaiLogger.logInteraction).toHaveBeenCalledWith( - mockOpenAIRequest, - expect.objectContaining({ - choices: [ - { - index: 0, - message: { - role: 'assistant', - content: null, - refusal: null, - }, - finish_reason: 'stop', - logprobs: null, - }, - ], - }), - ); - }); - - it('should use default values when usage is missing', async () => { - const mockResponses: GenerateContentResponse[] = [ - { responseId: 'response-1' } as GenerateContentResponse, - ]; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - const mockOpenAIChunks = [ - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { content: 'Hello' }, - finish_reason: 'stop', - }, - ], - } as OpenAI.Chat.ChatCompletionChunk, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - mockOpenAIRequest, - mockOpenAIChunks, - ); - - expect(openaiLogger.logInteraction).toHaveBeenCalledWith( - mockOpenAIRequest, - expect.objectContaining({ - usage: { - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - }, - }), - ); - }); - - it('should use default finish_reason when missing', async () => { - const mockResponses: GenerateContentResponse[] = [ - { responseId: 'response-1' } as GenerateContentResponse, - ]; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - const mockOpenAIChunks = [ - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { content: 'Hello' }, - finish_reason: null, - }, - ], - } as OpenAI.Chat.ChatCompletionChunk, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - mockOpenAIRequest, - mockOpenAIChunks, - ); - - expect(openaiLogger.logInteraction).toHaveBeenCalledWith( - mockOpenAIRequest, - expect.objectContaining({ - choices: [ - { - index: 0, - message: expect.any(Object), - finish_reason: 'stop', - logprobs: null, - }, - ], - }), - ); - }); - - it('should filter out empty tool calls', async () => { - const mockResponses: GenerateContentResponse[] = [ - { responseId: 'response-1' } as GenerateContentResponse, - ]; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - const mockOpenAIChunks = [ - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { - tool_calls: [ - { - index: 0, - id: '', // Empty ID should be filtered out - type: 'function', - function: { name: 'test', arguments: '{}' }, - }, - { - index: 1, - id: 'call_valid', - type: 'function', - function: { name: 'valid_call', arguments: '{}' }, - }, - ], - }, - finish_reason: 'tool_calls', - }, - ], - } as OpenAI.Chat.ChatCompletionChunk, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - mockOpenAIRequest, - mockOpenAIChunks, - ); - - expect(openaiLogger.logInteraction).toHaveBeenCalledWith( - mockOpenAIRequest, - expect.objectContaining({ - choices: [ - { - index: 0, - message: { - role: 'assistant', - content: null, - refusal: null, - tool_calls: [ - { - id: 'call_valid', - type: 'function', - function: { - name: 'valid_call', - arguments: '{}', - }, - }, - ], - }, - finish_reason: 'tool_calls', - logprobs: null, - }, - ], - }), - ); - }); - }); - - describe('integration with telemetry events', () => { - beforeEach(() => { - telemetryService = new DefaultTelemetryService(mockConfig, false); - }); - - it('should create ApiResponseEvent with correct structure', async () => { - const mockResponse: GenerateContentResponse = { - responseId: 'test-response-id', - usageMetadata: { - promptTokenCount: 100, - candidatesTokenCount: 50, - totalTokenCount: 150, - }, - } as GenerateContentResponse; - - await telemetryService.logSuccess(mockRequestContext, mockResponse); - - expect(logApiResponse).toHaveBeenCalledWith( - mockConfig, - expect.any(ApiResponseEvent), - ); - - const mockLogApiResponse = vi.mocked(logApiResponse); - const callArgs = mockLogApiResponse.mock.calls[0]; - const event = callArgs[1] as ApiResponseEvent; - - expect(event['event.name']).toBe('api_response'); - expect(event['event.timestamp']).toBeDefined(); - expect(event.response_id).toBe('test-response-id'); - expect(event.model).toBe('test-model'); - expect(event.duration_ms).toBe(1000); - expect(event.prompt_id).toBe('test-prompt-id'); - expect(event.auth_type).toBe('test-auth'); - }); - - it('should create ApiErrorEvent with correct structure', async () => { - const error = new Error('Test error message') as ExtendedError; - error.requestID = 'test-request-id'; - error.type = 'TestError'; - error.code = 'TEST_CODE'; - - await telemetryService.logError(mockRequestContext, error); - - expect(logApiError).toHaveBeenCalledWith( - mockConfig, - expect.any(ApiErrorEvent), - ); - - const mockLogApiError = vi.mocked(logApiError); - const callArgs = mockLogApiError.mock.calls[0]; - const event = callArgs[1] as ApiErrorEvent; - - expect(event['event.name']).toBe('api_error'); - expect(event['event.timestamp']).toBeDefined(); - expect(event.response_id).toBe('test-request-id'); - expect(event.model).toBe('test-model'); - expect(event.error).toBe('Test error message'); - expect(event.duration_ms).toBe(1000); - expect(event.prompt_id).toBe('test-prompt-id'); - expect(event.auth_type).toBe('test-auth'); - expect(event.error_type).toBe('TestError'); - expect(event.status_code).toBe('TEST_CODE'); - }); - }); -}); diff --git a/packages/core/src/core/openaiContentGenerator/telemetryService.ts b/packages/core/src/core/openaiContentGenerator/telemetryService.ts deleted file mode 100644 index 66a96ad07..000000000 --- a/packages/core/src/core/openaiContentGenerator/telemetryService.ts +++ /dev/null @@ -1,275 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { Config } from '../../config/config.js'; -import { logApiError, logApiResponse } from '../../telemetry/loggers.js'; -import { ApiErrorEvent, ApiResponseEvent } from '../../telemetry/types.js'; -import { OpenAILogger } from '../../utils/openaiLogger.js'; -import type { GenerateContentResponse } from '@google/genai'; -import type OpenAI from 'openai'; -import type { ExtendedCompletionChunkDelta } from './converter.js'; - -export interface RequestContext { - userPromptId: string; - model: string; - authType: string; - startTime: number; - duration: number; - isStreaming: boolean; -} - -export interface TelemetryService { - logSuccess( - context: RequestContext, - response: GenerateContentResponse, - openaiRequest?: OpenAI.Chat.ChatCompletionCreateParams, - openaiResponse?: OpenAI.Chat.ChatCompletion, - ): Promise; - - logError( - context: RequestContext, - error: unknown, - openaiRequest?: OpenAI.Chat.ChatCompletionCreateParams, - ): Promise; - - logStreamingSuccess( - context: RequestContext, - responses: GenerateContentResponse[], - openaiRequest?: OpenAI.Chat.ChatCompletionCreateParams, - openaiChunks?: OpenAI.Chat.ChatCompletionChunk[], - ): Promise; -} - -export class DefaultTelemetryService implements TelemetryService { - private logger: OpenAILogger; - - constructor( - private config: Config, - private enableOpenAILogging: boolean = false, - openAILoggingDir?: string, - ) { - // Always create a new logger instance to ensure correct working directory - // If no custom directory is provided, undefined will use the default path - this.logger = new OpenAILogger(openAILoggingDir); - } - - async logSuccess( - context: RequestContext, - response: GenerateContentResponse, - openaiRequest?: OpenAI.Chat.ChatCompletionCreateParams, - openaiResponse?: OpenAI.Chat.ChatCompletion, - ): Promise { - // Log API response event for UI telemetry - const responseEvent = new ApiResponseEvent( - response.responseId || 'unknown', - context.model, - context.duration, - context.userPromptId, - context.authType, - response.usageMetadata, - ); - - logApiResponse(this.config, responseEvent); - - // Log interaction if enabled - if (this.enableOpenAILogging && openaiRequest && openaiResponse) { - await this.logger.logInteraction(openaiRequest, openaiResponse); - } - } - - async logError( - context: RequestContext, - error: unknown, - openaiRequest?: OpenAI.Chat.ChatCompletionCreateParams, - ): Promise { - const errorMessage = error instanceof Error ? error.message : String(error); - - // Log API error event for UI telemetry - const errorEvent = new ApiErrorEvent( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (error as any)?.requestID || 'unknown', - context.model, - errorMessage, - context.duration, - context.userPromptId, - context.authType, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (error as any)?.type, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (error as any)?.code, - ); - logApiError(this.config, errorEvent); - - // Log error interaction if enabled - if (this.enableOpenAILogging && openaiRequest) { - await this.logger.logInteraction( - openaiRequest, - undefined, - error as Error, - ); - } - } - - async logStreamingSuccess( - context: RequestContext, - responses: GenerateContentResponse[], - openaiRequest?: OpenAI.Chat.ChatCompletionCreateParams, - openaiChunks?: OpenAI.Chat.ChatCompletionChunk[], - ): Promise { - // Get final usage metadata from the last response that has it - const finalUsageMetadata = responses - .slice() - .reverse() - .find((r) => r.usageMetadata)?.usageMetadata; - - // Log API response event for UI telemetry - const responseEvent = new ApiResponseEvent( - responses[responses.length - 1]?.responseId || 'unknown', - context.model, - context.duration, - context.userPromptId, - context.authType, - finalUsageMetadata, - ); - - logApiResponse(this.config, responseEvent); - - // Log interaction if enabled - combine chunks only when needed - if ( - this.enableOpenAILogging && - openaiRequest && - openaiChunks && - openaiChunks.length > 0 - ) { - const combinedResponse = this.combineOpenAIChunksForLogging(openaiChunks); - await this.logger.logInteraction(openaiRequest, combinedResponse); - } - } - - /** - * Combine OpenAI chunks for logging purposes - * This method consolidates all OpenAI stream chunks into a single ChatCompletion response - * for telemetry and logging purposes, avoiding unnecessary format conversions - */ - private combineOpenAIChunksForLogging( - chunks: OpenAI.Chat.ChatCompletionChunk[], - ): OpenAI.Chat.ChatCompletion { - if (chunks.length === 0) { - throw new Error('No chunks to combine'); - } - - const firstChunk = chunks[0]; - - // Combine all content from chunks - let combinedContent = ''; - const toolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[] = []; - let finishReason: - | 'stop' - | 'length' - | 'tool_calls' - | 'content_filter' - | 'function_call' - | null = null; - let combinedReasoning = ''; - let usage: - | { - prompt_tokens: number; - completion_tokens: number; - total_tokens: number; - } - | undefined; - - for (const chunk of chunks) { - const choice = chunk.choices?.[0]; - if (choice) { - // Combine reasoning content - const reasoningContent = (choice.delta as ExtendedCompletionChunkDelta) - ?.reasoning_content; - if (reasoningContent) { - combinedReasoning += reasoningContent; - } - // Combine text content - if (choice.delta?.content) { - combinedContent += choice.delta.content; - } - - // Collect tool calls - if (choice.delta?.tool_calls) { - for (const toolCall of choice.delta.tool_calls) { - if (toolCall.index !== undefined) { - if (!toolCalls[toolCall.index]) { - toolCalls[toolCall.index] = { - id: toolCall.id || '', - type: toolCall.type || 'function', - function: { name: '', arguments: '' }, - }; - } - - if (toolCall.function?.name) { - toolCalls[toolCall.index].function.name += - toolCall.function.name; - } - if (toolCall.function?.arguments) { - toolCalls[toolCall.index].function.arguments += - toolCall.function.arguments; - } - } - } - } - - // Get finish reason from the last chunk - if (choice.finish_reason) { - finishReason = choice.finish_reason; - } - } - - // Get usage from the last chunk that has it - if (chunk.usage) { - usage = chunk.usage; - } - } - - // Create the combined ChatCompletion response - const message: OpenAI.Chat.ChatCompletionMessage = { - role: 'assistant', - content: combinedContent || null, - refusal: null, - }; - if (combinedReasoning) { - // Attach reasoning content if any thought tokens were streamed - (message as { reasoning_content?: string }).reasoning_content = - combinedReasoning; - } - - // Add tool calls if any - if (toolCalls.length > 0) { - message.tool_calls = toolCalls.filter((tc) => tc.id); // Filter out empty tool calls - } - - const combinedResponse: OpenAI.Chat.ChatCompletion = { - id: firstChunk.id, - object: 'chat.completion', - created: firstChunk.created, - model: firstChunk.model, - choices: [ - { - index: 0, - message, - finish_reason: finishReason || 'stop', - logprobs: null, - }, - ], - usage: usage || { - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - }, - system_fingerprint: firstChunk.system_fingerprint, - }; - - return combinedResponse; - } -} diff --git a/packages/core/src/telemetry/loggers.test.ts b/packages/core/src/telemetry/loggers.test.ts index 0117e0bfa..ab026304a 100644 --- a/packages/core/src/telemetry/loggers.test.ts +++ b/packages/core/src/telemetry/loggers.test.ts @@ -264,7 +264,7 @@ describe('loggers', () => { 'event.timestamp': '2025-01-01T00:00:00.000Z', prompt_length: 11, prompt_id: 'prompt-id-9', - auth_type: 'gemini-api-key', + auth_type: 'gemini', }, }); }); @@ -333,7 +333,7 @@ describe('loggers', () => { total_token_count: 0, response_text: 'test-response', prompt_id: 'prompt-id-1', - auth_type: 'gemini-api-key', + auth_type: 'gemini', }, });