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:
parent
4ee0fb4b96
commit
68719879c5
27 changed files with 2666 additions and 133 deletions
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
// 自动注册所有转换器
|
||||
|
|
|
|||
661
src/converters/strategies/GrokConverter.js
Normal file
661
src/converters/strategies/GrokConverter.js
Normal 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 ``;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染视频为 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[](${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 += `\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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
634
src/providers/grok/grok-core.js
Normal file
634
src/providers/grok/grok-core.js
Normal 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 };
|
||||
}
|
||||
}
|
||||
56
src/providers/grok/grok-strategy.js
Normal file
56
src/providers/grok/grok-strategy.js
Normal 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 };
|
||||
|
|
@ -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'
|
||||
]
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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} 格式化后的用量信息
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取特定提供商类型的详细信息
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 中的定义)
|
||||
|
|
|
|||
|
|
@ -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 顺序
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
454
tests/concurrent-test.js
Normal 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);
|
||||
});
|
||||
Loading…
Reference in a new issue