From ed8b88958622091481c88764ae7db02b7aaeb1e0 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Fri, 20 Feb 2026 19:52:50 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=A1=AE=E4=BF=9D=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E6=B6=88=E6=81=AF=E5=86=85=E5=AE=B9=E5=9C=A8?= =?UTF-8?q?=E8=BD=AC=E6=8D=A2=E6=97=B6=E8=A2=AB=E6=AD=A3=E7=A1=AE=E5=BA=8F?= =?UTF-8?q?=E5=88=97=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 OpenAIConverter 中工具结果消息内容为对象时未序列化为字符串的问题 - 修复 ClaudeConverter 中工具结果消息内容为对象时未序列化为字符串的问题 - 修复 Kiro 提供程序中工具描述为空时导致请求失败的问题 - 更新 README 文档,添加 Kiro 扩展思考和提供商优先级配置说明 - 清理过时和未使用的测试文件以保持代码库整洁 --- README-JA.md | 71 ++- README-ZH.md | 71 ++- src/converters/strategies/ClaudeConverter.js | 7 +- src/converters/strategies/OpenAIConverter.js | 6 +- src/providers/claude/claude-kiro.js | 87 ++-- tests/claude-converter-thinking.test.js | 43 -- tests/concurrent-test.js | 454 ------------------ tests/cors-config.test.js | 91 ---- tests/gemini-converter.test.js | 90 ---- tests/kiro-thinking-parsing.test.js | 40 -- tests/openai-converter-thinking.test.js | 55 --- ...penai-responses-converter-thinking.test.js | 27 -- tests/openai-responses-converter.test.js | 80 --- 13 files changed, 211 insertions(+), 911 deletions(-) delete mode 100644 tests/claude-converter-thinking.test.js delete mode 100644 tests/concurrent-test.js delete mode 100644 tests/cors-config.test.js delete mode 100644 tests/gemini-converter.test.js delete mode 100644 tests/kiro-thinking-parsing.test.js delete mode 100644 tests/openai-converter-thinking.test.js delete mode 100644 tests/openai-responses-converter-thinking.test.js delete mode 100644 tests/openai-responses-converter.test.js diff --git a/README-JA.md b/README-JA.md index b70e237..7d0d280 100644 --- a/README-JA.md +++ b/README-JA.md @@ -250,6 +250,46 @@ Web UI管理インターフェースでは、極めて迅速に認証設定を 3. **ベストプラクティス**:**Claude Code**との併用を推奨、最適な体験を得られる 4. **重要なお知らせ**:Kiroサービス使用ポリシーが更新されました、最新の使用制限と条件については公式ウェブサイトをご確認ください。 +#### Kiro 拡張思考 (Claude モデル) +AIClient-2-API は、`claude-kiro-oauth` にルーティングされた Claude 互換リクエスト (`/v1/messages`) または OpenAI 互換リクエスト (`/v1/chat/completions`) を使用する場合、Kiro 拡張思考をサポートします。 + +**Claude 互換インターフェース (`/v1/messages`)**: +```bash +curl http://localhost:3000/claude-kiro-oauth/v1/messages \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer your-api-key" \ + -d '{ + "model": "claude-sonnet-4-5", + "max_tokens": 1024, + "thinking": { "type": "enabled", "budget_tokens": 10000 }, + "messages": [{ "role": "user", "content": "この問題をステップバイステップで解決してください。" }] + }' +``` + +**OpenAI 互換インターフェース (`/v1/chat/completions`)**: +```bash +curl http://localhost:3000/claude-kiro-oauth/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer your-api-key" \ + -d '{ + "model": "claude-sonnet-4-5", + "messages": [{ "role": "user", "content": "この問題をステップバイステップで解決してください。" }], + "extra_body": { + "anthropic": { + "thinking": { "type": "enabled", "budget_tokens": 10000 } + } + } + }' +``` + +**アダプティブモード**: +- Claude: `"thinking": { "type": "adaptive", "effort": "high" }` +- OpenAI: `"extra_body.anthropic.thinking": { "type": "adaptive", "effort": "high" }` + +注意: +- `budget_tokens` は `[1024, 24576]` の範囲に制限されます(省略または無効な場合はデフォルトの `20000` が適用されます)。 +- トークンの取得/リフレッシュ/プールローテーションメカニズムは変更されません。 + #### iFlow OAuth設定 1. **初回認証**:Web UIの「設定管理」または「プロバイダープール」ページで、iFlowの「認証生成」ボタンをクリック 2. **電話番号ログイン**:システムがiFlow認証ページを開き、電話番号でログイン認証を完了 @@ -414,7 +454,36 @@ curl http://localhost:3000/ollama/api/chat \ - 一部のアカウントは割り当てまたは権限の制限により特定のモデルにアクセスできない - 異なるアカウントに異なるモデルアクセス権限を割り当てる必要がある -#### 3. クロスタイプフォールバック設定 +#### 3. プロバイダー優先度設定 + +`provider_pools.json` 内のノードごとの `priority` フィールドを通じて、確定的なアカウント順序をサポートします。 + +**設定方法**(数値が小さいほど優先度が高くなります): + +```json +{ + "claude-kiro-oauth": [ + { + "uuid": "primary-node-uuid", + "priority": 1, + "checkHealth": true + }, + { + "uuid": "backup-node-uuid", + "priority": 2, + "checkHealth": true + } + ] +} +``` + +**動作原理**: +- プールマネージャーはまず、最も低い `priority` 値によって健全/利用可能なノードをフィルタリングします +- その最高優先度ティアのノードのみが LRU/スコアベースの負荷分散に参加します +- 最高優先度ティア全体が利用不可になった場合、次の優先度ティアが自動的に使用されます +- `priority` が省略されているか無効な場合、デフォルトの `100` が適用されます(後方互換性のある動作) + +#### 4. クロスタイプフォールバック設定 あるProvider Type(例:`gemini-cli-oauth`)のすべてのアカウントが429割り当て制限により枯渇したり、unhealthyとマークされた場合、システムは直接エラーを返すのではなく、互換性のある別のProvider Type(例:`gemini-antigravity`)に自動的にフォールバックできます。 diff --git a/README-ZH.md b/README-ZH.md index 35374c0..436aec3 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -249,6 +249,46 @@ docker compose up -d 3. **最佳实践**:推荐配合 **Claude Code** 使用,可获得最优体验 4. **重要提示**:Kiro 服务使用政策已更新,请访问官方网站查看最新使用限制和条款 +#### Kiro 扩展思考 (Claude 模型) +AIClient-2-API 在使用路由到 `claude-kiro-oauth` 的 Claude 兼容请求 (`/v1/messages`) 或 OpenAI 兼容请求 (`/v1/chat/completions`) 时支持 Kiro 扩展思考。 + +**Claude 兼容接口 (`/v1/messages`)**: +```bash +curl http://localhost:3000/claude-kiro-oauth/v1/messages \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer your-api-key" \ + -d '{ + "model": "claude-sonnet-4-5", + "max_tokens": 1024, + "thinking": { "type": "enabled", "budget_tokens": 10000 }, + "messages": [{ "role": "user", "content": "逐步解决这个问题。" }] + }' +``` + +**OpenAI 兼容接口 (`/v1/chat/completions`)**: +```bash +curl http://localhost:3000/claude-kiro-oauth/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer your-api-key" \ + -d '{ + "model": "claude-sonnet-4-5", + "messages": [{ "role": "user", "content": "逐步解决这个问题。" }], + "extra_body": { + "anthropic": { + "thinking": { "type": "enabled", "budget_tokens": 10000 } + } + } + }' +``` + +**自适应模式**: +- Claude: `"thinking": { "type": "adaptive", "effort": "high" }` +- OpenAI: `"extra_body.anthropic.thinking": { "type": "adaptive", "effort": "high" }` + +注意: +- `budget_tokens` 被限制在 `[1024, 24576]` 之间(如果省略或无效,默认值为 `20000`)。 +- Token 获取/刷新/池轮换机制保持不变。 + #### iFlow OAuth 配置 1. **首次授权**:在 Web UI 的"配置管理"或"提供商池"页面,点击 iFlow 的"生成授权"按钮 2. **手机登录**:系统将打开 iFlow 授权页面,使用手机号完成登录验证 @@ -413,7 +453,36 @@ curl http://localhost:3000/ollama/api/chat \ - 某些账号因配额或权限限制无法访问特定模型 - 需要为不同账号分配不同的模型访问权限 -#### 3. 跨类型 Fallback 配置 +#### 3. 提供商优先级配置 + +支持通过 `provider_pools.json` 中每个节点的 `priority` 字段实现确定的账号排序。 + +**配置方式**(数字越小,优先级越高): + +```json +{ + "claude-kiro-oauth": [ + { + "uuid": "primary-node-uuid", + "priority": 1, + "checkHealth": true + }, + { + "uuid": "backup-node-uuid", + "priority": 2, + "checkHealth": true + } + ] +} +``` + +**工作原理**: +- 池管理器首先按最低 `priority` 值过滤健康/可用的节点 +- 只有处于该最高优先级层级的节点才会参与基于 LRU/评分的负载均衡 +- 如果整个最高优先级层级不可用,系统将自动使用下一个优先级层级 +- 如果省略 `priority` 或其无效,将应用默认值 `100`(向后兼容行为) + +#### 4. 跨类型 Fallback 配置 当某一 Provider Type(如 `gemini-cli-oauth`)下的所有账号都因 429 配额耗尽或被标记为 unhealthy 时,系统能够自动 fallback 到另一个兼容的 Provider Type(如 `gemini-antigravity`),而不是直接返回错误。 diff --git a/src/converters/strategies/ClaudeConverter.js b/src/converters/strategies/ClaudeConverter.js index e30895b..03584cc 100644 --- a/src/converters/strategies/ClaudeConverter.js +++ b/src/converters/strategies/ClaudeConverter.js @@ -140,7 +140,12 @@ export class ClaudeConverter extends BaseConverter { for (const item of msg.content) { if (item && typeof item === 'object' && item.type === "tool_result") { const toolUseId = item.tool_use_id || item.id || ""; - const contentStr = String(item.content || ""); + let contentStr = item.content || ""; + if (typeof contentStr === 'object') { + contentStr = JSON.stringify(contentStr); + } else { + contentStr = String(contentStr); + } tempOpenAIMessages.push({ role: "tool", tool_call_id: toolUseId, diff --git a/src/converters/strategies/OpenAIConverter.js b/src/converters/strategies/OpenAIConverter.js index 04488bb..33bf82d 100644 --- a/src/converters/strategies/OpenAIConverter.js +++ b/src/converters/strategies/OpenAIConverter.js @@ -149,10 +149,14 @@ export class OpenAIConverter extends BaseConverter { if (message.role === 'tool') { // 工具结果消息 + let toolContent = message.content; + if (typeof toolContent === 'object' && toolContent !== null) { + toolContent = JSON.stringify(toolContent); + } content.push({ type: 'tool_result', tool_use_id: message.tool_call_id, - content: safeParseJSON(message.content) + content: toolContent }); claudeMessages.push({ role: 'user', content: content }); } else if (message.role === 'assistant' && (message.tool_calls?.length || message.function_calls?.length)) { diff --git a/src/providers/claude/claude-kiro.js b/src/providers/claude/claude-kiro.js index e2438e4..46719c6 100644 --- a/src/providers/claude/claude-kiro.js +++ b/src/providers/claude/claude-kiro.js @@ -964,35 +964,62 @@ async saveCredentialsToFile(filePath, newData) { }; toolsContext = { tools: [placeholderTool] }; } else { - const MAX_DESCRIPTION_LENGTH = 9216; + const MAX_DESCRIPTION_LENGTH = 9216; - let truncatedCount = 0; - const kiroTools = filteredTools.map(tool => { - let desc = tool.description || ""; - const originalLength = desc.length; - - if (desc.length > MAX_DESCRIPTION_LENGTH) { - desc = desc.substring(0, MAX_DESCRIPTION_LENGTH) + "..."; - truncatedCount++; - logger.info(`[Kiro] Truncated tool '${tool.name}' description: ${originalLength} -> ${desc.length} chars`); - } - - return { - toolSpecification: { - name: tool.name, - description: desc, - inputSchema: { - json: tool.input_schema || {} + let truncatedCount = 0; + const kiroTools = filteredTools + .filter(tool => { + // 过滤掉描述为空的工具 + if (!tool.description || tool.description.trim() === '') { + logger.info(`[Kiro] Ignoring tool with empty description: ${tool.name}`); + return false; } - } - }; - }); - - if (truncatedCount > 0) { - logger.info(`[Kiro] Truncated ${truncatedCount} tool description(s) to max ${MAX_DESCRIPTION_LENGTH} chars`); - } + return true; + }) + .map(tool => { + let desc = tool.description || ""; + const originalLength = desc.length; + + if (desc.length > MAX_DESCRIPTION_LENGTH) { + desc = desc.substring(0, MAX_DESCRIPTION_LENGTH) + "..."; + truncatedCount++; + logger.info(`[Kiro] Truncated tool '${tool.name}' description: ${originalLength} -> ${desc.length} chars`); + } + + return { + toolSpecification: { + name: tool.name, + description: desc, + inputSchema: { + json: tool.input_schema || {} + } + } + }; + }); + + if (truncatedCount > 0) { + logger.info(`[Kiro] Truncated ${truncatedCount} tool description(s) to max ${MAX_DESCRIPTION_LENGTH} chars`); + } - toolsContext = { tools: kiroTools }; + // 检查过滤后是否还有有效工具 + if (kiroTools.length === 0) { + logger.info('[Kiro] All tools were filtered out (empty descriptions), adding placeholder tool'); + const placeholderTool = { + toolSpecification: { + name: "no_tool_available", + description: "This is a placeholder tool when no other tools are available. It does nothing.", + inputSchema: { + json: { + type: "object", + properties: {} + } + } + } + }; + toolsContext = { tools: [placeholderTool] }; + } else { + toolsContext = { tools: kiroTools }; + } } } else { // tools 为空或长度为 0 时,自动添加一个占位工具 @@ -2778,7 +2805,13 @@ async saveCredentialsToFile(filePath, newData) { }); outputTokens += this.countTextTokens(tc.function.arguments); } - stopReason = "tool_use"; + stopReason = "tool_use"; // Set stop_reason to "tool_use" when toolCalls exist + } else if (content) { + contentArray.push({ + type: "text", + text: content + }); + outputTokens += this.countTextTokens(content); } return { diff --git a/tests/claude-converter-thinking.test.js b/tests/claude-converter-thinking.test.js deleted file mode 100644 index 2e654e4..0000000 --- a/tests/claude-converter-thinking.test.js +++ /dev/null @@ -1,43 +0,0 @@ -import { ClaudeConverter } from '../src/converters/strategies/ClaudeConverter.js'; - -describe('ClaudeConverter thinking -> OpenAI reasoning_content', () => { - let converter; - - beforeEach(() => { - converter = new ClaudeConverter(); - }); - - test('toOpenAIResponse surfaces thinking blocks as reasoning_content', () => { - const claudeResponse = { - content: [ - { type: 'thinking', thinking: 'x' }, - { type: 'text', text: 'y' } - ], - stop_reason: 'end_turn', - usage: { input_tokens: 1, output_tokens: 2 } - }; - - const openai = converter.toOpenAIResponse(claudeResponse, 'claude-sonnet-4-5'); - expect(openai.choices[0].message.content).toBe('y'); - expect(openai.choices[0].message.reasoning_content).toBe('x'); - }); - - test('toOpenAIResponse includes tool_calls and reasoning_content together', () => { - const claudeResponse = { - content: [ - { type: 'thinking', thinking: 'r' }, - { type: 'text', text: 't' }, - { type: 'tool_use', id: 'toolu_1', name: 'my_tool', input: { a: 1 } } - ], - stop_reason: 'tool_use', - usage: { input_tokens: 1, output_tokens: 2 } - }; - - const openai = converter.toOpenAIResponse(claudeResponse, 'claude-sonnet-4-5'); - expect(openai.choices[0].message.content).toBe('t'); - expect(openai.choices[0].message.reasoning_content).toBe('r'); - expect(openai.choices[0].message.tool_calls).toHaveLength(1); - expect(openai.choices[0].message.tool_calls[0].function.name).toBe('my_tool'); - }); -}); - diff --git a/tests/concurrent-test.js b/tests/concurrent-test.js deleted file mode 100644 index f515667..0000000 --- a/tests/concurrent-test.js +++ /dev/null @@ -1,454 +0,0 @@ -/** - * 并发测试脚本 - * 用于测试 API 服务器在高并发场景下的性能和稳定性 - * - * 使用方法: - * node tests/concurrent-test.js [选项] - * - * 选项: - * --url API 服务器地址 (默认: http://localhost:3000) - * --api-key API 密钥 (默认: 123456) - * --concurrency 并发数 (默认: 10) - * --requests 总请求数 (默认: 100) - * --endpoint 测试端点 (默认: /v1/chat/completions) - * --model 模型名称 (默认: gpt-4) - * --stream 使用流式响应 (默认: false) - * --timeout 请求超时时间 (默认: 60000) - * --verbose 显示详细日志 - */ - -import http from 'http'; -import https from 'https'; - -// 解析命令行参数 -function parseArgs() { - const args = process.argv.slice(2); - const config = { - url: 'http://localhost:3000', - apiKey: '123456', - concurrency: 10, - totalRequests: 100, - rpm: 0, - endpoint: '/v1/chat/completions', - model: 'gpt-4', - stream: false, - timeout: 60000, - verbose: false - }; - - for (let i = 0; i < args.length; i++) { - switch (args[i]) { - case '--url': - config.url = args[++i]; - break; - case '--api-key': - config.apiKey = args[++i]; - break; - case '--concurrency': - config.concurrency = parseInt(args[++i], 10); - break; - case '--requests': - config.totalRequests = parseInt(args[++i], 10); - break; - case '--rpm': - config.rpm = parseInt(args[++i], 10); - break; - case '--endpoint': - config.endpoint = args[++i]; - break; - case '--model': - config.model = args[++i]; - break; - case '--stream': - config.stream = true; - break; - case '--timeout': - config.timeout = parseInt(args[++i], 10); - break; - case '--verbose': - config.verbose = true; - break; - case '--help': - console.log(` -并发测试脚本 - 测试 API 服务器性能 - -使用方法: - node tests/concurrent-test.js [选项] - -选项: - --url API 服务器地址 (默认: http://localhost:3000) - --api-key API 密钥 (默认: 123456) - --concurrency 并发数 (默认: 10) - --requests 总请求数 (默认: 100) - --endpoint 测试端点 (默认: /v1/chat/completions) - --model 模型名称 (默认: gpt-4) - --stream 使用流式响应 (默认: false) - --timeout 请求超时时间 (默认: 60000) - --verbose 显示详细日志 - --help 显示帮助信息 - `); - process.exit(0); - } - } - - return config; -} - -// 统计数据 -class Statistics { - constructor() { - this.completed = 0; - this.failed = 0; - this.responseTimes = []; - this.errors = {}; - this.startTime = null; - this.endTime = null; - } - - recordSuccess(responseTime) { - this.completed++; - this.responseTimes.push(responseTime); - } - - recordFailure(error) { - this.failed++; - const errorKey = error.message || String(error); - this.errors[errorKey] = (this.errors[errorKey] || 0) + 1; - } - - start() { - this.startTime = Date.now(); - } - - end() { - this.endTime = Date.now(); - } - - getReport() { - const totalTime = this.endTime - this.startTime; - const sortedTimes = [...this.responseTimes].sort((a, b) => a - b); - - const percentile = (p) => { - if (sortedTimes.length === 0) return 0; - const index = Math.ceil((p / 100) * sortedTimes.length) - 1; - return sortedTimes[Math.max(0, index)]; - }; - - const avg = sortedTimes.length > 0 - ? sortedTimes.reduce((a, b) => a + b, 0) / sortedTimes.length - : 0; - - return { - totalRequests: this.completed + this.failed, - completed: this.completed, - failed: this.failed, - successRate: ((this.completed / (this.completed + this.failed)) * 100).toFixed(2) + '%', - totalTime: totalTime, - requestsPerSecond: ((this.completed + this.failed) / (totalTime / 1000)).toFixed(2), - responseTime: { - min: sortedTimes.length > 0 ? sortedTimes[0] : 0, - max: sortedTimes.length > 0 ? sortedTimes[sortedTimes.length - 1] : 0, - avg: avg.toFixed(2), - p50: percentile(50), - p90: percentile(90), - p95: percentile(95), - p99: percentile(99) - }, - errors: this.errors - }; - } -} - -// 创建测试请求体 -function createRequestBody(config, requestId) { - // OpenAI Chat Completions 格式 - if (config.endpoint.includes('/chat/completions')) { - return JSON.stringify({ - model: config.model, - messages: [ - { - role: 'user', - content: `这是并发测试请求 #${requestId}。请简短回复"收到"。` - } - ], - stream: config.stream, - max_tokens: 50 - }); - } - - // OpenAI Responses 格式 - if (config.endpoint.includes('/responses')) { - return JSON.stringify({ - model: config.model, - input: `这是并发测试请求 #${requestId}。请简短回复"收到"。`, - stream: config.stream - }); - } - - // Claude Messages 格式 - if (config.endpoint.includes('/messages')) { - return JSON.stringify({ - model: config.model, - messages: [ - { - role: 'user', - content: `这是并发测试请求 #${requestId}。请简短回复"收到"。` - } - ], - stream: config.stream, - max_tokens: 50 - }); - } - - // 默认格式 - return JSON.stringify({ - model: config.model, - messages: [ - { - role: 'user', - content: `这是并发测试请求 #${requestId}。请简短回复"收到"。` - } - ], - stream: config.stream, - max_tokens: 50 - }); -} - -// 发送单个请求 -function sendRequest(config, requestId) { - return new Promise((resolve, reject) => { - const startTime = Date.now(); - const url = new URL(config.endpoint, config.url); - const isHttps = url.protocol === 'https:'; - const client = isHttps ? https : http; - - const requestBody = createRequestBody(config, requestId); - - const options = { - hostname: url.hostname, - port: url.port || (isHttps ? 443 : 80), - path: url.pathname + url.search, - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(requestBody), - 'Authorization': `Bearer ${config.apiKey}` - }, - timeout: config.timeout - }; - - const req = client.request(options, (res) => { - let data = ''; - - res.on('data', (chunk) => { - data += chunk; - }); - - res.on('end', () => { - const responseTime = Date.now() - startTime; - - if (res.statusCode >= 200 && res.statusCode < 300) { - resolve({ - success: true, - requestId, - statusCode: res.statusCode, - responseTime, - dataLength: data.length - }); - } else { - reject({ - success: false, - requestId, - statusCode: res.statusCode, - responseTime, - error: `HTTP ${res.statusCode}: ${data.substring(0, 200)}` - }); - } - }); - }); - - req.on('error', (error) => { - const responseTime = Date.now() - startTime; - reject({ - success: false, - requestId, - responseTime, - error: error.code === 'ECONNREFUSED' - ? `连接被拒绝 (${url.hostname}:${url.port || (isHttps ? 443 : 80)})` - : (error.message || error.code || 'Unknown error') - }); - }); - - req.on('timeout', () => { - req.destroy(); - const responseTime = Date.now() - startTime; - reject({ - success: false, - requestId, - responseTime, - error: '请求超时' - }); - }); - - req.write(requestBody); - req.end(); - }); -} - -// 并发控制器 -class ConcurrencyController { - constructor(concurrency) { - this.concurrency = concurrency; - this.running = 0; - this.queue = []; - } - - async run(task) { - return new Promise((resolve, reject) => { - this.queue.push({ task, resolve, reject }); - this.processQueue(); - }); - } - - async processQueue() { - while (this.running < this.concurrency && this.queue.length > 0) { - const { task, resolve, reject } = this.queue.shift(); - this.running++; - - task() - .then(resolve) - .catch(reject) - .finally(() => { - this.running--; - this.processQueue(); - }); - } - } -} - -// 进度条显示 -function showProgress(current, total, stats) { - const percentage = ((current / total) * 100).toFixed(1); - const barLength = 30; - const filled = Math.round((current / total) * barLength); - const bar = '█'.repeat(filled) + '░'.repeat(barLength - filled); - - process.stdout.write(`\r[${bar}] ${percentage}% (${current}/${total}) | 成功: ${stats.completed} | 失败: ${stats.failed}`); -} - -// 主函数 -async function main() { - const config = parseArgs(); - - console.log('╔════════════════════════════════════════════════════════════╗'); - console.log('║ API 并发测试脚本 ║'); - console.log('╠════════════════════════════════════════════════════════════╣'); - console.log(`║ 目标地址: ${config.url.padEnd(47)}║`); - console.log(`║ 测试端点: ${config.endpoint.padEnd(47)}║`); - console.log(`║ 并发数量: ${String(config.concurrency).padEnd(47)}║`); - console.log(`║ 总请求数: ${String(config.totalRequests).padEnd(47)}║`); - console.log(`║ 模型名称: ${config.model.padEnd(47)}║`); - console.log(`║ 流式响应: ${String(config.stream).padEnd(47)}║`); - console.log(`║ 超时时间: ${(config.timeout + 'ms').padEnd(47)}║`); - console.log('╚════════════════════════════════════════════════════════════╝'); - console.log(''); - - const stats = new Statistics(); - const controller = new ConcurrencyController(config.concurrency); - - console.log('开始测试...\n'); - stats.start(); - - const tasks = []; - for (let i = 1; i <= config.totalRequests; i++) { - const requestId = i; - - // 如果设置了 RPM,计算延迟时间 - if (config.rpm > 0) { - const delay = (60000 / config.rpm) * (i - 1); - tasks.push( - new Promise(resolve => setTimeout(resolve, delay)) - .then(() => controller.run(() => sendRequest(config, requestId))) - .then((result) => { - stats.recordSuccess(result.responseTime); - if (config.verbose) { - console.log(`\n[成功] 请求 #${result.requestId} - ${result.responseTime}ms - ${result.dataLength} bytes`); - } - }) - .catch((result) => { - stats.recordFailure(new Error(result.error)); - if (config.verbose) { - console.log(`\n[失败] 请求 #${result.requestId} - ${result.error}`); - } - }) - .finally(() => { - showProgress(stats.completed + stats.failed, config.totalRequests, stats); - }) - ); - } else { - tasks.push( - controller.run(() => sendRequest(config, requestId)) - .then((result) => { - stats.recordSuccess(result.responseTime); - if (config.verbose) { - console.log(`\n[成功] 请求 #${result.requestId} - ${result.responseTime}ms - ${result.dataLength} bytes`); - } - }) - .catch((result) => { - stats.recordFailure(new Error(result.error)); - if (config.verbose) { - console.log(`\n[失败] 请求 #${result.requestId} - ${result.error}`); - } - }) - .finally(() => { - showProgress(stats.completed + stats.failed, config.totalRequests, stats); - }) - ); - } - } - - await Promise.all(tasks); - stats.end(); - - console.log('\n\n'); - console.log('╔════════════════════════════════════════════════════════════╗'); - console.log('║ 测试结果报告 ║'); - console.log('╚════════════════════════════════════════════════════════════╝'); - - const report = stats.getReport(); - - console.log('\n📊 总体统计:'); - console.log(` 总请求数: ${report.totalRequests}`); - console.log(` 成功请求: ${report.completed}`); - console.log(` 失败请求: ${report.failed}`); - console.log(` 成功率: ${report.successRate}`); - console.log(` 总耗时: ${report.totalTime}ms`); - console.log(` 吞吐量: ${report.requestsPerSecond} req/s`); - - console.log('\n⏱️ 响应时间统计 (ms):'); - console.log(` 最小值: ${report.responseTime.min}`); - console.log(` 最大值: ${report.responseTime.max}`); - console.log(` 平均值: ${report.responseTime.avg}`); - console.log(` P50: ${report.responseTime.p50}`); - console.log(` P90: ${report.responseTime.p90}`); - console.log(` P95: ${report.responseTime.p95}`); - console.log(` P99: ${report.responseTime.p99}`); - - if (Object.keys(report.errors).length > 0) { - console.log('\n❌ 错误统计:'); - for (const [error, count] of Object.entries(report.errors)) { - console.log(` ${error}: ${count}次`); - } - } - - console.log('\n════════════════════════════════════════════════════════════════'); - - // 返回退出码 - process.exit(report.failed > 0 ? 1 : 0); -} - -// 运行主函数 -main().catch((error) => { - console.error('测试脚本执行失败:', error); - process.exit(1); -}); diff --git a/tests/cors-config.test.js b/tests/cors-config.test.js deleted file mode 100644 index 5e6e757..0000000 --- a/tests/cors-config.test.js +++ /dev/null @@ -1,91 +0,0 @@ -import { jest } from '@jest/globals'; - -// Mock `open` module before importing anything that uses it -jest.mock('open', () => ({ - default: jest.fn() -})); - -// Now import the module under test -import { createRequestHandler } from '../src/request-handler.js'; - -describe('CORS Configuration', () => { - let mockConfig; - let mockProviderPoolManager; - let handler; - - beforeEach(() => { - mockConfig = { - MODEL_PROVIDER: 'mock-provider', - REQUIRED_API_KEY: 'mock-key', - providerPools: {} - }; - - mockProviderPoolManager = { - getPool: () => null - }; - - handler = createRequestHandler(mockConfig, mockProviderPoolManager); - }); - - test('should set CORS headers for POST requests', async () => { - const headers = {}; - const req = { - url: '/v1/test', - method: 'POST', - headers: { - host: 'localhost:3000' - } - }; - - const res = { - setHeader: (name, value) => { - headers[name] = value; - }, - writeHead: (statusCode, h) => { - if (h) Object.assign(headers, h); - }, - end: () => {} - }; - - try { - await handler(req, res); - } catch (e) { - // Expected to fail/error due to mock environment, but headers should be set - } - - expect(headers['Access-Control-Allow-Origin']).toBe('*'); - expect(headers['Access-Control-Allow-Methods']).toBe('GET, POST, PUT, DELETE, OPTIONS'); - expect(headers['Access-Control-Allow-Headers']).toBe('Content-Type, Authorization, x-goog-api-key, Model-Provider'); - }); - - test('should set CORS headers for OPTIONS requests', async () => { - const headers = {}; - const req = { - url: '/v1/test', - method: 'OPTIONS', - headers: { - host: 'localhost:3000' - } - }; - - const res = { - setHeader: (name, value) => { - headers[name] = value; - }, - writeHead: (statusCode, h) => { - if (h) Object.assign(headers, h); - }, - end: () => {} - }; - - try { - await handler(req, res); - } catch (e) { - // Expected - } - - expect(headers['Access-Control-Allow-Origin']).toBe('*'); - expect(headers['Access-Control-Allow-Methods']).toBe('GET, POST, PUT, DELETE, OPTIONS'); - expect(headers['Access-Control-Allow-Headers']).toBe('Content-Type, Authorization, x-goog-api-key, Model-Provider'); - }); -}); diff --git a/tests/gemini-converter.test.js b/tests/gemini-converter.test.js deleted file mode 100644 index aac0168..0000000 --- a/tests/gemini-converter.test.js +++ /dev/null @@ -1,90 +0,0 @@ - -import GeminiConverter from '../src/converters/strategies/GeminiConverter.js'; -import { jest } from '@jest/globals'; - -describe('GeminiConverter', () => { - let converter; - - beforeEach(() => { - converter = new GeminiConverter(); - }); - - test('toOpenAIStreamChunk adds index to tool_calls', () => { - const geminiChunk = { - candidates: [{ - content: { - parts: [{ - functionCall: { - name: 'test_tool', - args: { param: 'value' } - } - }] - } - }] - }; - - const result = converter.toOpenAIStreamChunk(geminiChunk, 'gemini-pro'); - - expect(result).not.toBeNull(); - expect(result.choices[0].delta).toHaveProperty('tool_calls'); - expect(result.choices[0].delta.tool_calls).toHaveLength(1); - expect(result.choices[0].delta.tool_calls[0]).toHaveProperty('index'); - expect(result.choices[0].delta.tool_calls[0].index).toBe(0); - expect(result.choices[0].delta.tool_calls[0].function.name).toBe('test_tool'); - }); - - test('toOpenAIStreamChunk handles multiple tool_calls with correct indices', () => { - const geminiChunk = { - candidates: [{ - content: { - parts: [ - { - functionCall: { - name: 'tool_one', - args: {} - } - }, - { - functionCall: { - name: 'tool_two', - args: {} - } - } - ] - } - }] - }; - - const result = converter.toOpenAIStreamChunk(geminiChunk, 'gemini-pro'); - - expect(result).not.toBeNull(); - expect(result.choices[0].delta.tool_calls).toHaveLength(2); - - expect(result.choices[0].delta.tool_calls[0].index).toBe(0); - expect(result.choices[0].delta.tool_calls[0].function.name).toBe('tool_one'); - - expect(result.choices[0].delta.tool_calls[1].index).toBe(1); - expect(result.choices[0].delta.tool_calls[1].function.name).toBe('tool_two'); - }); - - test('toOpenAIStreamChunk sets finish_reason to tool_calls when tool calls are present and finishReason is STOP', () => { - const geminiChunk = { - candidates: [{ - finishReason: 'STOP', - content: { - parts: [{ - functionCall: { - name: 'test_tool', - args: {} - } - }] - } - }] - }; - - const result = converter.toOpenAIStreamChunk(geminiChunk, 'gemini-pro'); - - expect(result).not.toBeNull(); - expect(result.choices[0].finish_reason).toBe('tool_calls'); - }); -}); diff --git a/tests/kiro-thinking-parsing.test.js b/tests/kiro-thinking-parsing.test.js deleted file mode 100644 index 8baccd5..0000000 --- a/tests/kiro-thinking-parsing.test.js +++ /dev/null @@ -1,40 +0,0 @@ -import { KiroApiService } from '../src/providers/claude/claude-kiro.js'; - -describe('KiroApiService thinking tag parsing', () => { - let svc; - - beforeEach(() => { - svc = new KiroApiService({}); - }); - - test('splits ... into Claude content blocks', () => { - const blocks = svc._toClaudeContentBlocksFromKiroText('a\n\nhello'); - expect(blocks).toEqual([ - { type: 'thinking', thinking: 'a' }, - { type: 'text', text: 'hello' } - ]); - }); - - test('ignores quoted inside thinking content', () => { - const blocks = svc._toClaudeContentBlocksFromKiroText('about `` tag\n\nhi'); - expect(blocks).toEqual([ - { type: 'thinking', thinking: 'about `` tag' }, - { type: 'text', text: 'hi' } - ]); - }); - - test('does not treat without delimiter as a real end tag', () => { - const blocks = svc._toClaudeContentBlocksFromKiroText('ahello'); - expect(blocks).toEqual([ - { type: 'thinking', thinking: 'ahello' } - ]); - }); - - test('treats at buffer end as an end tag', () => { - const blocks = svc._toClaudeContentBlocksFromKiroText('a'); - expect(blocks).toEqual([ - { type: 'thinking', thinking: 'a' } - ]); - }); -}); - diff --git a/tests/openai-converter-thinking.test.js b/tests/openai-converter-thinking.test.js deleted file mode 100644 index 5ab6251..0000000 --- a/tests/openai-converter-thinking.test.js +++ /dev/null @@ -1,55 +0,0 @@ -import { OpenAIConverter } from '../src/converters/strategies/OpenAIConverter.js'; - -describe('OpenAIConverter thinking passthrough', () => { - let converter; - - beforeEach(() => { - converter = new OpenAIConverter(); - }); - - test('toClaudeRequest maps extra_body.anthropic.thinking enabled', () => { - const openaiRequest = { - model: 'claude-sonnet-4-5', - messages: [{ role: 'user', content: 'hi' }], - extra_body: { - anthropic: { - thinking: { type: 'enabled', budget_tokens: '10000' } - } - } - }; - - const claudeRequest = converter.toClaudeRequest(openaiRequest); - expect(claudeRequest.thinking).toEqual({ type: 'enabled', budget_tokens: 10000 }); - }); - - test('toClaudeRequest maps extra_body.anthropic.thinking adaptive', () => { - const openaiRequest = { - model: 'claude-sonnet-4-5', - messages: [{ role: 'user', content: 'hi' }], - extra_body: { - anthropic: { - thinking: { type: 'adaptive', effort: 'Medium' } - } - } - }; - - const claudeRequest = converter.toClaudeRequest(openaiRequest); - expect(claudeRequest.thinking).toEqual({ type: 'adaptive', effort: 'medium' }); - }); - - test('toClaudeRequest ignores invalid thinking objects', () => { - const openaiRequest = { - model: 'claude-sonnet-4-5', - messages: [{ role: 'user', content: 'hi' }], - extra_body: { - anthropic: { - thinking: 'enabled' - } - } - }; - - const claudeRequest = converter.toClaudeRequest(openaiRequest); - expect(claudeRequest.thinking).toBeUndefined(); - }); -}); - diff --git a/tests/openai-responses-converter-thinking.test.js b/tests/openai-responses-converter-thinking.test.js deleted file mode 100644 index 70290e0..0000000 --- a/tests/openai-responses-converter-thinking.test.js +++ /dev/null @@ -1,27 +0,0 @@ -import { OpenAIResponsesConverter } from '../src/converters/strategies/OpenAIResponsesConverter.js'; - -describe('OpenAIResponsesConverter reasoning -> thinking mapping', () => { - let converter; - - beforeEach(() => { - converter = new OpenAIResponsesConverter(); - }); - - test.each([ - ['low', 2048], - ['medium', 8192], - ['high', 20000], - ['unknown', 20000], - ])('toClaudeRequest maps reasoning.effort=%s to budget_tokens=%i', (effort, budgetTokens) => { - const responsesRequest = { - model: 'claude-sonnet-4-5', - max_output_tokens: 64, - reasoning: { effort }, - input: [{ role: 'user', content: 'hi' }] - }; - - const claudeRequest = converter.toClaudeRequest(responsesRequest); - expect(claudeRequest.thinking).toEqual({ type: 'enabled', budget_tokens: budgetTokens }); - }); -}); - diff --git a/tests/openai-responses-converter.test.js b/tests/openai-responses-converter.test.js deleted file mode 100644 index 9aada0e..0000000 --- a/tests/openai-responses-converter.test.js +++ /dev/null @@ -1,80 +0,0 @@ -import { OpenAIResponsesConverter } from '../src/converters/strategies/OpenAIResponsesConverter.js'; - -describe('OpenAIResponsesConverter', () => { - let converter; - - beforeEach(() => { - converter = new OpenAIResponsesConverter(); - }); - - test('toGeminiRequest should handle input without explicit type as message', () => { - const responsesRequest = { - "model": "gemini-2.0-flash-exp", - "input": [ - { - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Hello, world!" - } - ] - } - ], - "max_output_tokens": 200 - }; - - const geminiRequest = converter.toGeminiRequest(responsesRequest); - - expect(geminiRequest.contents).toBeDefined(); - expect(geminiRequest.contents.length).toBe(1); - expect(geminiRequest.contents[0].role).toBe('user'); - expect(geminiRequest.contents[0].parts[0].text).toBe('Hello, world!'); - }); - - test('toGeminiRequest should handle string content in input', () => { - const responsesRequest = { - "model": "gemini-2.0-flash-exp", - "input": [ - { - "role": "user", - "content": "Hello, world!" - } - ], - "max_output_tokens": 200 - }; - - const geminiRequest = converter.toGeminiRequest(responsesRequest); - - expect(geminiRequest.contents).toBeDefined(); - expect(geminiRequest.contents.length).toBe(1); - expect(geminiRequest.contents[0].role).toBe('user'); - expect(geminiRequest.contents[0].parts[0].text).toBe('Hello, world!'); - }); - - test('toGeminiRequest should handle input with explicit message type', () => { - const responsesRequest = { - "model": "gemini-2.0-flash-exp", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Hello, world!" - } - ] - } - ], - "max_output_tokens": 200 - }; - - const geminiRequest = converter.toGeminiRequest(responsesRequest); - - expect(geminiRequest.contents).toBeDefined(); - expect(geminiRequest.contents.length).toBe(1); - expect(geminiRequest.contents[0].role).toBe('user'); - expect(geminiRequest.contents[0].parts[0].text).toBe('Hello, world!'); - }); -});