feat: 添加统一错误响应生成功能

refactor(service-manager): 移除NoAvailableProviderError和严格池检查
feat(converters): 增强token统计功能,支持缓存和推理token详情
refactor(claude-kiro): 改进JSON解析和工具调用处理
feat(common): 添加统一错误响应生成功能
This commit is contained in:
hex2077 2025-12-03 20:21:20 +08:00
parent 0e9d0912cd
commit bc3725e901
8 changed files with 463 additions and 100 deletions

32
package-lock.json generated
View file

@ -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",

View file

@ -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(/(?<!\\)\\n/g, '\n');
// 无须处理转义的换行符,原来要处理是因为智能体返回的 content 需要通过换行符切割不同的json
// decodedContent = decodedContent.replace(/(?<!\\)\\n/g, '\n');
events.push({ type: 'content', data: decodedContent });
}
// 处理结构化工具调用事件 - 开始事件(包含 name 和 toolUseId
@ -1258,8 +1284,8 @@ async initializeAuth(forceRefresh = false) {
if (error.response?.status === 429 && retryCount < maxRetries) {
const delay = baseDelay * Math.pow(2, retryCount);
console.log(`[Kiro] Received 429 in stream. Retrying in ${delay}ms...`);
await new Promise(resolve => 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;
}

View file

@ -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`;
}
}

View file

@ -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)
};
}

View file

@ -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
};
}

View file

@ -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
};
}

View file

@ -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
}
};
}

View file

@ -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<Object>} 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);