mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-17 04:12:17 +03:00
fix(providers): xAI translators drop legacy function_call and zero total_tokens (#12692, #12700) (#13753)
Merged in the 2026-09-16 sweep of the maintainer's own open PRs, at the owner's explicit instruction. No push was made to the PR branch: the merge took the head as the owning session left it (verified OPEN, non-draft and MERGEABLE against the release tip immediately before merging).
This commit is contained in:
committed by
GitHub
parent
47032e7769
commit
19b9d05e08
1
changelog.d/fixes/12692-xai-legacy-function-call.md
Normal file
1
changelog.d/fixes/12692-xai-legacy-function-call.md
Normal file
@@ -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
|
||||
1
changelog.d/fixes/12700-xai-usage-total.md
Normal file
1
changelog.d/fixes/12700-xai-usage-total.md
Normal file
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, unknown> };
|
||||
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<string, unknown> };
|
||||
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"
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user