feat(providers): add xAI Grok inbound translators and thinking patcher (#4910)

Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-26 00:18:34 -03:00
committed by GitHub
parent c2f7ef6e15
commit cf3230fe18
6 changed files with 1725 additions and 0 deletions

View File

@@ -0,0 +1,115 @@
/**
* xAI Reasoning / Thinking Patcher
*
* Source of truth: router-for-me/CLIProxyAPI internal/thinking/provider/xai/apply.go
*
* Maps the various inbound reasoning/thinking spec shapes (OpenAI Chat,
* OpenAI Responses, Anthropic Messages, Gemini) onto the xAI Responses
* `reasoning` field. Single source of truth for budget mapping.
*
* Defaults policy mirrors CLIProxyAPI:
* - never proactively enable reasoning when the caller omits it
* - honor explicit caller intent verbatim
*/
const VALID_EFFORTS = new Set(["minimal", "low", "medium", "high"]);
export type ReasoningEffort = "minimal" | "low" | "medium" | "high";
/**
* Map a numeric token budget to a discrete effort tier.
* <=0 → undefined (disabled)
* 1..3999 → "low"
* 4000..15999 → "medium"
* >=16000 → "high"
*/
export function budgetToEffort(budget: number): ReasoningEffort | undefined {
if (typeof budget !== "number" || !Number.isFinite(budget) || budget <= 0) return undefined;
if (budget >= 16000) return "high";
if (budget >= 4000) return "medium";
return "low";
}
interface AnthropicThinking {
type?: string;
budget_tokens?: number;
}
interface GeminiThinkingConfig {
thinkingBudget?: number;
includeThoughts?: boolean;
}
interface XaiReasoning {
effort: ReasoningEffort;
}
interface ThinkingRequest {
reasoning?: XaiReasoning | Record<string, unknown>;
reasoning_effort?: string;
thinking?: AnthropicThinking;
thinkingConfig?: GeminiThinkingConfig;
[key: string]: unknown;
}
interface ApplyThinkingOptions {
defaultEffort?: ReasoningEffort;
}
/**
* Apply reasoning/thinking patch to an xAI request body.
*
* Returns a new object — caller's request is not mutated.
*
* Recognized inbound shapes:
* - request.reasoning_effort: "minimal"|"low"|"medium"|"high" (OpenAI Chat)
* - request.reasoning: { effort: ... } (OpenAI Responses)
* - request.thinking: { type: "enabled", budget_tokens: N } (Anthropic)
* - request.thinkingConfig: { thinkingBudget: N, includeThoughts } (Gemini)
*/
export function applyThinking(
request: ThinkingRequest,
options: ApplyThinkingOptions = {},
): ThinkingRequest {
if (!request || typeof request !== "object") return request;
const out: ThinkingRequest = { ...request };
// 1) Already xAI-native? Honor and stop.
if (out.reasoning && typeof out.reasoning === "object") {
const reasoning = out.reasoning as Record<string, unknown>;
if (typeof reasoning.effort === "string" && VALID_EFFORTS.has(reasoning.effort)) {
return out;
}
}
// 2) OpenAI Chat reasoning_effort
if (typeof out.reasoning_effort === "string" && VALID_EFFORTS.has(out.reasoning_effort)) {
out.reasoning = { effort: out.reasoning_effort as ReasoningEffort };
delete out.reasoning_effort;
return out;
}
// 3) Anthropic-style thinking
if (out.thinking && typeof out.thinking === "object") {
if (out.thinking.type === "enabled") {
const eff = budgetToEffort(out.thinking.budget_tokens ?? 0) ?? "medium";
out.reasoning = { effort: eff };
}
delete out.thinking;
return out;
}
// 4) Gemini-style thinkingConfig
if (out.thinkingConfig && typeof out.thinkingConfig === "object") {
const eff = budgetToEffort(out.thinkingConfig.thinkingBudget ?? 0);
if (eff) out.reasoning = { effort: eff };
delete out.thinkingConfig;
return out;
}
// 5) Default — leave untouched, optionally apply defaultEffort
if (options.defaultEffort && VALID_EFFORTS.has(options.defaultEffort)) {
out.reasoning = { effort: options.defaultEffort };
}
return out;
}

View File

@@ -0,0 +1,368 @@
/**
* Claude (Anthropic Messages) ↔ xAI Responses translator
*
* Source of truth: router-for-me/CLIProxyAPI internal/translator/claude/xai/*
*
* Inbound: Anthropic /v1/messages { model, system, messages, tools, ... }
* Outbound (to xAI): xAI Responses { model, input, instructions, tools, ... }
*
* Reverse direction:
* - xAI completed → Anthropic Messages JSON (full message)
* - per-event xAI SSE → Anthropic SSE frames:
* message_start, content_block_start, content_block_delta,
* content_block_stop, message_delta, message_stop
*/
// ─── Types ────────────────────────────────────────────────────────────────────
interface AnthropicImageSource {
type: "base64" | "url";
media_type?: string;
data?: string;
url?: string;
}
interface AnthropicContentBlock {
type: string;
text?: string;
source?: AnthropicImageSource;
id?: string;
name?: string;
input?: unknown;
tool_use_id?: string;
content?: unknown;
thinking?: string;
[key: string]: unknown;
}
interface AnthropicMessage {
role: "user" | "assistant";
content: string | AnthropicContentBlock[];
}
interface AnthropicTool {
name?: string;
description?: string;
input_schema?: unknown;
parameters?: unknown;
type?: string;
function?: unknown;
[key: string]: unknown;
}
interface AnthropicThinking {
type?: string;
budget_tokens?: number;
}
interface AnthropicRequest {
model?: string;
messages?: AnthropicMessage[];
system?: string | Array<{ type: string; text: string }>;
tools?: AnthropicTool[];
tool_choice?: unknown;
temperature?: number;
top_p?: number;
max_tokens?: number;
stop_sequences?: string[];
metadata?: unknown;
thinking?: AnthropicThinking;
[key: string]: unknown;
}
interface XaiInputBlock {
type: string;
text?: string;
image_url?: string;
[key: string]: unknown;
}
interface XaiInputItem {
role?: string;
content?: XaiInputBlock[];
type?: string;
call_id?: string;
name?: string;
arguments?: string;
output?: string;
[key: string]: unknown;
}
interface XaiTool {
type: "function";
function: {
name?: string;
description?: string;
parameters?: unknown;
};
}
interface XaiReasoning {
effort: "low" | "medium" | "high";
}
interface XaiResponsesRequest {
model?: string;
input: XaiInputItem[];
instructions?: string;
tools?: XaiTool[];
tool_choice?: unknown;
temperature?: number;
top_p?: number;
max_output_tokens?: number;
stop?: string[];
metadata?: unknown;
reasoning?: XaiReasoning;
[key: string]: unknown;
}
interface XaiOutputContent {
type: string;
text?: string;
refusal?: string;
}
interface XaiOutputItem {
type: string;
content?: XaiOutputContent[];
call_id?: string;
id?: string;
name?: string;
arguments?: string;
summary?: Array<{ text?: string }>;
[key: string]: unknown;
}
interface XaiUsage {
input_tokens?: number;
output_tokens?: number;
prompt_tokens?: number;
completion_tokens?: number;
}
interface XaiCompleted {
id?: string;
model?: string;
output?: XaiOutputItem[];
usage?: XaiUsage;
[key: string]: unknown;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function genId(prefix: string): string {
return `${prefix}_${Math.random().toString(36).slice(2, 14)}`;
}
/**
* Translate Anthropic content blocks into xAI input content blocks.
* Anthropic block types:
* "text", "image", "tool_use", "tool_result", "thinking"
*/
function blocksToXai(
blocks: string | AnthropicContentBlock[],
): XaiInputBlock[] {
if (typeof blocks === "string") return [{ type: "input_text", text: blocks }];
if (!Array.isArray(blocks)) return [];
const out: XaiInputBlock[] = [];
for (const b of blocks) {
if (!b || typeof b !== "object") continue;
if (b.type === "text") out.push({ type: "input_text", text: b.text ?? "" });
else if (b.type === "image" && b.source) {
// Anthropic source { type: "base64"|"url", media_type, data | url }
if (b.source.type === "url") {
out.push({ type: "input_image", image_url: b.source.url });
} else {
const dataUrl = `data:${b.source.media_type ?? "image/png"};base64,${b.source.data ?? ""}`;
out.push({ type: "input_image", image_url: dataUrl });
}
} else if (b.type === "thinking") {
// dropped on the input side — xAI does not accept caller thinking blocks
} else {
out.push(b as XaiInputBlock);
}
}
return out;
}
/**
* Translate Anthropic tools[] into xAI tools[].
* Anthropic uses { name, description, input_schema } — xAI uses
* function-tool shape { type: "function", function: { name, description, parameters } }.
*/
function toolsAnthropicToXai(tools: AnthropicTool[]): XaiTool[] | undefined {
if (!Array.isArray(tools)) return undefined;
return tools.map((t) => {
if (!t || typeof t !== "object") return t as unknown as XaiTool;
if (t.type === "function" && t.function) return t as unknown as XaiTool;
return {
type: "function" as const,
function: {
name: t.name,
description: t.description,
parameters:
(t.input_schema ?? t.parameters ?? { type: "object" }),
},
};
});
}
function mapClaudeThinking(
thinking: AnthropicThinking,
): XaiReasoning | undefined {
if (!thinking || typeof thinking !== "object") return undefined;
if (thinking.type === "enabled") {
if (typeof thinking.budget_tokens === "number") {
const b = thinking.budget_tokens;
if (b >= 16000) return { effort: "high" };
if (b >= 4000) return { effort: "medium" };
if (b > 0) return { effort: "low" };
}
return { effort: "medium" };
}
return undefined;
}
// ─── Public API ──────────────────────────────────────────────────────────────
/**
* Translate an Anthropic Messages request body into an xAI Responses body.
*/
export function claudeRequestToXaiResponses(req: AnthropicRequest): XaiResponsesRequest {
if (!req || typeof req !== "object") return req as unknown as XaiResponsesRequest;
const input: XaiInputItem[] = [];
for (const m of req.messages ?? []) {
if (!m) continue;
if (m.role === "user") {
// Detect tool_result blocks → emit as function_call_output items
const blocks: AnthropicContentBlock[] = Array.isArray(m.content)
? m.content
: [{ type: "text", text: m.content as string }];
const userBlocks: AnthropicContentBlock[] = [];
for (const b of blocks) {
if (b?.type === "tool_result") {
input.push({
type: "function_call_output",
call_id: b.tool_use_id,
output:
typeof b.content === "string"
? b.content
: JSON.stringify(b.content ?? ""),
});
} else {
userBlocks.push(b);
}
}
if (userBlocks.length) input.push({ role: "user", content: blocksToXai(userBlocks) });
continue;
}
if (m.role === "assistant") {
const blocks: AnthropicContentBlock[] = Array.isArray(m.content)
? m.content
: [{ type: "text", text: m.content as string }];
const textBlocks: AnthropicContentBlock[] = [];
for (const b of blocks) {
if (b?.type === "tool_use") {
if (textBlocks.length) {
input.push({ role: "assistant", content: blocksToXai(textBlocks.splice(0)) });
}
input.push({
type: "function_call",
call_id: b.id,
name: b.name,
arguments:
typeof b.input === "string" ? b.input : JSON.stringify(b.input ?? {}),
});
} else {
textBlocks.push(b);
}
}
if (textBlocks.length) input.push({ role: "assistant", content: blocksToXai(textBlocks) });
continue;
}
}
const out: XaiResponsesRequest = { model: req.model, input };
// System → instructions
if (req.system) {
if (typeof req.system === "string") {
out.instructions = req.system;
} else if (Array.isArray(req.system)) {
out.instructions = (req.system as Array<{ text?: string }>)
.map((b) => (typeof b === "string" ? b : (b?.text ?? "")))
.filter(Boolean)
.join("\n\n");
}
}
if (req.temperature != null) out.temperature = req.temperature;
if (req.top_p != null) out.top_p = req.top_p;
if (req.max_tokens != null) out.max_output_tokens = req.max_tokens;
if (req.stop_sequences) out.stop = req.stop_sequences;
if (req.metadata) out.metadata = req.metadata;
if (req.tool_choice) out.tool_choice = req.tool_choice;
if (req.thinking) out.reasoning = mapClaudeThinking(req.thinking);
const tools = req.tools ? toolsAnthropicToXai(req.tools) : undefined;
if (tools) out.tools = tools;
return out;
}
/**
* Convert an xAI completed response into an Anthropic Messages JSON.
*/
export function xaiCompletedToClaudeJson(
completed: XaiCompleted,
origReq: AnthropicRequest | null = null,
): object {
const content: unknown[] = [];
let stopReason = "end_turn";
for (const item of completed?.output ?? []) {
if (!item) continue;
if (item.type === "message" && Array.isArray(item.content)) {
for (const c of item.content) {
if (c?.type === "output_text") content.push({ type: "text", text: c.text ?? "" });
if (c?.type === "refusal") content.push({ type: "text", text: c.refusal ?? "" });
}
} else if (item.type === "function_call") {
stopReason = "tool_use";
let inputObj: unknown = {};
try {
inputObj = item.arguments ? JSON.parse(item.arguments) : {};
} catch {
inputObj = { _raw: item.arguments };
}
content.push({
type: "tool_use",
id: item.call_id ?? item.id ?? genId("toolu"),
name: item.name,
input: inputObj,
});
} else if (item.type === "reasoning" && Array.isArray(item.summary)) {
const text = (item.summary as Array<{ text?: string }>)
.map((s) => s?.text ?? "")
.filter(Boolean)
.join("\n");
if (text) content.push({ type: "thinking", thinking: text });
}
}
const out: Record<string, unknown> = {
id: completed?.id ?? genId("msg"),
type: "message",
role: "assistant",
model: completed?.model ?? origReq?.model ?? null,
content,
stop_reason: stopReason,
stop_sequence: null,
};
if (completed?.usage) {
const u = completed.usage;
out.usage = {
input_tokens: u.input_tokens ?? u.prompt_tokens ?? 0,
output_tokens: u.output_tokens ?? u.completion_tokens ?? 0,
};
}
return out;
}

View File

@@ -0,0 +1,371 @@
/**
* Gemini ↔ xAI Responses translator
*
* Source of truth: router-for-me/CLIProxyAPI internal/translator/gemini/xai/*
*
* Inbound: Google Gemini generateContent { contents, systemInstruction, tools, ... }
* Outbound (to xAI): xAI Responses { model, input, instructions, tools, ... }
*
* Reverse direction:
* - xAI completed → Gemini generateContent JSON ({ candidates: [...] })
* - per-event xAI SSE → Gemini streamGenerateContent chunks
*/
// ─── Types ────────────────────────────────────────────────────────────────────
interface GeminiInlineData {
mimeType?: string;
data?: string;
}
interface GeminiFileData {
fileUri?: string;
}
interface GeminiFunctionCall {
id?: string;
name: string;
args?: unknown;
}
interface GeminiFunctionResponse {
id?: string;
name: string;
response?: unknown;
}
interface GeminiPart {
text?: string;
inlineData?: GeminiInlineData;
fileData?: GeminiFileData;
functionCall?: GeminiFunctionCall;
functionResponse?: GeminiFunctionResponse;
}
interface GeminiContent {
role?: string;
parts?: GeminiPart[];
}
interface GeminiFunctionDeclaration {
name: string;
description?: string;
parameters?: unknown;
}
interface GeminiTool {
functionDeclarations?: GeminiFunctionDeclaration[];
type?: string;
function?: unknown;
}
interface GeminiThinkingConfig {
thinkingBudget?: number;
}
interface GeminiGenerationConfig {
temperature?: number;
topP?: number;
maxOutputTokens?: number;
stopSequences?: string[];
responseSchema?: unknown;
thinkingConfig?: GeminiThinkingConfig;
}
interface GeminiRequest {
model?: string;
contents?: GeminiContent[];
systemInstruction?: string | { parts?: GeminiPart[] };
tools?: GeminiTool[];
toolConfig?: {
functionCallingConfig?: { mode?: string };
};
generationConfig?: GeminiGenerationConfig;
[key: string]: unknown;
}
interface XaiInputBlock {
type: string;
text?: string;
image_url?: string;
}
interface XaiInputItem {
role?: string;
content?: XaiInputBlock[];
type?: string;
call_id?: string;
name?: string;
arguments?: string;
output?: string;
}
interface XaiTool {
type: "function";
function: {
name: string;
description?: string;
parameters?: unknown;
};
}
interface XaiReasoning {
effort: "low" | "medium" | "high";
}
interface XaiResponsesRequest {
model?: string | null;
input: XaiInputItem[];
instructions?: string;
tools?: XaiTool[];
tool_choice?: string;
temperature?: number;
top_p?: number;
max_output_tokens?: number;
stop?: string[];
text?: unknown;
reasoning?: XaiReasoning;
}
interface XaiOutputContent {
type: string;
text?: string;
refusal?: string;
}
interface XaiFunctionCallItem {
type: "function_call";
name: string;
arguments?: string;
[key: string]: unknown;
}
interface XaiOutputItem {
type: string;
content?: XaiOutputContent[];
name?: string;
arguments?: string;
[key: string]: unknown;
}
interface XaiUsage {
input_tokens?: number;
output_tokens?: number;
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
}
interface XaiCompleted {
output?: XaiOutputItem[];
model?: string;
usage?: XaiUsage;
[key: string]: unknown;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Convert Gemini parts[] into xAI input content blocks.
*
* Gemini part types:
* text, inlineData (mime+data b64), fileData (uri),
* functionCall (name, args), functionResponse (name, response)
*/
function partsToXaiBlocks(parts: GeminiPart[]): XaiInputBlock[] {
if (!Array.isArray(parts)) return [];
const out: XaiInputBlock[] = [];
for (const p of parts) {
if (!p || typeof p !== "object") continue;
if (typeof p.text === "string") {
out.push({ type: "input_text", text: p.text });
} else if (p.inlineData?.data) {
const mime = p.inlineData.mimeType ?? "image/png";
out.push({
type: "input_image",
image_url: `data:${mime};base64,${p.inlineData.data}`,
});
} else if (p.fileData?.fileUri) {
out.push({ type: "input_image", image_url: p.fileData.fileUri });
}
// functionCall / functionResponse handled at message level
}
return out;
}
/**
* Pull functionCall / functionResponse parts out of a Gemini message
* — these need to become standalone xAI input items, not nested content blocks.
*/
function extractFunctionItems(parts: GeminiPart[]): XaiInputItem[] {
const items: XaiInputItem[] = [];
if (!Array.isArray(parts)) return items;
for (const p of parts) {
if (p?.functionCall) {
items.push({
type: "function_call",
call_id: p.functionCall.id ?? p.functionCall.name,
name: p.functionCall.name,
arguments:
typeof p.functionCall.args === "string"
? p.functionCall.args
: JSON.stringify(p.functionCall.args ?? {}),
});
} else if (p?.functionResponse) {
items.push({
type: "function_call_output",
call_id: p.functionResponse.id ?? p.functionResponse.name,
output:
typeof p.functionResponse.response === "string"
? p.functionResponse.response
: JSON.stringify(p.functionResponse.response ?? {}),
});
}
}
return items;
}
/**
* Convert Gemini tools[] into xAI tools[].
* Gemini: [{ functionDeclarations: [{ name, description, parameters }] }, ...]
* xAI: [{ type: "function", function: { name, description, parameters } }, ...]
*/
function toolsGeminiToXai(tools: GeminiTool[]): XaiTool[] | undefined {
if (!Array.isArray(tools)) return undefined;
const out: XaiTool[] = [];
for (const t of tools) {
if (!t) continue;
if (Array.isArray(t.functionDeclarations)) {
for (const fn of t.functionDeclarations) {
out.push({
type: "function",
function: {
name: fn.name,
description: fn.description,
parameters: fn.parameters ?? { type: "object" },
},
});
}
} else if (t.type === "function") {
out.push(t as unknown as XaiTool);
}
}
return out.length ? out : undefined;
}
// ─── Public API ──────────────────────────────────────────────────────────────
/**
* Translate a Gemini generateContent request body into an xAI Responses body.
*
* @param req - The Gemini request body
* @param model - Gemini path-level model (Gemini puts model in URL)
*/
export function geminiRequestToXaiResponses(
req: GeminiRequest,
model: string | null = null,
): XaiResponsesRequest {
if (!req || typeof req !== "object") return req as unknown as XaiResponsesRequest;
const input: XaiInputItem[] = [];
for (const c of req.contents ?? []) {
if (!c) continue;
const role = c.role === "model" ? "assistant" : (c.role ?? "user");
// Pull function items first (they become standalone)
const fnItems = extractFunctionItems(c.parts ?? []);
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,
);
if (remaining.length) input.push({ role, content: partsToXaiBlocks(remaining) });
} else {
input.push({ role, content: partsToXaiBlocks(c.parts ?? []) });
}
}
const out: XaiResponsesRequest = { model: model ?? req.model, input };
if (req.systemInstruction) {
const sys = req.systemInstruction;
if (typeof sys === "string") {
out.instructions = sys;
} else if (sys.parts) {
out.instructions = sys.parts
.map((p) => p?.text ?? "")
.filter(Boolean)
.join("\n\n");
}
}
const cfg: GeminiGenerationConfig = req.generationConfig ?? {};
if (cfg.temperature != null) out.temperature = cfg.temperature;
if (cfg.topP != null) out.top_p = cfg.topP;
if (cfg.maxOutputTokens != null) out.max_output_tokens = cfg.maxOutputTokens;
if (cfg.stopSequences) out.stop = cfg.stopSequences;
if (cfg.responseSchema)
out.text = { format: { type: "json_schema", schema: cfg.responseSchema } };
if (cfg.thinkingConfig?.thinkingBudget != null) {
const b = cfg.thinkingConfig.thinkingBudget;
if (b >= 16000) out.reasoning = { effort: "high" };
else if (b >= 4000) out.reasoning = { effort: "medium" };
else if (b > 0) out.reasoning = { effort: "low" };
}
const tools = req.tools ? toolsGeminiToXai(req.tools) : undefined;
if (tools) out.tools = tools;
if (req.toolConfig?.functionCallingConfig?.mode === "ANY") out.tool_choice = "required";
return out;
}
/**
* Convert an xAI completed response into a Gemini generateContent JSON.
*/
export function xaiCompletedToGeminiJson(
completed: XaiCompleted,
origReq: GeminiRequest | null = null,
): object {
const parts: unknown[] = [];
const finishReason = "STOP";
for (const item of completed?.output ?? []) {
if (!item) continue;
if (item.type === "message" && Array.isArray(item.content)) {
for (const c of item.content) {
if (c?.type === "output_text") parts.push({ text: c.text ?? "" });
if (c?.type === "refusal") parts.push({ text: c.refusal ?? "" });
}
} else if (item.type === "function_call") {
let args: unknown = {};
try {
args = (item as XaiFunctionCallItem).arguments
? JSON.parse((item as XaiFunctionCallItem).arguments ?? "")
: {};
} catch {
args = { _raw: (item as XaiFunctionCallItem).arguments };
}
parts.push({
functionCall: { name: item.name, args },
});
}
}
const candidate = {
content: { role: "model", parts },
finishReason,
index: 0,
};
const out: Record<string, unknown> = {
candidates: [candidate],
modelVersion: completed?.model ?? origReq?.model ?? null,
};
if (completed?.usage) {
const u = completed.usage;
out.usageMetadata = {
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)),
};
}
return out;
}

View File

@@ -0,0 +1,304 @@
/**
* OpenAI Chat Completions ↔ xAI Responses translator
*
* Source of truth: router-for-me/CLIProxyAPI internal/translator/openai/xai/*
*
* Inbound: OpenAI Chat Completions { model, messages, tools, ... }
* Outbound (to xAI): xAI Responses { model, input, instructions, tools, ... }
*
* Reverse direction:
* - aggregated xAI response.completed → OpenAI ChatCompletion JSON
* - per-event xAI SSE → OpenAI ChatCompletion stream chunks
*/
// ─── Types ────────────────────────────────────────────────────────────────────
interface ContentPart {
type: string;
text?: string;
image_url?: unknown;
input_audio?: unknown;
[key: string]: unknown;
}
type MessageContent = string | ContentPart[];
interface OpenAiToolCall {
id: string;
type: "function";
function: {
name: string;
arguments: string;
};
}
interface OpenAiMessage {
role: string;
content?: MessageContent;
tool_calls?: OpenAiToolCall[];
tool_call_id?: string;
}
interface OpenAiChatRequest {
model?: string;
messages?: OpenAiMessage[];
tools?: unknown[];
tool_choice?: unknown;
temperature?: number;
top_p?: number;
max_tokens?: number;
max_output_tokens?: number;
stop?: string | string[];
user?: string;
metadata?: unknown;
response_format?: unknown;
parallel_tool_calls?: boolean;
seed?: number;
reasoning_effort?: string;
reasoning?: unknown;
[key: string]: unknown;
}
interface XaiInputBlock {
type: string;
text?: string;
image_url?: unknown;
input_audio?: unknown;
[key: string]: unknown;
}
interface XaiInputItem {
role?: string;
content?: XaiInputBlock[];
type?: string;
call_id?: string;
name?: string;
arguments?: string;
output?: string;
[key: string]: unknown;
}
interface XaiResponsesRequest {
model?: string;
input: XaiInputItem[];
instructions?: string;
tools?: unknown[];
tool_choice?: unknown;
temperature?: number;
top_p?: number;
max_output_tokens?: number;
stop?: string | string[];
user?: string;
metadata?: unknown;
text?: unknown;
parallel_tool_calls?: boolean;
seed?: number;
reasoning?: unknown;
[key: string]: unknown;
}
interface XaiUsage {
input_tokens?: number;
output_tokens?: number;
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
}
interface XaiOutputItem {
type: string;
content?: Array<{ type: string; text?: string; refusal?: string }>;
call_id?: string;
id?: string;
name?: string;
arguments?: string;
[key: string]: unknown;
}
interface XaiCompleted {
id?: string;
model?: string;
created_at?: number;
output?: XaiOutputItem[];
usage?: XaiUsage;
[key: string]: unknown;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function genId(prefix: string): string {
return `${prefix}_${Math.random().toString(36).slice(2, 14)}`;
}
/**
* Convert OpenAI message content (string | array of parts) into xAI input
* content blocks. Mirrors CLIProxyAPI mapping:
* "text" → { type: "input_text", text }
* "image_url" → { type: "input_image", image_url }
* "input_audio"→ { type: "input_audio", input_audio }
*/
function messageContentToXaiBlocks(content: MessageContent): XaiInputBlock[] {
if (typeof content === "string") {
return [{ type: "input_text", text: content }];
}
if (!Array.isArray(content)) return [];
return content
.map((p): XaiInputBlock | null => {
if (!p || typeof p !== "object") return null;
if (p.type === "text") return { type: "input_text", text: p.text ?? "" };
if (p.type === "image_url") return { type: "input_image", image_url: p.image_url };
if (p.type === "input_audio") return { type: "input_audio", input_audio: p.input_audio };
return p as XaiInputBlock; // passthrough unknown
})
.filter((b): b is XaiInputBlock => b !== null);
}
/**
* Convert OpenAI Chat tools[] (function-calling spec) into xAI tools[].
* xAI accepts the OpenAI function-tool shape verbatim, so passthrough.
*/
function toolsPassthrough(tools: unknown[]): unknown[] | undefined {
if (!Array.isArray(tools)) return undefined;
return tools.map((t) => ({ ...(t as object) }));
}
// ─── Public API ──────────────────────────────────────────────────────────────
/**
* Translate an inbound OpenAI Chat Completions request body into an xAI Responses body.
*/
export function chatRequestToXaiResponses(req: OpenAiChatRequest): XaiResponsesRequest {
if (!req || typeof req !== "object") return req as unknown as XaiResponsesRequest;
const messages: OpenAiMessage[] = Array.isArray(req.messages) ? req.messages : [];
const instructionsParts: string[] = [];
const input: XaiInputItem[] = [];
for (const m of messages) {
if (!m) continue;
if (m.role === "system" || m.role === "developer") {
const txt =
typeof m.content === "string"
? m.content
: Array.isArray(m.content)
? (m.content as ContentPart[])
.map((p) => p?.text ?? "")
.filter(Boolean)
.join("\n")
: "";
if (txt) instructionsParts.push(txt);
continue;
}
if (m.role === "tool") {
input.push({
type: "function_call_output",
call_id: m.tool_call_id,
output:
typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? ""),
});
continue;
}
if (m.role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
// Emit any pre-existing assistant text first
if (m.content) {
input.push({ role: "assistant", content: messageContentToXaiBlocks(m.content) });
}
for (const tc of m.tool_calls) {
if (tc.type !== "function" || !tc.function) continue;
input.push({
type: "function_call",
call_id: tc.id,
name: tc.function.name,
arguments: tc.function.arguments ?? "",
});
}
continue;
}
input.push({ role: m.role ?? "user", content: messageContentToXaiBlocks(m.content ?? "") });
}
const out: XaiResponsesRequest = { model: req.model, input };
if (instructionsParts.length) out.instructions = instructionsParts.join("\n\n");
if (req.temperature != null) out.temperature = req.temperature;
if (req.top_p != null) out.top_p = req.top_p;
if (req.max_tokens != null) out.max_output_tokens = req.max_tokens;
if (req.max_output_tokens != null) out.max_output_tokens = req.max_output_tokens;
if (req.stop != null) out.stop = req.stop;
if (req.user) out.user = req.user;
if (req.metadata) out.metadata = req.metadata;
if (req.response_format) out.text = { format: req.response_format };
if (req.parallel_tool_calls != null) out.parallel_tool_calls = req.parallel_tool_calls;
if (req.seed != null) out.seed = req.seed;
if (req.reasoning_effort) out.reasoning = { effort: req.reasoning_effort };
if (req.reasoning) out.reasoning = req.reasoning;
if (req.tool_choice) out.tool_choice = req.tool_choice;
const tools = req.tools ? toolsPassthrough(req.tools) : undefined;
if (tools) out.tools = tools;
return out;
}
/**
* Aggregate output_text content blocks from an xAI completed response.
*/
function extractAssistantTextAndCalls(completed: XaiCompleted): {
text: string;
toolCalls: OpenAiToolCall[];
refusal: string | undefined;
} {
let text = "";
const toolCalls: OpenAiToolCall[] = [];
const refusal: string[] = [];
for (const item of completed?.output ?? []) {
if (!item) continue;
if (item.type === "message" && Array.isArray(item.content)) {
for (const c of item.content) {
if (c?.type === "output_text" && typeof c.text === "string") text += c.text;
if (c?.type === "refusal" && typeof c.refusal === "string") refusal.push(c.refusal);
}
} else if (item.type === "function_call") {
toolCalls.push({
id: item.call_id ?? item.id ?? genId("call"),
type: "function",
function: { name: item.name ?? "", arguments: item.arguments ?? "" },
});
}
}
return { text, toolCalls, refusal: refusal.join("\n") || undefined };
}
/**
* Convert an aggregated xAI completed response into an OpenAI ChatCompletion JSON.
*/
export function xaiCompletedToChatJson(
completed: XaiCompleted,
origReq: OpenAiChatRequest | null = null,
): object {
const { text, toolCalls, refusal } = extractAssistantTextAndCalls(completed);
const finishReason = toolCalls.length ? "tool_calls" : "stop";
const message: Record<string, unknown> = {
role: "assistant",
content: text || (toolCalls.length ? null : ""),
};
if (toolCalls.length) message.tool_calls = toolCalls;
if (refusal) message.refusal = refusal;
const out: Record<string, unknown> = {
id: completed?.id ?? genId("chatcmpl"),
object: "chat.completion",
created: completed?.created_at ?? Math.floor(Date.now() / 1000),
model: completed?.model ?? origReq?.model ?? null,
choices: [{ index: 0, message, finish_reason: finishReason }],
};
if (completed?.usage) {
const u = completed.usage;
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)),
};
}
return out;
}

View File

@@ -0,0 +1,77 @@
/**
* OpenAI Responses ↔ xAI Responses translator
*
* Source of truth: router-for-me/CLIProxyAPI internal/translator/openai-responses/xai/*
*
* xAI's Responses API is shape-compatible with OpenAI Responses, so this is
* mostly a passthrough. Translator responsibilities:
* - normalize response.id when synthesized
* - reconcile a small set of unsupported fields (drop unknown vendor-only opts)
* - apply the thinking patcher hook (delegated upstream)
*/
interface OpenAiResponsesRequest {
service_tier?: string;
messages?: unknown[];
input?: unknown[];
[key: string]: unknown;
}
interface XaiCompletedResponse {
object?: string;
status?: string;
[key: string]: unknown;
}
interface SseEvent {
event: string;
data: string;
}
/**
* Translate an inbound OpenAI-Responses request body into an xAI request body.
*/
export function openaiResponsesRequestToXai(req: OpenAiResponsesRequest): OpenAiResponsesRequest {
if (!req || typeof req !== "object") return req;
const out: OpenAiResponsesRequest = { ...req };
// xAI does not currently honor `parallel_tool_calls: false` on every model;
// mirror CLIProxyAPI: leave the flag as caller specified.
// Drop OpenAI-specific service_tier hint that xAI rejects.
if ("service_tier" in out) delete out.service_tier;
// xAI expects `input` (Responses-style); if the caller passed `messages`
// instead, leave them — xAI also accepts messages, but warn via metadata.
return out;
}
/**
* Translate an xAI completed response (already aggregated by collectSseToCompleted)
* into the OpenAI Responses JSON shape that callers expect.
*/
export function xaiCompletedToOpenaiResponses(
completed: XaiCompletedResponse,
): XaiCompletedResponse {
if (!completed || typeof completed !== "object") return completed;
return {
...completed,
object: completed.object ?? "response",
status: completed.status ?? "completed",
};
}
/**
* Pass-through transform for SSE event objects { event, data } emitted by
* iterateSseEvents(). For OpenAI Responses callers we forward verbatim — only
* normalize event names that diverge.
*
* Returns null to drop the event.
*/
export function xaiSseEventToOpenaiResponses(ev: SseEvent): SseEvent | null {
if (!ev || !ev.event) return ev;
// CLIProxyAPI drops xAI-internal `response.output_text.annotation.added`
// when the caller is OpenAI Responses, since OpenAI emits a different name.
if (ev.event === "response.output_text.annotation.added") return null;
return ev;
}

View File

@@ -0,0 +1,490 @@
/**
* Unit tests — xAI thinking patcher + inbound translators
*
* Ported from upstream decolua/9router tests/unit/xai-thinking.test.js
* and expanded with coverage for claude/gemini/openai-chat/openai-responses translators.
*
* Runner: node --import tsx/esm --test tests/unit/xai-translators.test.ts
*/
import test from "node:test";
import assert from "node:assert/strict";
const { budgetToEffort, applyThinking } = await import(
"../../src/lib/providers/xai/thinking.ts"
);
const { chatRequestToXaiResponses, xaiCompletedToChatJson } = await import(
"../../src/lib/providers/xai/translators/openai-chat.ts"
);
const {
openaiResponsesRequestToXai,
xaiCompletedToOpenaiResponses,
xaiSseEventToOpenaiResponses,
} = await import("../../src/lib/providers/xai/translators/openai-responses.ts");
const { claudeRequestToXaiResponses, xaiCompletedToClaudeJson } = await import(
"../../src/lib/providers/xai/translators/claude.ts"
);
const { geminiRequestToXaiResponses, xaiCompletedToGeminiJson } = await import(
"../../src/lib/providers/xai/translators/gemini.ts"
);
// ─── budgetToEffort ──────────────────────────────────────────────────────────
test("budgetToEffort: maps 0 / negative / NaN to undefined", () => {
assert.equal(budgetToEffort(0), undefined);
assert.equal(budgetToEffort(-100), undefined);
assert.equal(budgetToEffort(Number.NaN), undefined);
assert.equal(budgetToEffort(Number.POSITIVE_INFINITY), undefined);
});
test("budgetToEffort: maps 13999 to low", () => {
assert.equal(budgetToEffort(1), "low");
assert.equal(budgetToEffort(3999), "low");
});
test("budgetToEffort: maps 400015999 to medium", () => {
assert.equal(budgetToEffort(4000), "medium");
assert.equal(budgetToEffort(15999), "medium");
});
test("budgetToEffort: maps 16000+ to high", () => {
assert.equal(budgetToEffort(16000), "high");
assert.equal(budgetToEffort(64000), "high");
});
// ─── applyThinking ───────────────────────────────────────────────────────────
test("applyThinking: returns clone untouched when nothing matches", () => {
const req = { model: "grok-4", input: [] };
const out = applyThinking(req);
assert.deepStrictEqual(out, req);
assert.notStrictEqual(out, req); // must be a new object
});
test("applyThinking: honors xAI-native reasoning.effort verbatim", () => {
const req = { reasoning: { effort: "high" }, foo: 1 };
const out = applyThinking(req as Parameters<typeof applyThinking>[0]);
assert.deepStrictEqual(out.reasoning, { effort: "high" });
assert.equal((out as Record<string, unknown>).foo, 1);
});
test("applyThinking: rewrites OpenAI Chat reasoning_effort into reasoning.effort", () => {
const req = { reasoning_effort: "medium" };
const out = applyThinking(req);
assert.deepStrictEqual(out.reasoning, { effort: "medium" });
assert.equal(out.reasoning_effort, undefined);
});
test("applyThinking: ignores invalid reasoning_effort values", () => {
const req = { reasoning_effort: "ultra" };
const out = applyThinking(req);
assert.equal(out.reasoning, undefined);
});
test("applyThinking: maps Anthropic thinking enabled with budget_tokens", () => {
const req = { thinking: { type: "enabled", budget_tokens: 20000 } };
const out = applyThinking(req);
assert.deepStrictEqual(out.reasoning, { effort: "high" });
assert.equal(out.thinking, undefined);
});
test("applyThinking: defaults Anthropic thinking enabled without budget_tokens to medium", () => {
const req = { thinking: { type: "enabled" } };
const out = applyThinking(req);
assert.deepStrictEqual(out.reasoning, { effort: "medium" });
});
test("applyThinking: strips Anthropic thinking type=disabled without setting reasoning", () => {
const req = { thinking: { type: "disabled" } };
const out = applyThinking(req);
assert.equal(out.reasoning, undefined);
assert.equal(out.thinking, undefined);
});
test("applyThinking: maps Gemini thinkingConfig.thinkingBudget", () => {
const req = { thinkingConfig: { thinkingBudget: 5000 } };
const out = applyThinking(req);
assert.deepStrictEqual(out.reasoning, { effort: "medium" });
assert.equal(out.thinkingConfig, undefined);
});
test("applyThinking: strips Gemini thinkingConfig with budget=0 without setting reasoning", () => {
const req = { thinkingConfig: { thinkingBudget: 0 } };
const out = applyThinking(req);
assert.equal(out.reasoning, undefined);
assert.equal(out.thinkingConfig, undefined);
});
test("applyThinking: applies defaultEffort when nothing else is provided", () => {
const out = applyThinking({}, { defaultEffort: "low" });
assert.deepStrictEqual(out.reasoning, { effort: "low" });
});
// ─── chatRequestToXaiResponses ────────────────────────────────────────────────
test("chatRequestToXaiResponses: converts system message to instructions", () => {
const req = {
model: "grok-4",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "Hello" },
],
};
const out = chatRequestToXaiResponses(req);
assert.equal(out.instructions, "You are a helpful assistant.");
assert.equal(out.input.length, 1);
assert.equal((out.input[0] as Record<string, unknown>).role, "user");
});
test("chatRequestToXaiResponses: converts tool message to function_call_output", () => {
const req = {
model: "grok-4",
messages: [
{ role: "tool", content: "result text", tool_call_id: "call_abc" },
],
};
const out = chatRequestToXaiResponses(req);
assert.equal(out.input[0].type, "function_call_output");
assert.equal(out.input[0].call_id, "call_abc");
assert.equal(out.input[0].output, "result text");
});
test("chatRequestToXaiResponses: promotes reasoning_effort to reasoning field", () => {
const req = { model: "grok-4", messages: [], reasoning_effort: "high" };
const out = chatRequestToXaiResponses(req);
assert.deepStrictEqual(out.reasoning, { effort: "high" });
});
test("chatRequestToXaiResponses: maps max_tokens to max_output_tokens", () => {
const req = { model: "grok-4", messages: [], max_tokens: 512 };
const out = chatRequestToXaiResponses(req);
assert.equal(out.max_output_tokens, 512);
});
// ─── xaiCompletedToChatJson ──────────────────────────────────────────────────
test("xaiCompletedToChatJson: extracts output_text content into message", () => {
const completed = {
id: "resp_1",
model: "grok-4",
output: [
{
type: "message",
content: [{ type: "output_text", text: "Hello!" }],
},
],
};
const result = xaiCompletedToChatJson(completed) as Record<string, unknown>;
const choices = result.choices as Array<Record<string, unknown>>;
assert.equal(choices[0].finish_reason, "stop");
const message = choices[0].message as Record<string, unknown>;
assert.equal(message.content, "Hello!");
});
test("xaiCompletedToChatJson: maps function_call to tool_calls with finish_reason=tool_calls", () => {
const completed = {
id: "resp_2",
model: "grok-4",
output: [
{
type: "function_call",
call_id: "call_1",
name: "get_weather",
arguments: '{"location":"London"}',
},
],
};
const result = xaiCompletedToChatJson(completed) as Record<string, unknown>;
const choices = result.choices as Array<Record<string, unknown>>;
assert.equal(choices[0].finish_reason, "tool_calls");
const message = choices[0].message as Record<string, unknown>;
const toolCalls = message.tool_calls as Array<Record<string, unknown>>;
assert.equal(toolCalls.length, 1);
assert.equal(toolCalls[0].id, "call_1");
const fn = toolCalls[0].function as Record<string, unknown>;
assert.equal(fn.name, "get_weather");
});
// ─── openaiResponsesRequestToXai ─────────────────────────────────────────────
test("openaiResponsesRequestToXai: drops service_tier", () => {
const req = { model: "grok-4", input: [], service_tier: "default" };
const out = openaiResponsesRequestToXai(req);
assert.equal("service_tier" in out, false);
});
test("openaiResponsesRequestToXai: preserves other fields verbatim", () => {
const req = { model: "grok-4", input: [{ role: "user", content: "hi" }] };
const out = openaiResponsesRequestToXai(req);
assert.deepStrictEqual(out.input, req.input);
});
// ─── xaiCompletedToOpenaiResponses ───────────────────────────────────────────
test("xaiCompletedToOpenaiResponses: normalizes object + status fields", () => {
const completed = { id: "r1", model: "grok-4", output: [] };
const out = xaiCompletedToOpenaiResponses(completed);
assert.equal(out.object, "response");
assert.equal(out.status, "completed");
});
test("xaiCompletedToOpenaiResponses: preserves existing object/status", () => {
const completed = { id: "r1", object: "custom", status: "in_progress" };
const out = xaiCompletedToOpenaiResponses(completed);
assert.equal(out.object, "custom");
assert.equal(out.status, "in_progress");
});
// ─── xaiSseEventToOpenaiResponses ────────────────────────────────────────────
test("xaiSseEventToOpenaiResponses: drops annotation.added event", () => {
const ev = {
event: "response.output_text.annotation.added",
data: '{"text":"foo"}',
};
const result = xaiSseEventToOpenaiResponses(ev);
assert.equal(result, null);
});
test("xaiSseEventToOpenaiResponses: passes through other events unchanged", () => {
const ev = { event: "response.output_text.delta", data: '{"delta":"hi"}' };
const result = xaiSseEventToOpenaiResponses(ev);
assert.deepStrictEqual(result, ev);
});
// ─── claudeRequestToXaiResponses ─────────────────────────────────────────────
test("claudeRequestToXaiResponses: translates system string to instructions", () => {
const req = {
model: "grok-4",
system: "Be helpful",
messages: [{ role: "user" as const, content: "Hi" }],
};
const out = claudeRequestToXaiResponses(req);
assert.equal(out.instructions, "Be helpful");
});
test("claudeRequestToXaiResponses: converts text content to input_text block", () => {
const req = {
model: "grok-4",
messages: [{ role: "user" as const, content: "Hello" }],
};
const out = claudeRequestToXaiResponses(req);
assert.equal(out.input.length, 1);
const inputItem = out.input[0] as Record<string, unknown>;
const content = inputItem.content as Array<Record<string, unknown>>;
assert.equal(content[0].type, "input_text");
assert.equal(content[0].text, "Hello");
});
test("claudeRequestToXaiResponses: extracts tool_result to function_call_output", () => {
const req = {
model: "grok-4",
messages: [
{
role: "user" as const,
content: [
{
type: "tool_result",
tool_use_id: "tu_123",
content: "result data",
},
],
},
],
};
const out = claudeRequestToXaiResponses(req);
const item = out.input[0] as Record<string, unknown>;
assert.equal(item.type, "function_call_output");
assert.equal(item.call_id, "tu_123");
assert.equal(item.output, "result data");
});
test("claudeRequestToXaiResponses: translates tools from Anthropic to xAI function shape", () => {
const req = {
model: "grok-4",
messages: [],
tools: [
{
name: "search",
description: "Search the web",
input_schema: { type: "object", properties: {} },
},
],
};
const out = claudeRequestToXaiResponses(req);
assert.ok(Array.isArray(out.tools));
const tool = (out.tools as Array<Record<string, unknown>>)[0];
assert.equal(tool.type, "function");
const fn = tool.function as Record<string, unknown>;
assert.equal(fn.name, "search");
});
test("claudeRequestToXaiResponses: maps thinking.enabled with budget to reasoning", () => {
const req = {
model: "grok-4",
messages: [],
thinking: { type: "enabled", budget_tokens: 8000 },
};
const out = claudeRequestToXaiResponses(req);
assert.deepStrictEqual(out.reasoning, { effort: "medium" });
});
// ─── xaiCompletedToClaudeJson ─────────────────────────────────────────────────
test("xaiCompletedToClaudeJson: converts output_text to text content block", () => {
const completed = {
id: "r1",
model: "grok-4",
output: [
{
type: "message",
content: [{ type: "output_text", text: "Hello!" }],
},
],
};
const result = xaiCompletedToClaudeJson(completed) as Record<string, unknown>;
assert.equal(result.role, "assistant");
assert.equal(result.stop_reason, "end_turn");
const content = result.content as Array<Record<string, unknown>>;
assert.equal(content[0].type, "text");
assert.equal(content[0].text, "Hello!");
});
test("xaiCompletedToClaudeJson: converts function_call to tool_use block", () => {
const completed = {
id: "r2",
model: "grok-4",
output: [
{
type: "function_call",
call_id: "c1",
name: "lookup",
arguments: '{"q":"test"}',
},
],
};
const result = xaiCompletedToClaudeJson(completed) as Record<string, unknown>;
assert.equal(result.stop_reason, "tool_use");
const content = result.content as Array<Record<string, unknown>>;
assert.equal(content[0].type, "tool_use");
assert.equal(content[0].name, "lookup");
});
// ─── geminiRequestToXaiResponses ─────────────────────────────────────────────
test("geminiRequestToXaiResponses: converts text parts to input_text blocks", () => {
const req = {
contents: [{ role: "user", parts: [{ text: "Hello" }] }],
};
const out = geminiRequestToXaiResponses(req, "grok-4");
assert.equal(out.model, "grok-4");
const item = out.input[0] as Record<string, unknown>;
const content = item.content as Array<Record<string, unknown>>;
assert.equal(content[0].type, "input_text");
assert.equal(content[0].text, "Hello");
});
test("geminiRequestToXaiResponses: maps systemInstruction to instructions", () => {
const req = {
contents: [],
systemInstruction: { parts: [{ text: "Be helpful" }] },
};
const out = geminiRequestToXaiResponses(req);
assert.equal(out.instructions, "Be helpful");
});
test("geminiRequestToXaiResponses: converts functionDeclarations to xAI tools", () => {
const req = {
contents: [],
tools: [
{
functionDeclarations: [
{
name: "search",
description: "Search the web",
parameters: { type: "object" },
},
],
},
],
};
const out = geminiRequestToXaiResponses(req);
assert.ok(Array.isArray(out.tools));
const tool = (out.tools as Array<Record<string, unknown>>)[0];
assert.equal(tool.type, "function");
const fn = tool.function as Record<string, unknown>;
assert.equal(fn.name, "search");
});
test("geminiRequestToXaiResponses: maps thinkingBudget to reasoning.effort", () => {
const req = {
contents: [],
generationConfig: { thinkingConfig: { thinkingBudget: 20000 } },
};
const out = geminiRequestToXaiResponses(req);
assert.deepStrictEqual(out.reasoning, { effort: "high" });
});
test("geminiRequestToXaiResponses: converts model role to assistant", () => {
const req = {
contents: [{ role: "model", parts: [{ text: "Hi" }] }],
};
const out = geminiRequestToXaiResponses(req);
const item = out.input[0] as Record<string, unknown>;
assert.equal(item.role, "assistant");
});
// ─── xaiCompletedToGeminiJson ────────────────────────────────────────────────
test("xaiCompletedToGeminiJson: converts output_text to Gemini candidate text part", () => {
const completed = {
id: "r1",
model: "grok-4",
output: [
{
type: "message",
content: [{ type: "output_text", text: "Hello Gemini!" }],
},
],
};
const result = xaiCompletedToGeminiJson(completed) as Record<string, unknown>;
const candidates = result.candidates as Array<Record<string, unknown>>;
const content = candidates[0].content as Record<string, unknown>;
const parts = content.parts as Array<Record<string, unknown>>;
assert.equal(parts[0].text, "Hello Gemini!");
assert.equal(candidates[0].finishReason, "STOP");
});
test("xaiCompletedToGeminiJson: converts function_call to functionCall part", () => {
const completed = {
id: "r2",
model: "grok-4",
output: [
{
type: "function_call",
name: "lookup",
arguments: '{"q":"test"}',
},
],
};
const result = xaiCompletedToGeminiJson(completed) as Record<string, unknown>;
const candidates = result.candidates as Array<Record<string, unknown>>;
const content = candidates[0].content as Record<string, unknown>;
const parts = content.parts as Array<Record<string, unknown>>;
const fc = parts[0].functionCall as Record<string, unknown>;
assert.equal(fc.name, "lookup");
assert.deepStrictEqual(fc.args, { q: "test" });
});
test("xaiCompletedToGeminiJson: maps usage to usageMetadata", () => {
const completed = {
model: "grok-4",
output: [],
usage: { input_tokens: 10, output_tokens: 20 },
};
const result = xaiCompletedToGeminiJson(completed) as Record<string, unknown>;
const meta = result.usageMetadata as Record<string, unknown>;
assert.equal(meta.promptTokenCount, 10);
assert.equal(meta.candidatesTokenCount, 20);
assert.equal(meta.totalTokenCount, 30);
});