From 68719879c5b5f2ae47b9ea69932b6737feb772ea Mon Sep 17 00:00:00 2001 From: hex2077 Date: Thu, 26 Feb 2026 18:19:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(architecture):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E9=80=82=E9=85=8D=E5=99=A8=E6=B3=A8=E5=86=8C=E6=9C=BA=E5=88=B6?= =?UTF-8?q?=E5=B9=B6=E5=BC=95=E5=85=A5=E5=B9=B6=E5=8F=91=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 建立可扩展的提供商适配器注册表,实现动态服务发现与插槽管理: 架构改进: - 采用 Map 注册表替代 switch-case 硬编码,支持热插拔适配器 - 实现 acquireSlot/releaseSlot 机制,精确追踪活跃请求与等待队列 - 新增节点评分算法,综合考量并发数、队列长度、健康状态 核心能力: - 支持并发限制与队列等待,避免单节点过载 (concurrencyLimit/queueLimit) - 实现 Fallback 链式调用,429 错误自动切换备用凭证 - 添加请求级 IP 追踪,日志格式优化为 `clientIp:requestId` 配套更新: - 管理界面新增并发/队列配置字段与 Grok 逆向提供商选项 - 用量查询服务扩展 Grok 支持,同步剩余查询次数 (固定总量 80) - 新增并发测试脚本 (tests/concurrent-test.js),支持自定义并发数与 RPM 限制 配置项: - GROK_COOKIE_TOKEN, GROK_CF_CLEARANCE, GROK_USER_AGENT, GROK_BASE_URL --- configs/config.json.example | 4 + src/converters/register-converters.js | 2 + src/converters/strategies/GrokConverter.js | 661 +++++++++++++++++++ src/converters/strategies/OpenAIConverter.js | 77 ++- src/handlers/request-handler.js | 28 +- src/providers/adapter.js | 126 +++- src/providers/grok/grok-core.js | 634 ++++++++++++++++++ src/providers/grok/grok-strategy.js | 56 ++ src/providers/provider-models.js | 19 +- src/providers/provider-pool-manager.js | 241 ++++++- src/services/service-manager.js | 26 +- src/services/ui-manager.js | 5 + src/services/usage-service.js | 100 ++- src/ui-modules/provider-api.js | 11 + src/ui-modules/usage-api.js | 13 +- src/utils/common.js | 61 +- src/utils/provider-strategies.js | 3 + src/utils/provider-utils.js | 11 + static/app/i18n.js | 22 + static/app/modal.js | 35 +- static/app/provider-manager.js | 32 +- static/app/routing-examples.js | 32 + static/app/usage-manager.js | 6 +- static/app/utils.js | 63 +- static/components/section-config.html | 24 +- static/components/section-dashboard.html | 53 ++ tests/concurrent-test.js | 454 +++++++++++++ 27 files changed, 2666 insertions(+), 133 deletions(-) create mode 100644 src/converters/strategies/GrokConverter.js create mode 100644 src/providers/grok/grok-core.js create mode 100644 src/providers/grok/grok-strategy.js create mode 100644 tests/concurrent-test.js diff --git a/configs/config.json.example b/configs/config.json.example index 76f9e66..5543780 100644 --- a/configs/config.json.example +++ b/configs/config.json.example @@ -13,6 +13,10 @@ "CRON_REFRESH_TOKEN": false, "PROVIDER_POOLS_FILE_PATH": "configs/provider_pools.json", "MAX_ERROR_COUNT": 3, + "GROK_COOKIE_TOKEN": "your-sso-cookie-token", + "GROK_CF_CLEARANCE": "your-cf-clearance-cookie", + "GROK_USER_AGENT": "Mozilla/5.0 ...", + "GROK_BASE_URL": "https://grok.com", "providerFallbackChain": { "gemini-cli-oauth": ["gemini-antigravity"], "gemini-antigravity": ["gemini-cli-oauth"], diff --git a/src/converters/register-converters.js b/src/converters/register-converters.js index 209e3ba..bf1d6db 100644 --- a/src/converters/register-converters.js +++ b/src/converters/register-converters.js @@ -11,6 +11,7 @@ import { ClaudeConverter } from './strategies/ClaudeConverter.js'; import { GeminiConverter } from './strategies/GeminiConverter.js'; import { OllamaConverter } from './strategies/OllamaConverter.js'; import { CodexConverter } from './strategies/CodexConverter.js'; +import { GrokConverter } from './strategies/GrokConverter.js'; /** * 注册所有转换器到工厂 @@ -23,6 +24,7 @@ export function registerAllConverters() { ConverterFactory.registerConverter(MODEL_PROTOCOL_PREFIX.GEMINI, GeminiConverter); ConverterFactory.registerConverter(MODEL_PROTOCOL_PREFIX.OLLAMA, OllamaConverter); ConverterFactory.registerConverter(MODEL_PROTOCOL_PREFIX.CODEX, CodexConverter); + ConverterFactory.registerConverter(MODEL_PROTOCOL_PREFIX.GROK, GrokConverter); } // 自动注册所有转换器 diff --git a/src/converters/strategies/GrokConverter.js b/src/converters/strategies/GrokConverter.js new file mode 100644 index 0000000..8afa249 --- /dev/null +++ b/src/converters/strategies/GrokConverter.js @@ -0,0 +1,661 @@ +/** + * Grok转换器 + * 处理Grok协议与其他协议之间的转换 + */ + +import { v4 as uuidv4 } from 'uuid'; +import logger from '../../utils/logger.js'; +import { BaseConverter } from '../BaseConverter.js'; +import { MODEL_PROTOCOL_PREFIX } from '../../utils/common.js'; + +/** + * Grok转换器类 + * 实现Grok协议到其他协议的转换 + */ +export class GrokConverter extends BaseConverter { + constructor() { + super('grok'); + // 用于跟踪每个请求的状态 + this.requestStates = new Map(); + } + + /** + * 获取或初始化请求状态 + */ + _getState(requestId) { + if (!this.requestStates.has(requestId)) { + this.requestStates.set(requestId, { + think_opened: false, + image_think_active: false, + video_think_active: false, + role_sent: false, + tool_buffer: "", + last_is_thinking: false, + fingerprint: "", + content_buffer: "", // 用于缓存内容以解析工具调用 + has_tool_call: false, + rollout_id: "", + in_tool_call: false // 是否处于 块内 + }); + } + return this.requestStates.get(requestId); + } + + /** + * 构建工具系统提示词 (build_tool_prompt) + */ + buildToolPrompt(tools, toolChoice = "auto", parallelToolCalls = true) { + if (!tools || tools.length === 0 || toolChoice === "none") { + return ""; + } + + const lines = [ + "# Available Tools", + "", + "You have access to the following tools. To call a tool, output a block with a JSON object containing \"name\" and \"arguments\".", + "", + "Format:", + "", + '{"name": "function_name", "arguments": {"param": "value"}}', + "", + "", + ]; + + if (parallelToolCalls) { + lines.push("You may make multiple tool calls in a single response by using multiple blocks."); + lines.push(""); + } + + lines.push("## Tool Definitions"); + lines.push(""); + for (const tool of tools) { + if (tool.type !== "function") continue; + const func = tool.function || {}; + lines.push(`### ${func.name}`); + if (func.description) lines.push(func.description); + if (func.parameters) lines.push(`Parameters: ${JSON.stringify(func.parameters)}`); + lines.push(""); + } + + if (toolChoice === "required") { + lines.push("IMPORTANT: You MUST call at least one tool in your response. Do not respond with only text."); + } else if (typeof toolChoice === 'object' && toolChoice.function?.name) { + lines.push(`IMPORTANT: You MUST call the tool "${toolChoice.function.name}" in your response.`); + } else { + lines.push("Decide whether to call a tool based on the user's request. If you don't need a tool, respond normally with text only."); + } + + lines.push(""); + lines.push("When you call a tool, you may include text before or after the blocks, but the tool call blocks must be valid JSON."); + + return lines.join("\n"); + } + + /** + * 格式化工具历史 (format_tool_history) + */ + formatToolHistory(messages) { + const result = []; + for (const msg of messages) { + const role = msg.role; + const content = msg.content; + const toolCalls = msg.tool_calls; + + if (role === "assistant" && toolCalls && toolCalls.length > 0) { + const parts = []; + if (content) parts.push(typeof content === 'string' ? content : JSON.stringify(content)); + for (const tc of toolCalls) { + const func = tc.function || {}; + parts.push(`{"name":"${func.name}","arguments":${func.arguments || "{}"}}`); + } + result.push({ role: "assistant", content: parts.join("\n") }); + } else if (role === "tool") { + const toolName = msg.name || "unknown"; + const callId = msg.tool_call_id || ""; + const contentStr = typeof content === 'string' ? content : JSON.stringify(content); + result.push({ + role: "user", + content: `tool (${toolName}, ${callId}): ${contentStr}` + }); + } else { + result.push(msg); + } + } + return result; + } + + /** + * 解析工具调用 (parse_tool_calls) + */ + parseToolCalls(content) { + if (!content) return { text: content, toolCalls: null }; + + const toolCallRegex = /\s*(.*?)\s*<\/tool_call>/gs; + const matches = [...content.matchAll(toolCallRegex)]; + + if (matches.length === 0) return { text: content, toolCalls: null }; + + const toolCalls = []; + for (const match of matches) { + try { + const parsed = JSON.parse(match[1].trim()); + if (parsed.name) { + let args = parsed.arguments || {}; + const argumentsStr = typeof args === 'string' ? args : JSON.stringify(args); + + toolCalls.push({ + id: `call_${uuidv4().replace(/-/g, '').slice(0, 24)}`, + type: "function", + function: { + name: parsed.name, + arguments: argumentsStr + } + }); + } + } catch (e) { + // 忽略解析失败的块 + } + } + + if (toolCalls.length === 0) return { text: content, toolCalls: null }; + + // 提取文本内容 + let text = content; + for (const match of matches) { + text = text.replace(match[0], ""); + } + text = text.trim() || null; + + return { text, toolCalls }; + } + + /** + * 转换请求 + */ + convertRequest(data, targetProtocol) { + return data; + } + + /** + * 转换响应 + */ + convertResponse(data, targetProtocol, model) { + switch (targetProtocol) { + case MODEL_PROTOCOL_PREFIX.OPENAI: + return this.toOpenAIResponse(data, model); + default: + return data; + } + } + + /** + * 转换流式响应块 + */ + convertStreamChunk(chunk, targetProtocol, model) { + switch (targetProtocol) { + case MODEL_PROTOCOL_PREFIX.OPENAI: + return this.toOpenAIStreamChunk(chunk, model); + default: + return chunk; + } + } + + /** + * 转换模型列表 + */ + convertModelList(data, targetProtocol) { + return data; + } + + /** + * 构建工具覆盖配置 (build_tool_overrides) + */ + buildToolOverrides(tools) { + if (!tools || !Array.isArray(tools)) { + return {}; + } + + const toolOverrides = {}; + for (const tool of tools) { + if (tool.type !== "function") continue; + const func = tool.function || {}; + const name = func.name; + if (!name) continue; + + toolOverrides[name] = { + "enabled": true, + "description": func.description || "", + "parameters": func.parameters || {} + }; + } + + return toolOverrides; + } + + /** + * 递归收集响应中的图片 URL + */ + _collectImages(obj) { + const urls = []; + const seen = new Set(); + + const add = (url) => { + if (!url || seen.has(url)) return; + seen.add(url); + urls.push(url); + }; + + const walk = (value) => { + if (value && typeof value === 'object') { + if (Array.isArray(value)) { + value.forEach(walk); + } else { + for (const [key, item] of Object.entries(value)) { + if (key === "generatedImageUrls" || key === "imageUrls" || key === "imageURLs") { + if (Array.isArray(item)) { + item.forEach(url => typeof url === 'string' && add(url)); + } else if (typeof item === 'string') { + add(item); + } + continue; + } + walk(item); + } + } + } + }; + + walk(obj); + return urls; + } + + /** + * 渲染图片为 Markdown + */ + _renderImage(url, imageId = "image") { + let finalUrl = url; + if (!url.startsWith('http')) { + finalUrl = `https://assets.grok.com${url.startsWith('/') ? '' : '/'}${url}`; + } + return `![${imageId}](${finalUrl})`; + } + + /** + * 渲染视频为 Markdown/HTML (render_video) + */ + _renderVideo(videoUrl, thumbnailImageUrl = "") { + let finalVideoUrl = videoUrl; + if (!videoUrl.startsWith('http')) { + finalVideoUrl = `https://assets.grok.com${videoUrl.startsWith('/') ? '' : '/'}${videoUrl}`; + } + + let finalThumbUrl = thumbnailImageUrl; + if (thumbnailImageUrl && !thumbnailImageUrl.startsWith('http')) { + finalThumbUrl = `https://assets.grok.com${thumbnailImageUrl.startsWith('/') ? '' : '/'}${thumbnailImageUrl}`; + } + + return `\n[![video](${finalThumbUrl || 'https://assets.grok.com/favicon.ico'})](${finalVideoUrl})\n[Play Video](${finalVideoUrl})\n`; + } + + /** + * 提取工具卡片文本 (extract_tool_text) + */ + _extractToolText(raw, rolloutId = "") { + if (!raw) return ""; + + const nameMatch = raw.match(/(.*?)<\/xai:tool_name>/s); + const argsMatch = raw.match(/(.*?)<\/xai:tool_args>/s); + + let name = nameMatch ? nameMatch[1].replace(//gs, "$1").trim() : ""; + let args = argsMatch ? argsMatch[1].replace(//gs, "$1").trim() : ""; + + let payload = null; + if (args) { + try { + payload = JSON.parse(args); + } catch (e) { + payload = null; + } + } + + let label = name; + let text = args; + const prefix = rolloutId ? `[${rolloutId}]` : ""; + + if (name === "web_search") { + label = `${prefix}[WebSearch]`; + if (payload && typeof payload === 'object') { + text = payload.query || payload.q || ""; + } + } else if (name === "search_images") { + label = `${prefix}[SearchImage]`; + if (payload && typeof payload === 'object') { + text = payload.image_description || payload.description || payload.query || ""; + } + } else if (name === "chatroom_send") { + label = `${prefix}[AgentThink]`; + if (payload && typeof payload === 'object') { + text = payload.message || ""; + } + } + + if (label && text) return `${label} ${text}`.trim(); + if (label) return label; + if (text) return text; + return raw.replace(/<[^>]+>/g, "").trim(); + } + + /** + * 过滤特殊标签 + */ + _filterToken(token, requestId = "") { + if (!token) return token; + + let filtered = token; + + // 移除 xai:tool_usage_card 及其内容,不显示工具调用的过程输出 + filtered = filtered.replace(/]*>.*?<\/xai:tool_usage_card>/gs, ""); + filtered = filtered.replace(/]*\/>/gs, ""); + + // 移除其他内部标签 + const tagsToFilter = ["rolloutId", "responseId", "isThinking"]; + for (const tag of tagsToFilter) { + const pattern = new RegExp(`<${tag}[^>]*>.*?<\\/${tag}>|<${tag}[^>]*\\/>`, 'gs'); + filtered = filtered.replace(pattern, ""); + } + + return filtered; + } + + /** + * Grok响应 -> OpenAI响应 + */ + toOpenAIResponse(grokResponse, model) { + if (!grokResponse) return null; + + const responseId = grokResponse.responseId || `chatcmpl-${uuidv4()}`; + let content = grokResponse.message || ""; + const modelHash = grokResponse.llmInfo?.modelHash || ""; + + // 过滤内容 + content = this._filterToken(content, responseId); + + // 收集图片并追加 + const imageUrls = this._collectImages(grokResponse); + if (imageUrls.length > 0) { + content += "\n"; + for (const url of imageUrls) { + content += this._renderImage(url) + "\n"; + } + } + + // 处理视频 (非流式模式) + if (grokResponse.finalVideoUrl) { + content += this._renderVideo(grokResponse.finalVideoUrl, grokResponse.finalThumbnailUrl); + } + + // 解析工具调用 + const { text, toolCalls } = this.parseToolCalls(content); + + const result = { + id: responseId, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: model, + system_fingerprint: modelHash, + choices: [{ + index: 0, + message: { + role: "assistant", + content: text, + }, + finish_reason: toolCalls ? "tool_calls" : "stop", + }], + usage: { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + }, + }; + + if (toolCalls) { + result.choices[0].message.tool_calls = toolCalls; + } + + return result; + } + + _formatResponseId(id) { + if (!id) return `chatcmpl-${uuidv4()}`; + if (id.startsWith('chatcmpl-')) return id; + return `chatcmpl-${id}`; + } + + /** + * Grok流式响应块 -> OpenAI流式响应块 + */ + toOpenAIStreamChunk(grokChunk, model) { + if (!grokChunk || !grokChunk.result || !grokChunk.result.response) { + return null; + } + + const resp = grokChunk.result.response; + const rawResponseId = resp.responseId || ""; + const responseId = this._formatResponseId(rawResponseId); + const state = this._getState(responseId); + + if (resp.llmInfo?.modelHash && !state.fingerprint) { + state.fingerprint = resp.llmInfo.modelHash; + } + if (resp.rolloutId) { + state.rollout_id = String(resp.rolloutId); + } + + const chunks = []; + + // 0. 发送角色信息(仅第一次) + if (!state.role_sent) { + chunks.push({ + id: responseId, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: model, + system_fingerprint: state.fingerprint, + choices: [{ + index: 0, + delta: { role: "assistant", content: "" }, + finish_reason: null + }] + }); + state.role_sent = true; + } + + // 处理结束标志 + if (resp.isDone) { + let finalContent = ""; + /* + if (state.think_opened) { + finalContent += "\n\n"; + state.think_opened = false; + } + */ + + // 处理 buffer 中的工具调用 + const { text, toolCalls } = this.parseToolCalls(state.content_buffer); + + if (toolCalls) { + chunks.push({ + id: responseId, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: model, + system_fingerprint: state.fingerprint, + choices: [{ + index: 0, + delta: { + content: ((/* finalContent + */ "") + (text || "")).trim() || null, + tool_calls: toolCalls + }, + finish_reason: "tool_calls" + }] + }); + } else { + chunks.push({ + id: responseId, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: model, + system_fingerprint: state.fingerprint, + choices: [{ + index: 0, + delta: { content: /* finalContent || */ null }, + finish_reason: "stop" + }] + }); + } + + // 清理状态 + this.requestStates.delete(responseId); + return chunks; + } + + let deltaContent = ""; + let deltaReasoning = ""; + + // 1. 处理图片生成进度 + if (resp.streamingImageGenerationResponse) { + const img = resp.streamingImageGenerationResponse; + state.image_think_active = true; + /* + if (!state.think_opened) { + deltaReasoning += "\n"; + state.think_opened = true; + } + */ + const idx = (img.imageIndex || 0) + 1; + const progress = img.progress || 0; + deltaReasoning += `正在生成第${idx}张图片中,当前进度${progress}%\n`; + } + + // 2. 处理视频生成进度 (VideoStreamProcessor) + if (resp.streamingVideoGenerationResponse) { + const vid = resp.streamingVideoGenerationResponse; + state.video_think_active = true; + /* + if (!state.think_opened) { + deltaReasoning += "\n"; + state.think_opened = true; + } + */ + const progress = vid.progress || 0; + deltaReasoning += `正在生成视频中,当前进度${progress}%\n`; + + if (progress === 100 && vid.videoUrl) { + /* + if (state.think_opened) { + deltaContent += "\n\n"; + state.think_opened = false; + } + */ + state.video_think_active = false; + deltaContent += this._renderVideo(vid.videoUrl, vid.thumbnailImageUrl); + } + } + + // 3. 处理模型响应(通常包含完整消息或图片) + if (resp.modelResponse) { + const mr = resp.modelResponse; + /* + if ((state.image_think_active || state.video_think_active) && state.think_opened) { + deltaContent += "\n\n"; + state.think_opened = false; + } + */ + state.image_think_active = false; + state.video_think_active = false; + + const imageUrls = this._collectImages(mr); + for (const url of imageUrls) { + deltaContent += this._renderImage(url) + "\n"; + } + + if (mr.metadata?.llm_info?.modelHash) { + state.fingerprint = mr.metadata.llm_info.modelHash; + } + } + + // 4. 处理卡片附件 + if (resp.cardAttachment) { + const card = resp.cardAttachment; + if (card.jsonData) { + try { + const cardData = JSON.parse(card.jsonData); + const original = cardData.image?.original; + const title = cardData.image?.title || "image"; + if (original) { + deltaContent += `![${title}](${original})\n`; + } + } catch (e) { + // 忽略 JSON 解析错误 + } + } + } + + // 5. 处理普通 Token 和 思考状态 + if (resp.token !== undefined && resp.token !== null) { + const token = resp.token; + const filtered = this._filterToken(token, responseId); + const isThinking = !!resp.isThinking; + const inThink = isThinking || state.image_think_active || state.video_think_active; + + if (inThink) { + deltaReasoning += filtered; + } else { + // 工具调用抑制逻辑:不向客户端输出 块及其内容 + let outputToken = filtered; + + // 简单的状态切换检测 + if (outputToken.includes('')) { + state.in_tool_call = true; + state.has_tool_call = true; + // 移除标签之后的部分(如果有) + outputToken = outputToken.split('')[0]; + } else if (state.in_tool_call && outputToken.includes('')) { + state.in_tool_call = false; + // 只保留标签之后的部分 + outputToken = outputToken.split('')[1] || ""; + } else if (state.in_tool_call) { + // 处于块内,完全抑制 + outputToken = ""; + } + + deltaContent += outputToken; + + // 将内容加入 buffer 用于最终解析工具调用 + state.content_buffer += filtered; + } + state.last_is_thinking = isThinking; + } + + if (deltaContent || deltaReasoning) { + const delta = {}; + if (deltaContent) delta.content = deltaContent; + if (deltaReasoning) delta.reasoning_content = deltaReasoning; + + chunks.push({ + id: responseId, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: model, + system_fingerprint: state.fingerprint, + choices: [{ + index: 0, + delta: delta, + finish_reason: null + }] + }); + } + + return chunks.length > 0 ? chunks : null; + } +} diff --git a/src/converters/strategies/OpenAIConverter.js b/src/converters/strategies/OpenAIConverter.js index 33bf82d..c337896 100644 --- a/src/converters/strategies/OpenAIConverter.js +++ b/src/converters/strategies/OpenAIConverter.js @@ -60,6 +60,8 @@ export class OpenAIConverter extends BaseConverter { return this.toOpenAIResponsesRequest(data); case MODEL_PROTOCOL_PREFIX.CODEX: return this.toCodexRequest(data); + case MODEL_PROTOCOL_PREFIX.GROK: + return this.toGrokRequest(data); default: throw new Error(`Unsupported target protocol: ${targetProtocol}`); } @@ -78,6 +80,8 @@ export class OpenAIConverter extends BaseConverter { return this.toGeminiResponse(data, model); case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: return this.toOpenAIResponsesResponse(data, model); + case MODEL_PROTOCOL_PREFIX.GROK: + return this.toGrokResponse(data, model); default: throw new Error(`Unsupported target protocol: ${targetProtocol}`); } @@ -94,6 +98,8 @@ export class OpenAIConverter extends BaseConverter { return this.toGeminiStreamChunk(chunk, model); case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: return this.toOpenAIResponsesStreamChunk(chunk, model); + case MODEL_PROTOCOL_PREFIX.GROK: + return this.toGrokStreamChunk(chunk, model); default: throw new Error(`Unsupported target protocol: ${targetProtocol}`); } @@ -1328,42 +1334,51 @@ export class OpenAIConverter extends BaseConverter { } }] }; + } - // 添加finish_reason(如果存在) - if (choice.finish_reason) { - const finishReasonMap = { - 'stop': 'STOP', - 'length': 'MAX_TOKENS', - 'tool_calls': 'STOP', - 'content_filter': 'SAFETY' - }; - result.candidates[0].finishReason = finishReasonMap[choice.finish_reason] || 'STOP'; - } + // ========================================================================= + // OpenAI -> Grok 转换 + // ========================================================================= - // 添加usage信息(如果存在) - if (openaiChunk.usage) { - result.usageMetadata = { - promptTokenCount: openaiChunk.usage.prompt_tokens || 0, - candidatesTokenCount: openaiChunk.usage.completion_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 - }; - } - - return result; + /** + * OpenAI请求 -> Grok请求 + */ + toGrokRequest(openaiRequest) { + // 我们需要 GrokConverter 来处理复杂的仿真逻辑 + const { ConverterFactory } = (import.meta.url ? { ConverterFactory: null } : { ConverterFactory: null }); // 这是一个占位,实际会从全局获取 + + // 直接返回结构化数据,由 GrokApiService.buildPayload 最终处理 + // 这样可以保留原始的 messages, tools, tool_choice 以进行高质量仿真 + return { + ...openaiRequest, + // 保持原始结构以便 GrokApiService 处理 + _isConverted: true + }; } /** - * OpenAI请求 -> Codex请求(委托给 CodexConverter) + * OpenAI响应 -> Grok响应(通常不使用) + */ + toGrokResponse(openaiResponse, model) { + return openaiResponse; + } + + /** + * OpenAI流式响应 -> Grok流式响应(通常不使用) + */ + toGrokStreamChunk(openaiChunk, model) { + return openaiChunk; + } + + /** + * OpenAI模型列表 -> Grok模型列表(通常不使用) + */ + toGrokModelList(openaiModels) { + return openaiModels; + } + + /** + * 将 OpenAI 模型列表转换为 Gemini 模型列表 */ toCodexRequest(openaiRequest) { return this.codexConverter.toOpenAIRequestToCodexRequest(openaiRequest); diff --git a/src/handlers/request-handler.js b/src/handlers/request-handler.js index 7689176..9acbf3d 100644 --- a/src/handlers/request-handler.js +++ b/src/handlers/request-handler.js @@ -1,11 +1,12 @@ import deepmerge from 'deepmerge'; import logger from '../utils/logger.js'; -import { handleError } from '../utils/common.js'; +import { handleError, getClientIp } from '../utils/common.js'; import { handleUIApiRequests, serveStaticFiles } from '../services/ui-manager.js'; import { handleAPIRequests } from '../services/api-manager.js'; import { getApiService, getProviderStatus } from '../services/service-manager.js'; import { getProviderPoolManager } from '../services/service-manager.js'; import { MODEL_PROVIDER } from '../utils/common.js'; +import { getRegisteredProviders } from '../providers/adapter.js'; import { PROMPT_LOG_FILENAME } from '../core/config-manager.js'; import { handleOllamaRequest, handleOllamaShow } from './ollama-handler.js'; import { getPluginManager } from '../core/plugin-manager.js'; @@ -17,6 +18,7 @@ import { randomUUID } from 'crypto'; function generateRequestId() { return randomUUID().slice(0, 8); } + /** * Parse request body as JSON */ @@ -45,7 +47,8 @@ function parseRequestBody(req) { export function createRequestHandler(config, providerPoolManager) { return async function requestHandler(req, res) { // Generate unique request ID and set it in logger context - const requestId = generateRequestId(); + const clientIp = getClientIp(req); + const requestId = `${clientIp}:${generateRequestId()}`; logger.setRequestContext(requestId); // Deep copy the config for each request to allow dynamic modification @@ -141,8 +144,16 @@ export function createRequestHandler(config, providerPoolManager) { // Allow overriding MODEL_PROVIDER via request header const modelProviderHeader = req.headers['model-provider']; if (modelProviderHeader) { - currentConfig.MODEL_PROVIDER = modelProviderHeader; - logger.info(`[Config] MODEL_PROVIDER overridden by header to: ${currentConfig.MODEL_PROVIDER}`); + const registeredProviders = getRegisteredProviders(); + if (registeredProviders.includes(modelProviderHeader)) { + currentConfig.MODEL_PROVIDER = modelProviderHeader; + logger.info(`[Config] MODEL_PROVIDER overridden by header to: ${currentConfig.MODEL_PROVIDER}`); + } else { + logger.warn(`[Config] Provider ${modelProviderHeader} in header is not available.`); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: `Provider ${modelProviderHeader} is not available.` } })); + return; + } } // Check if the first path segment matches a MODEL_PROVIDER and switch if it does @@ -152,13 +163,20 @@ export function createRequestHandler(config, providerPoolManager) { if (pathSegments.length > 0 && !isOllamaPath) { const firstSegment = pathSegments[0]; - const isValidProvider = Object.values(MODEL_PROVIDER).includes(firstSegment); + const registeredProviders = getRegisteredProviders(); + const isValidProvider = registeredProviders.includes(firstSegment); if (firstSegment && isValidProvider) { currentConfig.MODEL_PROVIDER = firstSegment; logger.info(`[Config] MODEL_PROVIDER overridden by path segment to: ${currentConfig.MODEL_PROVIDER}`); pathSegments.shift(); path = '/' + pathSegments.join('/'); requestUrl.pathname = path; + } else if (firstSegment && Object.values(MODEL_PROVIDER).includes(firstSegment)) { + // 如果在 MODEL_PROVIDER 中但没注册适配器,拦截并报错 + logger.warn(`[Config] Provider ${firstSegment} is recognized but no adapter is registered.`); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: `Provider ${firstSegment} is not available.` } })); + return; } else if (firstSegment && !isValidProvider) { logger.info(`[Config] Ignoring invalid MODEL_PROVIDER in path segment: ${firstSegment}`); } diff --git a/src/providers/adapter.js b/src/providers/adapter.js index 749d3e5..036f034 100644 --- a/src/providers/adapter.js +++ b/src/providers/adapter.js @@ -8,9 +8,31 @@ import { QwenApiService } from './openai/qwen-core.js'; import { IFlowApiService } from './openai/iflow-core.js'; import { CodexApiService } from './openai/codex-core.js'; import { ForwardApiService } from './forward/forward-core.js'; +import { GrokApiService } from './grok/grok-core.js'; import { MODEL_PROVIDER } from '../utils/common.js'; import logger from '../utils/logger.js'; +// 适配器注册表 +const adapterRegistry = new Map(); + +/** + * 注册服务适配器 + * @param {string} provider - 提供商名称 (来自 MODEL_PROVIDER) + * @param {typeof ApiServiceAdapter} adapterClass - 适配器类 + */ +export function registerAdapter(provider, adapterClass) { + logger.info(`[Adapter] Registering adapter for provider: ${provider}`); + adapterRegistry.set(provider, adapterClass); +} + +/** + * 获取所有已注册的提供商 + * @returns {string[]} 已注册的提供商名称列表 + */ +export function getRegisteredProviders() { + return Array.from(adapterRegistry.keys()); +} + // 定义AI服务适配器接口 // 所有的服务适配器都应该实现这些方法 export class ApiServiceAdapter { @@ -614,6 +636,71 @@ export class ForwardApiServiceAdapter extends ApiServiceAdapter { } } +// Grok API 服务适配器 +export class GrokApiServiceAdapter extends ApiServiceAdapter { + constructor(config) { + super(); + this.grokApiService = new GrokApiService(config); + } + + async generateContent(model, requestBody) { + if (!this.grokApiService.isInitialized) { + await this.grokApiService.initialize(); + } + return this.grokApiService.generateContent(model, requestBody); + } + + async *generateContentStream(model, requestBody) { + if (!this.grokApiService.isInitialized) { + await this.grokApiService.initialize(); + } + yield* this.grokApiService.generateContentStream(model, requestBody); + } + + async listModels() { + if (!this.grokApiService.isInitialized) { + await this.grokApiService.initialize(); + } + return this.grokApiService.listModels(); + } + + async refreshToken() { + return this.grokApiService.refreshToken(); + } + + async forceRefreshToken() { + return this.grokApiService.refreshToken(); + } + + isExpiryDateNear() { + return this.grokApiService.isExpiryDateNear(); + } + + /** + * 获取用量限制信息 + * @returns {Promise} 用量限制信息 + */ + async getUsageLimits() { + if (!this.grokApiService.isInitialized) { + await this.grokApiService.initialize(); + } + return this.grokApiService.getUsageLimits(); + } +} + +// 注册所有内置适配器 +registerAdapter(MODEL_PROVIDER.OPENAI_CUSTOM, OpenAIApiServiceAdapter); +registerAdapter(MODEL_PROVIDER.OPENAI_CUSTOM_RESPONSES, OpenAIResponsesApiServiceAdapter); +registerAdapter(MODEL_PROVIDER.GEMINI_CLI, GeminiApiServiceAdapter); +registerAdapter(MODEL_PROVIDER.ANTIGRAVITY, AntigravityApiServiceAdapter); +registerAdapter(MODEL_PROVIDER.CLAUDE_CUSTOM, ClaudeApiServiceAdapter); +registerAdapter(MODEL_PROVIDER.KIRO_API, KiroApiServiceAdapter); +registerAdapter(MODEL_PROVIDER.QWEN_API, QwenApiServiceAdapter); +registerAdapter(MODEL_PROVIDER.IFLOW_API, IFlowApiServiceAdapter); +registerAdapter(MODEL_PROVIDER.CODEX_API, CodexApiServiceAdapter); +registerAdapter(MODEL_PROVIDER.GROK_CUSTOM, GrokApiServiceAdapter); +// registerAdapter(MODEL_PROVIDER.FORWARD_API, ForwardApiServiceAdapter); + // 用于存储服务适配器单例的映射 export const serviceInstances = {}; @@ -623,40 +710,13 @@ export function getServiceAdapter(config) { logger.info(`[Adapter] getServiceAdapter, provider: ${config.MODEL_PROVIDER}, uuid: ${config.uuid}${customNameDisplay}`); const provider = config.MODEL_PROVIDER; const providerKey = config.uuid ? provider + config.uuid : provider; + if (!serviceInstances[providerKey]) { - switch (provider) { - case MODEL_PROVIDER.OPENAI_CUSTOM: - serviceInstances[providerKey] = new OpenAIApiServiceAdapter(config); - break; - case MODEL_PROVIDER.OPENAI_CUSTOM_RESPONSES: - serviceInstances[providerKey] = new OpenAIResponsesApiServiceAdapter(config); - break; - case MODEL_PROVIDER.GEMINI_CLI: - serviceInstances[providerKey] = new GeminiApiServiceAdapter(config); - break; - case MODEL_PROVIDER.ANTIGRAVITY: - serviceInstances[providerKey] = new AntigravityApiServiceAdapter(config); - break; - case MODEL_PROVIDER.CLAUDE_CUSTOM: - serviceInstances[providerKey] = new ClaudeApiServiceAdapter(config); - break; - case MODEL_PROVIDER.KIRO_API: - serviceInstances[providerKey] = new KiroApiServiceAdapter(config); - break; - case MODEL_PROVIDER.QWEN_API: - serviceInstances[providerKey] = new QwenApiServiceAdapter(config); - break; - case MODEL_PROVIDER.IFLOW_API: - serviceInstances[providerKey] = new IFlowApiServiceAdapter(config); - break; - case MODEL_PROVIDER.CODEX_API: - serviceInstances[providerKey] = new CodexApiServiceAdapter(config); - break; - case MODEL_PROVIDER.FORWARD_API: - serviceInstances[providerKey] = new ForwardApiServiceAdapter(config); - break; - default: - throw new Error(`Unsupported model provider: ${provider}`); + const AdapterClass = adapterRegistry.get(provider); + if (AdapterClass) { + serviceInstances[providerKey] = new AdapterClass(config); + } else { + throw new Error(`Unsupported model provider: ${provider}`); } } return serviceInstances[providerKey]; diff --git a/src/providers/grok/grok-core.js b/src/providers/grok/grok-core.js new file mode 100644 index 0000000..76665bf --- /dev/null +++ b/src/providers/grok/grok-core.js @@ -0,0 +1,634 @@ +import axios from 'axios'; +import logger from '../../utils/logger.js'; +import * as http from 'http'; +import * as https from 'https'; +import { v4 as uuidv4 } from 'uuid'; +import { API_ACTIONS, isRetryableNetworkError } from '../../utils/common.js'; +import { getProviderModels } from '../provider-models.js'; +import { configureAxiosProxy } from '../../utils/proxy-utils.js'; +import { MODEL_PROVIDER } from '../../utils/common.js'; +import { GrokConverter } from '../../converters/strategies/GrokConverter.js'; +import { ConverterFactory } from '../../converters/ConverterFactory.js'; +import * as readline from 'readline'; +import { getProviderPoolManager } from '../../services/service-manager.js'; + +// 配置 HTTP/HTTPS agent 限制连接池大小 +const httpAgent = new http.Agent({ + keepAlive: true, + maxSockets: 100, + maxFreeSockets: 5, + timeout: 120000, +}); +const httpsAgent = new https.Agent({ + keepAlive: true, + maxSockets: 100, + maxFreeSockets: 5, + timeout: 120000, +}); + +const DEFAULT_GROK_ENDPOINT = 'https://grok.com/rest/app-chat/conversations/new'; +const GROK_MODELS = getProviderModels(MODEL_PROVIDER.GROK_CUSTOM); + +const MODEL_MAPPING = { + 'grok-3': { name: 'grok-3', mode: 'MODEL_MODE_GROK_3' }, + 'grok-3-mini': { name: 'grok-3', mode: 'MODEL_MODE_GROK_3_MINI_THINKING' }, + 'grok-3-thinking': { name: 'grok-3', mode: 'MODEL_MODE_GROK_3_THINKING' }, + 'grok-4': { name: 'grok-4', mode: 'MODEL_MODE_GROK_4' }, + 'grok-4-mini': { name: 'grok-4-mini', mode: 'MODEL_MODE_GROK_4_MINI_THINKING' }, + 'grok-4-thinking': { name: 'grok-4', mode: 'MODEL_MODE_GROK_4_THINKING' }, + 'grok-4-heavy': { name: 'grok-4', mode: 'MODEL_MODE_HEAVY' }, + 'grok-4.1-mini': { name: 'grok-4-1-thinking-1129', mode: 'MODEL_MODE_GROK_4_1_MINI_THINKING' }, + 'grok-4.1-fast': { name: 'grok-4-1-thinking-1129', mode: 'MODEL_MODE_FAST' }, + 'grok-4.1-expert': { name: 'grok-4-1-thinking-1129', mode: 'MODEL_MODE_EXPERT' }, + 'grok-4.1-thinking': { name: 'grok-4-1-thinking-1129', mode: 'MODEL_MODE_GROK_4_1_THINKING' }, + 'grok-4.20-beta': { name: 'grok-420', mode: 'MODEL_MODE_GROK_420' }, + 'grok-imagine-1.0': { name: 'grok-3', mode: 'MODEL_MODE_FAST' }, + 'grok-imagine-1.0-edit': { name: 'imagine-image-edit', mode: 'MODEL_MODE_FAST' }, + 'grok-imagine-1.0-video': { name: 'grok-3', mode: 'MODEL_MODE_FAST' } +}; + +export class GrokApiService { + constructor(config) { + this.config = config; + this.uuid = config.uuid; // 存储 UUID 以便后续调用账号池方法 + this.token = config.GROK_COOKIE_TOKEN; + this.cfClearance = config.GROK_CF_CLEARANCE; + this.userAgent = config.GROK_USER_AGENT || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36'; + this.baseUrl = config.GROK_BASE_URL || 'https://grok.com'; + this.chatApi = `${this.baseUrl}/rest/app-chat/conversations/new`; + this.isInitialized = false; + this.converter = new GrokConverter(); + this.lastSyncAt = null; + } + + async initialize() { + if (this.isInitialized) return; + logger.info('[Grok] Initializing Grok API Service...'); + if (!this.token) { + logger.warn('[Grok] GROK_COOKIE_TOKEN is missing. Requests will fail if authorization is required.'); + } + if (!this.cfClearance) { + logger.warn('[Grok] GROK_CF_CLEARANCE is missing. This might cause Cloudflare challenges.'); + } + + // Initial usage sync + try { + await this.getUsageLimits(); + } catch (error) { + logger.warn('[Grok] Initial usage sync failed:', error.message); + } + + this.isInitialized = true; + } + + async refreshToken() { + // Grok SSO tokens are manual for now, but we use this to sync usage/quota from API + logger.info('[Grok] Syncing usage limits...'); + try { + await this.getUsageLimits(); + return Promise.resolve(); + } catch (error) { + logger.error('[Grok] Failed to sync usage limits:', error.message); + return Promise.reject(error); + } + } + + /** + * Fetch rate limits from Grok (RateLimitsReverse) + */ + async getUsageLimits() { + const headers = this.buildHeaders(); + const rateLimitsApi = `${this.baseUrl}/rest/rate-limits`; + + const payload = { + "requestKind": "DEFAULT", + "modelName": "grok-4-1-thinking-1129", // Default model for checking limits + }; + + const axiosConfig = { + method: 'post', + url: rateLimitsApi, + headers: headers, + data: payload, + httpAgent, + httpsAgent, + timeout: 30000 + }; + + configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); + + try { + const response = await axios(axiosConfig); + const data = response.data; + console.log("111111111111111111111111111", JSON.stringify(data)) + + let remaining = data.remainingTokens; + if (remaining === undefined) { + remaining = data.remainingQueries !== undefined ? data.remainingQueries : data.totalQueries; + } + + // 注入固定总量逻辑 (根据反馈:查询总数固定为 80) + if (data.remainingQueries !== undefined || data.totalQueries !== undefined) { + data.totalLimit = 80; + // 计算已用次数 + data.usedQueries = Math.max(0, 80 - (data.remainingQueries !== undefined ? data.remainingQueries : data.totalQueries)); + } + + this.lastSyncAt = Date.now(); + logger.info(`[Grok Usage] Synced: remaining=${remaining}, token=${this.token.substring(0, 10)}...`); + + // 将同步到的数据保存到 config 中,以便持久化和 UI 显示 + this.config.usageData = data; + this.config.lastHealthCheckTime = new Date().toISOString(); + + return { + lastUpdated: this.lastSyncAt, + remaining: remaining, + ...data + }; + } catch (error) { + const status = error.response?.status; + if (status === 401 || status === 403) { + logger.error('[Grok Usage] Authentication failed during usage sync.'); + } + throw error; + } + } + + isExpiryDateNear() { + // Grok tokens don't have a fixed expiry date, but we use this to trigger periodic usage sync + // If not synced for more than X minutes, consider it "near expiry" to trigger a refresh/sync + if (!this.lastSyncAt) return true; + + const now = Date.now(); + const nearMinutes = this.config.CRON_NEAR_MINUTES || 15; + const interval = nearMinutes * 60 * 1000; + const isNear = (now - this.lastSyncAt) > interval; + + if (isNear) { + logger.debug(`[Grok] Usage sync is stale (> ${nearMinutes}m), triggering refresh.`); + } + + return isNear; + } + + /** + * Generate Statsig ID (StatsigGenerator) + */ + genStatsigId() { + // Static Statsig ID from code + return "ZTpUeXBlRXJyb3I6IENhbm5vdCByZWFkIHByb3BlcnRpZXMgb2YgdW5kZWZpbmVkIChyZWFkaW5nICdjaGlsZE5vZGVzJyk="; + } + + buildHeaders() { + let ssoToken = this.token || ""; + if (ssoToken.startsWith("sso=")) { + ssoToken = ssoToken.substring(4); + } + + const cookie = ssoToken ? [`sso=${ssoToken}`, `sso-rw=${ssoToken}`] : []; + if (this.cfClearance) { + cookie.push(`cf_clearance=${this.cfClearance}`); + } + + const headers = { + 'accept': '*/*', + 'accept-encoding': 'gzip, deflate, br', + 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8', + 'baggage': 'sentry-environment=production,sentry-release=d6add6fb0460641fd482d767a335ef72b9b6abb8,sentry-public_key=b311e0f2690c81f25e2c4cf6d4f7ce1c', + 'content-type': 'application/json', + 'cookie': cookie.join('; '), + 'origin': this.baseUrl, + 'priority': 'u=1, i', + 'referer': `${this.baseUrl}/`, + 'sec-fetch-dest': 'empty', + 'sec-fetch-mode': 'cors', + 'sec-fetch-site': 'same-origin', + 'user-agent': this.userAgent, + 'x-statsig-id': this.genStatsigId(), + 'x-xai-request-id': uuidv4() + }; + + // Sync Sec-Ch-Ua logic + if (this.userAgent && (this.userAgent.includes("Chrome/") || this.userAgent.includes("Chromium/") || this.userAgent.includes("Edg/"))) { + let brand = "Google Chrome"; + if (this.userAgent.includes("Edg/")) brand = "Microsoft Edge"; + + const versionMatch = this.userAgent.match(/(?:Chrome|Chromium|Edg)\/(\d+)/); + const version = versionMatch ? versionMatch[1] : "133"; + + headers['sec-ch-ua'] = `"${brand}";v="${version}", "Chromium";v="${version}", "Not(A:Brand";v="24"`; + headers['sec-ch-ua-mobile'] = this.userAgent.toLowerCase().includes("mobile") ? '?1' : '?0'; + + // Platform detection + let platform = "Windows"; + if (this.userAgent.includes("Mac OS X")) platform = "macOS"; + else if (this.userAgent.includes("Android")) platform = "Android"; + else if (this.userAgent.includes("iPhone") || this.userAgent.includes("iPad")) platform = "iOS"; + else if (this.userAgent.includes("Linux")) platform = "Linux"; + + headers['sec-ch-ua-platform'] = `"${platform}"`; + } + + return headers; + } + + buildPayload(modelId, requestBody) { + const mapping = MODEL_MAPPING[modelId] || MODEL_MAPPING['grok-3']; + + let message = requestBody.message || ""; + let toolOverrides = requestBody.toolOverrides || {}; + let fileAttachments = requestBody.fileAttachments || []; + let modelConfigOverride = requestBody.responseMetadata?.modelConfigOverride || {}; + + if (requestBody.messages && Array.isArray(requestBody.messages)) { + // 1. 格式化工具历史 (仅当提供了 tools 时,逻辑) + let processedMessages = requestBody.messages; + if (requestBody.tools && requestBody.tools.length > 0) { + processedMessages = this.converter.formatToolHistory(requestBody.messages); + } + + // 2. 构建工具提示词并注入 (逻辑) + const toolPrompt = this.converter.buildToolPrompt(requestBody.tools, requestBody.tool_choice); + + // 3. 构建 Tool Overrides (仿真 passthrough 模式) + if (requestBody.tools && Object.keys(toolOverrides).length === 0) { + toolOverrides = this.converter.buildToolOverrides(requestBody.tools); + } + + // 4. 提取文本和附件 (MessageExtractor.extract 逻辑) + const extracted = []; + const imageAttachments = []; + const localFileAttachments = []; + + for (const msg of processedMessages) { + const role = msg.role || "user"; + const content = msg.content; + const parts = []; + + if (typeof content === 'string') { + if (content.trim()) parts.push(content.trim()); + } else if (Array.isArray(content)) { + for (const item of content) { + if (item.type === 'text' && item.text?.trim()) { + parts.push(item.text.trim()); + } else if (item.type === 'image_url' && item.image_url?.url) { + imageAttachments.push(item.image_url.url); + } else if (item.type === 'input_audio' && item.input_audio?.data) { + localFileAttachments.push(item.input_audio.data); + } else if (item.type === 'file' && item.file?.file_data) { + localFileAttachments.push(item.file.file_data); + } + } + } + + // 保留工具调用轨迹 (逻辑: [tool_call] 格式) + const toolCalls = msg.tool_calls; + if (role === "assistant" && parts.length === 0 && Array.isArray(toolCalls)) { + for (const call of toolCalls) { + const fn = call.function || {}; + const name = fn.name || call.name || "tool"; + let args = fn.arguments || ""; + if (typeof args !== 'string') args = JSON.stringify(args); + parts.push(`[tool_call] ${name} ${args.trim()}`.trim()); + } + } + + if (parts.length > 0) { + let roleLabel = role; + if (role === "tool") { + const name = msg.name || "unknown"; + const callId = msg.tool_call_id || ""; + roleLabel = `tool[${name.trim()}]`; + if (callId.trim()) roleLabel += `#${callId.trim()}`; + } + extracted.push({ role: roleLabel, text: parts.join("\n") }); + } + } + + // 5. 处理提取后的文本拼接 (逻辑) + let lastUserIndex = -1; + for (let i = extracted.length - 1; i >= 0; i--) { + if (extracted[i].role === 'user') { + lastUserIndex = i; + break; + } + } + + const texts = []; + for (let i = 0; i < extracted.length; i++) { + const item = extracted[i]; + if (i === lastUserIndex) { + texts.push(item.text); + } else { + texts.push(`${item.role}: ${item.text}`); + } + } + + message = texts.join("\n\n"); + if (toolPrompt) { + message = `${toolPrompt}\n\n${message}`; + } + + // Fallback for attachments (逻辑) + if (!message.trim() && (requestBody.fileAttachments?.length || imageAttachments.length || localFileAttachments.length)) { + message = "Refer to the following content:"; + } + + // 6. 附件准备 (供后续上传) + requestBody._extractedImages = imageAttachments; + requestBody._extractedFiles = localFileAttachments; + } + + // 视频生成支持 (特定参数从 requestBody 透传) + if (requestBody.videoGenModelConfig) { + modelConfigOverride.modelMap = { + videoGenModelConfig: requestBody.videoGenModelConfig + }; + toolOverrides.videoGen = true; + if (requestBody.videoGenPrompt) { + message = requestBody.videoGenPrompt; + } + } + + const payload = { + "deviceEnvInfo": { + "darkModeEnabled": false, + "devicePixelRatio": 2, + "screenWidth": 2056, + "screenHeight": 1329, + "viewportWidth": 2056, + "viewportHeight": 1083, + }, + "disableMemory": false, + "disableSearch": false, + "disableSelfHarmShortCircuit": false, + "disableTextFollowUps": false, + "enableImageGeneration": true, + "enableImageStreaming": true, + "enableSideBySide": true, + "fileAttachments": fileAttachments, + "forceConcise": false, + "forceSideBySide": false, + "imageAttachments": [], + "imageGenerationCount": 2, + "isAsyncChat": false, + "isReasoning": false, + "message": message, + "modelMode": mapping.mode, + "modelName": mapping.name, + "responseMetadata": { + "requestModelDetails": { "modelId": mapping.name }, + "modelConfigOverride": modelConfigOverride + }, + "returnImageBytes": false, + "returnRawGrokInXaiRequest": false, + "sendFinalMetadata": true, + "temporary": true, + "toolOverrides": toolOverrides, + }; + + return payload; + } + + async generateContent(model, requestBody) { + const stream = this.generateContentStream(model, requestBody); + const collected = { + message: "", + responseId: "", + llmInfo: {}, + rolloutId: "", + modelResponse: null, + cardAttachment: null, + streamingImageGenerationResponse: null, + streamingVideoGenerationResponse: null, + finalVideoUrl: null, + finalThumbnailUrl: null + }; + + for await (const chunk of stream) { + const resp = chunk.result?.response; + if (!resp) continue; + + if (resp.token) collected.message += resp.token; + if (resp.responseId) collected.responseId = resp.responseId; + if (resp.llmInfo) Object.assign(collected.llmInfo, resp.llmInfo); + if (resp.rolloutId) collected.rolloutId = resp.rolloutId; + + if (resp.modelResponse) collected.modelResponse = resp.modelResponse; + if (resp.cardAttachment) collected.cardAttachment = resp.cardAttachment; + + if (resp.streamingImageGenerationResponse) { + collected.streamingImageGenerationResponse = resp.streamingImageGenerationResponse; + } + + if (resp.streamingVideoGenerationResponse) { + collected.streamingVideoGenerationResponse = resp.streamingVideoGenerationResponse; + if (resp.streamingVideoGenerationResponse.progress === 100 && resp.streamingVideoGenerationResponse.videoUrl) { + collected.finalVideoUrl = resp.streamingVideoGenerationResponse.videoUrl; + collected.finalThumbnailUrl = resp.streamingVideoGenerationResponse.thumbnailImageUrl; + } + } + } + + return collected; + } + + /** + * Upload file to Grok (UploadService) + */ + async uploadFile(fileInput) { + let fileName = "file.bin"; + let b64 = ""; + let mime = "application/octet-stream"; + + if (fileInput.startsWith("data:")) { + const match = fileInput.match(/^data:([^;]+);base64,(.*)$/); + if (match) { + mime = match[1]; + b64 = match[2]; + const ext = mime.split("/")[1] || "bin"; + fileName = `file.${ext}`; + } + } else if (fileInput.startsWith("http")) { + // 这里简单处理,后续可以实现下载再上传 + return null; + } + + if (!b64) return null; + + const headers = this.buildHeaders(); + const uploadApi = `${this.baseUrl}/rest/app-chat/upload-file`; + + const axiosConfig = { + method: 'post', + url: uploadApi, + headers: headers, + data: { + fileName, + fileMimeType: mime, + content: b64 + }, + httpAgent, + httpsAgent, + timeout: 30000 + }; + + configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); + + try { + const response = await axios(axiosConfig); + return response.data; // { fileMetadataId: "...", fileUri: "..." } + } catch (error) { + logger.error(`[Grok Upload] Failed to upload file:`, error.message); + return null; + } + } + + async * generateContentStream(model, requestBody) { + // 检查是否即将到期(需要同步用量),如果是则推送到刷新队列 + if (this.isExpiryDateNear()) { + const poolManager = getProviderPoolManager(); + if (poolManager && this.uuid) { + logger.info(`[Grok] Usage sync is stale, marking credential ${this.uuid} for refresh`); + poolManager.markProviderNeedRefresh(MODEL_PROVIDER.GROK_CUSTOM, { + uuid: this.uuid + }); + } + } + + // 1. 先构建一次 payload 以便触发消息提取和附件解析 (逻辑顺序) + // 这一步会填充 requestBody._extractedImages 和 requestBody._extractedFiles + this.buildPayload(model, requestBody); + + let fileAttachments = requestBody.fileAttachments || []; + const imagesToUpload = requestBody._extractedImages || []; + const filesToUpload = requestBody._extractedFiles || []; + + // 2. 处理附件上传 + if (imagesToUpload.length > 0 || filesToUpload.length > 0) { + const allToUpload = [...imagesToUpload, ...filesToUpload]; + logger.info(`[Grok] Found ${allToUpload.length} attachments to upload.`); + + for (const data of allToUpload) { + const result = await this.uploadFile(data); + if (result?.fileMetadataId) { + fileAttachments.push(result.fileMetadataId); + } + } + // 更新附件列表 + requestBody.fileAttachments = fileAttachments; + } + + // 3. 重新构建最终 payload (附件已上传并关联) + const payload = this.buildPayload(model, requestBody); + const headers = this.buildHeaders(); + + const axiosConfig = { + method: 'post', + url: this.chatApi, + headers: headers, + data: payload, + responseType: 'stream', + httpAgent, + httpsAgent, + timeout: 60000, + maxRedirects: 0 + }; + + configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); + + try { + const response = await axios(axiosConfig); + const contentType = response.headers['content-type'] || ''; + logger.debug(`[Grok Stream] Connected. Status: ${response.status}, Content-Type: ${contentType}`); + + if (!contentType.includes('text/event-stream') && !contentType.includes('application/x-ndjson') && !contentType.includes('application/json')) { + logger.warn(`[Grok Stream] Unexpected Content-Type: ${contentType}. Possible redirect to login page?`); + if (contentType.includes('text/html')) { + throw new Error('Grok returned HTML instead of SSE. Your SSO token might be invalid or expired.'); + } + } + + const rl = readline.createInterface({ + input: response.data, + terminal: false + }); + + let lineCount = 0; + let lastResponseId = payload.responseMetadata?.requestModelDetails?.modelId || "final"; + + for await (const line of rl) { + lineCount++; + const trimmedLine = line.trim(); + if (!trimmedLine) continue; + + // Log raw line for debugging (only first few characters or if short) + if (lineCount <= 5) { + logger.debug(`[Grok Stream] Raw line ${lineCount}: ${trimmedLine.slice(0, 100)}`); + } + + let dataStr = trimmedLine; + if (trimmedLine.startsWith('data: ')) { + dataStr = trimmedLine.slice(6).trim(); + } + + if (dataStr === '[DONE]') break; + + try { + const json = JSON.parse(dataStr); + if (json.result?.response?.responseId) { + lastResponseId = json.result.response.responseId; + } + yield json; + } catch (e) { + // Grok sometimes sends empty data or comments + if (dataStr !== ':' && !dataStr.startsWith(':')) { + logger.debug('[Grok Stream] Non-JSON line ignored:', dataStr); + } + } + } + + logger.debug(`[Grok Stream] Finished loop. Total lines: ${lineCount}`); + + // Yield a final chunk to signal the converter to finish and cleanup + yield { + result: { + response: { + isDone: true, + responseId: lastResponseId + } + } + }; + } catch (error) { + this.handleApiError(error); + } + } + + handleApiError(error) { + const status = error.response?.status; + const errorMessage = error.message || ''; + logger.error(`[Grok API] Error (Status: ${status}):` ,errorMessage); + + if (status === 401 || status === 403) { + error.shouldSwitchCredential = true; + error.message = 'Grok authentication failed (SSO token invalid or expired)'; + } + + throw error; + } + + async listModels() { + const formattedModels = GROK_MODELS.map(modelId => { + const displayName = modelId.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' '); + return { + id: modelId, + object: "model", + created: Math.floor(Date.now() / 1000), + owned_by: "xai", + display_name: displayName, + }; + }); + return { data: formattedModels }; + } +} diff --git a/src/providers/grok/grok-strategy.js b/src/providers/grok/grok-strategy.js new file mode 100644 index 0000000..623100a --- /dev/null +++ b/src/providers/grok/grok-strategy.js @@ -0,0 +1,56 @@ +import { API_ACTIONS, MODEL_PROTOCOL_PREFIX } from '../../utils/common.js'; +import logger from '../../utils/logger.js'; +import { ProviderStrategy } from '../../utils/provider-strategy.js'; + +/** + * Grok provider strategy implementation. + */ +class GrokStrategy extends ProviderStrategy { + extractModelAndStreamInfo(req, requestBody) { + // Grok protocol usually used internally, but if exposed: + const model = requestBody.model || 'grok-3'; + const isStream = requestBody.stream !== false; + return { model, isStream }; + } + + extractResponseText(response) { + // From Grok response + return response.message || ''; + } + + extractPromptText(requestBody) { + // From converted Grok request + return requestBody.message || ''; + } + + async applySystemPromptFromFile(config, requestBody) { + if (!config.SYSTEM_PROMPT_FILE_PATH) { + return requestBody; + } + + const filePromptContent = config.SYSTEM_PROMPT_CONTENT; + if (filePromptContent === null) { + return requestBody; + } + + // Grok reverse interface combines system prompt into message + // Here we can prepend it if needed, or handle it during request conversion. + // Since requestBody already contains the converted message, we might need to prepend it here. + + const existingMessage = requestBody.message || ""; + const newSystemText = config.SYSTEM_PROMPT_MODE === 'append' + ? `${existingMessage}\n\nSystem: ${filePromptContent}` + : `System: ${filePromptContent}\n\n${existingMessage}`; + + requestBody.message = newSystemText; + logger.info(`[System Prompt] Applied system prompt for Grok in '${config.SYSTEM_PROMPT_MODE}' mode.`); + + return requestBody; + } + + async manageSystemPrompt(requestBody) { + // Not implemented for Grok yet + } +} + +export { GrokStrategy }; diff --git a/src/providers/provider-models.js b/src/providers/provider-models.js index 0c14675..67389be 100644 --- a/src/providers/provider-models.js +++ b/src/providers/provider-models.js @@ -87,7 +87,24 @@ export const PROVIDER_MODELS = { 'gpt-5.3-codex', 'gpt-5.3-codex-spark' ], - 'forward-api': [] + 'forward-api': [], + 'grok-custom': [ + 'grok-3', + 'grok-3-mini', + 'grok-3-thinking', + 'grok-4', + 'grok-4-mini', + 'grok-4-thinking', + 'grok-4-heavy', + 'grok-4.1-mini', + 'grok-4.1-fast', + 'grok-4.1-expert', + 'grok-4.1-thinking', + 'grok-4.20-beta', + 'grok-imagine-1.0', + 'grok-imagine-1.0-edit', + 'grok-imagine-1.0-video' + ] }; /** diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index 25ec8d5..02d1b14 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -410,17 +410,33 @@ export class ProviderPoolManager { */ _calculateNodeScore(providerStatus, now = Date.now()) { const config = providerStatus.config; + const state = providerStatus.state; // 1. 基础健康分:不健康的排最后 if (!config.isHealthy || config.isDisabled) return 1e18; + // 检查并发限制 + const concurrencyLimit = parseInt(config.concurrencyLimit || 0); + const queueLimit = parseInt(config.queueLimit || 0); + + if (concurrencyLimit > 0) { + if (state.activeCount >= concurrencyLimit) { + // 如果队列也满了,排在最后(但优于不健康节点) + if (queueLimit > 0 && state.waitingCount >= queueLimit) { + return 1e17; + } + // 没满,但需要排队。排队数量越多,权重越大 + return 1e15 + (state.waitingCount || 0) * 1e10; + } + } + // 2. 预热/刷新分:60秒内刷新过且使用次数极少的节点视为“新鲜”,分数极低(最高优) const lastHealthCheckTime = config.lastHealthCheckTime ? new Date(config.lastHealthCheckTime).getTime() : 0; const isFresh = lastHealthCheckTime && (now - lastHealthCheckTime < 60000); - if (isFresh) return -2e18 + (config.usageCount || 0) * 10000 + (now - lastHealthCheckTime); // 极其优先 + if (isFresh) return -2e18 + (config.usageCount || 0) * 10000 + (now - lastHealthCheckTime) + (state.activeCount * 5000); // 极其优先 // 3. 权重计算逻辑: - // 改进点:使用 lastUsedTime + usageCount 惩罚 + selectionSequence 惩罚 + // 改进点:使用 lastUsedTime + usageCount 惩罚 + selectionSequence 惩罚 + load 惩罚 // selectionSequence 用于在同一毫秒内彻底打破平局 const lastUsedTime = config.lastUsed ? new Date(config.lastUsed).getTime() : (now - 86400000); // 没用过的视为 24 小时前用过(更旧) @@ -431,6 +447,7 @@ export class ProviderPoolManager { // - lastUsedTime 越久,分越小。 // - usageCount 越多,分越大。 // - lastSelectionSeq 越大(最近选过),分越大。 + // - activeCount 越多,分越大(负载均衡) // --- 策略优化:相对序列号 --- // 为了防止全局自增序列号导致的“老节点排挤新节点”或“重置节点排挤未重置节点” @@ -443,10 +460,12 @@ export class ProviderPoolManager { // usageCount * 10000: 每多用一次,权重增加 10 秒 // cappedRelativeSeq * 1000: 序列号偏移只在 100 秒(10次使用)范围内波动 + // activeCount * 5000: 每个活跃请求增加 5 秒权重,用于平滑负载 const baseScore = lastUsedTime + (usageCount * 10000); const sequenceScore = cappedRelativeSeq * 1000; + const loadScore = (state.activeCount || 0) * 5000; - return baseScore + sequenceScore; + return baseScore + sequenceScore + loadScore; } /** @@ -570,6 +589,7 @@ export class ProviderPoolManager { */ initializeProviderStatus() { for (const providerType in this.providerPools) { + const oldStatus = this.providerStatus[providerType] || []; this.providerStatus[providerType] = []; this.roundRobinIndex[providerType] = 0; // Initialize round-robin index for each type // 只有在锁不存在时才初始化,避免在运行中被重置导致并发问题 @@ -577,6 +597,9 @@ export class ProviderPoolManager { this._selectionLocks[providerType] = Promise.resolve(); } this.providerPools[providerType].forEach((providerConfig) => { + // 尝试从旧状态中恢复活跃请求计数和队列,避免重载配置时重置并发限制 + const existing = oldStatus.find(p => p.uuid === providerConfig.uuid); + // Ensure initial health and usage stats are present in the config providerConfig.isHealthy = providerConfig.isHealthy !== undefined ? providerConfig.isHealthy : true; providerConfig.isDisabled = providerConfig.isDisabled !== undefined ? providerConfig.isDisabled : false; @@ -603,12 +626,116 @@ export class ProviderPoolManager { config: providerConfig, uuid: providerConfig.uuid, // Still keep uuid at the top level for easy access type: providerType, // 保存 providerType 引用 + state: existing ? existing.state : { + activeCount: 0, + waitingCount: 0, + queue: [] + } }); }); } this._log('info', `Initialized provider statuses: ok (maxErrorCount: ${this.maxErrorCount})`); } + /** + * 获取一个可用的提供商插槽,考虑并发限制和队列 + * @param {string} providerType + * @param {string} requestedModel + * @param {object} options + */ + async acquireSlot(providerType, requestedModel = null, options = {}) { + // 使用 selectProvider 进行初次选择(评分逻辑已经包含了并发考虑) + const selectedConfig = await this.selectProvider(providerType, requestedModel, { ...options, skipUsageCount: true }); + + if (!selectedConfig) { + return null; + } + + const provider = this._findProvider(providerType, selectedConfig.uuid); + if (!provider) return selectedConfig; + + const config = provider.config; + const state = provider.state; + const concurrencyLimit = parseInt(config.concurrencyLimit || 0); + const queueLimit = parseInt(config.queueLimit || 0); + + // 如果没有限制,直接增加活跃计数并返回 + if (concurrencyLimit <= 0) { + state.activeCount++; + return config; + } + + // 检查是否在并发限制内 + if (state.activeCount < concurrencyLimit) { + state.activeCount++; + return config; + } + + // 超过并发限制,尝试进入队列 + if (queueLimit > 0 && state.waitingCount < queueLimit) { + this._log('info', `[Concurrency] Node ${config.uuid} busy (${state.activeCount}/${concurrencyLimit}), enqueuing request (queue: ${state.waitingCount + 1}/${queueLimit})`); + + state.waitingCount++; + try { + // 等待释放信号 + await new Promise((resolve, reject) => { + // 设置较短的超时用于测试验证,或者由外部控制 + const timeoutMs = options.queueTimeout || 300000; + const timeout = setTimeout(() => { + const idx = state.queue.indexOf(handler); + if (idx !== -1) { + state.queue.splice(idx, 1); + reject(new Error(`Queue timeout after ${timeoutMs/1000}s`)); + } + }, timeoutMs); + + const handler = () => { + clearTimeout(timeout); + resolve(); + }; + state.queue.push(handler); + }); + } finally { + state.waitingCount--; + } + + // 获得信号后,增加活跃计数 + state.activeCount++; + return config; + } + + // 队列也满了 + this._log('warn', `[Concurrency] Node ${config.uuid} full capacity (${state.activeCount}/${concurrencyLimit}, queue: ${state.waitingCount}/${queueLimit}), returning 429`); + const error = new Error('Too many requests: account concurrency limit and queue reached'); + error.status = 429; + error.code = 429; + throw error; + } + + /** + * 释放提供商插槽 + */ + releaseSlot(providerType, uuid) { + if (!providerType || !uuid) return; + + const provider = this._findProvider(providerType, uuid); + if (!provider) return; + + const state = provider.state; + if (state.activeCount > 0) { + state.activeCount--; + } + + // 如果队列中有等待的任务,释放下一个 + if (state.queue && state.queue.length > 0) { + const next = state.queue.shift(); + if (next) { + // 异步触发 + setImmediate(next); + } + } + } + /** * Selects a provider from the pool for a given provider type. * Currently uses a simple round-robin for healthy providers. @@ -717,6 +844,114 @@ export class ProviderPoolManager { return selected.config; } + /** + * 获取一个可用的提供商插槽,支持 Fallback 机制 + */ + async acquireSlotWithFallback(providerType, requestedModel = null, options = {}) { + if (!providerType || typeof providerType !== 'string') { + this._log('error', `Invalid providerType: ${providerType}`); + return null; + } + + const triedTypes = new Set(); + const typesToTry = [providerType]; + + const fallbackTypes = this.fallbackChain[providerType] || []; + if (Array.isArray(fallbackTypes)) { + typesToTry.push(...fallbackTypes); + } + + for (const currentType of typesToTry) { + if (triedTypes.has(currentType)) continue; + triedTypes.add(currentType); + + if (!this.providerStatus[currentType] || this.providerStatus[currentType].length === 0) { + continue; + } + + if (currentType !== providerType && requestedModel) { + const primaryProtocol = getProtocolPrefix(providerType); + const fallbackProtocol = getProtocolPrefix(currentType); + if (primaryProtocol !== fallbackProtocol) continue; + + const supportedModels = getProviderModels(currentType); + if (supportedModels.length > 0 && !supportedModels.includes(requestedModel)) continue; + } + + // 尝试获取插槽 + try { + const selectedConfig = await this.acquireSlot(currentType, requestedModel, options); + if (selectedConfig) { + if (currentType !== providerType) { + this._log('info', `Fallback Slot activated (Chain): ${providerType} -> ${currentType} (uuid: ${selectedConfig.uuid})`); + } + return { + config: selectedConfig, + actualProviderType: currentType, + isFallback: currentType !== providerType + }; + } + } catch (err) { + if (err.status === 429) { + // 如果是因为 429 (并发/队列满),尝试下一个 Fallback + this._log('info', `Type ${currentType} busy (429), trying next fallback...`); + continue; + } + throw err; // 其他错误抛出 + } + } + + // Model Fallback Mapping + if (requestedModel && this.modelFallbackMapping && this.modelFallbackMapping[requestedModel]) { + const mapping = this.modelFallbackMapping[requestedModel]; + const targetProviderType = mapping.targetProviderType; + const targetModel = mapping.targetModel; + + if (targetProviderType && targetModel) { + if (this.providerStatus[targetProviderType] && this.providerStatus[targetProviderType].length > 0) { + try { + const selectedConfig = await this.acquireSlot(targetProviderType, targetModel, options); + if (selectedConfig) { + return { + config: selectedConfig, + actualProviderType: targetProviderType, + isFallback: true, + actualModel: targetModel + }; + } + } catch (err) { + // 如果目标类型繁忙,尝试它的 fallback chain + const targetFallbackTypes = this.fallbackChain[targetProviderType] || []; + for (const fallbackType of targetFallbackTypes) { + const targetProtocol = getProtocolPrefix(targetProviderType); + const fallbackProtocol = getProtocolPrefix(fallbackType); + if (targetProtocol !== fallbackProtocol) continue; + + const supportedModels = getProviderModels(fallbackType); + if (supportedModels.length > 0 && !supportedModels.includes(targetModel)) continue; + + try { + const fallbackSelectedConfig = await this.acquireSlot(fallbackType, targetModel, options); + if (fallbackSelectedConfig) { + return { + config: fallbackSelectedConfig, + actualProviderType: fallbackType, + isFallback: true, + actualModel: targetModel + }; + } + } catch (e) { + continue; + } + } + } + } + } + } + + return null; + } + /** * Selects a provider from the pool with fallback support. * When the primary provider type has no healthy providers, it will try fallback types. diff --git a/src/services/service-manager.js b/src/services/service-manager.js index 09e3b70..c152e12 100644 --- a/src/services/service-manager.js +++ b/src/services/service-manager.js @@ -398,11 +398,24 @@ export async function getApiServiceWithFallback(config, requestedModel = null, o if (providerPoolManager && config.providerPools && config.providerPools[config.MODEL_PROVIDER]) { // selectProviderWithFallback 现在是异步的,使用链式锁确保并发安全 - const selectedResult = await providerPoolManager.selectProviderWithFallback( - config.MODEL_PROVIDER, - requestedModel, - { skipUsageCount: true } - ); + // 如果开启了并发限制,则使用 acquireSlot 进行选择和占位 + const useAcquire = options.acquireSlot === true; + let selectedResult; + + if (useAcquire) { + // 我们需要一个支持 Fallback 的 acquireSlot + selectedResult = await providerPoolManager.acquireSlotWithFallback( + config.MODEL_PROVIDER, + requestedModel, + options + ); + } else { + selectedResult = await providerPoolManager.selectProviderWithFallback( + config.MODEL_PROVIDER, + requestedModel, + { ...options, skipUsageCount: true } + ); + } if (selectedResult) { const { config: selectedProviderConfig, actualProviderType: selectedType, isFallback: fallbackUsed, actualModel: fallbackModel } = selectedResult; @@ -497,7 +510,8 @@ export async function getProviderStatus(config, options = {}) { 'openai-qwen-oauth': 'QWEN_OAUTH_CREDS_FILE_PATH', 'gemini-antigravity': 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH', 'openai-iflow': 'IFLOW_TOKEN_FILE_PATH', - 'forward-api': 'FORWARD_BASE_URL' + 'forward-api': 'FORWARD_BASE_URL', + 'grok-custom': 'GROK_COOKIE_TOKEN' }; let providerPoolsSlim = []; let unhealthyProvideIdentifyList = []; diff --git a/src/services/ui-manager.js b/src/services/ui-manager.js index f01c7f0..523fa12 100644 --- a/src/services/ui-manager.js +++ b/src/services/ui-manager.js @@ -123,6 +123,11 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo return await providerApi.handleGetProviders(req, res, currentConfig, providerPoolManager); } + // Get supported provider types based on registered adapters + if (method === 'GET' && pathParam === '/api/providers/supported') { + return await providerApi.handleGetSupportedProviders(req, res); + } + // Get specific provider type details const providerTypeMatch = pathParam.match(/^\/api\/providers\/([^\/]+)$/); if (method === 'GET' && providerTypeMatch) { diff --git a/src/services/usage-service.js b/src/services/usage-service.js index 5f57b92..ebe4583 100644 --- a/src/services/usage-service.js +++ b/src/services/usage-service.js @@ -18,9 +18,11 @@ export class UsageService { [MODEL_PROVIDER.GEMINI_CLI]: this.getGeminiUsage.bind(this), [MODEL_PROVIDER.ANTIGRAVITY]: this.getAntigravityUsage.bind(this), [MODEL_PROVIDER.CODEX_API]: this.getCodexUsage.bind(this), + [MODEL_PROVIDER.GROK_CUSTOM]: this.getGrokUsage.bind(this), }; } + /** * 获取指定提供商的用量信息 * @param {string} providerType - 提供商类型 @@ -184,8 +186,31 @@ export class UsageService { throw new Error(`Codex 服务实例不支持用量查询: ${providerKey}`); } + /** + * 获取 Grok 提供商的用量信息 + * @param {string} [uuid] - 可选的提供商实例 UUID + * @returns {Promise} Grok 用量信息 + */ + async getGrokUsage(uuid = null) { + const providerKey = uuid ? MODEL_PROVIDER.GROK_CUSTOM + uuid : MODEL_PROVIDER.GROK_CUSTOM; + const adapter = serviceInstances[providerKey]; + + if (!adapter) { + throw new Error(`Grok 服务实例未找到: ${providerKey}`); + } + + // 使用适配器的 getUsageLimits 方法 + if (typeof adapter.getUsageLimits === 'function') { + const rawUsage = await adapter.getUsageLimits(); + return formatGrokUsage(rawUsage); + } + + throw new Error(`Grok 服务实例不支持用量查询: ${providerKey}`); + } + /** * 获取支持用量查询的提供商列表 + * @returns {Array} 支持的提供商类型列表 */ getSupportedProviders() { @@ -509,7 +534,80 @@ export function formatAntigravityUsage(usageData) { } /** - * 格式化 Codex 用量信息为易读格式(映射到 Kiro 数据结构) + * 格式化 Grok 用量信息为易读格式(映射到 Kiro 数据结构) + * @param {Object} usageData - 原始用量数据 + * @returns {Object} 格式化后的用量信息 + */ +export function formatGrokUsage(usageData) { + if (!usageData) { + return null; + } + + const result = { + // 基本信息 - 映射到 Kiro 结构 + daysUntilReset: null, + nextDateReset: null, + + // 订阅信息 + subscription: { + title: 'Grok Custom', + type: 'grok-custom', + upgradeCapability: null, + overageCapability: null + }, + + // 用户信息 + user: { + email: null, + userId: null + }, + + // 用量明细 + usageBreakdown: [] + }; + + // Grok 返回的数据结构已在 core 中预处理:{ remainingTokens, remainingQueries, totalQueries, totalLimit, usedQueries, ... } + if (usageData.totalLimit !== undefined && usageData.usedQueries !== undefined) { + const item = { + resourceType: 'TOKEN_USAGE', + displayName: 'Remaining Queries', + displayNamePlural: 'Remaining Queries', + unit: 'queries', + currency: null, + + // 使用从 core 传出的计算好的值 + currentUsage: usageData.usedQueries, + usageLimit: usageData.totalLimit, + + nextDateReset: null, + freeTrial: null, + bonuses: [] + }; + + result.usageBreakdown.push(item); + } else if (usageData.remainingTokens !== undefined) { + const item = { + resourceType: 'TOKEN_USAGE', + displayName: 'Remaining Tokens', + displayNamePlural: 'Remaining Tokens', + unit: 'tokens', + currency: null, + + currentUsage: 0, + usageLimit: usageData.remainingTokens, + + nextDateReset: null, + freeTrial: null, + bonuses: [] + }; + + result.usageBreakdown.push(item); + } + + return result; +} + +/* * @param {Object} usageData - 原始用量数据 * @returns {Object} 格式化后的用量信息 */ diff --git a/src/ui-modules/provider-api.js b/src/ui-modules/provider-api.js index 813f542..722ce25 100644 --- a/src/ui-modules/provider-api.js +++ b/src/ui-modules/provider-api.js @@ -4,6 +4,7 @@ import { getRequestBody } from '../utils/common.js'; import { getAllProviderModels, getProviderModels } from '../providers/provider-models.js'; import { generateUUID, createProviderConfig, formatSystemPath, detectProviderFromPath, addToUsedPaths, isPathUsed, pathsEqual } from '../utils/provider-utils.js'; import { broadcastEvent } from './event-broadcast.js'; +import { getRegisteredProviders } from '../providers/adapter.js'; /** * 获取提供商池摘要 @@ -27,6 +28,16 @@ export async function handleGetProviders(req, res, currentConfig, providerPoolMa return true; } +/** + * 获取支持的提供商类型(已注册适配器的) + */ +export async function handleGetSupportedProviders(req, res) { + const supportedProviders = getRegisteredProviders(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(supportedProviders)); + return true; +} + /** * 获取特定提供商类型的详细信息 */ diff --git a/src/ui-modules/usage-api.js b/src/ui-modules/usage-api.js index d9298c9..c23a8cf 100644 --- a/src/ui-modules/usage-api.js +++ b/src/ui-modules/usage-api.js @@ -1,11 +1,12 @@ import { CONFIG } from '../core/config-manager.js'; import logger from '../utils/logger.js'; import { serviceInstances, getServiceAdapter } from '../providers/adapter.js'; -import { formatKiroUsage, formatGeminiUsage, formatAntigravityUsage, formatCodexUsage } from '../services/usage-service.js'; +import { formatKiroUsage, formatGeminiUsage, formatAntigravityUsage, formatCodexUsage, formatGrokUsage } from '../services/usage-service.js'; import { readUsageCache, writeUsageCache, readProviderUsageCache, updateProviderUsageCache } from './usage-cache.js'; import path from 'path'; -const supportedProviders = ['claude-kiro-oauth', 'gemini-cli-oauth', 'gemini-antigravity', 'openai-codex-oauth']; +const supportedProviders = ['claude-kiro-oauth', 'gemini-cli-oauth', 'gemini-antigravity', 'openai-codex-oauth', 'grok-custom']; + /** * 获取所有支持用量查询的提供商的用量信息 @@ -179,6 +180,14 @@ async function getAdapterUsage(adapter, providerType) { } throw new Error('This adapter does not support usage query'); } + + if (providerType === 'grok-custom') { + if (typeof adapter.getUsageLimits === 'function') { + const rawUsage = await adapter.getUsageLimits(); + return formatGrokUsage(rawUsage); + } + throw new Error('This adapter does not support usage query'); + } throw new Error(`Unsupported provider type: ${providerType}`); } diff --git a/src/utils/common.js b/src/utils/common.js index 1933d36..42ff37f 100644 --- a/src/utils/common.js +++ b/src/utils/common.js @@ -58,6 +58,7 @@ export const MODEL_PROTOCOL_PREFIX = { OLLAMA: 'ollama', CODEX: 'codex', FORWARD: 'forward', + GROK: 'grok', } export const MODEL_PROVIDER = { @@ -72,6 +73,7 @@ export const MODEL_PROVIDER = { IFLOW_API: 'openai-iflow', CODEX_API: 'openai-codex-oauth', FORWARD_API: 'forward-api', + GROK_CUSTOM: 'grok-custom', } /** @@ -165,6 +167,23 @@ export function formatExpiryLog(tag, expiryDate, nearMinutes) { return { message, isNearExpiry }; } +/** + * Get client IP address from request + * @param {http.IncomingMessage} req - The HTTP request object. + * @returns {string} The client IP address. + */ +export function getClientIp(req) { + const forwarded = req.headers['x-forwarded-for']; + let ip = forwarded ? forwarded.split(',')[0].trim() : req.socket.remoteAddress; + + // Clean up IPv4-mapped IPv6 addresses (e.g., ::ffff:127.0.0.1 -> 127.0.0.1) + if (ip && ip.includes('::ffff:')) { + ip = ip.replace('::ffff:', ''); + } + + return ip || 'unknown'; +} + /** * Reads the entire request body from an HTTP request. * @param {http.IncomingMessage} req - The HTTP request object. @@ -314,17 +333,17 @@ export async function handleStreamRequest(res, service, model, requestBody, from await handleUnifiedResponse(res, '', true); } - // fs.writeFile('request'+Date.now()+'.json', JSON.stringify(requestBody)); - // The service returns a stream in its native format (toProvider). - const needsConversion = getProtocolPrefix(fromProvider) !== getProtocolPrefix(toProvider); - requestBody.model = model; - const nativeStream = await service.generateContentStream(model, requestBody); - const addEvent = getProtocolPrefix(fromProvider) === MODEL_PROTOCOL_PREFIX.CLAUDE || getProtocolPrefix(fromProvider) === MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES; - let hasToolCall = false; let hasMessageStop = false; // 跟踪是否已经发送过结束标志(message_stop / done) try { + // fs.writeFile('request'+Date.now()+'.json', JSON.stringify(requestBody)); + // The service returns a stream in its native format (toProvider). + const needsConversion = getProtocolPrefix(fromProvider) !== getProtocolPrefix(toProvider); + requestBody.model = model; + const nativeStream = await service.generateContentStream(model, requestBody); + const addEvent = getProtocolPrefix(fromProvider) === MODEL_PROTOCOL_PREFIX.CLAUDE || getProtocolPrefix(fromProvider) === MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES; + for await (const nativeChunk of nativeStream) { // 检查客户端是否已断开连接 if (clientDisconnected.value) { @@ -504,10 +523,6 @@ export async function handleStreamRequest(res, service, model, requestBody, from }, error.message); credentialMarkedUnhealthy = true; } - } else if (credentialMarkedUnhealthy) { - logger.info(`[Provider Pool] Credential ${pooluuid} already marked as unhealthy by lower layer, skipping duplicate marking`); - } else if (skipErrorCount) { - logger.info(`[Provider Pool] Skipping error count for ${toProvider} (${pooluuid}) - will switch credential without marking unhealthy`); } // 如果需要切换凭证(无论是否标记不健康),都设置标记以触发重试 @@ -526,7 +541,8 @@ export async function handleStreamRequest(res, service, model, requestBody, from try { // 动态导入以避免循环依赖 const { getApiServiceWithFallback } = await import('../services/service-manager.js'); - const result = await getApiServiceWithFallback(CONFIG, model); + // 使用 acquireSlot: true 以占用新凭证的并发插槽 + const result = await getApiServiceWithFallback(CONFIG, model, { acquireSlot: true }); if (result && result.service) { logger.info(`[Stream Retry] Switched to new credential: ${result.uuid} (provider: ${result.actualProviderType})`); @@ -575,6 +591,11 @@ export async function handleStreamRequest(res, service, model, requestBody, from } responseClosed = true; } finally { + // 释放并发插槽 + if (providerPoolManager && pooluuid) { + providerPoolManager.releaseSlot(toProvider, pooluuid); + } + // 只在首次请求时移除事件监听器(避免重试时误删) if (!isRetry) { res.off('close', onClientClose); @@ -702,10 +723,6 @@ export async function handleUnaryRequest(res, service, model, requestBody, fromP }, error.message); credentialMarkedUnhealthy = true; } - } else if (credentialMarkedUnhealthy) { - logger.info(`[Provider Pool] Credential ${pooluuid} already marked as unhealthy by lower layer, skipping duplicate marking`); - } else if (skipErrorCount) { - logger.info(`[Provider Pool] Skipping error count for ${toProvider} (${pooluuid}) - will switch credential without marking unhealthy`); } // 如果需要切换凭证(无论是否标记不健康),都设置标记以触发重试 @@ -724,7 +741,8 @@ export async function handleUnaryRequest(res, service, model, requestBody, fromP try { // 动态导入以避免循环依赖 const { getApiServiceWithFallback } = await import('../services/service-manager.js'); - const result = await getApiServiceWithFallback(CONFIG, model); + // 使用 acquireSlot: true 以占用新凭证的并发插槽 + const result = await getApiServiceWithFallback(CONFIG, model, { acquireSlot: true }); if (result && result.service) { logger.info(`[Unary Retry] Switched to new credential: ${result.uuid} (provider: ${result.actualProviderType})`); @@ -763,6 +781,11 @@ export async function handleUnaryRequest(res, service, model, requestBody, fromP // 使用新方法创建符合 fromProvider 格式的错误响应 const errorResponse = createErrorResponse(error, fromProvider); await handleUnifiedResponse(res, JSON.stringify(errorResponse), false); + } finally { + // 确保在请求结束或出错时释放插槽 + if (providerPoolManager && pooluuid) { + providerPoolManager.releaseSlot(toProvider, pooluuid); + } } } @@ -860,10 +883,10 @@ export async function handleContentGenerationRequest(req, res, service, endpoint let actualCustomName = CONFIG.customName; // 2.5. 如果使用了提供商池,根据模型重新选择提供商(支持 Fallback) - // 注意:这里使用 skipUsageCount: true,因为初次选择时已经增加了 usageCount + // 注意:这里开启 acquireSlot: true,会占用并发名额或进入队列 if (providerPoolManager && CONFIG.providerPools && CONFIG.providerPools[CONFIG.MODEL_PROVIDER]) { const { getApiServiceWithFallback } = await import('../services/service-manager.js'); - const result = await getApiServiceWithFallback(CONFIG, model); + const result = await getApiServiceWithFallback(CONFIG, model, { acquireSlot: true }); service = result.service; toProvider = result.actualProviderType; diff --git a/src/utils/provider-strategies.js b/src/utils/provider-strategies.js index 5c02b1a..fd875f5 100644 --- a/src/utils/provider-strategies.js +++ b/src/utils/provider-strategies.js @@ -5,6 +5,7 @@ import { ClaudeStrategy } from '../providers/claude/claude-strategy.js'; import { ResponsesAPIStrategy } from '../providers/openai/openai-responses-strategy.js'; import { CodexResponsesAPIStrategy } from '../providers/openai/codex-responses-strategy.js'; import { ForwardStrategy } from '../providers/forward/forward-strategy.js'; +import { GrokStrategy } from '../providers/grok/grok-strategy.js'; /** * Strategy factory that returns the appropriate strategy instance based on the provider protocol. @@ -24,6 +25,8 @@ class ProviderStrategyFactory { return new CodexResponsesAPIStrategy(); case MODEL_PROTOCOL_PREFIX.FORWARD: return new ForwardStrategy(); + case MODEL_PROTOCOL_PREFIX.GROK: + return new GrokStrategy(); default: throw new Error(`Unsupported provider protocol: ${providerProtocol}`); } diff --git a/src/utils/provider-utils.js b/src/utils/provider-utils.js index 23313c2..8f1f854 100644 --- a/src/utils/provider-utils.js +++ b/src/utils/provider-utils.js @@ -77,6 +77,17 @@ export const PROVIDER_MAPPINGS = [ displayName: 'OpenAI Codex OAuth', needsProjectId: false, urlKeys: ['CODEX_BASE_URL'] + }, + { + // Grok Reverse 配置 + dirName: 'grok', + patterns: ['configs/grok/', '/grok/'], + providerType: 'grok-custom', + credPathKey: 'GROK_COOKIE_TOKEN', + defaultCheckModel: 'grok-3', + displayName: 'Grok Reverse', + needsProjectId: false, + urlKeys: ['GROK_BASE_URL', 'GROK_CF_CLEARANCE', 'GROK_USER_AGENT'] } ]; diff --git a/static/app/i18n.js b/static/app/i18n.js index 03adce6..cefda4f 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -90,6 +90,7 @@ const translations = { 'dashboard.routing.nodeName.qwen': 'Qwen OAuth', 'dashboard.routing.nodeName.iflow': 'iFlow OAuth', 'dashboard.routing.nodeName.codex': 'OpenAI Codex OAuth', + 'dashboard.routing.nodeName.grok': 'Grok Reverse', 'dashboard.contact.title': '联系与赞助', 'dashboard.contact.wechat': '扫码进群,注明来意', 'dashboard.contact.wechatDesc': '添加微信获取更多技术支持和交流', @@ -355,6 +356,7 @@ const translations = { 'upload.providerFilter.antigravity': 'Antigravity', 'upload.providerFilter.codex': 'Codex OAuth', 'upload.providerFilter.iflow': 'iFlow OAuth', + 'upload.providerFilter.grok': 'Grok Reverse', 'upload.providerFilter.other': '其他/未识别', 'upload.statusFilter': '关联状态', 'upload.statusFilter.all': '全部状态', @@ -454,6 +456,8 @@ const translations = { 'modal.provider.lastUsed': '最后使用:', 'modal.provider.lastCheck': '最后检测:', 'modal.provider.checkModel': '检测模型:', + 'modal.provider.concurrencyLimit': '并发限制', + 'modal.provider.queueLimit': '队列限制', 'modal.provider.usageCount': '使用次数:', 'modal.provider.errorCount': '失败次数:', 'modal.provider.neverUsed': '从未使用', @@ -484,6 +488,12 @@ const translations = { 'modal.provider.field.headerName': 'Header 名称', 'modal.provider.field.headerPrefix': 'Header 值前缀', 'modal.provider.field.useSystemProxy': '使用系统代理', + 'modal.provider.field.ssoToken': 'SSO Token (Cookie)', + 'modal.provider.field.cfClearance': 'CF Clearance (Cookie)', + 'modal.provider.field.userAgent': 'User-Agent (浏览器指纹)', + 'modal.provider.field.iflowBaseUrl': 'iFlow Base URL', + 'modal.provider.field.grokBaseUrl': 'Grok Base URL', + 'modal.provider.field.codexBaseUrl': 'Codex Base URL', 'modal.provider.field.apiKey': 'API 密钥', 'modal.provider.field.apiKey.placeholder': '请输入 API 密钥', 'modal.provider.field.projectId.placeholder': 'Google Cloud 项目 ID', @@ -633,6 +643,7 @@ const translations = { 'guide.providers.claude.desc': '使用 Claude 官方 API 或第三方代理访问 Claude 系列模型', 'guide.providers.openai.desc': '使用 OpenAI 官方 API 或第三方代理访问 GPT 系列模型', 'guide.providers.iflow.desc': '通过 iFlow OAuth 认证访问 Qwen、Kimi、DeepSeek、GLM 等模型', + 'guide.providers.grok.desc': '通过 Grok 逆向接口访问 Grok-3、Grok-4 等模型,支持生图与视频生成', 'guide.client.title': '客户端配置指南', 'guide.client.desc': '以下是常见 AI 客户端的配置方法,将 API 端点设置为本服务地址即可使用:', 'guide.client.cherry.step1': '打开设置 → 模型服务商', @@ -887,6 +898,7 @@ const translations = { 'dashboard.routing.nodeName.qwen': 'Qwen OAuth', 'dashboard.routing.nodeName.iflow': 'iFlow OAuth', 'dashboard.routing.nodeName.codex': 'OpenAI Codex OAuth', + 'dashboard.routing.nodeName.grok': 'Grok Reverse', 'dashboard.contact.title': 'Contact & Support', 'dashboard.contact.wechat': 'Scan to Join Group', 'dashboard.contact.wechatDesc': 'Add WeChat for more technical support and communication', @@ -1152,6 +1164,7 @@ const translations = { 'upload.providerFilter.antigravity': 'Antigravity', 'upload.providerFilter.codex': 'Codex OAuth', 'upload.providerFilter.iflow': 'iFlow OAuth', + 'upload.providerFilter.grok': 'Grok Reverse', 'upload.providerFilter.other': 'Other/Unknown', 'upload.statusFilter': 'Association Status', 'upload.statusFilter.all': 'All Status', @@ -1251,6 +1264,8 @@ const translations = { 'modal.provider.lastUsed': 'Last Used:', 'modal.provider.lastCheck': 'Last Check:', 'modal.provider.checkModel': 'Check Model:', + 'modal.provider.concurrencyLimit': 'Concurrency Limit', + 'modal.provider.queueLimit': 'Queue Limit', 'modal.provider.usageCount': 'Usage Count:', 'modal.provider.errorCount': 'Error Count:', 'modal.provider.neverUsed': 'Never Used', @@ -1281,6 +1296,12 @@ const translations = { 'modal.provider.field.headerName': 'Header Name', 'modal.provider.field.headerPrefix': 'Header Value Prefix', 'modal.provider.field.useSystemProxy': 'Use System Proxy', + 'modal.provider.field.ssoToken': 'SSO Token (Cookie)', + 'modal.provider.field.cfClearance': 'CF Clearance (Cookie)', + 'modal.provider.field.userAgent': 'User-Agent', + 'modal.provider.field.iflowBaseUrl': 'iFlow Base URL', + 'modal.provider.field.grokBaseUrl': 'Grok Base URL', + 'modal.provider.field.codexBaseUrl': 'Codex Base URL', 'modal.provider.field.apiKey': 'API Key', 'modal.provider.field.apiKey.placeholder': 'Please enter API Key', 'modal.provider.field.projectId.placeholder': 'Google Cloud Project ID', @@ -1430,6 +1451,7 @@ const translations = { 'guide.providers.claude.desc': 'Access Claude models via official API or third-party proxy', 'guide.providers.openai.desc': 'Access GPT models via official API or third-party proxy', 'guide.providers.iflow.desc': 'Access Qwen, Kimi, DeepSeek, GLM via iFlow OAuth', + 'guide.providers.grok.desc': 'Access Grok-3, Grok-4 models via Grok reverse interface, supports image and video generation', 'guide.client.title': 'Client Configuration Guide', 'guide.client.desc': 'Here are configuration methods for common AI clients. Set the API endpoint to this service address:', 'guide.client.cherry.step1': 'Open Settings → Model Providers', diff --git a/static/app/modal.js b/static/app/modal.js index 45e4fde..16d0419 100644 --- a/static/app/modal.js +++ b/static/app/modal.js @@ -453,16 +453,16 @@ function renderProviderConfig(provider) { // 先渲染基础配置字段(customName、checkModelName 和 checkHealth) let html = '
'; - const baseFields = ['customName', 'checkModelName', 'checkHealth']; + const baseFields = ['customName', 'checkModelName', 'checkHealth', 'concurrencyLimit', 'queueLimit']; baseFields.forEach(fieldKey => { const displayLabel = getFieldLabel(fieldKey); const value = provider[fieldKey]; - const displayValue = value || ''; + const displayValue = value !== undefined ? value : ''; // 查找字段定义以获取 placeholder const fieldDef = fieldConfigs.find(f => f.id === fieldKey) || fieldConfigs.find(f => f.id.toUpperCase() === fieldKey.toUpperCase()) || {}; - const placeholder = fieldDef.placeholder || (fieldKey === 'customName' ? '节点自定义名称' : (fieldKey === 'checkModelName' ? '例如: gpt-3.5-turbo' : '')); + const placeholder = fieldDef.placeholder || (fieldKey === 'customName' ? '节点自定义名称' : (fieldKey === 'checkModelName' ? '例如: gpt-3.5-turbo' : (fieldKey === 'concurrencyLimit' ? '最大并发, 默认0不限制' : (fieldKey === 'queueLimit' ? '最大队列, 默认0不限制' : '')))); // 如果是 customName 字段,使用普通文本输入框 if (fieldKey === 'customName') { @@ -687,6 +687,8 @@ function getFieldOrder(provider) { 'openai-qwen-oauth': ['QWEN_OAUTH_CREDS_FILE_PATH', 'QWEN_BASE_URL', 'QWEN_OAUTH_BASE_URL'], 'gemini-antigravity': ['PROJECT_ID', 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH', 'ANTIGRAVITY_BASE_URL_DAILY', 'ANTIGRAVITY_BASE_URL_AUTOPUSH'], 'openai-iflow': ['IFLOW_OAUTH_CREDS_FILE_PATH', 'IFLOW_BASE_URL'], + 'openai-codex-oauth': ['CODEX_OAUTH_CREDS_FILE_PATH', 'CODEX_EMAIL', 'CODEX_BASE_URL'], + 'grok-custom': ['GROK_COOKIE_TOKEN', 'GROK_CF_CLEARANCE', 'GROK_USER_AGENT', 'GROK_BASE_URL'], 'forward-api': ['FORWARD_API_KEY', 'FORWARD_BASE_URL', 'FORWARD_HEADER_NAME', 'FORWARD_HEADER_VALUE_PREFIX'] }; @@ -707,6 +709,10 @@ function getFieldOrder(provider) { providerType = 'gemini-antigravity'; } else if (provider.IFLOW_OAUTH_CREDS_FILE_PATH) { providerType = 'openai-iflow'; + } else if (provider.CODEX_OAUTH_CREDS_FILE_PATH) { + providerType = 'openai-codex-oauth'; + } else if (provider.GROK_COOKIE_TOKEN) { + providerType = 'grok-custom'; } else if (provider.FORWARD_API_KEY) { providerType = 'forward-api'; } @@ -896,7 +902,10 @@ async function saveProvider(uuid, event) { configInputs.forEach(input => { const key = input.dataset.configKey; - const value = input.value; + let value = input.value; + if (key === 'concurrencyLimit' || key === 'queueLimit') { + value = parseInt(value || '0'); + } providerConfig[key] = value; }); @@ -1090,6 +1099,14 @@ function showAddProviderForm(providerType) {
+
+ + +
+
+ + +
@@ -1126,8 +1143,8 @@ function addDynamicConfigFields(form, providerType) { // 获取该提供商类型的字段配置(已经在 utils.js 中包含了 URL 字段) const allFields = getProviderTypeFields(providerType); - // 过滤掉已经在 form-grid 中硬编码显示的三个基础字段,避免重复 - const baseFields = ['customName', 'checkModelName', 'checkHealth']; + // 过滤掉已经在 form-grid 中硬编码显示的五个基础字段,避免重复 + const baseFields = ['customName', 'checkModelName', 'checkHealth', 'concurrencyLimit', 'queueLimit']; const filteredFields = allFields.filter(f => !baseFields.some(bf => f.id.toLowerCase().includes(bf.toLowerCase()))); let fields = ''; @@ -1265,11 +1282,15 @@ async function addProvider(providerType) { const customName = document.getElementById('newCustomName')?.value; const checkModelName = document.getElementById('newCheckModelName')?.value; const checkHealth = document.getElementById('newCheckHealth')?.value === 'true'; + const concurrencyLimit = parseInt(document.getElementById('newConcurrencyLimit')?.value || '0'); + const queueLimit = parseInt(document.getElementById('newQueueLimit')?.value || '0'); const providerConfig = { customName: customName || '', // 允许为空 checkModelName: checkModelName || '', // 允许为空 - checkHealth + checkHealth, + concurrencyLimit, + queueLimit }; // 根据提供商类型动态收集配置字段(自动匹配 utils.js 中的定义) diff --git a/static/app/provider-manager.js b/static/app/provider-manager.js index ca40881..92a911c 100644 --- a/static/app/provider-manager.js +++ b/static/app/provider-manager.js @@ -181,8 +181,11 @@ function updateTimeDisplay() { */ async function loadProviders() { try { - const data = await window.apiClient.get('/providers'); - renderProviders(data); + const [providers, supportedProviders] = await Promise.all([ + window.apiClient.get('/providers'), + window.apiClient.get('/providers/supported') + ]); + renderProviders(providers, supportedProviders); } catch (error) { console.error('Failed to load providers:', error); } @@ -191,8 +194,9 @@ async function loadProviders() { /** * 渲染提供商列表 * @param {Object} providers - 提供商数据 + * @param {string[]} supportedProviders - 已注册的提供商类型列表 */ -function renderProviders(providers) { +function renderProviders(providers, supportedProviders = []) { const container = document.getElementById('providersList'); if (!container) return; @@ -206,17 +210,19 @@ function renderProviders(providers) { if (statsGrid) statsGrid.style.display = 'grid'; // 定义所有支持的提供商配置(顺序、显示名称、是否显示) + // visible 现在由 supportedProviders 决定 const providerConfigs = [ - { id: 'forward-api', name: 'NewAPI', visible: false }, - { id: 'gemini-cli-oauth', name: 'Gemini CLI OAuth', visible: true }, - { id: 'gemini-antigravity', name: 'Gemini Antigravity', visible: true }, - { id: 'openai-custom', name: 'OpenAI Custom', visible: true }, - { id: 'claude-custom', name: 'Claude Custom', visible: true }, - { id: 'claude-kiro-oauth', name: 'Claude Kiro OAuth', visible: true }, - { id: 'openai-qwen-oauth', name: 'OpenAI Qwen OAuth', visible: true }, - { id: 'openaiResponses-custom', name: 'OpenAI Responses', visible: true }, - { id: 'openai-iflow', name: 'OpenAI iFlow', visible: true }, - { id: 'openai-codex-oauth', name: 'OpenAI Codex OAuth', visible: true }, + { id: 'forward-api', name: 'NewAPI', visible: supportedProviders.includes('forward-api') }, + { id: 'gemini-cli-oauth', name: 'Gemini CLI OAuth', visible: supportedProviders.includes('gemini-cli-oauth') }, + { id: 'gemini-antigravity', name: 'Gemini Antigravity', visible: supportedProviders.includes('gemini-antigravity') }, + { id: 'openai-custom', name: 'OpenAI Custom', visible: supportedProviders.includes('openai-custom') }, + { id: 'claude-custom', name: 'Claude Custom', visible: supportedProviders.includes('claude-custom') }, + { id: 'claude-kiro-oauth', name: 'Claude Kiro OAuth', visible: supportedProviders.includes('claude-kiro-oauth') }, + { id: 'openai-qwen-oauth', name: 'OpenAI Qwen OAuth', visible: supportedProviders.includes('openai-qwen-oauth') }, + { id: 'openaiResponses-custom', name: 'OpenAI Responses', visible: supportedProviders.includes('openaiResponses-custom') }, + { id: 'openai-iflow', name: 'OpenAI iFlow', visible: supportedProviders.includes('openai-iflow') }, + { id: 'openai-codex-oauth', name: 'OpenAI Codex OAuth', visible: supportedProviders.includes('openai-codex-oauth') }, + { id: 'grok-custom', name: 'Grok Reverse', visible: supportedProviders.includes('grok-custom') }, ]; // 提取显示的 ID 顺序 diff --git a/static/app/routing-examples.js b/static/app/routing-examples.js index 1dbb54f..20de420 100644 --- a/static/app/routing-examples.js +++ b/static/app/routing-examples.js @@ -183,6 +183,17 @@ function getAvailableRoutes() { description: '结构化对话API', badge: 'Responses', badgeClass: 'responses' + }, + { + provider: 'grok-custom', + name: 'Grok Reverse', + paths: { + openai: '/grok-custom/v1/chat/completions', + claude: '/grok-custom/v1/messages' + }, + description: t('dashboard.routing.free'), + badge: t('dashboard.routing.free'), + badgeClass: 'oauth' } ]; } @@ -316,6 +327,27 @@ async function copyCurlExample(provider, options = {}) { "model": "${model}", "max_tokens": 1000, "messages": [{"role": "user", "content": "${message}"}] + }'`; + } + break; + case 'grok-custom': + if (protocol === 'openai') { + curlCommand = `curl http://localhost:3000${path} \\ + -H "Content-Type: application/json" \\ + -H "Authorization: Bearer YOUR_API_KEY" \\ + -d '{ + "model": "grok-3", + "messages": [{"role": "user", "content": "${message}"}], + "stream": true + }'`; + } else { + curlCommand = `curl http://localhost:3000${path} \\ + -H "Content-Type: application/json" \\ + -H "X-API-Key: YOUR_API_KEY" \\ + -d '{ + "model": "grok-3", + "max_tokens": 1000, + "messages": [{"role": "user", "content": "${message}"}] }'`; } break; diff --git a/static/app/usage-manager.js b/static/app/usage-manager.js index c4d23ab..dd908c6 100644 --- a/static/app/usage-manager.js +++ b/static/app/usage-manager.js @@ -827,7 +827,8 @@ function getProviderDisplayName(providerType) { 'gemini-cli-oauth': 'Gemini CLI OAuth', 'gemini-antigravity': 'Gemini Antigravity', 'openai-codex-oauth': 'Codex OAuth', - 'openai-qwen-oauth': 'Qwen OAuth' + 'openai-qwen-oauth': 'Qwen OAuth', + 'grok-custom': 'Grok Reverse' }; return names[providerType] || providerType; } @@ -843,7 +844,8 @@ function getProviderIcon(providerType) { 'gemini-cli-oauth': 'fas fa-gem', 'gemini-antigravity': 'fas fa-rocket', 'openai-codex-oauth': 'fas fa-terminal', - 'openai-qwen-oauth': 'fas fa-code' + 'openai-qwen-oauth': 'fas fa-code', + 'grok-custom': 'fas fa-brain' }; return icons[providerType] || 'fas fa-server'; } diff --git a/static/app/utils.js b/static/app/utils.js index e8a809d..49a06ad 100644 --- a/static/app/utils.js +++ b/static/app/utils.js @@ -72,6 +72,8 @@ function getFieldLabel(key) { 'customName': t('modal.provider.customName') + ' ' + t('config.optional'), 'checkModelName': t('modal.provider.checkModelName') + ' ' + t('config.optional'), 'checkHealth': t('modal.provider.healthCheckLabel'), + 'concurrencyLimit': t('modal.provider.concurrencyLimit') + ' ' + t('config.optional'), + 'queueLimit': t('modal.provider.queueLimit') + ' ' + t('config.optional'), 'OPENAI_API_KEY': 'OpenAI API Key', 'OPENAI_BASE_URL': 'OpenAI Base URL', 'CLAUDE_API_KEY': 'Claude API Key', @@ -83,6 +85,9 @@ function getFieldLabel(key) { 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH': t('modal.provider.field.oauthPath'), 'IFLOW_OAUTH_CREDS_FILE_PATH': t('modal.provider.field.oauthPath'), 'CODEX_OAUTH_CREDS_FILE_PATH': t('modal.provider.field.oauthPath'), + 'GROK_COOKIE_TOKEN': t('modal.provider.field.ssoToken'), + 'GROK_CF_CLEARANCE': t('modal.provider.field.cfClearance'), + 'GROK_USER_AGENT': t('modal.provider.field.userAgent'), 'GEMINI_BASE_URL': 'Gemini Base URL', 'KIRO_BASE_URL': t('modal.provider.field.baseUrl'), 'KIRO_REFRESH_URL': t('modal.provider.field.refreshUrl'), @@ -91,7 +96,9 @@ function getFieldLabel(key) { 'QWEN_OAUTH_BASE_URL': t('modal.provider.field.oauthBaseUrl'), 'ANTIGRAVITY_BASE_URL_DAILY': t('modal.provider.field.dailyBaseUrl'), 'ANTIGRAVITY_BASE_URL_AUTOPUSH': t('modal.provider.field.autopushBaseUrl'), - 'IFLOW_BASE_URL': 'iFlow Base URL', + 'IFLOW_BASE_URL': t('modal.provider.field.iflowBaseUrl'), + 'CODEX_BASE_URL': t('modal.provider.field.codexBaseUrl'), + 'GROK_BASE_URL': t('modal.provider.field.grokBaseUrl'), 'FORWARD_API_KEY': 'Forward API Key', 'FORWARD_BASE_URL': 'Forward Base URL', 'FORWARD_HEADER_NAME': t('modal.provider.field.headerName'), @@ -272,11 +279,63 @@ function getProviderTypeFields(providerType) { }, { id: 'CODEX_BASE_URL', - label: `Codex Base URL ${t('config.optional')}`, + label: `${t('modal.provider.field.codexBaseUrl')} ${t('config.optional')}`, type: 'text', placeholder: 'https://api.openai.com/v1/codex' } ], + 'grok-custom': [ + { + id: 'GROK_COOKIE_TOKEN', + label: t('modal.provider.field.ssoToken'), + type: 'password', + placeholder: 'sso cookie token' + }, + { + id: 'GROK_CF_CLEARANCE', + label: t('modal.provider.field.cfClearance'), + type: 'text', + placeholder: 'cf_clearance cookie value' + }, + { + id: 'GROK_USER_AGENT', + label: t('modal.provider.field.userAgent'), + type: 'text', + placeholder: 'Mozilla/5.0 ...' + }, + { + id: 'GROK_BASE_URL', + label: `${t('modal.provider.field.grokBaseUrl')} ${t('config.optional')}`, + type: 'text', + placeholder: 'https://grok.com' + } + ], + 'grok-custom': [ + { + id: 'GROK_COOKIE_TOKEN', + label: t('modal.provider.field.ssoToken'), + type: 'password', + placeholder: 'sso cookie token' + }, + { + id: 'GROK_CF_CLEARANCE', + label: t('modal.provider.field.cfClearance'), + type: 'text', + placeholder: 'cf_clearance cookie value' + }, + { + id: 'GROK_USER_AGENT', + label: t('modal.provider.field.userAgent'), + type: 'text', + placeholder: 'Mozilla/5.0 ...' + }, + { + id: 'GROK_BASE_URL', + label: `Grok Base URL ${t('config.optional')}`, + type: 'text', + placeholder: 'https://grok.com' + } + ], 'forward-api': [ { id: 'FORWARD_API_KEY', diff --git a/static/components/section-config.html b/static/components/section-config.html index 357dd73..6625b72 100644 --- a/static/components/section-config.html +++ b/static/components/section-config.html @@ -31,27 +31,27 @@
+
点击选择启动时初始化的模型提供商 (必须至少选择一个) @@ -117,6 +121,10 @@ OpenAI Codex OAuth +
点击选择需要通过代理访问的提供商,未选中的提供商将直接连接 diff --git a/static/components/section-dashboard.html b/static/components/section-dashboard.html index 5d44db1..f86380b 100644 --- a/static/components/section-dashboard.html +++ b/static/components/section-dashboard.html @@ -544,6 +544,59 @@ "model": "gpt-5", "max_tokens": 4096, "messages": [{"role": "user", "content": "解释PKCE认证流程"}] + }' + + + + + +
+
+ +

Grok Reverse

+ 突破限制/免费使用 +
+
+ +
+ + +
+ + +
+
+ + /grok-custom/v1/chat/completions +
+
+ +
curl http://localhost:3000/grok-custom/v1/chat/completions \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer YOUR_API_KEY" \
+  -d '{
+    "model": "grok-3",
+    "messages": [{"role": "user", "content": "你好"}],
+    "stream": true
+  }'
+
+
+ + +
+
+ + /grok-custom/v1/messages +
+
+ +
curl http://localhost:3000/grok-custom/v1/messages \
+  -H "Content-Type: application/json" \
+  -H "X-API-Key: YOUR_API_KEY" \
+  -d '{
+    "model": "grok-3",
+    "max_tokens": 4096,
+    "messages": [{"role": "user", "content": "你好"}]
   }'
diff --git a/tests/concurrent-test.js b/tests/concurrent-test.js new file mode 100644 index 0000000..f515667 --- /dev/null +++ b/tests/concurrent-test.js @@ -0,0 +1,454 @@ +/** + * 并发测试脚本 + * 用于测试 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); +});