From cf3230fe183f76b01fe78db0930a26fe03b5a08e Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:18:34 -0300 Subject: [PATCH] feat(providers): add xAI Grok inbound translators and thinking patcher (#4910) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. --- src/lib/providers/xai/thinking.ts | 115 ++++ src/lib/providers/xai/translators/claude.ts | 368 +++++++++++++ src/lib/providers/xai/translators/gemini.ts | 371 +++++++++++++ .../providers/xai/translators/openai-chat.ts | 304 +++++++++++ .../xai/translators/openai-responses.ts | 77 +++ tests/unit/xai-translators.test.ts | 490 ++++++++++++++++++ 6 files changed, 1725 insertions(+) create mode 100644 src/lib/providers/xai/thinking.ts create mode 100644 src/lib/providers/xai/translators/claude.ts create mode 100644 src/lib/providers/xai/translators/gemini.ts create mode 100644 src/lib/providers/xai/translators/openai-chat.ts create mode 100644 src/lib/providers/xai/translators/openai-responses.ts create mode 100644 tests/unit/xai-translators.test.ts diff --git a/src/lib/providers/xai/thinking.ts b/src/lib/providers/xai/thinking.ts new file mode 100644 index 0000000000..8348d08864 --- /dev/null +++ b/src/lib/providers/xai/thinking.ts @@ -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; + 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; + 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; +} diff --git a/src/lib/providers/xai/translators/claude.ts b/src/lib/providers/xai/translators/claude.ts new file mode 100644 index 0000000000..7f9850ca1d --- /dev/null +++ b/src/lib/providers/xai/translators/claude.ts @@ -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 = { + 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; +} diff --git a/src/lib/providers/xai/translators/gemini.ts b/src/lib/providers/xai/translators/gemini.ts new file mode 100644 index 0000000000..531dff4a9a --- /dev/null +++ b/src/lib/providers/xai/translators/gemini.ts @@ -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 = { + 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; +} diff --git a/src/lib/providers/xai/translators/openai-chat.ts b/src/lib/providers/xai/translators/openai-chat.ts new file mode 100644 index 0000000000..aa2bee6deb --- /dev/null +++ b/src/lib/providers/xai/translators/openai-chat.ts @@ -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 = { + role: "assistant", + content: text || (toolCalls.length ? null : ""), + }; + if (toolCalls.length) message.tool_calls = toolCalls; + if (refusal) message.refusal = refusal; + + const out: Record = { + 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; +} diff --git a/src/lib/providers/xai/translators/openai-responses.ts b/src/lib/providers/xai/translators/openai-responses.ts new file mode 100644 index 0000000000..9f5c9f9884 --- /dev/null +++ b/src/lib/providers/xai/translators/openai-responses.ts @@ -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; +} diff --git a/tests/unit/xai-translators.test.ts b/tests/unit/xai-translators.test.ts new file mode 100644 index 0000000000..56ed994cd0 --- /dev/null +++ b/tests/unit/xai-translators.test.ts @@ -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 1–3999 to low", () => { + assert.equal(budgetToEffort(1), "low"); + assert.equal(budgetToEffort(3999), "low"); +}); + +test("budgetToEffort: maps 4000–15999 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[0]); + assert.deepStrictEqual(out.reasoning, { effort: "high" }); + assert.equal((out as Record).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).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; + const choices = result.choices as Array>; + assert.equal(choices[0].finish_reason, "stop"); + const message = choices[0].message as Record; + 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; + const choices = result.choices as Array>; + assert.equal(choices[0].finish_reason, "tool_calls"); + const message = choices[0].message as Record; + const toolCalls = message.tool_calls as Array>; + assert.equal(toolCalls.length, 1); + assert.equal(toolCalls[0].id, "call_1"); + const fn = toolCalls[0].function as Record; + 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; + const content = inputItem.content as Array>; + 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; + 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>)[0]; + assert.equal(tool.type, "function"); + const fn = tool.function as Record; + 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; + assert.equal(result.role, "assistant"); + assert.equal(result.stop_reason, "end_turn"); + const content = result.content as Array>; + 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; + assert.equal(result.stop_reason, "tool_use"); + const content = result.content as Array>; + 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; + const content = item.content as Array>; + 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>)[0]; + assert.equal(tool.type, "function"); + const fn = tool.function as Record; + 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; + 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; + const candidates = result.candidates as Array>; + const content = candidates[0].content as Record; + const parts = content.parts as Array>; + 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; + const candidates = result.candidates as Array>; + const content = candidates[0].content as Record; + const parts = content.parts as Array>; + const fc = parts[0].functionCall as Record; + 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; + const meta = result.usageMetadata as Record; + assert.equal(meta.promptTokenCount, 10); + assert.equal(meta.candidatesTokenCount, 20); + assert.equal(meta.totalTokenCount, 30); +});