From bc3725e9018ce578116b07e9058bfd9a44ec0219 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Wed, 3 Dec 2025 20:21:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E5=93=8D=E5=BA=94=E7=94=9F=E6=88=90=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor(service-manager): 移除NoAvailableProviderError和严格池检查 feat(converters): 增强token统计功能,支持缓存和推理token详情 refactor(claude-kiro): 改进JSON解析和工具调用处理 feat(common): 添加统一错误响应生成功能 --- package-lock.json | 32 +++ src/claude/claude-kiro.js | 68 ++++-- src/common.js | 222 +++++++++++++++--- src/converters/strategies/ClaudeConverter.js | 54 ++++- src/converters/strategies/GeminiConverter.js | 60 ++++- src/converters/strategies/OpenAIConverter.js | 48 +++- .../strategies/OpenAIResponsesConverter.js | 50 +++- src/service-manager.js | 29 +-- 8 files changed, 463 insertions(+), 100 deletions(-) diff --git a/package-lock.json b/package-lock.json index f852bba..4c13297 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "dependencies": { + "@anthropic-ai/tokenizer": "^0.0.4", "axios": "^1.10.0", "deepmerge": "^4.3.1", "dotenv": "^16.4.5", @@ -39,6 +40,31 @@ "node": ">=6.0.0" } }, + "node_modules/@anthropic-ai/tokenizer": { + "version": "0.0.4", + "resolved": "https://registry.npmmirror.com/@anthropic-ai/tokenizer/-/tokenizer-0.0.4.tgz", + "integrity": "sha512-EHRKbxlxlc8W4KCBEseByJ7YwyYCmgu9OyN59H9+IYIGPoKv8tXyQXinkeGDI+cI8Tiuz9wk2jZb/kK7AyvL7g==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "^18.11.18", + "tiktoken": "^1.0.10" + } + }, + "node_modules/@anthropic-ai/tokenizer/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@anthropic-ai/tokenizer/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -6217,6 +6243,12 @@ "node": ">=8" } }, + "node_modules/tiktoken": { + "version": "1.0.22", + "resolved": "https://registry.npmmirror.com/tiktoken/-/tiktoken-1.0.22.tgz", + "integrity": "sha512-PKvy1rVF1RibfF3JlXBSP0Jrcw2uq3yXdgcEXtKTYn3QJ/cBRBHDnrJ5jHky+MENZ6DIPwNUGWpkVx+7joCpNA==", + "license": "MIT" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", diff --git a/src/claude/claude-kiro.js b/src/claude/claude-kiro.js index 6a0acd1..7e08576 100644 --- a/src/claude/claude-kiro.js +++ b/src/claude/claude-kiro.js @@ -76,9 +76,18 @@ async function getMacAddressSha256() { return sha256Hash; } -// Helper functions for tool calls -function findMatchingBracket(text, startPos) { - if (!text || startPos >= text.length || text[startPos] !== '[') { +// Helper functions for tool calls and JSON parsing + +/** + * 通用的括号匹配函数 - 支持多种括号类型 + * @param {string} text - 要搜索的文本 + * @param {number} startPos - 起始位置 + * @param {string} openChar - 开括号字符 (默认 '[') + * @param {string} closeChar - 闭括号字符 (默认 ']') + * @returns {number} 匹配的闭括号位置,未找到返回 -1 + */ +function findMatchingBracket(text, startPos, openChar = '[', closeChar = ']') { + if (!text || startPos >= text.length || text[startPos] !== openChar) { return -1; } @@ -105,9 +114,9 @@ function findMatchingBracket(text, startPos) { } if (!inString) { - if (char === '[') { + if (char === openChar) { bracketCount++; - } else if (char === ']') { + } else if (char === closeChar) { bracketCount--; if (bracketCount === 0) { return i; @@ -118,6 +127,28 @@ function findMatchingBracket(text, startPos) { return -1; } + +/** + * 尝试修复常见的 JSON 格式问题 + * @param {string} jsonStr - 可能有问题的 JSON 字符串 + * @returns {string} 修复后的 JSON 字符串 + */ +function repairJson(jsonStr) { + let repaired = jsonStr; + // 移除尾部逗号 + repaired = repaired.replace(/,\s*([}\]])/g, '$1'); + // 为未引用的键添加引号 + repaired = repaired.replace(/([{,]\s*)([a-zA-Z0-9_]+?)\s*:/g, '$1"$2":'); + // 确保字符串值被正确引用 + repaired = repaired.replace(/:\s*([a-zA-Z0-9_]+)(?=[,\}\]])/g, ':"$1"'); + return repaired; +} + +/** + * 解析单个工具调用文本 + * @param {string} toolCallText - 工具调用文本 + * @returns {Object|null} 解析后的工具调用对象或 null + */ function parseSingleToolCall(toolCallText) { const namePattern = /\[Called\s+(\w+)\s+with\s+args:/i; const nameMatch = toolCallText.match(namePattern); @@ -144,16 +175,7 @@ function parseSingleToolCall(toolCallText) { const jsonCandidate = toolCallText.substring(argsStart, argsEnd).trim(); try { - // Simple repair for common issues like trailing commas or unquoted keys - let repairedJson = jsonCandidate; - // Remove trailing comma before closing brace/bracket - repairedJson = repairedJson.replace(/,\s*([}\]])/g, '$1'); - // Add quotes to unquoted keys (basic attempt) - repairedJson = repairedJson.replace(/([{,]\s*)([a-zA-Z0-9_]+?)\s*:/g, '$1"$2":'); - // Ensure string values are properly quoted if they contain special characters and are not already quoted - repairedJson = repairedJson.replace(/:\s*([a-zA-Z0-9_]+)(?=[,\}\]])/g, ':"$1"'); - - + const repairedJson = repairJson(jsonCandidate); const argumentsObj = JSON.parse(repairedJson); if (typeof argumentsObj !== 'object' || argumentsObj === null) { @@ -934,6 +956,9 @@ async initializeAuth(forceRefresh = false) { } + /** + * 调用 API 并处理错误重试 + */ async callApi(method, model, body, isRetry = false, retryCount = 0) { if (!this.isInitialized) await this.initialize(); const maxRetries = this.config.REQUEST_MAX_RETRIES || 3; @@ -963,13 +988,13 @@ async initializeAuth(forceRefresh = false) { throw refreshError; } } - + // Handle 429 (Too Many Requests) with exponential backoff if (error.response?.status === 429 && retryCount < maxRetries) { const delay = baseDelay * Math.pow(2, retryCount); console.log(`[Kiro] Received 429 (Too Many Requests). Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - return this.callApi(method, model, body, isRetry, retryCount + 1); + await new Promise(resolve => setTimeout(resolve, delay)); + return this.callApi(method, model, body, isRetry, retryCount + 1); } // Handle other retryable errors (5xx server errors) @@ -1135,7 +1160,8 @@ async initializeAuth(forceRefresh = false) { if (parsed.content !== undefined && !parsed.followupPrompt) { // 处理转义字符 let decodedContent = parsed.content; - decodedContent = decodedContent.replace(/(? setTimeout(resolve, delay)); - yield* this.streamApiReal(method, model, body, isRetry, retryCount + 1); + await new Promise(resolve => setTimeout(resolve, delay)); + yield* this.streamApiReal(method, model, body, isRetry, retryCount + 1); return; } diff --git a/src/common.js b/src/common.js index e2816d2..f84cf03 100644 --- a/src/common.js +++ b/src/common.js @@ -243,12 +243,11 @@ export async function handleStreamRequest(res, service, model, requestBody, from }); } - if (!res.writableEnded) { - const errorPayload = { error: { message: "An error occurred during streaming.", details: error.message } }; - res.end(JSON.stringify(errorPayload)); - responseClosed = true; - } - + // 使用新方法创建符合 fromProvider 格式的流式错误响应 + const errorPayload = createStreamErrorResponse(error, fromProvider); + res.write(errorPayload); + res.end(); + responseClosed = true; } finally { if (!responseClosed) { res.end(); @@ -259,6 +258,7 @@ export async function handleStreamRequest(res, service, model, requestBody, from } } + export async function handleUnaryRequest(res, service, model, requestBody, fromProvider, toProvider, PROMPT_LOG_MODE, PROMPT_LOG_FILENAME, providerPoolManager, pooluuid) { try{ // The service returns the response in its native format (toProvider). @@ -288,14 +288,8 @@ export async function handleUnaryRequest(res, service, model, requestBody, fromP }); } - // 返回错误响应给客户端 - const errorResponse = { - error: { - message: error.message || "An error occurred during processing.", - code: error.status || 500, - details: error.stack - } - }; + // 使用新方法创建符合 fromProvider 格式的错误响应 + const errorResponse = createErrorResponse(error, fromProvider); await handleUnifiedResponse(res, JSON.stringify(errorResponse), false); } } @@ -402,24 +396,9 @@ export async function handleContentGenerationRequest(req, res, service, endpoint // 2.5. 如果使用了提供商池,根据模型重新选择提供商 if (providerPoolManager && CONFIG.providerPools && CONFIG.providerPools[CONFIG.MODEL_PROVIDER]) { - const { getApiService, NoAvailableProviderError } = await import('./service-manager.js'); - try { - service = await getApiService(CONFIG, model); - console.log(`[Content Generation] Re-selected service adapter based on model: ${model}`); - } catch (error) { - if (error instanceof NoAvailableProviderError) { - // 号池无可用账号,返回 400 错误 - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - type: 'no_available_provider', - message: error.message - } - })); - return; - } - throw error; - } + const { getApiService } = await import('./service-manager.js'); + service = await getApiService(CONFIG, model); + console.log(`[Content Generation] Re-selected service adapter based on model: ${model}`); } // 3. Apply system prompt from file if configured. @@ -619,3 +598,182 @@ export function getMD5Hash(obj) { const jsonString = JSON.stringify(obj); return crypto.createHash('md5').update(jsonString).digest('hex'); } + + +/** + * 创建符合 fromProvider 格式的错误响应(非流式) + * @param {Error} error - 错误对象 + * @param {string} fromProvider - 客户端期望的提供商格式 + * @returns {Object} 格式化的错误响应对象 + */ +function createErrorResponse(error, fromProvider) { + const protocolPrefix = getProtocolPrefix(fromProvider); + const statusCode = error.status || error.code || 500; + const errorMessage = error.message || "An error occurred during processing."; + + // 根据 HTTP 状态码映射错误类型 + const getErrorType = (code) => { + if (code === 401) return 'authentication_error'; + if (code === 403) return 'permission_error'; + if (code === 429) return 'rate_limit_error'; + if (code >= 500) return 'server_error'; + return 'invalid_request_error'; + }; + + // 根据 HTTP 状态码映射 Gemini 的 status + const getGeminiStatus = (code) => { + if (code === 400) return 'INVALID_ARGUMENT'; + if (code === 401) return 'UNAUTHENTICATED'; + if (code === 403) return 'PERMISSION_DENIED'; + if (code === 404) return 'NOT_FOUND'; + if (code === 429) return 'RESOURCE_EXHAUSTED'; + if (code >= 500) return 'INTERNAL'; + return 'UNKNOWN'; + }; + + switch (protocolPrefix) { + case MODEL_PROTOCOL_PREFIX.OPENAI: + // OpenAI 非流式错误格式 + return { + error: { + message: errorMessage, + type: getErrorType(statusCode), + code: getErrorType(statusCode) // OpenAI 使用 code 字段作为核心判断 + } + }; + + case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: + // OpenAI Responses API 非流式错误格式 + return { + error: { + type: getErrorType(statusCode), + message: errorMessage, + code: getErrorType(statusCode) + } + }; + + case MODEL_PROTOCOL_PREFIX.CLAUDE: + // Claude 非流式错误格式(外层有 type 标记) + return { + type: "error", // 核心区分标记 + error: { + type: getErrorType(statusCode), // Claude 使用 error.type 作为核心判断 + message: errorMessage + } + }; + + case MODEL_PROTOCOL_PREFIX.GEMINI: + // Gemini 非流式错误格式(遵循 Google Cloud 标准) + return { + error: { + code: statusCode, + message: errorMessage, + status: getGeminiStatus(statusCode) // Gemini 使用 status 作为核心判断 + } + }; + + default: + // 默认使用 OpenAI 格式 + return { + error: { + message: errorMessage, + type: getErrorType(statusCode), + code: getErrorType(statusCode) + } + }; + } +} + +/** + * 创建符合 fromProvider 格式的流式错误响应 + * @param {Error} error - 错误对象 + * @param {string} fromProvider - 客户端期望的提供商格式 + * @returns {string} 格式化的流式错误响应字符串 + */ +function createStreamErrorResponse(error, fromProvider) { + const protocolPrefix = getProtocolPrefix(fromProvider); + const statusCode = error.status || error.code || 500; + const errorMessage = error.message || "An error occurred during streaming."; + + // 根据 HTTP 状态码映射错误类型 + const getErrorType = (code) => { + if (code === 401) return 'authentication_error'; + if (code === 403) return 'permission_error'; + if (code === 429) return 'rate_limit_error'; + if (code >= 500) return 'server_error'; + return 'invalid_request_error'; + }; + + // 根据 HTTP 状态码映射 Gemini 的 status + const getGeminiStatus = (code) => { + if (code === 400) return 'INVALID_ARGUMENT'; + if (code === 401) return 'UNAUTHENTICATED'; + if (code === 403) return 'PERMISSION_DENIED'; + if (code === 404) return 'NOT_FOUND'; + if (code === 429) return 'RESOURCE_EXHAUSTED'; + if (code >= 500) return 'INTERNAL'; + return 'UNKNOWN'; + }; + + switch (protocolPrefix) { + case MODEL_PROTOCOL_PREFIX.OPENAI: + // OpenAI 流式错误格式(SSE data 块) + const openaiError = { + error: { + message: errorMessage, + type: getErrorType(statusCode), + code: null + } + }; + return `data: ${JSON.stringify(openaiError)}\n\n`; + + case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: + // OpenAI Responses API 流式错误格式(SSE event + data) + const responsesError = { + id: `resp_${Date.now()}`, + object: "error", + created: Math.floor(Date.now() / 1000), + error: { + type: getErrorType(statusCode), + message: errorMessage, + code: getErrorType(statusCode) + } + }; + return `event: error\ndata: ${JSON.stringify(responsesError)}\n\n`; + + case MODEL_PROTOCOL_PREFIX.CLAUDE: + // Claude 流式错误格式(SSE event + data) + const claudeError = { + type: "error", + error: { + type: getErrorType(statusCode), + message: errorMessage + } + }; + return `event: error\ndata: ${JSON.stringify(claudeError)}\n\n`; + + case MODEL_PROTOCOL_PREFIX.GEMINI: + // Gemini 流式错误格式 + // 注意:虽然 Gemini 原生使用 JSON 数组,但在我们的实现中已经转换为 SSE 格式 + // 所以这里也需要使用 data: 前缀,保持与正常流式响应一致 + const geminiError = { + error: { + code: statusCode, + message: errorMessage, + status: getGeminiStatus(statusCode) + } + }; + return `data: ${JSON.stringify(geminiError)}\n\n`; + + default: + // 默认使用 OpenAI SSE 格式 + const defaultError = { + error: { + message: errorMessage, + type: getErrorType(statusCode), + code: null + } + }; + return `data: ${JSON.stringify(defaultError)}\n\n`; + } +} \ No newline at end of file diff --git a/src/converters/strategies/ClaudeConverter.js b/src/converters/strategies/ClaudeConverter.js index a0b62bc..89e5fea 100644 --- a/src/converters/strategies/ClaudeConverter.js +++ b/src/converters/strategies/ClaudeConverter.js @@ -355,6 +355,10 @@ export class ClaudeConverter extends BaseConverter { prompt_tokens: claudeResponse.usage?.input_tokens || 0, completion_tokens: claudeResponse.usage?.output_tokens || 0, total_tokens: (claudeResponse.usage?.input_tokens || 0) + (claudeResponse.usage?.output_tokens || 0), + cached_tokens: claudeResponse.usage?.cache_read_input_tokens || 0, + prompt_tokens_details: { + cached_tokens: claudeResponse.usage?.cache_read_input_tokens || 0 + } }, }; } @@ -388,7 +392,8 @@ export class ClaudeConverter extends BaseConverter { usage: { prompt_tokens: claudeChunk.message?.usage?.input_tokens || 0, completion_tokens: 0, - total_tokens: claudeChunk.message?.usage?.input_tokens || 0 + total_tokens: claudeChunk.message?.usage?.input_tokens || 0, + cached_tokens: claudeChunk.message?.usage?.cache_read_input_tokens || 0 } }; } @@ -542,7 +547,11 @@ export class ClaudeConverter extends BaseConverter { usage: claudeChunk.usage ? { prompt_tokens: claudeChunk.usage.input_tokens || 0, completion_tokens: claudeChunk.usage.output_tokens || 0, - total_tokens: (claudeChunk.usage.input_tokens || 0) + (claudeChunk.usage.output_tokens || 0) + total_tokens: (claudeChunk.usage.input_tokens || 0) + (claudeChunk.usage.output_tokens || 0), + cached_tokens: claudeChunk.usage.cache_read_input_tokens || 0, + prompt_tokens_details: { + cached_tokens: claudeChunk.usage.cache_read_input_tokens || 0 + } } : undefined }; } @@ -887,7 +896,16 @@ export class ClaudeConverter extends BaseConverter { usageMetadata: claudeResponse.usage ? { promptTokenCount: claudeResponse.usage.input_tokens || 0, candidatesTokenCount: claudeResponse.usage.output_tokens || 0, - totalTokenCount: (claudeResponse.usage.input_tokens || 0) + (claudeResponse.usage.output_tokens || 0) + totalTokenCount: (claudeResponse.usage.input_tokens || 0) + (claudeResponse.usage.output_tokens || 0), + cachedContentTokenCount: claudeResponse.usage.cache_read_input_tokens || 0, + promptTokensDetails: [{ + modality: "TEXT", + tokenCount: claudeResponse.usage.input_tokens || 0 + }], + candidatesTokensDetails: [{ + modality: "TEXT", + tokenCount: claudeResponse.usage.output_tokens || 0 + }] } : {} }; } @@ -936,13 +954,33 @@ export class ClaudeConverter extends BaseConverter { // message_delta 事件 - 流结束 if (claudeChunk.type === 'message_delta') { const stopReason = claudeChunk.delta?.stop_reason; - return { + const result = { candidates: [{ finishReason: stopReason === 'end_turn' ? 'STOP' : stopReason === 'max_tokens' ? 'MAX_TOKENS' : 'OTHER' }] }; + + // 添加 usage 信息 + if (claudeChunk.usage) { + result.usageMetadata = { + promptTokenCount: claudeChunk.usage.input_tokens || 0, + candidatesTokenCount: claudeChunk.usage.output_tokens || 0, + totalTokenCount: (claudeChunk.usage.input_tokens || 0) + (claudeChunk.usage.output_tokens || 0), + cachedContentTokenCount: claudeChunk.usage.cache_read_input_tokens || 0, + promptTokensDetails: [{ + modality: "TEXT", + tokenCount: claudeChunk.usage.input_tokens || 0 + }], + candidatesTokensDetails: [{ + modality: "TEXT", + tokenCount: claudeChunk.usage.output_tokens || 0 + }] + }; + } + + return result; } } @@ -1153,7 +1191,7 @@ export class ClaudeConverter extends BaseConverter { usage: { input_tokens: claudeResponse.usage?.input_tokens || 0, input_tokens_details: { - cached_tokens: claudeResponse.usage?.cache_creation_input_tokens || 0, + cached_tokens: claudeResponse.usage?.cache_read_input_tokens || 0 }, output_tokens: claudeResponse.usage?.output_tokens || 0, output_tokens_details: { @@ -1266,7 +1304,13 @@ export class ClaudeConverter extends BaseConverter { if (lastEvent.response) { lastEvent.response.usage = { input_tokens: claudeChunk.usage.input_tokens || 0, + input_tokens_details: { + cached_tokens: claudeChunk.usage.cache_read_input_tokens || 0 + }, output_tokens: claudeChunk.usage.output_tokens || 0, + output_tokens_details: { + reasoning_tokens: 0 + }, total_tokens: (claudeChunk.usage.input_tokens || 0) + (claudeChunk.usage.output_tokens || 0) }; } diff --git a/src/converters/strategies/GeminiConverter.js b/src/converters/strategies/GeminiConverter.js index 37727d6..1ebd950 100644 --- a/src/converters/strategies/GeminiConverter.js +++ b/src/converters/strategies/GeminiConverter.js @@ -160,10 +160,24 @@ export class GeminiConverter extends BaseConverter { prompt_tokens: geminiResponse.usageMetadata.promptTokenCount || 0, completion_tokens: geminiResponse.usageMetadata.candidatesTokenCount || 0, total_tokens: geminiResponse.usageMetadata.totalTokenCount || 0, + cached_tokens: geminiResponse.usageMetadata.cachedContentTokenCount || 0, + prompt_tokens_details: { + cached_tokens: geminiResponse.usageMetadata.cachedContentTokenCount || 0 + }, + completion_tokens_details: { + reasoning_tokens: geminiResponse.usageMetadata.thoughtsTokenCount || 0 + } } : { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, + cached_tokens: 0, + prompt_tokens_details: { + cached_tokens: 0 + }, + completion_tokens_details: { + reasoning_tokens: 0 + } }, }; } @@ -235,10 +249,24 @@ export class GeminiConverter extends BaseConverter { prompt_tokens: geminiChunk.usageMetadata.promptTokenCount || 0, completion_tokens: geminiChunk.usageMetadata.candidatesTokenCount || 0, total_tokens: geminiChunk.usageMetadata.totalTokenCount || 0, + cached_tokens: geminiChunk.usageMetadata.cachedContentTokenCount || 0, + prompt_tokens_details: { + cached_tokens: geminiChunk.usageMetadata.cachedContentTokenCount || 0 + }, + completion_tokens_details: { + reasoning_tokens: geminiChunk.usageMetadata.thoughtsTokenCount || 0 + } } : { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, + cached_tokens: 0, + prompt_tokens_details: { + cached_tokens: 0 + }, + completion_tokens_details: { + reasoning_tokens: 0 + } }, }; } @@ -447,6 +475,8 @@ export class GeminiConverter extends BaseConverter { stop_sequence: null, usage: { input_tokens: geminiResponse.usageMetadata?.promptTokenCount || 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: geminiResponse.usageMetadata?.cachedContentTokenCount || 0, output_tokens: geminiResponse.usageMetadata?.candidatesTokenCount || 0 } }; @@ -483,7 +513,7 @@ export class GeminiConverter extends BaseConverter { // 处理finishReason if (candidate.finishReason) { - return { + const result = { type: "message_delta", delta: { stop_reason: candidate.finishReason === 'STOP' ? 'end_turn' : @@ -491,6 +521,22 @@ export class GeminiConverter extends BaseConverter { candidate.finishReason.toLowerCase() } }; + + // 添加 usage 信息 + if (geminiChunk.usageMetadata) { + result.usage = { + input_tokens: geminiChunk.usageMetadata.promptTokenCount || 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: geminiChunk.usageMetadata.cachedContentTokenCount || 0, + output_tokens: geminiChunk.usageMetadata.candidatesTokenCount || 0, + prompt_tokens: geminiChunk.usageMetadata.promptTokenCount || 0, + completion_tokens: geminiChunk.usageMetadata.candidatesTokenCount || 0, + total_tokens: geminiChunk.usageMetadata.totalTokenCount || 0, + cached_tokens: geminiChunk.usageMetadata.cachedContentTokenCount || 0 + }; + } + + return result; } } } @@ -719,13 +765,13 @@ export class GeminiConverter extends BaseConverter { usage: { input_tokens: geminiResponse.usageMetadata?.promptTokenCount || 0, input_tokens_details: { - cached_tokens: geminiResponse.usageMetadata?.cachedTokens || 0, + cached_tokens: geminiResponse.usageMetadata?.cachedContentTokenCount || 0 }, output_tokens: geminiResponse.usageMetadata?.candidatesTokenCount || 0, output_tokens_details: { - reasoning_tokens: 0 + reasoning_tokens: geminiResponse.usageMetadata?.thoughtsTokenCount || 0 }, - total_tokens: geminiResponse.usageMetadata?.totalTokenCount || 0, + total_tokens: geminiResponse.usageMetadata?.totalTokenCount || 0 }, user: null }; @@ -791,7 +837,13 @@ export class GeminiConverter extends BaseConverter { if (lastEvent.response) { lastEvent.response.usage = { input_tokens: geminiChunk.usageMetadata.promptTokenCount || 0, + input_tokens_details: { + cached_tokens: geminiChunk.usageMetadata.cachedContentTokenCount || 0 + }, output_tokens: geminiChunk.usageMetadata.candidatesTokenCount || 0, + output_tokens_details: { + reasoning_tokens: geminiChunk.usageMetadata.thoughtsTokenCount || 0 + }, total_tokens: geminiChunk.usageMetadata.totalTokenCount || 0 }; } diff --git a/src/converters/strategies/OpenAIConverter.js b/src/converters/strategies/OpenAIConverter.js index 522caa6..680852f 100644 --- a/src/converters/strategies/OpenAIConverter.js +++ b/src/converters/strategies/OpenAIConverter.js @@ -327,6 +327,8 @@ export class OpenAIConverter extends BaseConverter { stop_sequence: null, usage: { input_tokens: openaiResponse.usage?.prompt_tokens || 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: openaiResponse.usage?.prompt_tokens_details?.cached_tokens || 0, output_tokens: openaiResponse.usage?.completion_tokens || 0 } }; @@ -458,8 +460,10 @@ export class OpenAIConverter extends BaseConverter { stop_sequence: null }, usage: { - output_tokens: openaiChunk.usage?.completion_tokens || 0, input_tokens: openaiChunk.usage?.prompt_tokens || 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: openaiChunk.usage?.prompt_tokens_details?.cached_tokens || 0, + output_tokens: openaiChunk.usage?.completion_tokens || 0 } }); @@ -778,7 +782,17 @@ export class OpenAIConverter extends BaseConverter { usageMetadata: openaiResponse.usage ? { promptTokenCount: openaiResponse.usage.prompt_tokens || 0, candidatesTokenCount: openaiResponse.usage.completion_tokens || 0, - totalTokenCount: openaiResponse.usage.total_tokens || 0 + totalTokenCount: openaiResponse.usage.total_tokens || 0, + cachedContentTokenCount: openaiResponse.usage.prompt_tokens_details?.cached_tokens || 0, + promptTokensDetails: [{ + modality: "TEXT", + tokenCount: openaiResponse.usage.prompt_tokens || 0 + }], + candidatesTokensDetails: [{ + modality: "TEXT", + tokenCount: openaiResponse.usage.completion_tokens || 0 + }], + thoughtsTokenCount: openaiResponse.usage.completion_tokens_details?.reasoning_tokens || 0 } : {} }; } @@ -850,7 +864,17 @@ export class OpenAIConverter extends BaseConverter { result.usageMetadata = { promptTokenCount: openaiChunk.usage.prompt_tokens || 0, candidatesTokenCount: openaiChunk.usage.completion_tokens || 0, - totalTokenCount: openaiChunk.usage.total_tokens || 0 + totalTokenCount: openaiChunk.usage.total_tokens || 0, + cachedContentTokenCount: openaiChunk.usage.prompt_tokens_details?.cached_tokens || 0, + promptTokensDetails: [{ + modality: "TEXT", + tokenCount: openaiChunk.usage.prompt_tokens || 0 + }], + candidatesTokensDetails: [{ + modality: "TEXT", + tokenCount: openaiChunk.usage.completion_tokens || 0 + }], + thoughtsTokenCount: openaiChunk.usage.completion_tokens_details?.reasoning_tokens || 0 }; } @@ -946,11 +970,23 @@ export class OpenAIConverter extends BaseConverter { output: output, usage: openaiResponse.usage ? { input_tokens: openaiResponse.usage.prompt_tokens || 0, + input_tokens_details: { + cached_tokens: openaiResponse.usage.prompt_tokens_details?.cached_tokens || 0 + }, output_tokens: openaiResponse.usage.completion_tokens || 0, + output_tokens_details: { + reasoning_tokens: openaiResponse.usage.completion_tokens_details?.reasoning_tokens || 0 + }, total_tokens: openaiResponse.usage.total_tokens || 0 } : { input_tokens: 0, + input_tokens_details: { + cached_tokens: 0 + }, output_tokens: 0, + output_tokens_details: { + reasoning_tokens: 0 + }, total_tokens: 0 } }; @@ -1051,7 +1087,13 @@ export class OpenAIConverter extends BaseConverter { if (lastEvent.response) { lastEvent.response.usage = { input_tokens: openaiChunk.usage.prompt_tokens || 0, + input_tokens_details: { + cached_tokens: openaiChunk.usage.prompt_tokens_details?.cached_tokens || 0 + }, output_tokens: openaiChunk.usage.completion_tokens || 0, + output_tokens_details: { + reasoning_tokens: openaiChunk.usage.completion_tokens_details?.reasoning_tokens || 0 + }, total_tokens: openaiChunk.usage.total_tokens || 0 }; } diff --git a/src/converters/strategies/OpenAIResponsesConverter.js b/src/converters/strategies/OpenAIResponsesConverter.js index 50ce3c5..dfd7e06 100644 --- a/src/converters/strategies/OpenAIResponsesConverter.js +++ b/src/converters/strategies/OpenAIResponsesConverter.js @@ -173,10 +173,26 @@ export class OpenAIResponsesConverter extends BaseConverter { }, finish_reason: responsesResponse.finish_reason || 'stop' }], - usage: responsesResponse.usage || { + usage: responsesResponse.usage ? { + prompt_tokens: responsesResponse.usage.input_tokens || 0, + completion_tokens: responsesResponse.usage.output_tokens || 0, + total_tokens: responsesResponse.usage.total_tokens || 0, + prompt_tokens_details: { + cached_tokens: responsesResponse.usage.input_tokens_details?.cached_tokens || 0 + }, + completion_tokens_details: { + reasoning_tokens: responsesResponse.usage.output_tokens_details?.reasoning_tokens || 0 + } + } : { prompt_tokens: 0, completion_tokens: 0, - total_tokens: 0 + total_tokens: 0, + prompt_tokens_details: { + cached_tokens: 0 + }, + completion_tokens_details: { + reasoning_tokens: 0 + } } }; } @@ -283,8 +299,16 @@ export class OpenAIResponsesConverter extends BaseConverter { model: model || responsesResponse.model, stop_reason: responsesResponse.choices?.[0]?.finish_reason || 'end_turn', usage: { - input_tokens: responsesResponse.usage?.prompt_tokens || 0, - output_tokens: responsesResponse.usage?.completion_tokens || 0 + input_tokens: responsesResponse.usage?.input_tokens || responsesResponse.usage?.prompt_tokens || 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: responsesResponse.usage?.input_tokens_details?.cached_tokens || 0, + output_tokens: responsesResponse.usage?.output_tokens || responsesResponse.usage?.completion_tokens || 0, + prompt_tokens: responsesResponse.usage?.input_tokens || responsesResponse.usage?.prompt_tokens || 0, + completion_tokens: responsesResponse.usage?.output_tokens || responsesResponse.usage?.completion_tokens || 0, + total_tokens: responsesResponse.usage?.total_tokens || + ((responsesResponse.usage?.input_tokens || responsesResponse.usage?.prompt_tokens || 0) + + (responsesResponse.usage?.output_tokens || responsesResponse.usage?.completion_tokens || 0)), + cached_tokens: responsesResponse.usage?.input_tokens_details?.cached_tokens || 0 } }; } @@ -429,9 +453,21 @@ export class OpenAIResponsesConverter extends BaseConverter { index: 0 }], usageMetadata: { - promptTokenCount: responsesResponse.usage?.prompt_tokens || 0, - candidatesTokenCount: responsesResponse.usage?.completion_tokens || 0, - totalTokenCount: responsesResponse.usage?.total_tokens || 0 + promptTokenCount: responsesResponse.usage?.input_tokens || responsesResponse.usage?.prompt_tokens || 0, + candidatesTokenCount: responsesResponse.usage?.output_tokens || responsesResponse.usage?.completion_tokens || 0, + totalTokenCount: responsesResponse.usage?.total_tokens || + ((responsesResponse.usage?.input_tokens || responsesResponse.usage?.prompt_tokens || 0) + + (responsesResponse.usage?.output_tokens || responsesResponse.usage?.completion_tokens || 0)), + cachedContentTokenCount: responsesResponse.usage?.input_tokens_details?.cached_tokens || 0, + promptTokensDetails: [{ + modality: "TEXT", + tokenCount: responsesResponse.usage?.input_tokens || responsesResponse.usage?.prompt_tokens || 0 + }], + candidatesTokensDetails: [{ + modality: "TEXT", + tokenCount: responsesResponse.usage?.output_tokens || responsesResponse.usage?.completion_tokens || 0 + }], + thoughtsTokenCount: responsesResponse.usage?.output_tokens_details?.reasoning_tokens || 0 } }; } diff --git a/src/service-manager.js b/src/service-manager.js index ab704d3..03ab4f5 100644 --- a/src/service-manager.js +++ b/src/service-manager.js @@ -56,31 +56,11 @@ export async function initApiService(config) { return serviceInstances; // Return the collection of initialized service instances } -/** - * Custom error class for no available provider in pool - */ -export class NoAvailableProviderError extends Error { - constructor(providerType, requestedModel = null) { - const message = requestedModel - ? `号池无可用账号: ${providerType} (model: ${requestedModel})` - : `号池无可用账号: ${providerType}`; - super(message); - this.name = 'NoAvailableProviderError'; - this.providerType = providerType; - this.requestedModel = requestedModel; - this.statusCode = 400; - } -} - -// Provider types that should throw error when no available provider (instead of falling back to main config) -const STRICT_POOL_PROVIDERS = ['claude-kiro-oauth']; - /** * Get API service adapter, considering provider pools * @param {Object} config - The current request configuration * @param {string} [requestedModel] - Optional. The model name to filter providers by. * @returns {Promise} The API service adapter - * @throws {NoAvailableProviderError} If no healthy provider is available in the pool (only for strict pool providers) */ export async function getApiService(config, requestedModel = null) { let serviceConfig = config; @@ -94,14 +74,7 @@ export async function getApiService(config, requestedModel = null) { config.uuid = serviceConfig.uuid; console.log(`[API Service] Using pooled configuration for ${config.MODEL_PROVIDER}: ${serviceConfig.uuid}${requestedModel ? ` (model: ${requestedModel})` : ''}`); } else { - // 号池没有可用账号 - // 只有 Kiro 类型的 provider 抛出错误,其他 provider 回退到主配置 - if (STRICT_POOL_PROVIDERS.includes(config.MODEL_PROVIDER)) { - console.error(`[API Service] 号池无可用账号: ${config.MODEL_PROVIDER}${requestedModel ? ` (model: ${requestedModel})` : ''}`); - throw new NoAvailableProviderError(config.MODEL_PROVIDER, requestedModel); - } else { - console.warn(`[API Service] No healthy provider found in pool for ${config.MODEL_PROVIDER}${requestedModel ? ` supporting model: ${requestedModel}` : ''}. Falling back to main config.`); - } + console.warn(`[API Service] No healthy provider found in pool for ${config.MODEL_PROVIDER}${requestedModel ? ` supporting model: ${requestedModel}` : ''}. Falling back to main config.`); } } return getServiceAdapter(serviceConfig);