feat(grok): 添加资源代理功能并优化用量显示
- 新增 Grok 资源代理接口,将 assets.grok.com 的资源通过本地代理访问 - 在请求处理中注入 requestBaseUrl 配置,供转换器生成正确的代理链接 - 统一各提供商核心服务中删除 _requestBaseUrl 字段的逻辑 - 优化 Grok 用量显示逻辑,支持按 token 或 query 显示剩余额度 - 更新 UI 管理器,允许 /api/grok/assets 接口免认证访问 - 改进 Grok 转换器,在流式输出中智能处理被截断的 URL
This commit is contained in:
parent
1cf6f9092b
commit
c91d2ce3ab
18 changed files with 416 additions and 46 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
2.10.1
|
||||
2.10.2
|
||||
|
|
|
|||
|
|
@ -13,12 +13,82 @@ import { MODEL_PROTOCOL_PREFIX } from '../../utils/common.js';
|
|||
* 实现Grok协议到其他协议的转换
|
||||
*/
|
||||
export class GrokConverter extends BaseConverter {
|
||||
// 静态属性,确保所有实例共享最新的认证和基础 URL 配置
|
||||
static sharedSsoToken = null;
|
||||
static sharedRequestBaseUrl = "";
|
||||
|
||||
constructor() {
|
||||
super('grok');
|
||||
// 用于跟踪每个请求的状态
|
||||
this.requestStates = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Grok SSO token
|
||||
*/
|
||||
setSsoToken(token) {
|
||||
if (!token) return;
|
||||
|
||||
// 如果 token 包含 sso= 前缀,则去掉它
|
||||
let processedToken = token;
|
||||
if (processedToken.startsWith("sso=")) {
|
||||
processedToken = processedToken.substring(4);
|
||||
}
|
||||
GrokConverter.sharedSsoToken = processedToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置请求的基础 URL
|
||||
*/
|
||||
setRequestBaseUrl(baseUrl) {
|
||||
if (baseUrl) {
|
||||
GrokConverter.sharedRequestBaseUrl = baseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为 assets.grok.com 域名的资源 URL 添加 sso 参数,并转换为本地代理 URL
|
||||
*/
|
||||
_appendSsoToken(url, state = null) {
|
||||
const ssoToken = state?.ssoToken || GrokConverter.sharedSsoToken;
|
||||
const requestBaseUrl = state?.requestBaseUrl || GrokConverter.sharedRequestBaseUrl;
|
||||
|
||||
if (!url || !ssoToken) return url;
|
||||
|
||||
// 检查是否为 assets.grok.com 域名或相对路径
|
||||
const isGrokAsset = url.includes('assets.grok.com') || (!url.startsWith('http') && !url.startsWith('data:'));
|
||||
|
||||
if (!isGrokAsset) return url;
|
||||
|
||||
// 构造完整的原始 URL
|
||||
let originalUrl = url;
|
||||
if (!url.startsWith('http')) {
|
||||
originalUrl = `https://assets.grok.com${url.startsWith('/') ? '' : '/'}${url}`;
|
||||
}
|
||||
|
||||
// 返回本地代理接口 URL
|
||||
const proxyPath = `/api/grok/assets?url=${encodeURIComponent(originalUrl)}&sso=${encodeURIComponent(ssoToken)}`;
|
||||
if (requestBaseUrl) {
|
||||
return `${requestBaseUrl}${proxyPath}`;
|
||||
}
|
||||
return proxyPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在文本中查找并替换所有 assets.grok.com 的资源链接为绝对代理链接
|
||||
*/
|
||||
_processGrokAssetsInText(text, state = null) {
|
||||
const ssoToken = state?.ssoToken || GrokConverter.sharedSsoToken;
|
||||
if (!text || !ssoToken) return text;
|
||||
|
||||
// 更宽松的正则匹配 assets.grok.com 的 URL
|
||||
const grokUrlRegex = /https?:\/\/assets\.grok\.com\/[^\s\)\"\'\>]+/g;
|
||||
|
||||
return text.replace(grokUrlRegex, (url) => {
|
||||
return this._appendSsoToken(url, state);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或初始化请求状态
|
||||
*/
|
||||
|
|
@ -35,7 +105,10 @@ export class GrokConverter extends BaseConverter {
|
|||
content_buffer: "", // 用于缓存内容以解析工具调用
|
||||
has_tool_call: false,
|
||||
rollout_id: "",
|
||||
in_tool_call: false // 是否处于 <tool_call> 块内
|
||||
in_tool_call: false, // 是否处于 <tool_call> 块内
|
||||
ssoToken: null,
|
||||
requestBaseUrl: "",
|
||||
pending_text_buffer: "" // 用于处理流式输出中被截断的 URL
|
||||
});
|
||||
}
|
||||
return this.requestStates.get(requestId);
|
||||
|
|
@ -277,6 +350,7 @@ export class GrokConverter extends BaseConverter {
|
|||
if (!url.startsWith('http')) {
|
||||
finalUrl = `https://assets.grok.com${url.startsWith('/') ? '' : '/'}${url}`;
|
||||
}
|
||||
finalUrl = this._appendSsoToken(finalUrl);
|
||||
return ``;
|
||||
}
|
||||
|
||||
|
|
@ -288,13 +362,16 @@ export class GrokConverter extends BaseConverter {
|
|||
if (!videoUrl.startsWith('http')) {
|
||||
finalVideoUrl = `https://assets.grok.com${videoUrl.startsWith('/') ? '' : '/'}${videoUrl}`;
|
||||
}
|
||||
finalVideoUrl = this._appendSsoToken(finalVideoUrl);
|
||||
|
||||
let finalThumbUrl = thumbnailImageUrl;
|
||||
if (thumbnailImageUrl && !thumbnailImageUrl.startsWith('http')) {
|
||||
finalThumbUrl = `https://assets.grok.com${thumbnailImageUrl.startsWith('/') ? '' : '/'}${thumbnailImageUrl}`;
|
||||
}
|
||||
finalThumbUrl = this._appendSsoToken(finalThumbUrl);
|
||||
|
||||
return `\n[](${finalVideoUrl})\n[Play Video](${finalVideoUrl})\n`;
|
||||
const defaultThumb = this._appendSsoToken('https://assets.grok.com/favicon.ico');
|
||||
return `\n[](${finalVideoUrl})\n[Play Video](${finalVideoUrl})\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -376,22 +453,35 @@ export class GrokConverter extends BaseConverter {
|
|||
const responseId = grokResponse.responseId || `chatcmpl-${uuidv4()}`;
|
||||
let content = grokResponse.message || "";
|
||||
const modelHash = grokResponse.llmInfo?.modelHash || "";
|
||||
|
||||
const state = this._getState(this._formatResponseId(responseId));
|
||||
if (grokResponse._ssoToken) {
|
||||
let processedToken = grokResponse._ssoToken;
|
||||
if (processedToken.startsWith("sso=")) {
|
||||
processedToken = processedToken.substring(4);
|
||||
}
|
||||
state.ssoToken = processedToken;
|
||||
}
|
||||
if (grokResponse._requestBaseUrl) {
|
||||
state.requestBaseUrl = grokResponse._requestBaseUrl;
|
||||
}
|
||||
|
||||
// 过滤内容
|
||||
// 过滤内容并处理其中的 Grok 资源链接
|
||||
content = this._filterToken(content, responseId);
|
||||
content = this._processGrokAssetsInText(content, state);
|
||||
|
||||
// 收集图片并追加
|
||||
const imageUrls = this._collectImages(grokResponse);
|
||||
if (imageUrls.length > 0) {
|
||||
content += "\n";
|
||||
for (const url of imageUrls) {
|
||||
content += this._renderImage(url) + "\n";
|
||||
content += this._renderImage(url, "image", state) + "\n";
|
||||
}
|
||||
}
|
||||
|
||||
// 处理视频 (非流式模式)
|
||||
if (grokResponse.finalVideoUrl) {
|
||||
content += this._renderVideo(grokResponse.finalVideoUrl, grokResponse.finalThumbnailUrl);
|
||||
content += this._renderVideo(grokResponse.finalVideoUrl, grokResponse.finalThumbnailUrl, state);
|
||||
}
|
||||
|
||||
// 解析工具调用
|
||||
|
|
@ -444,6 +534,18 @@ export class GrokConverter extends BaseConverter {
|
|||
const responseId = this._formatResponseId(rawResponseId);
|
||||
const state = this._getState(responseId);
|
||||
|
||||
// 从响应块中同步 token 和基础 URL
|
||||
if (resp._ssoToken) {
|
||||
let processedToken = resp._ssoToken;
|
||||
if (processedToken.startsWith("sso=")) {
|
||||
processedToken = processedToken.substring(4);
|
||||
}
|
||||
state.ssoToken = processedToken;
|
||||
}
|
||||
if (resp._requestBaseUrl) {
|
||||
state.requestBaseUrl = resp._requestBaseUrl;
|
||||
}
|
||||
|
||||
if (resp.llmInfo?.modelHash && !state.fingerprint) {
|
||||
state.fingerprint = resp.llmInfo.modelHash;
|
||||
}
|
||||
|
|
@ -473,12 +575,11 @@ export class GrokConverter extends BaseConverter {
|
|||
// 处理结束标志
|
||||
if (resp.isDone) {
|
||||
let finalContent = "";
|
||||
/*
|
||||
if (state.think_opened) {
|
||||
finalContent += "\n</think>\n";
|
||||
state.think_opened = false;
|
||||
// 处理剩余的缓冲区
|
||||
if (state.pending_text_buffer) {
|
||||
finalContent += this._processGrokAssetsInText(state.pending_text_buffer, state);
|
||||
state.pending_text_buffer = "";
|
||||
}
|
||||
*/
|
||||
|
||||
// 处理 buffer 中的工具调用
|
||||
const { text, toolCalls } = this.parseToolCalls(state.content_buffer);
|
||||
|
|
@ -493,7 +594,7 @@ export class GrokConverter extends BaseConverter {
|
|||
choices: [{
|
||||
index: 0,
|
||||
delta: {
|
||||
content: ((/* finalContent + */ "") + (text || "")).trim() || null,
|
||||
content: (finalContent + (text || "")).trim() || null,
|
||||
tool_calls: toolCalls
|
||||
},
|
||||
finish_reason: "tool_calls"
|
||||
|
|
@ -508,7 +609,7 @@ export class GrokConverter extends BaseConverter {
|
|||
system_fingerprint: state.fingerprint,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { content: /* finalContent || */ null },
|
||||
delta: { content: finalContent || null },
|
||||
finish_reason: "stop"
|
||||
}]
|
||||
});
|
||||
|
|
@ -558,7 +659,7 @@ export class GrokConverter extends BaseConverter {
|
|||
}
|
||||
*/
|
||||
state.video_think_active = false;
|
||||
deltaContent += this._renderVideo(vid.videoUrl, vid.thumbnailImageUrl);
|
||||
deltaContent += this._renderVideo(vid.videoUrl, vid.thumbnailImageUrl, state);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -576,7 +677,7 @@ export class GrokConverter extends BaseConverter {
|
|||
|
||||
const imageUrls = this._collectImages(mr);
|
||||
for (const url of imageUrls) {
|
||||
deltaContent += this._renderImage(url) + "\n";
|
||||
deltaContent += this._renderImage(url, "image", state) + "\n";
|
||||
}
|
||||
|
||||
if (mr.metadata?.llm_info?.modelHash) {
|
||||
|
|
@ -590,9 +691,14 @@ export class GrokConverter extends BaseConverter {
|
|||
if (card.jsonData) {
|
||||
try {
|
||||
const cardData = JSON.parse(card.jsonData);
|
||||
const original = cardData.image?.original;
|
||||
let original = cardData.image?.original;
|
||||
const title = cardData.image?.title || "image";
|
||||
if (original) {
|
||||
// 确保是绝对路径
|
||||
if (!original.startsWith('http')) {
|
||||
original = `https://assets.grok.com${original.startsWith('/') ? '' : '/'}${original}`;
|
||||
}
|
||||
original = this._appendSsoToken(original, state);
|
||||
deltaContent += `\n`;
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -611,25 +717,54 @@ export class GrokConverter extends BaseConverter {
|
|||
if (inThink) {
|
||||
deltaReasoning += filtered;
|
||||
} else {
|
||||
// 工具调用抑制逻辑:不向客户端输出 <tool_call> 块及其内容
|
||||
let outputToken = filtered;
|
||||
// 将新 token 加入待处理缓冲区,解决 URL 被截断的问题
|
||||
state.pending_text_buffer += 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 = "";
|
||||
let outputFromBuffer = "";
|
||||
|
||||
// 启发式逻辑:检查缓冲区是否包含完整的 URL
|
||||
if (state.pending_text_buffer.includes("https://assets.grok.com")) {
|
||||
const lastUrlIndex = state.pending_text_buffer.lastIndexOf("https://assets.grok.com");
|
||||
const textAfterUrl = state.pending_text_buffer.slice(lastUrlIndex);
|
||||
|
||||
// 检查 URL 是否结束(空格、右括号、引号、换行、大于号等)
|
||||
const terminatorMatch = textAfterUrl.match(/[\s\)\"\'\>\n]/);
|
||||
if (terminatorMatch) {
|
||||
// URL 已结束,可以安全地处理并输出缓冲区
|
||||
outputFromBuffer = this._processGrokAssetsInText(state.pending_text_buffer, state);
|
||||
state.pending_text_buffer = "";
|
||||
} else if (state.pending_text_buffer.length > 1000) {
|
||||
// 缓冲区过长,强制处理输出,避免过度延迟
|
||||
outputFromBuffer = this._processGrokAssetsInText(state.pending_text_buffer, state);
|
||||
state.pending_text_buffer = "";
|
||||
}
|
||||
} else {
|
||||
// 不包含 Grok URL,直接输出
|
||||
outputFromBuffer = state.pending_text_buffer;
|
||||
state.pending_text_buffer = "";
|
||||
}
|
||||
|
||||
deltaContent += outputToken;
|
||||
if (outputFromBuffer) {
|
||||
// 工具调用抑制逻辑:不向客户端输出 <tool_call> 块及其内容
|
||||
let outputToken = outputFromBuffer;
|
||||
|
||||
// 简单的状态切换检测
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { PROMPT_LOG_FILENAME } from '../core/config-manager.js';
|
|||
import { handleOllamaRequest, handleOllamaShow } from './ollama-handler.js';
|
||||
import { getPluginManager } from '../core/plugin-manager.js';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { handleGrokAssetsProxy } from '../utils/grok-assets-proxy.js';
|
||||
|
||||
/**
|
||||
* Generate a short unique request ID (8 characters)
|
||||
|
|
@ -53,6 +54,12 @@ export function createRequestHandler(config, providerPoolManager) {
|
|||
|
||||
// Deep copy the config for each request to allow dynamic modification
|
||||
const currentConfig = deepmerge({}, config);
|
||||
|
||||
// 计算当前请求的基础 URL
|
||||
const protocol = req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http';
|
||||
const host = req.headers.host;
|
||||
currentConfig.requestBaseUrl = `${protocol}://${host}`;
|
||||
|
||||
const requestUrl = new URL(req.url, `http://${req.headers.host}`);
|
||||
let path = requestUrl.pathname;
|
||||
const method = req.method;
|
||||
|
|
@ -106,6 +113,12 @@ export function createRequestHandler(config, providerPoolManager) {
|
|||
return true;
|
||||
}
|
||||
|
||||
// Grok assets proxy endpoint
|
||||
if (method === 'GET' && path === '/api/grok/assets') {
|
||||
await handleGrokAssetsProxy(req, res, currentConfig);
|
||||
return true;
|
||||
}
|
||||
|
||||
// providers health endpoint
|
||||
// url params: provider[string], customName[string], unhealthRatioThreshold[float]
|
||||
// 支持provider, customName过滤记录
|
||||
|
|
|
|||
|
|
@ -231,6 +231,9 @@ export class ClaudeApiService {
|
|||
this.config._monitorRequestId = requestBody._monitorRequestId;
|
||||
delete requestBody._monitorRequestId;
|
||||
}
|
||||
if (requestBody._requestBaseUrl) {
|
||||
delete requestBody._requestBaseUrl;
|
||||
}
|
||||
|
||||
const response = await this.callApi('/messages', requestBody);
|
||||
return response;
|
||||
|
|
|
|||
|
|
@ -1776,6 +1776,9 @@ async saveCredentialsToFile(filePath, newData) {
|
|||
this.config._monitorRequestId = requestBody._monitorRequestId;
|
||||
delete requestBody._monitorRequestId;
|
||||
}
|
||||
if (requestBody._requestBaseUrl) {
|
||||
delete requestBody._requestBaseUrl;
|
||||
}
|
||||
|
||||
// 检查 token 是否即将过期,如果是则推送到刷新队列
|
||||
if (this.isExpiryDateNear()) {
|
||||
|
|
@ -2145,6 +2148,9 @@ async saveCredentialsToFile(filePath, newData) {
|
|||
this.config._monitorRequestId = requestBody._monitorRequestId;
|
||||
delete requestBody._monitorRequestId;
|
||||
}
|
||||
if (requestBody._requestBaseUrl) {
|
||||
delete requestBody._requestBaseUrl;
|
||||
}
|
||||
|
||||
// 检查 token 是否即将过期,如果是则推送到刷新队列
|
||||
if (this.isExpiryDateNear()) {
|
||||
|
|
|
|||
|
|
@ -151,6 +151,9 @@ export class ForwardApiService {
|
|||
this.config._monitorRequestId = requestBody._monitorRequestId;
|
||||
delete requestBody._monitorRequestId;
|
||||
}
|
||||
if (requestBody._requestBaseUrl) {
|
||||
delete requestBody._requestBaseUrl;
|
||||
}
|
||||
|
||||
// Transparently pass the endpoint if provided in requestBody, otherwise use default
|
||||
const endpoint = requestBody.endpoint || '';
|
||||
|
|
@ -163,6 +166,9 @@ export class ForwardApiService {
|
|||
this.config._monitorRequestId = requestBody._monitorRequestId;
|
||||
delete requestBody._monitorRequestId;
|
||||
}
|
||||
if (requestBody._requestBaseUrl) {
|
||||
delete requestBody._requestBaseUrl;
|
||||
}
|
||||
|
||||
const endpoint = requestBody.endpoint || '';
|
||||
yield* this.streamApi(endpoint, requestBody);
|
||||
|
|
|
|||
|
|
@ -1314,6 +1314,9 @@ export class AntigravityApiService {
|
|||
this.config._monitorRequestId = requestBody._monitorRequestId;
|
||||
delete requestBody._monitorRequestId;
|
||||
}
|
||||
if (requestBody._requestBaseUrl) {
|
||||
delete requestBody._requestBaseUrl;
|
||||
}
|
||||
|
||||
// 检查 token 是否即将过期,如果是则推送到刷新队列
|
||||
if (this.isExpiryDateNear()) {
|
||||
|
|
@ -1387,6 +1390,9 @@ export class AntigravityApiService {
|
|||
this.config._monitorRequestId = requestBody._monitorRequestId;
|
||||
delete requestBody._monitorRequestId;
|
||||
}
|
||||
if (requestBody._requestBaseUrl) {
|
||||
delete requestBody._requestBaseUrl;
|
||||
}
|
||||
|
||||
// 检查 token 是否即将过期,如果是则推送到刷新队列
|
||||
if (this.isExpiryDateNear()) {
|
||||
|
|
|
|||
|
|
@ -645,6 +645,9 @@ export class GeminiApiService {
|
|||
this.config._monitorRequestId = requestBody._monitorRequestId;
|
||||
delete requestBody._monitorRequestId;
|
||||
}
|
||||
if (requestBody._requestBaseUrl) {
|
||||
delete requestBody._requestBaseUrl;
|
||||
}
|
||||
|
||||
// 检查 token 是否即将过期,如果是则推送到刷新队列
|
||||
if (this.isExpiryDateNear()) {
|
||||
|
|
@ -676,6 +679,9 @@ export class GeminiApiService {
|
|||
this.config._monitorRequestId = requestBody._monitorRequestId;
|
||||
delete requestBody._monitorRequestId;
|
||||
}
|
||||
if (requestBody._requestBaseUrl) {
|
||||
delete requestBody._requestBaseUrl;
|
||||
}
|
||||
|
||||
// 检查 token 是否即将过期,如果是则推送到刷新队列
|
||||
if (this.isExpiryDateNear()) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import * as http from 'http';
|
|||
import * as https from 'https';
|
||||
import * as tls from 'tls';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { API_ACTIONS, isRetryableNetworkError } from '../../utils/common.js';
|
||||
import { API_ACTIONS, isRetryableNetworkError, MODEL_PROTOCOL_PREFIX } from '../../utils/common.js';
|
||||
import { getProviderModels } from '../provider-models.js';
|
||||
import { configureAxiosProxy } from '../../utils/proxy-utils.js';
|
||||
import { getTLSSidecar } from '../../utils/tls-sidecar.js';
|
||||
|
|
@ -107,7 +107,13 @@ export class GrokApiService {
|
|||
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();
|
||||
|
||||
// 使用全局转换器实例,确保与适配器层使用的是同一个实例,从而共享 SSO token 和基础 URL 配置
|
||||
this.converter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.GROK);
|
||||
if (this.converter) {
|
||||
this.converter.setSsoToken(this.token);
|
||||
}
|
||||
|
||||
this.lastSyncAt = null;
|
||||
}
|
||||
|
||||
|
|
@ -169,7 +175,7 @@ export class GrokApiService {
|
|||
|
||||
const payload = {
|
||||
"requestKind": "DEFAULT",
|
||||
"modelName": "grok-4-1-thinking-1129", // Default model for checking limits
|
||||
"modelName": "grok-3", // Default model for checking limits
|
||||
};
|
||||
|
||||
const axiosConfig = {
|
||||
|
|
@ -188,17 +194,29 @@ export class GrokApiService {
|
|||
try {
|
||||
const response = await axios(axiosConfig);
|
||||
const data = response.data;
|
||||
logger.info('[Grok] Raw rate limits response:', 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) {
|
||||
// 注入用量逻辑
|
||||
if (data.totalQueries > 0) {
|
||||
// 付费账号:totalQueries > 0 时用 totalQueries - remainingQueries 计算已用量
|
||||
data.totalLimit = data.totalQueries;
|
||||
data.usedQueries = Math.max(0, data.totalQueries - (data.remainingQueries !== undefined ? data.remainingQueries : 0));
|
||||
data.unit = 'queries';
|
||||
} else if (data.totalQueries === 0) {
|
||||
// 免费账号:totalQueries = 0 时用 token 额度(totalTokens - remainingTokens)
|
||||
data.totalLimit = data.totalTokens || 0;
|
||||
data.usedQueries = Math.max(0, (data.totalTokens || 0) - (data.remainingTokens || 0));
|
||||
data.unit = 'tokens';
|
||||
} else if (data.remainingQueries !== undefined || data.totalQueries !== undefined) {
|
||||
// 保底逻辑
|
||||
data.totalLimit = 80;
|
||||
// 计算已用次数
|
||||
data.usedQueries = Math.max(0, 80 - (data.remainingQueries !== undefined ? data.remainingQueries : data.totalQueries));
|
||||
data.unit = 'queries';
|
||||
}
|
||||
|
||||
this.lastSyncAt = Date.now();
|
||||
|
|
@ -501,6 +519,10 @@ export class GrokApiService {
|
|||
if (resp.llmInfo) Object.assign(collected.llmInfo, resp.llmInfo);
|
||||
if (resp.rolloutId) collected.rolloutId = resp.rolloutId;
|
||||
|
||||
// 同步私有字段到最终结果
|
||||
if (resp._ssoToken) collected._ssoToken = resp._ssoToken;
|
||||
if (resp._requestBaseUrl) collected._requestBaseUrl = resp._requestBaseUrl;
|
||||
|
||||
if (resp.modelResponse) collected.modelResponse = resp.modelResponse;
|
||||
if (resp.cardAttachment) collected.cardAttachment = resp.cardAttachment;
|
||||
|
||||
|
|
@ -573,6 +595,23 @@ export class GrokApiService {
|
|||
}
|
||||
|
||||
async * generateContentStream(model, requestBody) {
|
||||
// 确保全局转换器拥有最新的 SSO token 和基础 URL
|
||||
if (this.converter) {
|
||||
this.converter.setSsoToken(this.token);
|
||||
if (requestBody._requestBaseUrl) {
|
||||
this.converter.setRequestBaseUrl(requestBody._requestBaseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// 临时存储 monitorRequestId
|
||||
if (requestBody._monitorRequestId) {
|
||||
this.config._monitorRequestId = requestBody._monitorRequestId;
|
||||
delete requestBody._monitorRequestId;
|
||||
}
|
||||
if (requestBody._requestBaseUrl) {
|
||||
delete requestBody._requestBaseUrl;
|
||||
}
|
||||
|
||||
// 检查是否即将到期(需要同步用量),如果是则推送到刷新队列
|
||||
if (this.isExpiryDateNear()) {
|
||||
const poolManager = getProviderPoolManager();
|
||||
|
|
@ -665,8 +704,14 @@ export class GrokApiService {
|
|||
|
||||
try {
|
||||
const json = JSON.parse(dataStr);
|
||||
if (json.result?.response?.responseId) {
|
||||
lastResponseId = json.result.response.responseId;
|
||||
if (json.result?.response) {
|
||||
// 注入 SSO token 和基础 URL,以便全局转换器能获取到
|
||||
json.result.response._ssoToken = this.token;
|
||||
json.result.response._requestBaseUrl = requestBody._requestBaseUrl;
|
||||
|
||||
if (json.result.response.responseId) {
|
||||
lastResponseId = json.result.response.responseId;
|
||||
}
|
||||
}
|
||||
yield json;
|
||||
} catch (e) {
|
||||
|
|
@ -684,7 +729,9 @@ export class GrokApiService {
|
|||
result: {
|
||||
response: {
|
||||
isDone: true,
|
||||
responseId: lastResponseId
|
||||
responseId: lastResponseId,
|
||||
_ssoToken: this.token,
|
||||
_requestBaseUrl: requestBody._requestBaseUrl
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -134,6 +134,9 @@ export class CodexApiService {
|
|||
this.config._monitorRequestId = requestBody._monitorRequestId;
|
||||
delete requestBody._monitorRequestId;
|
||||
}
|
||||
if (requestBody._requestBaseUrl) {
|
||||
delete requestBody._requestBaseUrl;
|
||||
}
|
||||
|
||||
// 检查 token 是否即将过期,如果是则推送到刷新队列
|
||||
if (this.isExpiryDateNear()) {
|
||||
|
|
@ -205,6 +208,9 @@ export class CodexApiService {
|
|||
this.config._monitorRequestId = requestBody._monitorRequestId;
|
||||
delete requestBody._monitorRequestId;
|
||||
}
|
||||
if (requestBody._requestBaseUrl) {
|
||||
delete requestBody._requestBaseUrl;
|
||||
}
|
||||
|
||||
// 检查 token 是否即将过期,如果是则推送到刷新队列
|
||||
if (this.isExpiryDateNear()) {
|
||||
|
|
|
|||
|
|
@ -1042,6 +1042,9 @@ export class IFlowApiService {
|
|||
this.config._monitorRequestId = requestBody._monitorRequestId;
|
||||
delete requestBody._monitorRequestId;
|
||||
}
|
||||
if (requestBody._requestBaseUrl) {
|
||||
delete requestBody._requestBaseUrl;
|
||||
}
|
||||
|
||||
// 检查 token 是否即将过期,如果是则推送到刷新队列
|
||||
if (this.isExpiryDateNear()) {
|
||||
|
|
@ -1070,6 +1073,9 @@ export class IFlowApiService {
|
|||
this.config._monitorRequestId = requestBody._monitorRequestId;
|
||||
delete requestBody._monitorRequestId;
|
||||
}
|
||||
if (requestBody._requestBaseUrl) {
|
||||
delete requestBody._requestBaseUrl;
|
||||
}
|
||||
|
||||
// 检查 token 是否即将过期,如果是则推送到刷新队列
|
||||
if (this.isExpiryDateNear()) {
|
||||
|
|
|
|||
|
|
@ -194,6 +194,9 @@ export class OpenAIApiService {
|
|||
this.config._monitorRequestId = requestBody._monitorRequestId;
|
||||
delete requestBody._monitorRequestId;
|
||||
}
|
||||
if (requestBody._requestBaseUrl) {
|
||||
delete requestBody._requestBaseUrl;
|
||||
}
|
||||
|
||||
return this.callApi('/chat/completions', requestBody);
|
||||
}
|
||||
|
|
@ -204,6 +207,9 @@ export class OpenAIApiService {
|
|||
this.config._monitorRequestId = requestBody._monitorRequestId;
|
||||
delete requestBody._monitorRequestId;
|
||||
}
|
||||
if (requestBody._requestBaseUrl) {
|
||||
delete requestBody._requestBaseUrl;
|
||||
}
|
||||
|
||||
yield* this.streamApi('/chat/completions', requestBody);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,6 +162,9 @@ export class OpenAIResponsesApiService {
|
|||
this.config._monitorRequestId = requestBody._monitorRequestId;
|
||||
delete requestBody._monitorRequestId;
|
||||
}
|
||||
if (requestBody._requestBaseUrl) {
|
||||
delete requestBody._requestBaseUrl;
|
||||
}
|
||||
|
||||
return this.callApi('/responses', requestBody);
|
||||
}
|
||||
|
|
@ -172,6 +175,9 @@ export class OpenAIResponsesApiService {
|
|||
this.config._monitorRequestId = requestBody._monitorRequestId;
|
||||
delete requestBody._monitorRequestId;
|
||||
}
|
||||
if (requestBody._requestBaseUrl) {
|
||||
delete requestBody._requestBaseUrl;
|
||||
}
|
||||
|
||||
yield* this.streamApi('/responses', requestBody);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -656,6 +656,9 @@ export class QwenApiService {
|
|||
this.config._monitorRequestId = requestBody._monitorRequestId;
|
||||
delete requestBody._monitorRequestId;
|
||||
}
|
||||
if (requestBody._requestBaseUrl) {
|
||||
delete requestBody._requestBaseUrl;
|
||||
}
|
||||
|
||||
// 检查 token 是否即将过期,如果是则推送到刷新队列
|
||||
if (this.isExpiryDateNear()) {
|
||||
|
|
@ -677,6 +680,9 @@ export class QwenApiService {
|
|||
this.config._monitorRequestId = requestBody._monitorRequestId;
|
||||
delete requestBody._monitorRequestId;
|
||||
}
|
||||
if (requestBody._requestBaseUrl) {
|
||||
delete requestBody._requestBaseUrl;
|
||||
}
|
||||
|
||||
// 检查 token 是否即将过期,如果是则推送到刷新队列
|
||||
if (this.isExpiryDateNear()) {
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo
|
|||
}
|
||||
|
||||
// Handle UI management API requests (需要token验证,除了登录接口、健康检查和Events接口)
|
||||
if (pathParam.startsWith('/api/') && pathParam !== '/api/login' && pathParam !== '/api/health' && pathParam !== '/api/events' ) {
|
||||
if (pathParam.startsWith('/api/') && pathParam !== '/api/login' && pathParam !== '/api/health' && pathParam !== '/api/events' && pathParam !== '/api/grok/assets') {
|
||||
// 检查token验证
|
||||
const isAuth = await auth.checkAuth(req);
|
||||
if (!isAuth) {
|
||||
|
|
|
|||
|
|
@ -566,13 +566,14 @@ export function formatGrokUsage(usageData) {
|
|||
usageBreakdown: []
|
||||
};
|
||||
|
||||
// Grok 返回的数据结构已在 core 中预处理:{ remainingTokens, remainingQueries, totalQueries, totalLimit, usedQueries, ... }
|
||||
// Grok 返回的数据结构已在 core 中预处理:{ remainingTokens, remainingQueries, totalQueries, totalLimit, usedQueries, unit, ... }
|
||||
if (usageData.totalLimit !== undefined && usageData.usedQueries !== undefined) {
|
||||
const isTokens = usageData.unit === 'tokens';
|
||||
const item = {
|
||||
resourceType: 'TOKEN_USAGE',
|
||||
displayName: 'Remaining Queries',
|
||||
displayNamePlural: 'Remaining Queries',
|
||||
unit: 'queries',
|
||||
displayName: isTokens ? 'Remaining Tokens' : 'Remaining Queries',
|
||||
displayNamePlural: isTokens ? 'Remaining Tokens' : 'Remaining Queries',
|
||||
unit: usageData.unit || 'queries',
|
||||
currency: null,
|
||||
|
||||
// 使用从 core 传出的计算好的值
|
||||
|
|
|
|||
|
|
@ -912,6 +912,11 @@ export async function handleContentGenerationRequest(req, res, service, endpoint
|
|||
if (CONFIG._monitorRequestId) {
|
||||
processedRequestBody._monitorRequestId = CONFIG._monitorRequestId;
|
||||
}
|
||||
|
||||
// 将 requestBaseUrl 注入到 requestBody 中,以便在转换器中使用
|
||||
if (CONFIG.requestBaseUrl) {
|
||||
processedRequestBody._requestBaseUrl = CONFIG.requestBaseUrl;
|
||||
}
|
||||
|
||||
// fs.writeFile('originalRequestBody'+Date.now()+'.json', JSON.stringify(originalRequestBody));
|
||||
if (getProtocolPrefix(fromProvider) !== getProtocolPrefix(toProvider)) {
|
||||
|
|
|
|||
112
src/utils/grok-assets-proxy.js
Normal file
112
src/utils/grok-assets-proxy.js
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import axios from 'axios';
|
||||
import logger from './logger.js';
|
||||
import { configureAxiosProxy } from './proxy-utils.js';
|
||||
import { MODEL_PROVIDER } from './common.js';
|
||||
|
||||
/**
|
||||
* 处理 Grok 资源代理请求
|
||||
* @param {http.IncomingMessage} req 原始请求
|
||||
* @param {http.ServerResponse} res 原始响应
|
||||
* @param {Object} config 全局配置
|
||||
*/
|
||||
export async function handleGrokAssetsProxy(req, res, config) {
|
||||
try {
|
||||
const requestUrl = new URL(req.url, `http://${req.headers.host}`);
|
||||
const targetUrl = requestUrl.searchParams.get('url');
|
||||
let ssoToken = requestUrl.searchParams.get('sso');
|
||||
|
||||
if (!targetUrl) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Missing url parameter' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ssoToken) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Missing sso parameter' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// 清理 token
|
||||
if (ssoToken.startsWith('sso=')) {
|
||||
ssoToken = ssoToken.substring(4);
|
||||
}
|
||||
|
||||
// 构造完整的 assets.grok.com URL(如果是相对路径)
|
||||
let finalTargetUrl = targetUrl;
|
||||
if (!targetUrl.startsWith('http')) {
|
||||
finalTargetUrl = `https://assets.grok.com${targetUrl.startsWith('/') ? '' : '/'}${targetUrl}`;
|
||||
}
|
||||
|
||||
// 验证域名安全,只允许代理 assets.grok.com
|
||||
try {
|
||||
const parsedTarget = new URL(finalTargetUrl);
|
||||
if (parsedTarget.hostname !== 'assets.grok.com') {
|
||||
res.writeHead(403, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Forbidden: Only assets.grok.com is allowed' }));
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Invalid target URL' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = {
|
||||
'User-Agent': config.GROK_USER_AGENT || 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36',
|
||||
'Cookie': `sso=${ssoToken}; sso-rw=${ssoToken}`,
|
||||
'Referer': 'https://grok.com/',
|
||||
'Accept': '*/*'
|
||||
};
|
||||
|
||||
const axiosConfig = {
|
||||
method: 'get',
|
||||
url: finalTargetUrl,
|
||||
headers: headers,
|
||||
responseType: 'stream',
|
||||
timeout: 30000,
|
||||
validateStatus: false
|
||||
};
|
||||
|
||||
// 配置代理
|
||||
configureAxiosProxy(axiosConfig, config, MODEL_PROVIDER.GROK_CUSTOM);
|
||||
|
||||
logger.debug(`[Grok Proxy] Proxying request to: ${finalTargetUrl}`);
|
||||
|
||||
const response = await axios(axiosConfig);
|
||||
|
||||
// 转发响应头
|
||||
const responseHeaders = {
|
||||
'Content-Type': response.headers['content-type'] || 'application/octet-stream',
|
||||
'Cache-Control': response.headers['cache-control'] || 'public, max-age=3600',
|
||||
};
|
||||
|
||||
if (response.headers['content-length']) {
|
||||
responseHeaders['Content-Length'] = response.headers['content-length'];
|
||||
}
|
||||
|
||||
res.writeHead(response.status, responseHeaders);
|
||||
|
||||
// 管道传输数据
|
||||
response.data.pipe(res);
|
||||
|
||||
response.data.on('error', (err) => {
|
||||
logger.error(`[Grok Proxy] Stream error: ${err.message}`);
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(500);
|
||||
res.end();
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`[Grok Proxy] Error: ${error.message}`);
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Internal Server Error', message: error.message }));
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue