diff --git a/src/converters/strategies/ClaudeConverter.js b/src/converters/strategies/ClaudeConverter.js index d79c9c2..e4c07b9 100644 --- a/src/converters/strategies/ClaudeConverter.js +++ b/src/converters/strategies/ClaudeConverter.js @@ -53,6 +53,8 @@ export class ClaudeConverter 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}`); } @@ -2094,6 +2096,18 @@ export class ClaudeConverter extends BaseConverter { return codexRequest; } + /** + * Claude请求 -> Grok请求 + */ + toGrokRequest(claudeRequest) { + // 先转换为 OpenAI 格式,因为 Grok 兼容 OpenAI 格式 + const openaiRequest = this.toOpenAIRequest(claudeRequest); + return { + ...openaiRequest, + _isConverted: true + }; + } + /** * Claude响应 -> Codex响应 (实际上是 Codex 转 Claude) */ diff --git a/src/converters/strategies/GeminiConverter.js b/src/converters/strategies/GeminiConverter.js index 456c92c..af6f5d3 100644 --- a/src/converters/strategies/GeminiConverter.js +++ b/src/converters/strategies/GeminiConverter.js @@ -177,6 +177,8 @@ export class GeminiConverter 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}`); } @@ -1416,6 +1418,18 @@ export class GeminiConverter extends BaseConverter { return codexRequest; } + /** + * Gemini请求 -> Grok请求 + */ + toGrokRequest(geminiRequest) { + // 先转换为 OpenAI 格式 + const openaiRequest = this.toOpenAIRequest(geminiRequest); + return { + ...openaiRequest, + _isConverted: true + }; + } + /** * Gemini响应 -> Codex响应 (实际上是 Codex 转 Gemini) */ diff --git a/src/converters/strategies/GrokConverter.js b/src/converters/strategies/GrokConverter.js index 84f6a76..20a3de4 100644 --- a/src/converters/strategies/GrokConverter.js +++ b/src/converters/strategies/GrokConverter.js @@ -244,7 +244,10 @@ export class GrokConverter extends BaseConverter { * 转换请求 */ convertRequest(data, targetProtocol) { - return data; + switch (targetProtocol) { + default: + return data; + } } /** @@ -254,6 +257,12 @@ export class GrokConverter extends BaseConverter { switch (targetProtocol) { case MODEL_PROTOCOL_PREFIX.OPENAI: return this.toOpenAIResponse(data, model); + case MODEL_PROTOCOL_PREFIX.GEMINI: + return this.toGeminiResponse(data, model); + case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: + return this.toOpenAIResponsesResponse(data, model); + case MODEL_PROTOCOL_PREFIX.CODEX: + return this.toCodexResponse(data, model); default: return data; } @@ -266,6 +275,12 @@ export class GrokConverter extends BaseConverter { switch (targetProtocol) { case MODEL_PROTOCOL_PREFIX.OPENAI: return this.toOpenAIStreamChunk(chunk, model); + case MODEL_PROTOCOL_PREFIX.GEMINI: + return this.toGeminiStreamChunk(chunk, model); + case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: + return this.toOpenAIResponsesStreamChunk(chunk, model); + case MODEL_PROTOCOL_PREFIX.CODEX: + return this.toCodexStreamChunk(chunk, model); default: return chunk; } @@ -275,7 +290,14 @@ export class GrokConverter extends BaseConverter { * 转换模型列表 */ convertModelList(data, targetProtocol) { - return data; + switch (targetProtocol) { + case MODEL_PROTOCOL_PREFIX.OPENAI: + return this.toOpenAIModelList(data); + case MODEL_PROTOCOL_PREFIX.GEMINI: + return this.toGeminiModelList(data); + default: + return data; + } } /** @@ -360,15 +382,13 @@ export class GrokConverter extends BaseConverter { if (!videoUrl.startsWith('http')) { finalVideoUrl = `https://assets.grok.com${videoUrl.startsWith('/') ? '' : '/'}${videoUrl}`; } - finalVideoUrl = this._appendSsoToken(finalVideoUrl, state); let finalThumbUrl = thumbnailImageUrl; if (thumbnailImageUrl && !thumbnailImageUrl.startsWith('http')) { finalThumbUrl = `https://assets.grok.com${thumbnailImageUrl.startsWith('/') ? '' : '/'}${thumbnailImageUrl}`; } - finalThumbUrl = this._appendSsoToken(finalThumbUrl, state); - const defaultThumb = this._appendSsoToken('https://assets.grok.com/favicon.ico', state); + const defaultThumb = 'https://assets.grok.com/favicon.ico'; return `\n[![video](${finalThumbUrl || defaultThumb})](${finalVideoUrl})\n[Play Video](${finalVideoUrl})\n`; } @@ -783,4 +803,351 @@ export class GrokConverter extends BaseConverter { return chunks.length > 0 ? chunks : null; } + + /** + * Grok响应 -> Gemini响应 + */ + toGeminiResponse(grokResponse, model) { + const openaiRes = this.toOpenAIResponse(grokResponse, model); + if (!openaiRes) return null; + + const choice = openaiRes.choices[0]; + const message = choice.message; + const parts = []; + + if (message.reasoning_content) { + parts.push({ text: message.reasoning_content, thought: true }); + } + + if (message.content) { + parts.push({ text: message.content }); + } + + if (message.tool_calls) { + for (const tc of message.tool_calls) { + parts.push({ + functionCall: { + name: tc.function.name, + args: typeof tc.function.arguments === 'string' ? JSON.parse(tc.function.arguments) : tc.function.arguments + } + }); + } + } + + return { + candidates: [{ + content: { + role: 'model', + parts: parts + }, + finishReason: choice.finish_reason === 'tool_calls' ? 'STOP' : (choice.finish_reason === 'length' ? 'MAX_TOKENS' : 'STOP') + }], + usageMetadata: { + promptTokenCount: openaiRes.usage.prompt_tokens, + candidatesTokenCount: openaiRes.usage.completion_tokens, + totalTokenCount: openaiRes.usage.total_tokens + } + }; + } + + /** + * Grok流式响应块 -> Gemini流式响应块 + */ + toGeminiStreamChunk(grokChunk, model) { + const openaiChunks = this.toOpenAIStreamChunk(grokChunk, model); + if (!openaiChunks) return null; + + const geminiChunks = []; + for (const oachunk of openaiChunks) { + const choice = oachunk.choices[0]; + const delta = choice.delta; + const parts = []; + + if (delta.reasoning_content) { + parts.push({ text: delta.reasoning_content, thought: true }); + } + if (delta.content) { + parts.push({ text: delta.content }); + } + if (delta.tool_calls) { + for (const tc of delta.tool_calls) { + parts.push({ + functionCall: { + name: tc.function.name, + args: typeof tc.function.arguments === 'string' ? JSON.parse(tc.function.arguments) : tc.function.arguments + } + }); + } + } + + if (parts.length > 0 || choice.finish_reason) { + const gchunk = { + candidates: [{ + content: { + role: 'model', + parts: parts + } + }] + }; + if (choice.finish_reason) { + gchunk.candidates[0].finishReason = choice.finish_reason === 'length' ? 'MAX_TOKENS' : 'STOP'; + } + geminiChunks.push(gchunk); + } + } + + return geminiChunks.length > 0 ? geminiChunks : null; + } + + /** + * Grok响应 -> OpenAI Responses响应 + */ + toOpenAIResponsesResponse(grokResponse, model) { + const openaiRes = this.toOpenAIResponse(grokResponse, model); + if (!openaiRes) return null; + + const choice = openaiRes.choices[0]; + const message = choice.message; + const output = []; + + const content = []; + if (message.content) { + content.push({ + type: "output_text", + text: message.content + }); + } + + output.push({ + id: `msg_${uuidv4().replace(/-/g, '')}`, + type: "message", + role: "assistant", + status: "completed", + content: content + }); + + if (message.tool_calls) { + for (const tc of message.tool_calls) { + output.push({ + id: tc.id, + type: "function_call", + name: tc.function.name, + arguments: tc.function.arguments, + status: "completed" + }); + } + } + + return { + id: `resp_${uuidv4().replace(/-/g, '')}`, + object: "response", + created_at: Math.floor(Date.now() / 1000), + status: "completed", + model: model, + output: output, + usage: { + input_tokens: openaiRes.usage.prompt_tokens, + output_tokens: openaiRes.usage.completion_tokens, + total_tokens: openaiRes.usage.total_tokens + } + }; + } + + /** + * Grok流式响应块 -> OpenAI Responses流式响应块 + */ + toOpenAIResponsesStreamChunk(grokChunk, model) { + const openaiChunks = this.toOpenAIStreamChunk(grokChunk, model); + if (!openaiChunks) return null; + + const events = []; + for (const oachunk of openaiChunks) { + const choice = oachunk.choices[0]; + const delta = choice.delta; + + if (delta.role === 'assistant') { + events.push({ type: "response.created", response: { id: oachunk.id, model: model } }); + } + + if (delta.reasoning_content) { + events.push({ + type: "response.reasoning_summary_text.delta", + delta: delta.reasoning_content, + response_id: oachunk.id + }); + } + + if (delta.content) { + events.push({ + type: "response.output_text.delta", + delta: delta.content, + response_id: oachunk.id + }); + } + + if (delta.tool_calls) { + for (const tc of delta.tool_calls) { + if (tc.function?.name) { + events.push({ + type: "response.output_item.added", + item: { id: tc.id, type: "function_call", name: tc.function.name, arguments: "" }, + response_id: oachunk.id + }); + } + if (tc.function?.arguments) { + events.push({ + type: "response.custom_tool_call_input.delta", + delta: tc.function.arguments, + item_id: tc.id, + response_id: oachunk.id + }); + } + } + } + + if (choice.finish_reason) { + events.push({ type: "response.completed", response: { id: oachunk.id, status: "completed" } }); + } + } + + return events; + } + + /** + * Grok响应 -> Codex响应 + */ + toCodexResponse(grokResponse, model) { + const openaiRes = this.toOpenAIResponse(grokResponse, model); + if (!openaiRes) return null; + + const choice = openaiRes.choices[0]; + const message = choice.message; + const output = []; + + if (message.content) { + output.push({ + type: "message", + role: "assistant", + content: [{ type: "output_text", text: message.content }] + }); + } + + if (message.reasoning_content) { + output.push({ + type: "reasoning", + summary: [{ type: "summary_text", text: message.reasoning_content }] + }); + } + + if (message.tool_calls) { + for (const tc of message.tool_calls) { + output.push({ + type: "function_call", + call_id: tc.id, + name: tc.function.name, + arguments: tc.function.arguments + }); + } + } + + return { + response: { + id: openaiRes.id, + output: output, + usage: { + input_tokens: openaiRes.usage.prompt_tokens, + output_tokens: openaiRes.usage.completion_tokens, + total_tokens: openaiRes.usage.total_tokens + } + } + }; + } + + /** + * Grok流式响应块 -> Codex流式响应块 + */ + toCodexStreamChunk(grokChunk, model) { + const openaiChunks = this.toOpenAIStreamChunk(grokChunk, model); + if (!openaiChunks) return null; + + const codexChunks = []; + for (const oachunk of openaiChunks) { + const choice = oachunk.choices[0]; + const delta = choice.delta; + + if (delta.role === 'assistant') { + codexChunks.push({ type: "response.created", response: { id: oachunk.id } }); + } + + if (delta.reasoning_content) { + codexChunks.push({ + type: "response.reasoning_summary_text.delta", + delta: delta.reasoning_content, + response: { id: oachunk.id } + }); + } + + if (delta.content) { + codexChunks.push({ + type: "response.output_text.delta", + delta: delta.content, + response: { id: oachunk.id } + }); + } + + if (delta.tool_calls) { + for (const tc of delta.tool_calls) { + if (tc.function?.arguments) { + codexChunks.push({ + type: "response.custom_tool_call_input.delta", + delta: tc.function.arguments, + item_id: tc.id, + response: { id: oachunk.id } + }); + } + } + } + + if (choice.finish_reason) { + codexChunks.push({ type: "response.completed", response: { id: oachunk.id, usage: oachunk.usage } }); + } + } + + return codexChunks.length > 0 ? codexChunks : null; + } + + /** + * Grok模型列表 -> OpenAI模型列表 + */ + toOpenAIModelList(grokModels) { + const models = Array.isArray(grokModels) ? grokModels : (grokModels?.models || []); + return { + object: "list", + data: models.map(m => ({ + id: m.id || m.name, + object: "model", + created: Math.floor(Date.now() / 1000), + owned_by: "xai", + display_name: m.name || m.id, + })), + }; + } + + /** + * Grok模型列表 -> Gemini模型列表 + */ + toGeminiModelList(grokModels) { + const models = Array.isArray(grokModels) ? grokModels : (grokModels?.models || []); + return { + models: models.map(m => ({ + name: `models/${m.id || m.name}`, + version: "1.0", + displayName: m.name || m.id, + description: m.description || `Grok model: ${m.name || m.id}`, + inputTokenLimit: 131072, + outputTokenLimit: 8192, + supportedGenerationMethods: ["generateContent", "streamGenerateContent"] + })) + }; + } } diff --git a/src/converters/strategies/OpenAIResponsesConverter.js b/src/converters/strategies/OpenAIResponsesConverter.js index 46d0990..943ba4e 100644 --- a/src/converters/strategies/OpenAIResponsesConverter.js +++ b/src/converters/strategies/OpenAIResponsesConverter.js @@ -52,6 +52,8 @@ export class OpenAIResponsesConverter extends BaseConverter { return this.toGeminiRequest(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: ${toProtocol}`); } @@ -808,6 +810,18 @@ export class OpenAIResponsesConverter extends BaseConverter { return this.codexConverter.toOpenAIResponsesToCodexRequest(responsesRequest); } + /** + * OpenAI Responses → Grok 请求转换 + */ + toGrokRequest(responsesRequest) { + // 先转换为 OpenAI 格式 + const openaiRequest = this.toOpenAIRequest(responsesRequest); + return { + ...openaiRequest, + _isConverted: true + }; + } + // ============================================================================= // 辅助方法 // ============================================================================= diff --git a/src/providers/adapter.js b/src/providers/adapter.js index 036f034..1943953 100644 --- a/src/providers/adapter.js +++ b/src/providers/adapter.js @@ -696,7 +696,7 @@ 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.IFLOW_API, IFlowApiServiceAdapter); registerAdapter(MODEL_PROVIDER.CODEX_API, CodexApiServiceAdapter); registerAdapter(MODEL_PROVIDER.GROK_CUSTOM, GrokApiServiceAdapter); // registerAdapter(MODEL_PROVIDER.FORWARD_API, ForwardApiServiceAdapter); diff --git a/src/providers/grok/grok-core.js b/src/providers/grok/grok-core.js index 268af45..1065fb1 100644 --- a/src/providers/grok/grok-core.js +++ b/src/providers/grok/grok-core.js @@ -2,83 +2,39 @@ import axios from 'axios'; import logger from '../../utils/logger.js'; 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, MODEL_PROTOCOL_PREFIX } from '../../utils/common.js'; +import { 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'; 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'; -// Chrome 136 TLS cipher suites (精确匹配 Chrome 的 ClientHello 顺序) -// 参考: https://tls.peet.ws/api/all (Chrome 136 fingerprint) +// Chrome 136 TLS cipher suites const CHROME_CIPHERS = [ - 'TLS_AES_128_GCM_SHA256', - 'TLS_AES_256_GCM_SHA384', - 'TLS_CHACHA20_POLY1305_SHA256', - 'ECDHE-ECDSA-AES128-GCM-SHA256', - 'ECDHE-RSA-AES128-GCM-SHA256', - 'ECDHE-ECDSA-AES256-GCM-SHA384', - 'ECDHE-RSA-AES256-GCM-SHA384', - 'ECDHE-ECDSA-CHACHA20-POLY1305', - 'ECDHE-RSA-CHACHA20-POLY1305', - 'ECDHE-RSA-AES128-SHA', - 'ECDHE-RSA-AES256-SHA', - 'AES128-GCM-SHA256', - 'AES256-GCM-SHA384', - 'AES128-SHA', - 'AES256-SHA', + 'TLS_AES_128_GCM_SHA256', 'TLS_AES_256_GCM_SHA384', 'TLS_CHACHA20_POLY1305_SHA256', + 'ECDHE-ECDSA-AES128-GCM-SHA256', 'ECDHE-RSA-AES128-GCM-SHA256', 'ECDHE-ECDSA-AES256-GCM-SHA384', + 'ECDHE-RSA-AES256-GCM-SHA384', 'ECDHE-ECDSA-CHACHA20-POLY1305', 'ECDHE-RSA-CHACHA20-POLY1305', + 'ECDHE-RSA-AES128-SHA', 'ECDHE-RSA-AES256-SHA', 'AES128-GCM-SHA256', 'AES256-GCM-SHA384', + 'AES128-SHA', 'AES256-SHA', ].join(':'); -// Chrome 签名算法 (匹配 Chrome 的 signature_algorithms 扩展) const CHROME_SIGALGS = [ - 'ecdsa_secp256r1_sha256', - 'rsa_pss_rsae_sha256', - 'rsa_pkcs1_sha256', - 'ecdsa_secp384r1_sha384', - 'rsa_pss_rsae_sha384', - 'rsa_pkcs1_sha384', - 'rsa_pss_rsae_sha512', - 'rsa_pkcs1_sha512', + 'ecdsa_secp256r1_sha256', 'rsa_pss_rsae_sha256', 'rsa_pkcs1_sha256', + 'ecdsa_secp384r1_sha384', 'rsa_pss_rsae_sha384', 'rsa_pkcs1_sha384', + 'rsa_pss_rsae_sha512', 'rsa_pkcs1_sha512', ].join(':'); -// 配置 HTTP Agent -const httpAgent = new http.Agent({ - keepAlive: true, - maxSockets: 100, - maxFreeSockets: 5, - timeout: 120000, -}); - -// 配置 HTTPS Agent — 模拟 Chrome 136 TLS 指纹 +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, - // TLS 指纹伪装 - ciphers: CHROME_CIPHERS, - sigalgs: CHROME_SIGALGS, - minVersion: 'TLSv1.2', - maxVersion: 'TLSv1.3', - // axios 仅支持 HTTP/1.1,不能协商 h2(否则服务端返回 H2 帧会解析失败) - // 注意:真实 Chrome 会协商 h2,但 Node.js http 模块不支持 - ALPNProtocols: ['http/1.1'], - // Chrome 支持的 EC 曲线 - ecdhCurve: 'X25519:P-256:P-384', - // 允许不安全的旧版协商 (Chrome 也允许) - honorCipherOrder: false, - // 启用 session ticket (Chrome 默认行为) - sessionTimeout: 300, + keepAlive: true, maxSockets: 100, maxFreeSockets: 5, timeout: 120000, + ciphers: CHROME_CIPHERS, sigalgs: CHROME_SIGALGS, minVersion: 'TLSv1.2', maxVersion: 'TLSv1.3', + ALPNProtocols: ['http/1.1'], ecdhCurve: 'X25519:P-256:P-384', honorCipherOrder: false, sessionTimeout: 300, }); -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' }, @@ -100,267 +56,205 @@ const MODEL_MAPPING = { export class GrokApiService { constructor(config) { this.config = config; - this.uuid = config.uuid; // 存储 UUID 以便后续调用账号池方法 + this.uuid = config.uuid; this.token = config.GROK_COOKIE_TOKEN; this.cfClearance = config.GROK_CF_CLEARANCE; - this.userAgent = 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'; + this.userAgent = config.GROK_USER_AGENT || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.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 = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.GROK); - if (this.converter && this.uuid) { - this.converter.setUuid(this.uuid); - } - + if (this.converter && this.uuid) this.converter.setUuid(this.uuid); this.lastSyncAt = null; } - /** - * 如果 TLS sidecar 可用,将 axios 请求改为通过 sidecar 转发 - * sidecar 不可用时保持原有 https.Agent TLS 配置 - */ _applySidecar(axiosConfig) { const sidecar = getTLSSidecar(); if (sidecar.isReady()) { - // 获取上游代理 URL(如果有) - const proxyUrl = this.config.PROXY_URL && - this.config.PROXY_ENABLED_PROVIDERS?.includes(MODEL_PROVIDER.GROK_CUSTOM) - ? this.config.PROXY_URL : null; + const proxyUrl = this.config.PROXY_URL && this.config.PROXY_ENABLED_PROVIDERS?.includes(MODEL_PROVIDER.GROK_CUSTOM) ? this.config.PROXY_URL : null; sidecar.wrapAxiosConfig(axiosConfig, proxyUrl); - logger.debug('[Grok] Request routed through TLS sidecar'); } return axiosConfig; } 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.debug('[Grok] GROK_CF_CLEARANCE not set. TLS/header fingerprinting should bypass Cloudflare without it.'); - } - - // Initial usage sync - try { - await this.getUsageLimits(); - } catch (error) { - logger.warn('[Grok] Initial usage sync failed:', error.message); - } - + 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); - } + try { await this.getUsageLimits(); return Promise.resolve(); } catch (error) { 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-3", // Default model for checking limits - }; - - const axiosConfig = { - method: 'post', - url: rateLimitsApi, - headers: headers, - data: payload, - httpAgent, - httpsAgent, - timeout: 30000 - }; - + const payload = { "requestKind": "DEFAULT", "modelName": "grok-3" }; + const axiosConfig = { method: 'post', url: `${this.baseUrl}/rest/rate-limits`, headers, data: payload, httpAgent, httpsAgent, timeout: 30000 }; configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); this._applySidecar(axiosConfig); - 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; - } - - // 注入用量逻辑 + let remaining = data.remainingTokens !== undefined ? data.remainingTokens : (data.remainingQueries !== undefined ? data.remainingQueries : data.totalQueries); 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.usedQueries = Math.max(0, data.totalQueries - (data.remainingQueries || 0)); data.unit = 'queries'; - } else if (data.totalQueries === 0) { - // 免费账号:totalQueries = 0 时用 token 额度(totalTokens - remainingTokens) + } else { 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(); - 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; - } + return { lastUpdated: this.lastSyncAt, remaining, ...data }; + } catch (error) { 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; + return (Date.now() - this.lastSyncAt) > (this.config.CRON_NEAR_MINUTES || 15) * 60 * 1000; } - /** - * Generate Statsig ID (StatsigGenerator) - */ genStatsigId() { - const randomString = (len, alphanumeric = false) => { - const chars = alphanumeric - ? 'abcdefghijklmnopqrstuvwxyz0123456789' - : 'abcdefghijklmnopqrstuvwxyz'; - let result = ''; - for (let i = 0; i < len; i++) { - result += chars[Math.floor(Math.random() * chars.length)]; - } - return result; + const randomString = (len, alpha = false) => { + const chars = alpha ? 'abcdefghijklmnopqrstuvwxyz0123456789' : 'abcdefghijklmnopqrstuvwxyz'; + let res = ''; + for (let i = 0; i < len; i++) res += chars[Math.floor(Math.random() * chars.length)]; + return res; }; - - let msg; - if (Math.random() < 0.5) { - const rand = randomString(5, true); - msg = `e:TypeError: Cannot read properties of null (reading 'children['${rand}']')`; - } else { - const rand = randomString(10); - msg = `e:TypeError: Cannot read properties of undefined (reading '${rand}')`; - } + const msg = Math.random() < 0.5 ? `e:TypeError: Cannot read properties of null (reading 'children['${randomString(5, true)}']')` : `e:TypeError: Cannot read properties of undefined (reading '${randomString(10)}')`; return Buffer.from(msg).toString('base64'); } buildHeaders() { let ssoToken = this.token || ""; - if (ssoToken.startsWith("sso=")) { - ssoToken = ssoToken.substring(4); - } - + 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}`); - } - - // Extract browser version and platform from UA for consistent fingerprinting - const ua = this.userAgent; - let brand = 'Google Chrome'; - if (ua.includes('Edg/')) brand = 'Microsoft Edge'; - const versionMatch = ua.match(/(?:Chrome|Chromium|Edg)\/(\d+)/); - const version = versionMatch ? versionMatch[1] : '136'; - - let platform = 'macOS'; - if (ua.includes('Windows')) platform = 'Windows'; - else if (ua.includes('Android')) platform = 'Android'; - else if (ua.includes('iPhone') || ua.includes('iPad')) platform = 'iOS'; - else if (ua.includes('Linux') && !ua.includes('Android')) platform = 'Linux'; - - const isMobile = ua.toLowerCase().includes('mobile'); - - const headers = { + if (this.cfClearance) cookie.push(`cf_clearance=${this.cfClearance}`); + return { 'accept': '*/*', - 'accept-language': 'zh-CN,zh;q=0.9', - 'baggage': 'sentry-environment=production,sentry-release=d6add6fb0460641fd482d767a335ef72b9b6abb8,sentry-public_key=b311e0f2690c81f25e2c4cf6d4f7ce1c', - 'cache-control': 'no-cache', + 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,ja;q=0.7', 'content-type': 'application/json', 'cookie': cookie.join('; '), 'origin': this.baseUrl, - 'pragma': 'no-cache', 'priority': 'u=1, i', 'referer': `${this.baseUrl}/`, - 'sec-ch-ua': `"${brand}";v="${version}", "Chromium";v="${version}", "Not(A:Brand";v="24"`, - 'sec-ch-ua-arch': platform === 'macOS' ? 'arm' : 'x86', - 'sec-ch-ua-bitness': '64', - 'sec-ch-ua-mobile': isMobile ? '?1' : '?0', - 'sec-ch-ua-model': '', - 'sec-ch-ua-platform': `"${platform}"`, - 'sec-fetch-dest': 'empty', - 'sec-fetch-mode': 'cors', - 'sec-fetch-site': 'same-origin', - 'user-agent': ua, + 'sec-ch-ua': '"Google Chrome";v="143", "Chromium";v="143", "Not A(Brand";v="24"', + 'sec-ch-ua-arch': '"x86"', + 'sec-ch-ua-bitness': '"64"', + 'sec-ch-ua-mobile': '?0', + 'sec-ch-ua-platform': '"Windows"', + 'user-agent': this.userAgent, 'x-statsig-id': this.genStatsigId(), 'x-xai-request-id': uuidv4() }; + } - return headers; + _extractPostId(text) { + if (!text || typeof text !== 'string') return null; + const match = text.match(/\/post\/([0-9a-fA-F-]{32,36})/) || + text.match(/\/generated\/([0-9a-fA-F-]{32,36})\//) || + text.match(/\/([0-9a-fA-F-]{32,36})\/generated_video/); + return match ? match[1] : null; + } + + async createPost(mediaType, mediaUrl = null, prompt = null) { + const headers = this.buildHeaders(); + headers['referer'] = `${this.baseUrl}/imagine`; + + // 严格遵循成功示例的载荷结构 + const payload = { mediaType }; + if (prompt && prompt.trim()) payload.prompt = prompt; + if (mediaUrl && mediaUrl.trim()) payload.mediaUrl = mediaUrl; + + const axiosConfig = { method: 'post', url: `${this.baseUrl}/rest/media/post/create`, headers, data: payload, httpAgent, httpsAgent, timeout: 30000 }; + configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); + this._applySidecar(axiosConfig); + try { + const response = await axios(axiosConfig); + const postId = response.data?.post?.id; + if (postId) logger.info(`[Grok Post] Media post created: ${postId} (type=${mediaType})`); + return postId; + } catch (error) { + const detail = error.response?.data ? JSON.stringify(error.response.data) : error.message; + logger.error(`[Grok Post] Failed to create media post: ${detail}`); + return null; + } + } + + async upscaleVideo(videoUrl) { + if (!videoUrl) return videoUrl; + const idMatch = videoUrl.match(/\/generated\/([0-9a-fA-F-]{32,36})\//) || videoUrl.match(/\/([0-9a-fA-F-]{32,36})\/generated_video/); + if (!idMatch) return videoUrl; + const videoId = idMatch[1]; + const axiosConfig = { method: 'post', url: `${this.baseUrl}/rest/media/video/upscale`, headers: this.buildHeaders(), data: { videoId }, httpAgent, httpsAgent, timeout: 30000 }; + configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); + this._applySidecar(axiosConfig); + try { + const response = await axios(axiosConfig); + return response.data?.hdMediaUrl || videoUrl; + } catch (error) { return videoUrl; } + } + + async createVideoShareLink(postId) { + logger.info(`[Grok Video Link] Entering createVideoShareLink with postId: ${postId}`); + if (!postId) return null; + const headers = this.buildHeaders(); + headers['referer'] = `${this.baseUrl}/imagine/post/${postId}`; + const payload = { + "postId": postId, + "source": "post-page", + "platform": "web" + }; + const axiosConfig = { + method: 'post', + url: `${this.baseUrl}/rest/media/post/create-link`, + headers, + data: payload, + httpAgent, + httpsAgent, + timeout: 15000 + }; + configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); + this._applySidecar(axiosConfig); + try { + const response = await axios(axiosConfig); + const shareLink = response.data?.shareLink; + if (shareLink) { + // 从 shareLink 中提取 ID (通常与输入的 postId 一致) + const idMatch = shareLink.match(/\/post\/([0-9a-fA-F-]{36}|[0-9a-fA-F]{32})/); + const resourceId = idMatch ? idMatch[1] : postId; + + // 构造公开的视频资源地址 + const resourceUrl = `https://imagine-public.x.ai/imagine-public/share-videos/${resourceId}.mp4?cache=1`; + + logger.info(`[Grok Video Link] Public resource created for post ${postId}: ${resourceUrl}`); + return resourceUrl; + } + return null; + } catch (error) { + const detail = error.response?.data ? JSON.stringify(error.response.data) : error.message; + logger.warn(`[Grok Video Link] Failed to create share link for ${postId}: ${detail}`); + return null; + } } 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. 构建工具提示词并注入 (逻辑) + if (requestBody.tools?.length > 0) processedMessages = this.converter.formatToolHistory(requestBody.messages); 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); - } + if (requestBody.tools && Object.keys(toolOverrides).length === 0) toolOverrides = this.converter.buildToolOverrides(requestBody.tools); - // 4. 提取文本和附件 (MessageExtractor.extract 逻辑) const extracted = []; const imageAttachments = []; const localFileAttachments = []; @@ -369,169 +263,75 @@ export class GrokApiService { 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)) { + 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); - } + 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 === '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) { + if (role === "assistant" && parts.length === 0 && Array.isArray(msg.tool_calls)) { + for (const call of msg.tool_calls) { 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()); + parts.push(`[tool_call] ${fn.name || call.name} ${typeof fn.arguments === 'string' ? fn.arguments : JSON.stringify(fn.arguments)}`); } } - - 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}`); - } + if (parts.length > 0) extracted.push({ role, text: parts.join("\n") }); } + let lastUserIdx = -1; + for (let i = extracted.length - 1; i >= 0; i--) { if (extracted[i].role === 'user') { lastUserIdx = i; break; } } + const texts = extracted.map((item, i) => i === lastUserIdx ? item.text : `${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. 附件准备 (供后续上传) + if (toolPrompt) message = `${toolPrompt}\n\n${message}`; + if (!message.trim() && (imageAttachments.length || localFileAttachments.length)) message = "Refer to the following content:"; requestBody._extractedImages = imageAttachments; requestBody._extractedFiles = localFileAttachments; } - // 视频生成支持 (特定参数从 requestBody 透传) if (requestBody.videoGenModelConfig) { - modelConfigOverride.modelMap = { - videoGenModelConfig: requestBody.videoGenModelConfig - }; + modelConfigOverride.modelMap = { videoGenModelConfig: requestBody.videoGenModelConfig }; toolOverrides.videoGen = true; - if (requestBody.videoGenPrompt) { - message = requestBody.videoGenPrompt; - } + 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, - }; + const modelLower = modelId.toLowerCase(); + const isMediaModel = modelLower.includes('imagine') || modelLower.includes('video') || modelLower.includes('edit'); - return payload; + return { + "deviceEnvInfo": { "darkModeEnabled": false, "devicePixelRatio": 2, "screenWidth": 2056, "screenHeight": 1329, "viewportWidth": 2056, "viewportHeight": 1083 }, + "disableMemory": false, "disableSearch": false, "disableSelfHarmShortCircuit": false, "disableTextFollowUps": false, + "enableImageGeneration": isMediaModel, "enableImageStreaming": isMediaModel, "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, + }; } async generateContent(model, requestBody) { + logger.info(`[Grok] Starting generateContent (unified processing)`); const stream = this.generateContentStream(model, requestBody); - const collected = { - message: "", - responseId: "", - llmInfo: {}, - rolloutId: "", - modelResponse: null, - cardAttachment: null, - streamingImageGenerationResponse: null, - streamingVideoGenerationResponse: null, - finalVideoUrl: null, - finalThumbnailUrl: null - }; - + const collected = { message: "", responseId: "", postId: "", 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._requestBaseUrl) collected._requestBaseUrl = resp._requestBaseUrl; if (resp._uuid) collected._uuid = resp._uuid; - 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.postId) collected.postId = resp.streamingVideoGenerationResponse.postId; if (resp.streamingVideoGenerationResponse.progress === 100 && resp.streamingVideoGenerationResponse.videoUrl) { collected.finalVideoUrl = resp.streamingVideoGenerationResponse.videoUrl; collected.finalThumbnailUrl = resp.streamingVideoGenerationResponse.thumbnailImageUrl; @@ -539,233 +339,185 @@ export class GrokApiService { } } + logger.info(`[Grok] Finalizing collection. model: ${model}, respId: ${collected.responseId}, videoPostId: ${collected.postId}`); + + // 1. 仅针对视频进行 postId 提取和分享链接创建 + const isVideo = !!(collected.finalVideoUrl || collected.streamingVideoGenerationResponse || model.toLowerCase().includes('video')); + logger.info(`[Grok Decision] isVideo detected: ${isVideo}. (finalUrl: ${!!collected.finalVideoUrl}, streamResp: ${!!collected.streamingVideoGenerationResponse}, modelIncludeVideo: ${model.toLowerCase().includes('video')})`); + + if (isVideo && !collected.postId) { + if (collected.finalVideoUrl) { + collected.postId = this._extractPostId(collected.finalVideoUrl); + logger.info(`[Grok Decision] PostId extracted from finalVideoUrl: ${collected.postId}`); + } + if (!collected.postId && collected.message) { + collected.postId = this._extractPostId(collected.message); + logger.info(`[Grok Decision] PostId extracted from message text: ${collected.postId}`); + } + } + + // 2. 仅在确实是视频且有 postId 时,处理视频分享链接 (createVideoShareLink) + if (isVideo && collected.postId) { + logger.info(`[Grok Decision] Calling createVideoShareLink...`); + const shareUrl = await this.createVideoShareLink(collected.postId); + if (shareUrl) { + logger.info(`[Grok Video Result] ShareUrl created: ${shareUrl}. Replacing links...`); + if (collected.finalVideoUrl) collected.finalVideoUrl = shareUrl; + if (collected.streamingVideoGenerationResponse) collected.streamingVideoGenerationResponse.videoUrl = shareUrl; + + if (collected.message) { + const grokLinkRegex = /https?:\/\/grok\.com\/imagine\/post\/([0-9a-fA-F-]{32,36})/g; + collected.message = collected.message.replace(grokLinkRegex, shareUrl); + } + } else { + logger.warn(`[Grok Video Result] createVideoShareLink returned NULL for ${collected.postId}`); + } + } else if (isVideo) { + logger.warn(`[Grok Video Skip] isVideo is TRUE but NO postId found to create share link.`); + } + return collected; } - /** - * Upload file to Grok (UploadService) - */ async uploadFile(fileInput) { - let fileName = "file.bin"; - let b64 = ""; - let mime = "application/octet-stream"; - + let b64 = "", 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 (match) { mime = match[1]; b64 = match[2]; } } - 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 - }; - + const axiosConfig = { method: 'post', url: `${this.baseUrl}/rest/app-chat/upload-file`, headers: this.buildHeaders(), data: { fileName: `file.${mime.split("/")[1] || "bin"}`, fileMimeType: mime, content: b64 }, httpAgent, httpsAgent, timeout: 30000 }; configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); this._applySidecar(axiosConfig); - - 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; - } + try { return (await axios(axiosConfig)).data; } catch (error) { return null; } } async * generateContentStream(model, requestBody) { - // 确保全局转换器拥有最新的基础 URL 和 UUID if (this.converter) { - if (this.uuid) { - this.converter.setUuid(this.uuid); - } - if (requestBody._requestBaseUrl) { - this.converter.setRequestBaseUrl(requestBody._requestBaseUrl); - } + if (this.uuid) this.converter.setUuid(this.uuid); + 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 (requestBody._monitorRequestId) { this.config._monitorRequestId = requestBody._monitorRequestId; delete requestBody._monitorRequestId; } + const reqBaseUrl = requestBody._requestBaseUrl; + if (requestBody._requestBaseUrl) delete requestBody._requestBaseUrl; + + if (this.isExpiryDateNear() && getProviderPoolManager() && this.uuid) { + getProviderPoolManager().markProviderNeedRefresh(MODEL_PROVIDER.GROK_CUSTOM, { uuid: this.uuid }); } - // 检查是否即将到期(需要同步用量),如果是则推送到刷新队列 - 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 || []; + const modelLower = model.toLowerCase(); + const isVideoModel = modelLower.includes('video'); + const isImageModel = modelLower.includes('imagine') && !isVideoModel && !modelLower.includes('edit'); + const isImageEditModel = modelLower.includes('edit'); - // 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); + if (isVideoModel) { + const videoConfig = requestBody.videoGenModelConfig || {}; + const aspectRatio = requestBody.aspect_ratio || requestBody.aspectRatio || videoConfig.aspectRatio || "3:2"; + const videoLength = parseInt(requestBody.video_length || requestBody.videoLength || videoConfig.videoLength || 6); + const resolutionName = requestBody.resolution_name || requestBody.resolution || videoConfig.resolutionName || "480p"; + const preset = requestBody.preset || "normal"; + let parentPostId = videoConfig.parentPostId; + + if (!parentPostId) { + // 修复:从 requestBody.message 或 messages 数组中提取 prompt + let prompt = requestBody.videoGenPrompt || requestBody.message; + if (!prompt && requestBody.messages?.length > 0) { + const lastMsg = requestBody.messages[requestBody.messages.length - 1]; + if (typeof lastMsg.content === 'string') { + prompt = lastMsg.content; + } else if (Array.isArray(lastMsg.content)) { + const textPart = lastMsg.content.find(p => p.type === 'text'); + if (textPart) prompt = textPart.text; + } + } + prompt = prompt || ""; + + let lastMsgImages = []; + if (requestBody.messages?.length > 0) { + const lastMsg = requestBody.messages[requestBody.messages.length - 1]; + if (lastMsg.role === 'user' && Array.isArray(lastMsg.content)) { + lastMsg.content.forEach(item => { if (item.type === 'image_url' && item.image_url?.url) lastMsgImages.push(item.image_url.url); }); + } + } + if (lastMsgImages.length > 0) { + let mediaUrl = lastMsgImages[0]; + if (mediaUrl.startsWith('data:') || !mediaUrl.startsWith('http')) { + const up = await this.uploadFile(mediaUrl); + if (up?.fileUri) mediaUrl = `https://assets.grok.com/${up.fileUri}`; + } + parentPostId = await this.createPost("MEDIA_POST_TYPE_VIDEO", mediaUrl); + } else { + parentPostId = await this.createPost("MEDIA_POST_TYPE_VIDEO", null, prompt); } } - // 更新附件列表 + + if (parentPostId) { + requestBody.videoGenModelConfig = { aspectRatio, parentPostId, resolutionName, videoLength }; + const modeMap = { "fun": "--mode=extremely-crazy", "normal": "--mode=normal", "spicy": "--mode=extremely-spicy-or-crazy" }; + requestBody.videoGenPrompt = `${requestBody.videoGenPrompt || requestBody.message || ""} ${modeMap[preset] || "--mode=custom"}`; + requestBody.toolOverrides = { ...requestBody.toolOverrides, videoGen: true }; + } + } else if (isImageModel || isImageEditModel) { + requestBody.toolOverrides = { ...requestBody.toolOverrides, imageGen: true }; + } + + let fileAttachments = requestBody.fileAttachments || []; + const toUpload = [...(requestBody._extractedImages || []), ...(requestBody._extractedFiles || [])]; + if (toUpload.length > 0) { + for (const data of toUpload) { + const res = await this.uploadFile(data); + if (res?.fileMetadataId) fileAttachments.push(res.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 - }; - + const axiosConfig = { method: 'post', url: this.chatApi, headers: this.buildHeaders(), data: payload, responseType: 'stream', httpAgent, httpsAgent, timeout: 60000, maxRedirects: 0 }; configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); this._applySidecar(axiosConfig); 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; + const rl = readline.createInterface({ input: response.data, terminal: false }); 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(); - } - + const trimmed = line.trim(); + if (!trimmed) continue; + let dataStr = trimmed.startsWith('data: ') ? trimmed.slice(6).trim() : trimmed; if (dataStr === '[DONE]') break; - try { const json = JSON.parse(dataStr); if (json.result?.response) { - // 注入基础 URL 和 UUID,以便全局转换器能获取到 - json.result.response._requestBaseUrl = requestBody._requestBaseUrl; - json.result.response._uuid = this.uuid; - - if (json.result.response.responseId) { - lastResponseId = json.result.response.responseId; + const resp = json.result.response; + resp._requestBaseUrl = reqBaseUrl; + resp._uuid = this.uuid; + if (resp.responseId) lastResponseId = resp.responseId; + if (resp.streamingVideoGenerationResponse) { + const vid = resp.streamingVideoGenerationResponse; + if (vid.progress === 100 && vid.videoUrl && (requestBody.videoGenModelConfig?.resolutionName === "720p")) { + const hdUrl = await this.upscaleVideo(vid.videoUrl); + if (hdUrl) vid.videoUrl = hdUrl; + } } } yield json; - } catch (e) { - // Grok sometimes sends empty data or comments - if (dataStr !== ':' && !dataStr.startsWith(':')) { - logger.debug('[Grok Stream] Non-JSON line ignored:', dataStr); - } - } + } catch (e) {} } - - 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, - _requestBaseUrl: requestBody._requestBaseUrl, - _uuid: this.uuid - } - } - }; - } catch (error) { - this.handleApiError(error); - } + yield { result: { response: { isDone: true, responseId: lastResponseId, _requestBaseUrl: reqBaseUrl, _uuid: this.uuid } } }; + } 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)'; - } - + 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 }; + return { data: GROK_MODELS.map(id => ({ id, object: "model", created: Math.floor(Date.now() / 1000), owned_by: "xai", display_name: id.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ') })) }; } }