feat(architecture): 重构适配器注册机制并引入并发控制系统

建立可扩展的提供商适配器注册表,实现动态服务发现与插槽管理:

架构改进:
- 采用 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
This commit is contained in:
hex2077 2026-02-26 18:19:38 +08:00
parent 4ee0fb4b96
commit 68719879c5
27 changed files with 2666 additions and 133 deletions

View file

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

View file

@ -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);
}
// 自动注册所有转换器

View file

@ -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 // 是否处于 <tool_call> 块内
});
}
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 <tool_call> block with a JSON object containing \"name\" and \"arguments\".",
"",
"Format:",
"<tool_call>",
'{"name": "function_name", "arguments": {"param": "value"}}',
"</tool_call>",
"",
];
if (parallelToolCalls) {
lines.push("You may make multiple tool calls in a single response by using multiple <tool_call> 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 <tool_call> 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(`<tool_call>{"name":"${func.name}","arguments":${func.arguments || "{}"}}</tool_call>`);
}
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 = /<tool_call>\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>(.*?)<\/xai:tool_name>/s);
const argsMatch = raw.match(/<xai:tool_args>(.*?)<\/xai:tool_args>/s);
let name = nameMatch ? nameMatch[1].replace(/<!\[CDATA\[(.*?)\]\]>/gs, "$1").trim() : "";
let args = argsMatch ? argsMatch[1].replace(/<!\[CDATA\[(.*?)\]\]>/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[^>]*>.*?<\/xai:tool_usage_card>/gs, "");
filtered = filtered.replace(/<xai:tool_usage_card[^>]*\/>/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</think>\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 += "<think>\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 += "<think>\n";
state.think_opened = true;
}
*/
const progress = vid.progress || 0;
deltaReasoning += `正在生成视频中,当前进度${progress}%\n`;
if (progress === 100 && vid.videoUrl) {
/*
if (state.think_opened) {
deltaContent += "\n</think>\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</think>\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 {
// 工具调用抑制逻辑:不向客户端输出 <tool_call> 块及其内容
let outputToken = filtered;
// 简单的状态切换检测
if (outputToken.includes('<tool_call>')) {
state.in_tool_call = true;
state.has_tool_call = true;
// 移除标签之后的部分(如果有)
outputToken = outputToken.split('<tool_call>')[0];
} else if (state.in_tool_call && outputToken.includes('</tool_call>')) {
state.in_tool_call = false;
// 只保留标签之后的部分
outputToken = outputToken.split('</tool_call>')[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;
}
}

View file

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

View file

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

View file

@ -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<Object>} 用量限制信息
*/
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];

View file

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

View file

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

View file

@ -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'
]
};
/**

View file

@ -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.

View file

@ -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 = [];

View file

@ -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) {

View file

@ -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<Object>} 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<string>} 支持的提供商类型列表
*/
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} 格式化后的用量信息
*/

View file

@ -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;
}
/**
* 获取特定提供商类型的详细信息
*/

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -453,16 +453,16 @@ function renderProviderConfig(provider) {
// 先渲染基础配置字段customName、checkModelName 和 checkHealth
let html = '<div class="form-grid">';
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) {
<option value="true" data-i18n="modal.provider.enabled">启用</option>
</select>
</div>
<div class="form-group">
<label><span data-i18n="modal.provider.concurrencyLimit">并发限制</span> <span class="optional-mark" data-i18n="config.optional">()</span></label>
<input type="number" id="newConcurrencyLimit" placeholder="默认0不限制">
</div>
<div class="form-group">
<label><span data-i18n="modal.provider.queueLimit">队列限制</span> <span class="optional-mark" data-i18n="config.optional">()</span></label>
<input type="number" id="newQueueLimit" placeholder="默认0不限制">
</div>
</div>
<div id="dynamicConfigFields">
<!-- 动态配置字段将在这里显示 -->
@ -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 中的定义)

View file

@ -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 顺序

View file

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

View file

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

View file

@ -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 <span class="optional-tag">${t('config.optional')}</span>`,
label: `${t('modal.provider.field.codexBaseUrl')} <span class="optional-tag">${t('config.optional')}</span>`,
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')} <span class="optional-tag">${t('config.optional')}</span>`,
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 <span class="optional-tag">${t('config.optional')}</span>`,
type: 'text',
placeholder: 'https://grok.com'
}
],
'forward-api': [
{
id: 'FORWARD_API_KEY',

View file

@ -31,27 +31,27 @@
<div id="modelProvider" class="provider-tags">
<button type="button" class="provider-tag" data-value="gemini-cli-oauth">
<i class="fas fa-robot"></i>
<span>Gemini CLI OAuth</span>
<span data-i18n="dashboard.routing.nodeName.gemini">Gemini CLI OAuth</span>
</button>
<button type="button" class="provider-tag" data-value="gemini-antigravity">
<i class="fas fa-rocket"></i>
<span>Gemini Antigravity</span>
<span data-i18n="dashboard.routing.nodeName.antigravity">Gemini Antigravity</span>
</button>
<button type="button" class="provider-tag" data-value="openai-custom">
<i class="fas fa-brain"></i>
<span>OpenAI Custom</span>
<span data-i18n="dashboard.routing.nodeName.openai">OpenAI Custom</span>
</button>
<button type="button" class="provider-tag" data-value="claude-custom">
<i class="fas fa-comment-dots"></i>
<span>Claude Custom</span>
<span data-i18n="dashboard.routing.nodeName.claude">Claude Custom</span>
</button>
<button type="button" class="provider-tag" data-value="claude-kiro-oauth">
<i class="fas fa-key"></i>
<span>Claude Kiro OAuth</span>
<span data-i18n="dashboard.routing.nodeName.kiro">Claude Kiro OAuth</span>
</button>
<button type="button" class="provider-tag" data-value="openai-qwen-oauth">
<i class="fas fa-cloud"></i>
<span>Qwen OAuth</span>
<span data-i18n="dashboard.routing.nodeName.qwen">Qwen OAuth</span>
</button>
<button type="button" class="provider-tag" data-value="openaiResponses-custom">
<i class="fas fa-reply"></i>
@ -59,11 +59,15 @@
</button>
<button type="button" class="provider-tag" data-value="openai-iflow">
<i class="fas fa-stream"></i>
<span>iFlow OAuth</span>
<span data-i18n="dashboard.routing.nodeName.iflow">iFlow OAuth</span>
</button>
<button type="button" class="provider-tag" data-value="openai-codex-oauth">
<i class="fas fa-code"></i>
<span>OpenAI Codex OAuth</span>
<span data-i18n="dashboard.routing.nodeName.codex">OpenAI Codex OAuth</span>
</button>
<button type="button" class="provider-tag" data-value="grok-custom">
<i class="fas fa-search"></i>
<span data-i18n="dashboard.routing.nodeName.grok">Grok Reverse</span>
</button>
</div>
<small class="form-text" data-i18n="config.modelProviderHelp">点击选择启动时初始化的模型提供商 (必须至少选择一个)</small>
@ -117,6 +121,10 @@
<i class="fas fa-code"></i>
<span>OpenAI Codex OAuth</span>
</button>
<button type="button" class="provider-tag" data-value="grok-custom">
<i class="fas fa-search"></i>
<span>Grok Reverse</span>
</button>
</div>
<small class="form-text" data-i18n="config.proxy.enabledProvidersNote">点击选择需要通过代理访问的提供商,未选中的提供商将直接连接</small>
</div>

View file

@ -544,6 +544,59 @@
"model": "gpt-5",
"max_tokens": 4096,
"messages": [{"role": "user", "content": "解释PKCE认证流程"}]
}'</code></pre>
</div>
</div>
</div>
</div>
<div class="routing-example-card" data-provider="grok-custom-card">
<div class="routing-card-header">
<i class="fas fa-search"></i>
<h4 data-i18n="dashboard.routing.nodeName.grok">Grok Reverse</h4>
<span class="provider-badge oauth" data-i18n="dashboard.routing.free">突破限制/免费使用</span>
</div>
<div class="routing-card-content">
<!-- 协议标签切换 -->
<div class="protocol-tabs">
<button class="protocol-tab active" data-protocol="openai" data-i18n="dashboard.routing.openai">OpenAI协议</button>
<button class="protocol-tab" data-protocol="claude" data-i18n="dashboard.routing.claude">Claude协议</button>
</div>
<!-- OpenAI协议示例 -->
<div class="protocol-content active" data-protocol="openai">
<div class="endpoint-info">
<label data-i18n="dashboard.routing.endpoint">端点路径:</label>
<code class="endpoint-path">/grok-custom/v1/chat/completions</code>
</div>
<div class="usage-example">
<label data-i18n="dashboard.routing.exampleOpenAI">使用示例 (OpenAI格式):</label>
<pre><code>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
}'</code></pre>
</div>
</div>
<!-- Claude协议示例 -->
<div class="protocol-content" data-protocol="claude">
<div class="endpoint-info">
<label data-i18n="dashboard.routing.endpoint">端点路径:</label>
<code class="endpoint-path">/grok-custom/v1/messages</code>
</div>
<div class="usage-example">
<label data-i18n="dashboard.routing.exampleClaude">使用示例 (Claude格式):</label>
<pre><code>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": "你好"}]
}'</code></pre>
</div>
</div>

454
tests/concurrent-test.js Normal file
View file

@ -0,0 +1,454 @@
/**
* 并发测试脚本
* 用于测试 API 服务器在高并发场景下的性能和稳定性
*
* 使用方法:
* node tests/concurrent-test.js [选项]
*
* 选项:
* --url <url> API 服务器地址 (默认: http://localhost:3000)
* --api-key <key> API 密钥 (默认: 123456)
* --concurrency <n> 并发数 (默认: 10)
* --requests <n> 总请求数 (默认: 100)
* --endpoint <path> 测试端点 (默认: /v1/chat/completions)
* --model <model> 模型名称 (默认: gpt-4)
* --stream 使用流式响应 (默认: false)
* --timeout <ms> 请求超时时间 (默认: 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 <url> API 服务器地址 (默认: http://localhost:3000)
--api-key <key> API 密钥 (默认: 123456)
--concurrency <n> 并发数 (默认: 10)
--requests <n> 总请求数 (默认: 100)
--endpoint <path> 测试端点 (默认: /v1/chat/completions)
--model <model> 模型名称 (默认: gpt-4)
--stream 使用流式响应 (默认: false)
--timeout <ms> 请求超时时间 (默认: 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);
});