diff --git a/src/converters/strategies/GrokConverter.js b/src/converters/strategies/GrokConverter.js index 57993dd..efde2d9 100644 --- a/src/converters/strategies/GrokConverter.js +++ b/src/converters/strategies/GrokConverter.js @@ -5,8 +5,10 @@ import { v4 as uuidv4 } from 'uuid'; import logger from '../../utils/logger.js'; +import { countTextTokens } from '../../utils/token-utils.js'; import { BaseConverter } from '../BaseConverter.js'; import { MODEL_PROTOCOL_PREFIX } from '../../utils/common.js'; +import { ConverterFactory } from '../ConverterFactory.js'; /** * Grok转换器类 @@ -21,6 +23,8 @@ export class GrokConverter extends BaseConverter { super('grok'); // 用于跟踪每个请求的状态 this.requestStates = new Map(); + /** @type {Map} 流式 Claude 转换是否已发送 message_start(按 streamRequestId) */ + this._claudeMsgStartSent = new Map(); } /** @@ -111,12 +115,183 @@ export class GrokConverter extends BaseConverter { requestBaseUrl: "", uuid: null, seen_images: new Set(), // 用于去重已输出的图片 - pending_text_buffer: "" // 用于处理流式输出中被截断的 URL + pending_text_buffer: "", // 用于处理流式输出中被截断的 URL + usageAcc: null, // 流式过程中最后一次解析到的上游用量(末包常为合成 isDone 无用量) + usageEstimatePayload: null // grok-core 注入的 prompt/tools 文本,用于本地估算 }); } return this.requestStates.get(requestId); } + _nTok(v) { + const n = Number(v); + return Number.isFinite(n) && n >= 0 ? Math.trunc(n) : 0; + } + + _packOpenAIUsage(prompt, completion, total) { + const pt = this._nTok(prompt); + const ct = this._nTok(completion); + let tt = this._nTok(total); + if (!tt && (pt || ct)) tt = pt + ct; + if (!pt && !ct && !tt) return null; + return { prompt_tokens: pt, completion_tokens: ct, total_tokens: tt || pt + ct }; + } + + _usageFromUsageLike(u) { + if (!u || typeof u !== "object") return null; + return this._packOpenAIUsage( + u.prompt_tokens ?? u.input_tokens ?? u.promptTokens ?? u.inputTokens + ?? u.prompt_token_count ?? u.input_token_count, + u.completion_tokens ?? u.output_tokens ?? u.completionTokens ?? u.outputTokens + ?? u.completion_token_count ?? u.output_token_count, + u.total_tokens ?? u.totalTokens ?? u.total_token_count + ); + } + + _usageFromLlmInfoLike(li) { + if (!li || typeof li !== "object") return null; + return this._packOpenAIUsage( + li.inputTokens ?? li.promptTokens ?? li.prompt_tokens ?? li.input_tokens + ?? li.prompt_token_count ?? li.input_token_count, + li.outputTokens ?? li.completionTokens ?? li.completion_tokens ?? li.output_tokens + ?? li.completion_token_count ?? li.output_token_count, + li.totalTokens ?? li.total_tokens ?? li.total_token_count + ); + } + + _usageRank(u) { + return u ? (u.total_tokens || u.prompt_tokens + u.completion_tokens) : 0; + } + + _usageFromRecord(node) { + if (!node || typeof node !== "object") return null; + return this._usageFromUsageLike(node.usage) + || this._usageFromUsageLike(node.tokenUsage) + || this._usageFromLlmInfoLike(node.llmInfo) + || this._usageFromLlmInfoLike(node.llm_info); + } + + _bestUsageFromNodes(nodes) { + let best = null; + for (const node of nodes) { + const u = this._usageFromRecord(node); + if (u && this._usageRank(u) >= this._usageRank(best)) best = u; + } + return best; + } + + _preferHigherUsage(a, b) { + if (this._usageRank(b) > this._usageRank(a)) return b || a; + return a || b; + } + + /** + * 上游无有效 usage 时,用 Claude tokenizer 估算(与 Grok/xAI 官方计费可能不一致,仅作展示/配额参考) + */ + _fillUsageWithEstimateIfNeeded(upstream, payload, completionText) { + if (process.env.GROK_DISABLE_USAGE_ESTIMATE === '1' || /^true$/i.test(process.env.GROK_DISABLE_USAGE_ESTIMATE || '')) { + return upstream && typeof upstream === 'object' + ? upstream + : { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }; + } + const u = upstream && typeof upstream === 'object' + ? upstream + : { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }; + if (this._usageRank(u) > 0) { + return { + prompt_tokens: u.prompt_tokens, + completion_tokens: u.completion_tokens, + total_tokens: u.total_tokens || u.prompt_tokens + u.completion_tokens, + }; + } + const promptStr = `${payload?.promptText ?? ''}${payload?.toolsJson ?? ''}`; + const pt = countTextTokens(promptStr); + const ct = countTextTokens(completionText || ''); + return { + prompt_tokens: pt, + completion_tokens: ct, + total_tokens: pt + ct, + }; + } + + /** + * 在整块 JSON 内深度查找类 usage 对象(Grok 上游字段位置不固定时兜底) + */ + _deepFindUsage(obj, depth = 0, maxDepth = 6) { + if (!obj || typeof obj !== "object" || depth > maxDepth) return null; + if (Array.isArray(obj)) { + let best = null; + const lim = Math.min(obj.length, 80); + for (let i = 0; i < lim; i++) { + const u = this._deepFindUsage(obj[i], depth + 1, maxDepth); + best = this._preferHigherUsage(best, u); + } + return best; + } + const direct = this._usageFromUsageLike(obj) || this._usageFromLlmInfoLike(obj); + if (direct) return direct; + let best = null; + const keys = Object.keys(obj); + const lim = Math.min(keys.length, 80); + for (let i = 0; i < lim; i++) { + const v = obj[keys[i]]; + if (v == null || typeof v !== "object") continue; + const u = this._deepFindUsage(v, depth + 1, maxDepth); + best = this._preferHigherUsage(best, u); + } + return best; + } + + /** + * 从 Grok app-chat 流式块解析用量(兼容 result 层、response、modelResponse.metadata.llm_info 等) + */ + _extractGrokUsageFromChunk(grokChunk, resp) { + const nodes = []; + if (grokChunk?.result) nodes.push(grokChunk.result); + if (resp) nodes.push(resp); + if (resp?.modelResponse) { + nodes.push(resp.modelResponse); + const md = resp.modelResponse.metadata; + if (md) { + nodes.push(md); + if (md.llm_info) nodes.push(md.llm_info); + } + } + const shallow = this._bestUsageFromNodes(nodes); + const deep = this._deepFindUsage(grokChunk, 0, 6); + return this._preferHigherUsage(shallow, deep); + } + + /** + * 从非流式聚合结果解析用量 + */ + _extractGrokUsageFromCollected(grokResponse) { + const nodes = [grokResponse, grokResponse?.modelResponse]; + if (grokResponse?.usage) nodes.push({ usage: grokResponse.usage }); + const md = grokResponse?.modelResponse?.metadata; + if (md) { + nodes.push(md); + if (md.llm_info) nodes.push(md.llm_info); + } + if (grokResponse?.llmInfo) nodes.push(grokResponse.llmInfo); + const shallow = this._bestUsageFromNodes(nodes); + const deep = this._deepFindUsage(grokResponse, 0, 6); + return this._preferHigherUsage(shallow, deep); + } + + /** + * 部署后验证用量:环境变量 GROK_LOG_USAGE=1(或 true)时,每次完成响应打一行 info,默认关闭。 + */ + _maybeLogGrokUsage(kind, model, responseId, usage) { + const flag = process.env.GROK_LOG_USAGE; + if (flag !== '1' && !/^true$/i.test(String(flag || ''))) return; + if (!usage) return; + logger.info( + `[Grok usage] ${kind} model=${model ?? '?'} id=${responseId ?? '?'} ` + + `in=${usage.prompt_tokens} out=${usage.completion_tokens} total=${usage.total_tokens}` + ); + } + /** * 构建工具系统提示词 (build_tool_prompt) */ @@ -268,6 +443,12 @@ export class GrokConverter extends BaseConverter { return this.toOpenAIResponsesResponse(data, model); case MODEL_PROTOCOL_PREFIX.CODEX: return this.toCodexResponse(data, model); + case MODEL_PROTOCOL_PREFIX.CLAUDE: { + const openaiRes = this.toOpenAIResponse(data, model); + if (!openaiRes) return data; + const openaiConverter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.OPENAI); + return openaiConverter.toClaudeResponse(openaiRes, model); + } default: return data; } @@ -276,7 +457,7 @@ export class GrokConverter extends BaseConverter { /** * 转换流式响应块 */ - convertStreamChunk(chunk, targetProtocol, model) { + convertStreamChunk(chunk, targetProtocol, model, requestId) { switch (targetProtocol) { case MODEL_PROTOCOL_PREFIX.OPENAI: return this.toOpenAIStreamChunk(chunk, model); @@ -286,6 +467,48 @@ export class GrokConverter extends BaseConverter { return this.toOpenAIResponsesStreamChunk(chunk, model); case MODEL_PROTOCOL_PREFIX.CODEX: return this.toCodexStreamChunk(chunk, model); + case MODEL_PROTOCOL_PREFIX.CLAUDE: { + const openaiPieces = this.toOpenAIStreamChunk(chunk, model); + if (!openaiPieces) return null; + const key = requestId || '_'; + const openaiConverter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.OPENAI); + const pieces = Array.isArray(openaiPieces) ? openaiPieces : [openaiPieces]; + const out = []; + for (const p of pieces) { + const events = openaiConverter.toClaudeStreamChunk(p, model); + if (!events) continue; + const arr = Array.isArray(events) ? events : [events]; + for (const ev of arr) { + if (!this._claudeMsgStartSent.get(key)) { + this._claudeMsgStartSent.set(key, true); + const msgId = `msg_${String(p.id || uuidv4()).replace(/^chatcmpl-/, '')}`; + out.push({ + type: 'message_start', + message: { + id: msgId, + type: 'message', + role: 'assistant', + content: [], + model: model || p.model || 'unknown', + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0 + } + } + }); + } + out.push(ev); + } + } + if (chunk?.result?.response?.isDone) { + this._claudeMsgStartSent.delete(key); + } + return out.length === 0 ? null : (out.length === 1 ? out[0] : out); + } default: return chunk; } @@ -555,8 +778,20 @@ export class GrokConverter extends BaseConverter { } // 解析工具调用 + const contentForTokenCount = content; const { text, toolCalls } = this.parseToolCalls(content); + let usage = this._extractGrokUsageFromCollected(grokResponse) || { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + }; + usage = this._fillUsageWithEstimateIfNeeded( + usage, + grokResponse._grokUsageEstimatePayload, + contentForTokenCount + ); + const result = { id: responseId, object: "chat.completion", @@ -571,17 +806,14 @@ export class GrokConverter extends BaseConverter { }, finish_reason: toolCalls ? "tool_calls" : "stop", }], - usage: { - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - }, + usage, }; if (toolCalls) { result.choices[0].message.tool_calls = toolCalls; } + this._maybeLogGrokUsage('unary', model, result.id, result.usage); return result; } @@ -619,6 +851,15 @@ export class GrokConverter extends BaseConverter { state.rollout_id = String(resp.rolloutId); } + const usageHere = this._extractGrokUsageFromChunk(grokChunk, resp); + if (usageHere && this._usageRank(usageHere) >= this._usageRank(state.usageAcc)) { + state.usageAcc = usageHere; + } + const est = grokChunk.result?._grokUsageEstimatePayload; + if (est && !state.usageEstimatePayload) { + state.usageEstimatePayload = est; + } + const chunks = []; // 0. 发送角色信息(仅第一次) @@ -657,6 +898,17 @@ export class GrokConverter extends BaseConverter { // 处理 buffer 中的工具调用 const { text, toolCalls } = this.parseToolCalls(state.content_buffer); + let terminalUsage = state.usageAcc || usageHere || { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0 + }; + terminalUsage = this._fillUsageWithEstimateIfNeeded( + terminalUsage, + state.usageEstimatePayload, + state.content_buffer || '' + ); + this._maybeLogGrokUsage('stream', model, responseId, terminalUsage); if (toolCalls) { chunks.push({ id: responseId, @@ -664,6 +916,7 @@ export class GrokConverter extends BaseConverter { created: Math.floor(Date.now() / 1000), model: model, system_fingerprint: state.fingerprint, + usage: terminalUsage, choices: [{ index: 0, delta: { @@ -680,6 +933,7 @@ export class GrokConverter extends BaseConverter { created: Math.floor(Date.now() / 1000), model: model, system_fingerprint: state.fingerprint, + usage: terminalUsage, choices: [{ index: 0, delta: { content: finalContent || null }, diff --git a/src/providers/grok/grok-core.js b/src/providers/grok/grok-core.js index 7fbd981..da919a9 100644 --- a/src/providers/grok/grok-core.js +++ b/src/providers/grok/grok-core.js @@ -67,6 +67,15 @@ function normalizeGrokModelId(modelId) { return isGrokNsfwModel(modelId) ? modelId.slice(0, -5) : modelId; } +/** 供 GrokConverter 在上游无 token 字段时用 Claude tokenizer 估算(非 Grok 官方计费) */ +function attachGrokUsageEstimatePayload(collected, requestBody) { + if (!collected || !requestBody) return; + const promptText = requestBody.message || ""; + const toolsJson = requestBody.tools && Array.isArray(requestBody.tools) && requestBody.tools.length + ? JSON.stringify(requestBody.tools) : ""; + collected._grokUsageEstimatePayload = { promptText, toolsJson }; +} + export class GrokApiService { constructor(config) { this.config = config; @@ -722,8 +731,17 @@ export class GrokApiService { try { for await (const chunk of stream) { - const resp = chunk.result?.response; + const res = chunk.result; + if (res?.usage && typeof res.usage === 'object') { + if (!collected.usage) collected.usage = {}; + Object.assign(collected.usage, res.usage); + } + const resp = res?.response; if (!resp) continue; + if (resp.usage && typeof resp.usage === 'object') { + if (!collected.usage) collected.usage = {}; + Object.assign(collected.usage, resp.usage); + } // 增加原始输出日志以排查多图生成问题 if (resp.cardAttachment || resp.streamingImageGenerationResponse || resp.modelResponse?.cardAttachmentsJson) { @@ -756,6 +774,15 @@ export class GrokApiService { } else { // 合并 modelResponse 中的消息 if (mr.message) collected.modelResponse.message = mr.message; + if (mr.metadata) { + const prev = collected.modelResponse.metadata || {}; + const next = mr.metadata; + const merged = { ...prev, ...next }; + if (prev.llm_info && next.llm_info && typeof prev.llm_info === 'object' && typeof next.llm_info === 'object') { + merged.llm_info = { ...prev.llm_info, ...next.llm_info }; + } + collected.modelResponse.metadata = merged; + } // 合并 cardAttachmentsJson (如果存在且未预过滤,但此处通常已由流预处理) if (Array.isArray(mr.cardAttachmentsJson)) { if (!collected.modelResponse.cardAttachmentsJson) collected.modelResponse.cardAttachmentsJson = []; @@ -825,10 +852,12 @@ export class GrokApiService { // 如果已经采集到了图片或视频,则不抛出异常,而是返回已有的结果 if (collected.cardAttachments.length > 0 || collected.generatedImageUrls.length > 0 || collected.finalVideoUrl) { logger.warn(`[Grok] Error during collection, but partial results exist. Returning what we have: ${error.message}`); + attachGrokUsageEstimatePayload(collected, requestBody); return collected; } throw error; } + attachGrokUsageEstimatePayload(collected, requestBody); return collected; } @@ -994,6 +1023,7 @@ export class GrokApiService { logger.warn(`[Grok Video Skip] isVideo is TRUE but NO postId found to create share link.`); } + attachGrokUsageEstimatePayload(collected, requestBody); return collected; } @@ -1112,7 +1142,8 @@ export class GrokApiService { if (collected.cardAttachments.length === 0) { throw new Error("WebSocket generation returned no images"); } - + + attachGrokUsageEstimatePayload(collected, requestBody); return collected; } @@ -1308,6 +1339,7 @@ export class GrokApiService { }); const rl = readline.createInterface({ input: response.data, terminal: false }); let lastResponseId = payload.responseMetadata?.requestModelDetails?.modelId || "final"; + let grokStreamUsagePayloadAttached = false; for await (const line of rl) { const trimmed = line.trim(); @@ -1316,6 +1348,14 @@ export class GrokApiService { if (dataStr === '[DONE]') break; try { const json = JSON.parse(dataStr); + if (json.result && requestBody && !grokStreamUsagePayloadAttached) { + json.result._grokUsageEstimatePayload = { + promptText: requestBody.message || "", + toolsJson: requestBody.tools && Array.isArray(requestBody.tools) && requestBody.tools.length + ? JSON.stringify(requestBody.tools) : "" + }; + grokStreamUsagePayloadAttached = true; + } if (json.result?.response) { const resp = json.result.response; resp._requestBaseUrl = reqBaseUrl; @@ -1392,9 +1432,21 @@ export class GrokApiService { } } hasYieldedData = true; + if (process.env.GROK_LOG_LAST_CHUNK === '1' || /^true$/i.test(process.env.GROK_LOG_LAST_CHUNK || '')) { + this._grokLastStreamJsonForDebug = json; + } yield json; } catch (e) {} } + if ((process.env.GROK_LOG_LAST_CHUNK === '1' || /^true$/i.test(process.env.GROK_LOG_LAST_CHUNK || '')) && this._grokLastStreamJsonForDebug) { + try { + const s = JSON.stringify(this._grokLastStreamJsonForDebug); + logger.info(`[Grok] Last SSE chunk before synthetic isDone (truncated 4000): ${s.slice(0, 4000)}`); + } catch (err) { + logger.warn(`[Grok] Could not stringify last chunk: ${err.message}`); + } + this._grokLastStreamJsonForDebug = null; + } yield { result: { response: { isDone: true, responseId: lastResponseId, _requestBaseUrl: reqBaseUrl, _uuid: this.uuid } } }; } catch (error) { const { status, errorCode, errorMessage, isNetworkError } = this.classifyApiError(error);