diff --git a/changelog.d/fixes/12692-xai-legacy-function-call.md b/changelog.d/fixes/12692-xai-legacy-function-call.md new file mode 100644 index 0000000000..4ba198f73a --- /dev/null +++ b/changelog.d/fixes/12692-xai-legacy-function-call.md @@ -0,0 +1 @@ +- **fix(providers):** xAI requests no longer silently drop an assistant tool call sent in the legacy OpenAI `function_call` shape (instead of `tool_calls[]`) — the call is now translated into the xAI request the same way modern tool calls are (#12692) — thanks @soroush5 diff --git a/changelog.d/fixes/12700-xai-usage-total.md b/changelog.d/fixes/12700-xai-usage-total.md new file mode 100644 index 0000000000..c7bc861702 --- /dev/null +++ b/changelog.d/fixes/12700-xai-usage-total.md @@ -0,0 +1 @@ +- **fix(providers):** xAI responses no longer report `total_tokens`/`totalTokenCount` as `0` when upstream usage uses the legacy `prompt_tokens`/`completion_tokens` names instead of `input_tokens`/`output_tokens` (#12700) — thanks @soroush5 diff --git a/src/lib/providers/xai/translators/gemini.ts b/src/lib/providers/xai/translators/gemini.ts index 531dff4a9a..1a8dc0de90 100644 --- a/src/lib/providers/xai/translators/gemini.ts +++ b/src/lib/providers/xai/translators/gemini.ts @@ -263,7 +263,7 @@ function toolsGeminiToXai(tools: GeminiTool[]): XaiTool[] | undefined { */ export function geminiRequestToXaiResponses( req: GeminiRequest, - model: string | null = null, + model: string | null = null ): XaiResponsesRequest { if (!req || typeof req !== "object") return req as unknown as XaiResponsesRequest; const input: XaiInputItem[] = []; @@ -275,9 +275,7 @@ export function geminiRequestToXaiResponses( if (fnItems.length) { for (const it of fnItems) input.push(it); // Filter remaining text/image parts - const remaining = (c.parts ?? []).filter( - (p) => !p?.functionCall && !p?.functionResponse, - ); + const remaining = (c.parts ?? []).filter((p) => !p?.functionCall && !p?.functionResponse); if (remaining.length) input.push({ role, content: partsToXaiBlocks(remaining) }); } else { input.push({ role, content: partsToXaiBlocks(c.parts ?? []) }); @@ -324,7 +322,7 @@ export function geminiRequestToXaiResponses( */ export function xaiCompletedToGeminiJson( completed: XaiCompleted, - origReq: GeminiRequest | null = null, + origReq: GeminiRequest | null = null ): object { const parts: unknown[] = []; const finishReason = "STOP"; @@ -364,7 +362,8 @@ export function xaiCompletedToGeminiJson( promptTokenCount: u.input_tokens ?? u.prompt_tokens ?? 0, candidatesTokenCount: u.output_tokens ?? u.completion_tokens ?? 0, totalTokenCount: - u.total_tokens ?? ((u.input_tokens ?? 0) + (u.output_tokens ?? 0)), + u.total_tokens ?? + (u.input_tokens ?? u.prompt_tokens ?? 0) + (u.output_tokens ?? u.completion_tokens ?? 0), }; } return out; diff --git a/src/lib/providers/xai/translators/openai-chat.ts b/src/lib/providers/xai/translators/openai-chat.ts index e5c7274dd9..da4e219d8d 100644 --- a/src/lib/providers/xai/translators/openai-chat.ts +++ b/src/lib/providers/xai/translators/openai-chat.ts @@ -38,6 +38,7 @@ interface OpenAiMessage { content?: MessageContent; tool_calls?: OpenAiToolCall[]; tool_call_id?: string; + function_call?: { name?: string; arguments?: string }; } interface OpenAiChatRequest { @@ -213,6 +214,22 @@ export function chatRequestToXaiResponses(req: OpenAiChatRequest): XaiResponsesR } continue; } + // Legacy OpenAI Chat Completions form: assistant tool-call carried as a top-level + // `function_call` field instead of `tool_calls[]`. Some OpenAI-compatible clients still + // emit this shape; without this branch the message falls through to the generic case + // below with empty content and the tool invocation is silently dropped (#12692). + if (m.role === "assistant" && m.function_call?.name) { + if (m.content) { + input.push({ role: "assistant", content: messageContentToXaiBlocks(m.content) }); + } + input.push({ + type: "function_call", + call_id: genId("call"), + name: m.function_call.name, + arguments: m.function_call.arguments ?? "", + }); + continue; + } input.push({ role: m.role ?? "user", content: messageContentToXaiBlocks(m.content ?? "") }); } @@ -303,7 +320,9 @@ export function xaiCompletedToChatJson( out.usage = { prompt_tokens: u.input_tokens ?? u.prompt_tokens ?? 0, completion_tokens: u.output_tokens ?? u.completion_tokens ?? 0, - total_tokens: u.total_tokens ?? (u.input_tokens ?? 0) + (u.output_tokens ?? 0), + total_tokens: + u.total_tokens ?? + (u.input_tokens ?? u.prompt_tokens ?? 0) + (u.output_tokens ?? u.completion_tokens ?? 0), }; } return out; diff --git a/tests/unit/xai-translators.test.ts b/tests/unit/xai-translators.test.ts index b011da550c..b68d674b92 100644 --- a/tests/unit/xai-translators.test.ts +++ b/tests/unit/xai-translators.test.ts @@ -193,6 +193,51 @@ test("chatRequestToXaiResponses: maps max_tokens to max_output_tokens", () => { assert.equal(out.max_output_tokens, 512); }); +test("#12692: chatRequestToXaiResponses maps legacy assistant function_call to a function_call item", () => { + const req = { + model: "grok-4", + messages: [ + { + role: "assistant", + content: null, + function_call: { name: "get_weather", arguments: '{"city":"Paris"}' }, + }, + ], + }; + const out = chatRequestToXaiResponses(req); + const calls = (out.input as Array<{ type: string; name?: string; arguments?: string }>).filter( + (i) => i.type === "function_call" + ); + assert.equal(calls.length, 1, "expected a function_call item to be present in xAI input"); + assert.equal(calls[0]?.name, "get_weather"); + assert.equal(calls[0]?.arguments, '{"city":"Paris"}'); +}); + +test("#12692: chatRequestToXaiResponses preserves leading text alongside legacy function_call", () => { + const req = { + model: "grok-4", + messages: [ + { + role: "assistant", + content: "Let me check that for you.", + function_call: { name: "get_weather", arguments: '{"city":"Paris"}' }, + }, + ], + }; + const out = chatRequestToXaiResponses(req); + const items = out.input as Array<{ + type?: string; + role?: string; + content?: unknown; + name?: string; + }>; + const textItem = items.find((i) => i.role === "assistant"); + assert.ok(textItem, "expected the leading assistant text block to be preserved"); + const calls = items.filter((i) => i.type === "function_call"); + assert.equal(calls.length, 1); + assert.equal(calls[0]?.name, "get_weather"); +}); + // ─── xaiCompletedToChatJson ────────────────────────────────────────────────── test("xaiCompletedToChatJson: extracts output_text content into message", () => { @@ -237,6 +282,21 @@ test("xaiCompletedToChatJson: maps function_call to tool_calls with finish_reaso assert.equal(fn.name, "get_weather"); }); +test("#12700: xaiCompletedToChatJson sums legacy prompt_tokens/completion_tokens into total_tokens", () => { + const completed = { + output: [{ type: "message", content: [{ type: "output_text", text: "hi" }] }], + usage: { prompt_tokens: 10, completion_tokens: 5 }, + }; + const result = xaiCompletedToChatJson(completed) as { usage?: Record }; + assert.equal(result.usage?.prompt_tokens, 10); + assert.equal(result.usage?.completion_tokens, 5); + assert.equal( + result.usage?.total_tokens, + 15, + "total_tokens should sum legacy fields, not report 0" + ); +}); + // ─── openaiResponsesRequestToXai ───────────────────────────────────────────── test("openaiResponsesRequestToXai: drops service_tier", () => { @@ -521,3 +581,19 @@ test("xaiCompletedToGeminiJson: maps usage to usageMetadata", () => { assert.equal(meta.candidatesTokenCount, 20); assert.equal(meta.totalTokenCount, 30); }); + +test("#12700: xaiCompletedToGeminiJson sums legacy prompt_tokens/completion_tokens into totalTokenCount", () => { + const completed = { + model: "grok-4", + output: [], + usage: { prompt_tokens: 10, completion_tokens: 5 }, + }; + const result = xaiCompletedToGeminiJson(completed) as { usageMetadata?: Record }; + assert.equal(result.usageMetadata?.promptTokenCount, 10); + assert.equal(result.usageMetadata?.candidatesTokenCount, 5); + assert.equal( + result.usageMetadata?.totalTokenCount, + 15, + "totalTokenCount should sum legacy fields, not report 0" + ); +});