From c91d2ce3ab5f57df71e333ffcc7cda03ff97d124 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Sun, 1 Mar 2026 23:55:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(grok):=20=E6=B7=BB=E5=8A=A0=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E4=BB=A3=E7=90=86=E5=8A=9F=E8=83=BD=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E7=94=A8=E9=87=8F=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Grok 资源代理接口,将 assets.grok.com 的资源通过本地代理访问 - 在请求处理中注入 requestBaseUrl 配置,供转换器生成正确的代理链接 - 统一各提供商核心服务中删除 _requestBaseUrl 字段的逻辑 - 优化 Grok 用量显示逻辑,支持按 token 或 query 显示剩余额度 - 更新 UI 管理器,允许 /api/grok/assets 接口免认证访问 - 改进 Grok 转换器,在流式输出中智能处理被截断的 URL --- VERSION | 2 +- src/converters/strategies/GrokConverter.js | 197 +++++++++++++++--- src/handlers/request-handler.js | 13 ++ src/providers/claude/claude-core.js | 3 + src/providers/claude/claude-kiro.js | 6 + src/providers/forward/forward-core.js | 6 + src/providers/gemini/antigravity-core.js | 6 + src/providers/gemini/gemini-core.js | 6 + src/providers/grok/grok-core.js | 65 +++++- src/providers/openai/codex-core.js | 6 + src/providers/openai/iflow-core.js | 6 + src/providers/openai/openai-core.js | 6 + src/providers/openai/openai-responses-core.js | 6 + src/providers/openai/qwen-core.js | 6 + src/services/ui-manager.js | 2 +- src/services/usage-service.js | 9 +- src/utils/common.js | 5 + src/utils/grok-assets-proxy.js | 112 ++++++++++ 18 files changed, 416 insertions(+), 46 deletions(-) create mode 100644 src/utils/grok-assets-proxy.js diff --git a/VERSION b/VERSION index 8bbb6e4..c6436a8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.10.1 +2.10.2 diff --git a/src/converters/strategies/GrokConverter.js b/src/converters/strategies/GrokConverter.js index 8afa249..53dd1a9 100644 --- a/src/converters/strategies/GrokConverter.js +++ b/src/converters/strategies/GrokConverter.js @@ -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 // 是否处于 块内 + in_tool_call: false, // 是否处于 块内 + 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 `![${imageId}](${finalUrl})`; } @@ -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[![video](${finalThumbUrl || 'https://assets.grok.com/favicon.ico'})](${finalVideoUrl})\n[Play Video](${finalVideoUrl})\n`; + const defaultThumb = this._appendSsoToken('https://assets.grok.com/favicon.ico'); + return `\n[![video](${finalThumbUrl || defaultThumb})](${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\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 += `![${title}](${original})\n`; } } catch (e) { @@ -611,25 +717,54 @@ export class GrokConverter extends BaseConverter { if (inThink) { deltaReasoning += filtered; } else { - // 工具调用抑制逻辑:不向客户端输出 块及其内容 - let outputToken = filtered; + // 将新 token 加入待处理缓冲区,解决 URL 被截断的问题 + state.pending_text_buffer += filtered; - // 简单的状态切换检测 - if (outputToken.includes('')) { - state.in_tool_call = true; - state.has_tool_call = true; - // 移除标签之后的部分(如果有) - outputToken = outputToken.split('')[0]; - } else if (state.in_tool_call && outputToken.includes('')) { - state.in_tool_call = false; - // 只保留标签之后的部分 - outputToken = outputToken.split('')[1] || ""; - } else if (state.in_tool_call) { - // 处于块内,完全抑制 - outputToken = ""; + 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) { + // 工具调用抑制逻辑:不向客户端输出 块及其内容 + let outputToken = outputFromBuffer; + + // 简单的状态切换检测 + if (outputToken.includes('')) { + state.in_tool_call = true; + state.has_tool_call = true; + // 移除标签之后的部分(如果有) + outputToken = outputToken.split('')[0]; + } else if (state.in_tool_call && outputToken.includes('')) { + state.in_tool_call = false; + // 只保留标签之后的部分 + outputToken = outputToken.split('')[1] || ""; + } else if (state.in_tool_call) { + // 处于块内,完全抑制 + outputToken = ""; + } + + deltaContent += outputToken; + } // 将内容加入 buffer 用于最终解析工具调用 state.content_buffer += filtered; diff --git a/src/handlers/request-handler.js b/src/handlers/request-handler.js index 9acbf3d..b9664df 100644 --- a/src/handlers/request-handler.js +++ b/src/handlers/request-handler.js @@ -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过滤记录 diff --git a/src/providers/claude/claude-core.js b/src/providers/claude/claude-core.js index c90a1f7..eae966b 100644 --- a/src/providers/claude/claude-core.js +++ b/src/providers/claude/claude-core.js @@ -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; diff --git a/src/providers/claude/claude-kiro.js b/src/providers/claude/claude-kiro.js index 4736b72..74d9648 100644 --- a/src/providers/claude/claude-kiro.js +++ b/src/providers/claude/claude-kiro.js @@ -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()) { diff --git a/src/providers/forward/forward-core.js b/src/providers/forward/forward-core.js index fd36389..a1206d3 100644 --- a/src/providers/forward/forward-core.js +++ b/src/providers/forward/forward-core.js @@ -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); diff --git a/src/providers/gemini/antigravity-core.js b/src/providers/gemini/antigravity-core.js index f08c1e7..69c5c70 100644 --- a/src/providers/gemini/antigravity-core.js +++ b/src/providers/gemini/antigravity-core.js @@ -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()) { diff --git a/src/providers/gemini/gemini-core.js b/src/providers/gemini/gemini-core.js index ff9eb59..05992dc 100644 --- a/src/providers/gemini/gemini-core.js +++ b/src/providers/gemini/gemini-core.js @@ -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()) { diff --git a/src/providers/grok/grok-core.js b/src/providers/grok/grok-core.js index b5122d8..4f24adb 100644 --- a/src/providers/grok/grok-core.js +++ b/src/providers/grok/grok-core.js @@ -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 } } }; diff --git a/src/providers/openai/codex-core.js b/src/providers/openai/codex-core.js index 1aced0d..2627444 100644 --- a/src/providers/openai/codex-core.js +++ b/src/providers/openai/codex-core.js @@ -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()) { diff --git a/src/providers/openai/iflow-core.js b/src/providers/openai/iflow-core.js index 925448a..74acbf5 100644 --- a/src/providers/openai/iflow-core.js +++ b/src/providers/openai/iflow-core.js @@ -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()) { diff --git a/src/providers/openai/openai-core.js b/src/providers/openai/openai-core.js index a1ce70a..ca22489 100644 --- a/src/providers/openai/openai-core.js +++ b/src/providers/openai/openai-core.js @@ -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); } diff --git a/src/providers/openai/openai-responses-core.js b/src/providers/openai/openai-responses-core.js index 210496b..d1992bf 100644 --- a/src/providers/openai/openai-responses-core.js +++ b/src/providers/openai/openai-responses-core.js @@ -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); } diff --git a/src/providers/openai/qwen-core.js b/src/providers/openai/qwen-core.js index ae8aa2e..59c8195 100644 --- a/src/providers/openai/qwen-core.js +++ b/src/providers/openai/qwen-core.js @@ -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()) { diff --git a/src/services/ui-manager.js b/src/services/ui-manager.js index 523fa12..70f6318 100644 --- a/src/services/ui-manager.js +++ b/src/services/ui-manager.js @@ -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) { diff --git a/src/services/usage-service.js b/src/services/usage-service.js index ebe4583..e460347 100644 --- a/src/services/usage-service.js +++ b/src/services/usage-service.js @@ -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 传出的计算好的值 diff --git a/src/utils/common.js b/src/utils/common.js index 42ff37f..c9e3700 100644 --- a/src/utils/common.js +++ b/src/utils/common.js @@ -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)) { diff --git a/src/utils/grok-assets-proxy.js b/src/utils/grok-assets-proxy.js new file mode 100644 index 0000000..9330679 --- /dev/null +++ b/src/utils/grok-assets-proxy.js @@ -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(); + } + } +}