From 4533dd245f4fa31c9645b0a7058e209f64832f8b Mon Sep 17 00:00:00 2001 From: VXNCXNX <93332837+VXNCXNX@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:43:40 +0200 Subject: [PATCH] feat(providers): native xAI Agent Tools passthrough for /v1/responses (#9111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated in local merge-train (devbox-vm-06-dev002) @ combined-tip (FAST gates green: static + changed tests + vitest — only pre-existing audit.test.ts flake). Evidence: /home/diegosouzapw/dev/proxys/OmniRoute/.claude/worktrees/merge-train-20260805-213228-suite.log --- .../8964-xai-agent-tools-passthrough.md | 1 + config/quality/file-size-baseline.json | 6 +- open-sse/config/cliFingerprints.ts | 1 + open-sse/executors/xai.ts | 30 +- open-sse/handlers/chatCore.ts | 26 +- .../handlers/chatCore/passthroughHelpers.ts | 32 +- open-sse/handlers/chatCore/requestFormat.ts | 25 +- open-sse/handlers/chatCore/targetFormat.ts | 5 +- open-sse/handlers/responseSanitizer.ts | 13 + .../translator/request/openai-responses.ts | 7 +- .../request/openai-responses/helpers.ts | 1 + open-sse/utils/responsesEndpoint.ts | 5 + open-sse/utils/usageTracking.ts | 4 + .../chatcore-execution-credentials.test.ts | 10 + tests/unit/chatcore-request-format.test.ts | 29 +- tests/unit/web-search-fallback-format.test.ts | 13 + .../unit/xai-agent-tools-passthrough.test.ts | 394 ++++++++++++++++++ 17 files changed, 569 insertions(+), 33 deletions(-) create mode 100644 changelog.d/features/8964-xai-agent-tools-passthrough.md create mode 100644 open-sse/utils/responsesEndpoint.ts create mode 100644 tests/unit/xai-agent-tools-passthrough.test.ts diff --git a/changelog.d/features/8964-xai-agent-tools-passthrough.md b/changelog.d/features/8964-xai-agent-tools-passthrough.md new file mode 100644 index 0000000000..62536adb43 --- /dev/null +++ b/changelog.d/features/8964-xai-agent-tools-passthrough.md @@ -0,0 +1 @@ +- **feat(providers):** native xAI Agent Tools passthrough on `/v1/responses` for `xai` / `xai-oauth` (`xao`) — forward `web_search` + `x_search` to `api.x.ai` instead of rewriting or rejecting them ([#8964](https://github.com/diegosouzapw/OmniRoute/issues/8964)) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index eda3bfc98f..4034c147c2 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -350,9 +350,9 @@ "open-sse/executors/deepseek-web.ts": 1148, "open-sse/executors/grok-web.ts": 1044, "open-sse/executors/muse-spark-web.ts": 1405, - "open-sse/handlers/chatCore.ts": 5020, + "open-sse/handlers/chatCore.ts": 5034, "open-sse/handlers/imageGeneration.ts": 3101, - "open-sse/handlers/responseSanitizer.ts": 1115, + "open-sse/handlers/responseSanitizer.ts": 1128, "open-sse/handlers/search.ts": 1536, "open-sse/handlers/videoGeneration.ts": 1063, "open-sse/mcp-server/schemas/tools.ts": 1505, @@ -416,5 +416,7 @@ "_rebaseline_2026_07_28_8861_xiaomi_token_plan": "PR #8861 (feat/xiaomi-token-plan-protocol-selector) own growth: EditConnectionModal.tsx 1283->1316 (+33 = the per-connection API-protocol selector field) and open-sse/executors/base.ts 1540->1562 (+22 = alternate-format resolution at the existing buildUrl/headers chokepoint). Both are irreducible wiring at existing call sites.", "_rebaseline_2026_07_28_8863_firefly_detail_level": "PR #8863 (fix/adobe-firefly-gpt-detail-level-max) own growth: adobeFireflyClient.ts 2317->2322 (+5 = gpt-image detailLevel defaulting to maximal at the existing payload-build site). Covered by tests/unit/adobe-firefly.test.ts.", "_rebaseline_2026_07_29_8281_home_quickstart_prefetch": "Release v3.8.49 base-red fix (no PR — captain sweep): src/app/(dashboard)/dashboard/HomePageClient.tsx 1377->1381 (+4). #8292 added prefetch={false} to the sidebar but left /home's five quick-start Links prefetching, so first paint still fired 12 speculative RSC requests — caught by navigation.spec.ts only after the e2e helper bug (APP_ROUTE_PATTERN missing /home) was repaired in the same cycle. Growth is the five prefetch attributes; it was offset first by extracting the repeated className literals (INLINE_LINK x4, DOCS_LINK x1), which collapsed five wrapped blocks back to one line each — a naive fix measured 1391. Guard: tests/unit/sidebar-prefetch-policy-8281.test.ts.", + "_rebaseline_2026_08_01_8964_xai_agent_tools": "PR #8964 own growth: chatCore.ts 5020->5034 at the existing native-passthrough chokepoint. Adds xAI Agent Tools passthrough for /v1/responses (xai/xai-oauth/xao): resolve nativeXaiResponsesPassthrough, force openai-responses targetFormat, stamp body marker, and OR into the existing nativeCodexPassthrough sites (web-search bypass + requestEndpointPath). Leaf logic in passthroughHelpers, responsesEndpoint, targetFormat, xai executor, responseSanitizer, usageTracking. Cohesive wiring at the Codex passthrough boundary.", + "_rebaseline_2026_08_01_8964_response_sanitizer": "PR #8964 own growth: responseSanitizer.ts 1115->1128. Keep cost_in_usd_ticks / server_side_tool_usage(_details) through sanitizeResponsesApiResponse allowlists so native xAI tool responses retain usage.", "_rebaseline_2026_08_02_v3850_agentrouter_responses": "Release v3.8.50 AgentRouter/Codex compatibility reconciliation. open-sse/executors/base.ts 1562->1578: #9190 wires AgentRouter's selected Claude/OpenAI/Responses protocol through the existing executor URL, auth, identity-header and fingerprint chokepoints; the reusable alternate resolver remains outside base.ts. open-sse/utils/stream.ts 2887->2889: #9213 evaluates Responses ID and usage normalization independently so response.completed always receives finite usage.total_tokens instead of short-circuiting after an ID rewrite. tests/unit/chatcore-translation-paths.test.ts 2769->2776: #9191 updates the existing Claude-Code bridge assertions for the dynamic AgentRouter wire image. PR #9224 offsets its own chatCore growth by extracting the AgentRouter protocol decisions into chatCore/agentRouterProtocol.ts, leaving chatCore below its frozen ceiling. Covered by agentrouter executor/chatCore protocol tests, chatcore translation-path tests, and responses-commentary-passthrough tests." } diff --git a/open-sse/config/cliFingerprints.ts b/open-sse/config/cliFingerprints.ts index da65fce35b..b219cd1363 100644 --- a/open-sse/config/cliFingerprints.ts +++ b/open-sse/config/cliFingerprints.ts @@ -271,6 +271,7 @@ function stripInternalBodyFields(body: unknown): unknown { const record = body as Record; delete record._claudeCodeRequiresLowercaseToolNames; delete record._nativeCodexPassthrough; + delete record._nativeXaiResponsesPassthrough; delete record._omnirouteResponsesStore; return body; } diff --git a/open-sse/executors/xai.ts b/open-sse/executors/xai.ts index 5fc6217729..f9b92fa1f6 100644 --- a/open-sse/executors/xai.ts +++ b/open-sse/executors/xai.ts @@ -1,6 +1,7 @@ import { BaseExecutor, type ExecutorLog, type ProviderCredentials } from "./base.ts"; import { PROVIDERS } from "../config/constants.ts"; import { getModelTargetFormat } from "../config/providerModels.ts"; +import { isResponsesEndpointPath } from "../utils/responsesEndpoint.ts"; type JsonRecord = Record; @@ -52,21 +53,18 @@ export class XaiExecutor extends BaseExecutor { super(provider, PROVIDERS[provider]); } - /** - * Port of decolua/9router#2439 (author: @ryanngit): xAI ships a native - * `/v1/responses` endpoint alongside `/v1/chat/completions`. Models tagged - * `targetFormat: "openai-responses"` in the registry (currently - * grok-4.20-multi-agent-0309, per upstream) resolve to that endpoint instead - * of the default chat-completions bridge. The per-model registry tag is the - * single source of truth — it also drives chatCore's body translation — so - * the URL stays in lockstep with the translated body, mirroring the gh - * executor's targetFormat-driven routing (9router#102) and the "openai" - * -pro heuristic in open-sse/executors/default.ts. - */ - buildUrl(model: string, _stream: boolean, _urlIndex = 0) { + buildUrl( + model: string, + _stream: boolean, + _urlIndex = 0, + credentials: ProviderCredentials | null = null + ) { if (getModelTargetFormat(this.provider, model) === "openai-responses") { return this.config.responsesBaseUrl || this.config.baseUrl; } + if (isResponsesEndpointPath(credentials?.requestEndpointPath)) { + return this.config.responsesBaseUrl || this.config.baseUrl; + } return this.config.baseUrl; } @@ -127,6 +125,14 @@ export class XaiExecutor extends BaseExecutor { if (!record) return cleaned; const out: JsonRecord = { ...record }; + const nativeXaiPassthrough = record._nativeXaiResponsesPassthrough === true; + delete out._nativeXaiResponsesPassthrough; + delete out._nativeCodexPassthrough; + + if (nativeXaiPassthrough || getModelTargetFormat(this.provider, model) === "openai-responses") { + return out; + } + let modelId = typeof out.model === "string" ? out.model : model; let suffixEffort: string | null = null; diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 4eaa24ff57..7421d89628 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -38,6 +38,8 @@ import { } from "./chatCore/executorHelpers.ts"; import { shouldUseNativeCodexPassthrough, + shouldUseNativeXaiResponsesPassthrough, + stampNativeResponsesPassthroughBody, redactPassthroughThinkingSignatures, isClaudeCodeSemanticPassthroughRequest, } from "./chatCore/passthroughHelpers.ts"; @@ -56,6 +58,7 @@ import { // symbols from chatCore.ts (tests, sibling modules) keep resolving after the split. export { shouldUseNativeCodexPassthrough, + shouldUseNativeXaiResponsesPassthrough, redactPassthroughThinkingSignatures, isClaudeCodeSemanticPassthroughRequest, buildStreamingResponseHeaders, @@ -628,6 +631,7 @@ export async function handleChatCore({ sourceFormat, isResponsesEndpoint, nativeCodexPassthrough, + nativeXaiResponsesPassthrough, isDroidCLI, isOpencodeClient, copilotCompatibleReasoning, @@ -748,7 +752,9 @@ export async function handleChatCore({ sourceFormat, customModelTargetFormat, providerSpecificData: credentials?.providerSpecificData, + nativeXaiResponsesPassthrough, }); + const nativeResponsesPassthrough = nativeCodexPassthrough || nativeXaiResponsesPassthrough; const initialProviderRequest = body && typeof body === "object" && !Array.isArray(body) @@ -788,7 +794,7 @@ export async function handleChatCore({ provider, sourceFormat, targetFormat, - nativeCodexPassthrough, + nativeCodexPassthrough: nativeResponsesPassthrough, interceptSearchOverride, }); if (webSearchFallbackPlan.enabled) { @@ -806,7 +812,7 @@ export async function handleChatCore({ provider, sourceFormat, targetFormat, - nativeCodexPassthrough, + nativeCodexPassthrough: nativeResponsesPassthrough, interceptFetchOverride, }); if (webFetchFallbackPlan.enabled) { @@ -1976,9 +1982,17 @@ export async function handleChatCore({ ) => normalizeClaudeUpstreamMessagesFor(payload, options, log); try { - if (nativeCodexPassthrough) { - translatedBody = { ...body, _nativeCodexPassthrough: true }; - log?.debug?.("FORMAT", "native codex passthrough enabled"); + if (nativeResponsesPassthrough) { + translatedBody = stampNativeResponsesPassthroughBody( + body, + nativeCodexPassthrough ? "codex" : "xai" + ); + log?.debug?.( + "FORMAT", + nativeCodexPassthrough + ? "native codex passthrough enabled" + : "native xAI Responses Agent Tools passthrough enabled" + ); } else if (isClaudeCodeCompatible) { let normalizedForCc = { ...body }; @@ -2616,7 +2630,7 @@ export async function handleChatCore({ const getExecutionCredentials = () => resolveExecutionCredentialsFor({ credentials, - nativeCodexPassthrough, + nativeCodexPassthrough: nativeResponsesPassthrough, endpointPath, targetFormat, provider, diff --git a/open-sse/handlers/chatCore/passthroughHelpers.ts b/open-sse/handlers/chatCore/passthroughHelpers.ts index 3c0731c2b1..e644878329 100644 --- a/open-sse/handlers/chatCore/passthroughHelpers.ts +++ b/open-sse/handlers/chatCore/passthroughHelpers.ts @@ -1,7 +1,12 @@ import { FORMATS } from "../../translator/formats.ts"; import { isClaudeCodeCompatibleProvider } from "../../services/claudeCodeCompatible.ts"; +import { isResponsesEndpointPath } from "../../utils/responsesEndpoint.ts"; import { getHeaderValueCaseInsensitive } from "./headers.ts"; +export { isResponsesEndpointPath }; + +export const XAI_API_PROVIDERS = new Set(["xai", "xai-oauth", "xao"]); + export function shouldUseNativeCodexPassthrough({ provider, sourceFormat, @@ -13,10 +18,29 @@ export function shouldUseNativeCodexPassthrough({ }): boolean { if (provider !== "codex") return false; if (sourceFormat !== FORMATS.OPENAI_RESPONSES) return false; - let normalizedEndpoint = String(endpointPath || ""); - while (normalizedEndpoint.endsWith("/")) normalizedEndpoint = normalizedEndpoint.slice(0, -1); - const segments = normalizedEndpoint.split("/"); - return segments.includes("responses"); + return isResponsesEndpointPath(endpointPath); +} + +export function shouldUseNativeXaiResponsesPassthrough({ + provider, + sourceFormat, + endpointPath, +}: { + provider?: string | null; + sourceFormat?: string | null; + endpointPath?: string | null; +}): boolean { + if (!provider || !XAI_API_PROVIDERS.has(provider)) return false; + if (sourceFormat !== FORMATS.OPENAI_RESPONSES) return false; + return isResponsesEndpointPath(endpointPath); +} + +export function stampNativeResponsesPassthroughBody( + body: Record, + mode: "codex" | "xai" +): Record { + if (mode === "codex") return { ...body, _nativeCodexPassthrough: true }; + return { ...body, _nativeXaiResponsesPassthrough: true }; } /** diff --git a/open-sse/handlers/chatCore/requestFormat.ts b/open-sse/handlers/chatCore/requestFormat.ts index fa9e9194fb..d5ce5ad23b 100644 --- a/open-sse/handlers/chatCore/requestFormat.ts +++ b/open-sse/handlers/chatCore/requestFormat.ts @@ -11,7 +11,10 @@ */ import { detectFormatFromEndpoint } from "../../services/provider.ts"; -import { shouldUseNativeCodexPassthrough } from "./passthroughHelpers.ts"; +import { + shouldUseNativeCodexPassthrough, + shouldUseNativeXaiResponsesPassthrough, +} from "./passthroughHelpers.ts"; import { FORMATS } from "../../translator/formats.ts"; /** True when the request originates from a Copilot client (matched by user-agent or any header). */ @@ -49,13 +52,19 @@ function isOpencodeClient( if (headers instanceof Headers) { for (const [key, value] of headers as unknown as Iterable<[string, string]>) { - if (matchesHeaderKey(key) || (key.toLowerCase() === "user-agent" && matchesUserAgent(value))) { + if ( + matchesHeaderKey(key) || + (key.toLowerCase() === "user-agent" && matchesUserAgent(value)) + ) { return true; } } } else if (headers && typeof headers === "object") { for (const [key, value] of Object.entries(headers)) { - if (matchesHeaderKey(key) || (key.toLowerCase() === "user-agent" && matchesUserAgent(value))) { + if ( + matchesHeaderKey(key) || + (key.toLowerCase() === "user-agent" && matchesUserAgent(value)) + ) { return true; } } @@ -71,9 +80,7 @@ function isOpencodeClient( */ export function resolveChatCoreRequestFormat(opts: { clientRawRequest: - | { endpoint?: unknown; headers?: Headers | Record | null } - | null - | undefined; + { endpoint?: unknown; headers?: Headers | Record | null } | null | undefined; body: unknown; provider: string | null | undefined; userAgent: string | null | undefined; @@ -88,6 +95,11 @@ export function resolveChatCoreRequestFormat(opts: { sourceFormat, endpointPath, }); + const nativeXaiResponsesPassthrough = shouldUseNativeXaiResponsesPassthrough({ + provider, + sourceFormat, + endpointPath, + }); const isDroidCLI = userAgent?.toLowerCase().includes("droid") || userAgent?.toLowerCase().includes("codex-cli"); const copilotCompatibleReasoning = isCopilotClient(clientRawRequest?.headers, userAgent); @@ -101,6 +113,7 @@ export function resolveChatCoreRequestFormat(opts: { sourceFormat, isResponsesEndpoint, nativeCodexPassthrough, + nativeXaiResponsesPassthrough, isDroidCLI, copilotCompatibleReasoning, isOpencodeClient: isOpencodeClientRequest, diff --git a/open-sse/handlers/chatCore/targetFormat.ts b/open-sse/handlers/chatCore/targetFormat.ts index 9bb2f0da7a..e5f00eb81a 100644 --- a/open-sse/handlers/chatCore/targetFormat.ts +++ b/open-sse/handlers/chatCore/targetFormat.ts @@ -22,6 +22,7 @@ export function resolveChatCoreTargetFormat(opts: { sourceFormat?: string; customModelTargetFormat: string | undefined; providerSpecificData: unknown; + nativeXaiResponsesPassthrough?: boolean; }) { const { provider, @@ -30,6 +31,7 @@ export function resolveChatCoreTargetFormat(opts: { sourceFormat, customModelTargetFormat, providerSpecificData, + nativeXaiResponsesPassthrough = false, } = opts; const alias = PROVIDER_ID_TO_ALIAS[provider] || provider; const modelTargetFormat = getModelTargetFormat(alias, resolvedModel); @@ -44,13 +46,14 @@ export function resolveChatCoreTargetFormat(opts: { sourceFormat === FORMATS.CLAUDE) ? sourceFormat : undefined; - const targetFormat = + let targetFormat = apiFormat === "responses" ? FORMATS.OPENAI_RESPONSES : modelTargetFormat || customModelTargetFormat || inferredAgentRouterTargetFormat || getTargetFormat(provider, providerSpecificData); + if (nativeXaiResponsesPassthrough) targetFormat = FORMATS.OPENAI_RESPONSES; return { alias, targetFormat }; } diff --git a/open-sse/handlers/responseSanitizer.ts b/open-sse/handlers/responseSanitizer.ts index 140011f64e..77a126ed34 100644 --- a/open-sse/handlers/responseSanitizer.ts +++ b/open-sse/handlers/responseSanitizer.ts @@ -42,8 +42,17 @@ const ALLOWED_RESPONSES_USAGE_FIELDS = new Set([ "input_tokens_details", "output_tokens_details", "estimated", + "cost_in_usd_ticks", + "server_side_tool_usage_details", + "server_side_tool_usage", ]); +const RESPONSES_EXTRA_TOP_LEVEL_FIELDS = [ + "server_side_tool_usage_details", + "server_side_tool_usage", + "cost_in_usd_ticks", +] as const; + type JsonRecord = Record; type ParseOptions = { parseTextualReasoningTags?: boolean }; @@ -355,6 +364,10 @@ export function sanitizeResponsesApiResponse(body: unknown): unknown { sanitized.usage = sanitizeResponsesUsage(responseRoot.usage); } + for (const key of RESPONSES_EXTRA_TOP_LEVEL_FIELDS) { + if (responseRoot[key] !== undefined) sanitized[key] = responseRoot[key]; + } + return sanitized; } diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index ab4182b746..4b2186d552 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -20,6 +20,7 @@ import { RESPONSES_STORE_MARKER, COPILOT_REASONING_SUMMARY_MARKER, WEB_SEARCH_TOOL_TYPES, + X_SEARCH_TOOL_TYPES, TOOL_SEARCH_TOOL_TYPES, IMAGE_GENERATION_TOOL_TYPES, toRecord, @@ -103,7 +104,7 @@ export function openaiResponsesToOpenAIRequest( // namespace tools (MCP tool groups used by Codex/OpenAI Responses API), and web_search server tools // (Anthropic versioned: web_search_20250305, web_search_20250101, etc. — or plain web_search). // tool_search is a Responses API built-in sent by newer Codex clients; silently skip it here - // (it will be filtered out during tools conversion below). + // (it will be filtered out during tools conversion below). x_search (#8964) same pattern. if ( toolType && toolType !== "function" && @@ -112,6 +113,7 @@ export function openaiResponsesToOpenAIRequest( toolType !== "namespace" && toolType !== "local_shell" && !WEB_SEARCH_TOOL_TYPES.test(toolType) && + !X_SEARCH_TOOL_TYPES.test(toolType) && !TOOL_SEARCH_TOOL_TYPES.test(toolType) && !IMAGE_GENERATION_TOOL_TYPES.test(toolType) && !tool.function @@ -531,6 +533,9 @@ export function openaiResponsesToOpenAIRequest( if (WEB_SEARCH_TOOL_TYPES.test(toolType)) { return toolValue; } + if (X_SEARCH_TOOL_TYPES.test(toolType)) { + return []; + } // local_shell is a Responses API built-in (Codex CLI injects it for shell // execution). Non-OpenAI upstreams (Kiro/Claude) have no local_shell type, // so map it to a regular "shell" function tool. The response translator diff --git a/open-sse/translator/request/openai-responses/helpers.ts b/open-sse/translator/request/openai-responses/helpers.ts index a65a7fe86b..ac5f8b746d 100644 --- a/open-sse/translator/request/openai-responses/helpers.ts +++ b/open-sse/translator/request/openai-responses/helpers.ts @@ -7,6 +7,7 @@ export const COPILOT_REASONING_SUMMARY_MARKER = "_omnirouteCopilotReasoningSumma // Forward-compatible regex: matches web_search, web_search_20250305, and future versioned names. export const WEB_SEARCH_TOOL_TYPES = /^web_search/; +export const X_SEARCH_TOOL_TYPES = /^x_search/; // tool_search is a Responses API built-in sent by newer Codex clients; it has no Chat Completions // equivalent and must be silently dropped (not rejected with 400). export const TOOL_SEARCH_TOOL_TYPES = /^tool_search/; diff --git a/open-sse/utils/responsesEndpoint.ts b/open-sse/utils/responsesEndpoint.ts new file mode 100644 index 0000000000..216152f483 --- /dev/null +++ b/open-sse/utils/responsesEndpoint.ts @@ -0,0 +1,5 @@ +export function isResponsesEndpointPath(endpointPath?: string | null): boolean { + let normalizedEndpoint = String(endpointPath || ""); + while (normalizedEndpoint.endsWith("/")) normalizedEndpoint = normalizedEndpoint.slice(0, -1); + return normalizedEndpoint.split("/").includes("responses"); +} diff --git a/open-sse/utils/usageTracking.ts b/open-sse/utils/usageTracking.ts index 90f457b77e..f0d6990a90 100644 --- a/open-sse/utils/usageTracking.ts +++ b/open-sse/utils/usageTracking.ts @@ -232,9 +232,13 @@ export function filterUsageForFormat(usage, targetFormat) { [FORMATS.OPENAI_RESPONSES]: [ "input_tokens", "output_tokens", + "total_tokens", "input_tokens_details", "output_tokens_details", "estimated", + "cost_in_usd_ticks", + "server_side_tool_usage_details", + "server_side_tool_usage", ], // OpenAI format (default for OPENAI, CODEX, KIRO, etc.) default: [ diff --git a/tests/unit/chatcore-execution-credentials.test.ts b/tests/unit/chatcore-execution-credentials.test.ts index 692a662fbc..b8faeccf90 100644 --- a/tests/unit/chatcore-execution-credentials.test.ts +++ b/tests/unit/chatcore-execution-credentials.test.ts @@ -33,6 +33,16 @@ test("native Codex passthrough injects requestEndpointPath", () => { assert.equal(out.requestEndpointPath, "/v1/responses"); }); +test("native Responses passthrough (xAI via codex flag) injects requestEndpointPath (#8964)", () => { + // chatCore ORs xAI into nativeCodexPassthrough before calling this helper. + const out = resolveExecutionCredentials({ + ...base, + provider: "xai-oauth", + nativeCodexPassthrough: true, + }) as Record; + assert.equal(out.requestEndpointPath, "/v1/responses"); +}); + test("azure-ai + responses target forces apiType=responses and the upstream marker", () => { const out = resolveExecutionCredentials({ ...base, diff --git a/tests/unit/chatcore-request-format.test.ts b/tests/unit/chatcore-request-format.test.ts index 064a2384f2..52c9a154bf 100644 --- a/tests/unit/chatcore-request-format.test.ts +++ b/tests/unit/chatcore-request-format.test.ts @@ -10,7 +10,11 @@ import { resolveChatCoreRequestFormat } from "../../open-sse/handlers/chatCore/r import { shouldUseNativeCodexPassthrough } from "../../open-sse/handlers/chatCore/passthroughHelpers.ts"; import { FORMATS } from "../../open-sse/translator/formats.ts"; -const base = { body: { messages: [{ role: "user", content: "hi" }] }, provider: "openai", userAgent: "unit-test" }; +const base = { + body: { messages: [{ role: "user", content: "hi" }] }, + provider: "openai", + userAgent: "unit-test", +}; test("chat/completions endpoint → openai source, not a responses endpoint, no downgrade", () => { const r = resolveChatCoreRequestFormat({ @@ -38,6 +42,29 @@ test("/responses endpoint → openai-responses source + isResponsesEndpoint, kep assert.equal(r.clientResponseFormat, FORMATS.OPENAI_RESPONSES); }); +test("xAI oauth on /responses enables nativeXaiResponsesPassthrough (#8964)", () => { + const r = resolveChatCoreRequestFormat({ + body: { input: "x", tools: [{ type: "web_search" }, { type: "x_search" }] }, + provider: "xai-oauth", + userAgent: "unit-test", + clientRawRequest: { endpoint: "/v1/responses", headers: new Headers() }, + }); + assert.equal(r.sourceFormat, FORMATS.OPENAI_RESPONSES); + assert.equal(r.isResponsesEndpoint, true); + assert.equal(r.nativeXaiResponsesPassthrough, true); + assert.equal(r.nativeCodexPassthrough, false); +}); + +test("xao alias on /responses enables nativeXaiResponsesPassthrough (#8964)", () => { + const r = resolveChatCoreRequestFormat({ + body: { input: "x" }, + provider: "xao", + userAgent: "unit-test", + clientRawRequest: { endpoint: "/v1/responses", headers: new Headers() }, + }); + assert.equal(r.nativeXaiResponsesPassthrough, true); +}); + test("Responses-shaped body on a /chat/completions endpoint downgrades clientResponseFormat to openai", () => { const r = resolveChatCoreRequestFormat({ body: { input: "describe" }, // input + no messages → openai-responses via body diff --git a/tests/unit/web-search-fallback-format.test.ts b/tests/unit/web-search-fallback-format.test.ts index 39835e01dc..a4809780bc 100644 --- a/tests/unit/web-search-fallback-format.test.ts +++ b/tests/unit/web-search-fallback-format.test.ts @@ -90,6 +90,19 @@ test("bypass predicate: true for native Codex passthrough", () => { ); }); +test("bypass predicate: true when native Responses passthrough flag is set (#8964 xAI)", () => { + // Callers OR codex|xai into nativeCodexPassthrough (existing flag = "any native lane"). + assert.equal( + supportsNativeWebSearchFallbackBypass({ + provider: "xai-oauth", + sourceFormat: "openai-responses", + targetFormat: "openai-responses", + nativeCodexPassthrough: true, + }), + true + ); +}); + test("bypass predicate: true for Gemini target", () => { assert.equal( supportsNativeWebSearchFallbackBypass({ diff --git a/tests/unit/xai-agent-tools-passthrough.test.ts b/tests/unit/xai-agent-tools-passthrough.test.ts new file mode 100644 index 0000000000..ac9daa408f --- /dev/null +++ b/tests/unit/xai-agent-tools-passthrough.test.ts @@ -0,0 +1,394 @@ +/** + * #8964 — native xAI Agent Tools passthrough for /v1/responses + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { + shouldUseNativeXaiResponsesPassthrough, + shouldUseNativeCodexPassthrough, + isResponsesEndpointPath, + stampNativeResponsesPassthroughBody, + XAI_API_PROVIDERS, +} = await import("../../open-sse/handlers/chatCore/passthroughHelpers.ts"); + +const { + supportsNativeWebSearchFallbackBypass, + prepareWebSearchFallbackBody, + OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME, +} = await import("../../open-sse/services/webSearchFallback.ts"); + +const { openaiResponsesToOpenAIRequest } = + await import("../../open-sse/translator/request/openai-responses.ts"); + +const { XaiExecutor } = await import("../../open-sse/executors/xai.ts"); + +// ── Predicate ────────────────────────────────────────────────────────────── + +test("native xAI passthrough: true for xai-oauth + responses endpoint", () => { + assert.equal( + shouldUseNativeXaiResponsesPassthrough({ + provider: "xai-oauth", + sourceFormat: "openai-responses", + endpointPath: "/v1/responses", + }), + true + ); +}); + +test("native xAI passthrough: true for xao alias and xai API key provider", () => { + assert.equal( + shouldUseNativeXaiResponsesPassthrough({ + provider: "xao", + sourceFormat: "openai-responses", + endpointPath: "/v1/responses", + }), + true + ); + assert.equal( + shouldUseNativeXaiResponsesPassthrough({ + provider: "xai", + sourceFormat: "openai-responses", + endpointPath: "responses", + }), + true + ); +}); + +test("native xAI passthrough: false for grok-cli / chat completions / wrong format", () => { + assert.equal( + shouldUseNativeXaiResponsesPassthrough({ + provider: "grok-cli", + sourceFormat: "openai-responses", + endpointPath: "/v1/responses", + }), + false + ); + assert.equal( + shouldUseNativeXaiResponsesPassthrough({ + provider: "xai-oauth", + sourceFormat: "openai-responses", + endpointPath: "/v1/chat/completions", + }), + false + ); + assert.equal( + shouldUseNativeXaiResponsesPassthrough({ + provider: "xai-oauth", + sourceFormat: "openai", + endpointPath: "/v1/responses", + }), + false + ); + assert.ok(XAI_API_PROVIDERS.has("xai-oauth")); + assert.equal(XAI_API_PROVIDERS.has("grok-cli"), false); +}); + +test("Codex passthrough is unchanged and does not match xAI providers", () => { + assert.equal( + shouldUseNativeCodexPassthrough({ + provider: "xai-oauth", + sourceFormat: "openai-responses", + endpointPath: "/v1/responses", + }), + false + ); + assert.equal( + shouldUseNativeCodexPassthrough({ + provider: "codex", + sourceFormat: "openai-responses", + endpointPath: "/v1/responses", + }), + true + ); +}); + +test("isResponsesEndpointPath shared helper", () => { + assert.equal(isResponsesEndpointPath("/v1/responses"), true); + assert.equal(isResponsesEndpointPath("/v1/responses/"), true); + assert.equal(isResponsesEndpointPath("responses"), true); + assert.equal(isResponsesEndpointPath("/v1/chat/completions"), false); +}); + +test("stampNativeResponsesPassthroughBody modes", () => { + assert.deepEqual(stampNativeResponsesPassthroughBody({ a: 1 }, "codex"), { + a: 1, + _nativeCodexPassthrough: true, + }); + assert.deepEqual(stampNativeResponsesPassthroughBody({ a: 1 }, "xai"), { + a: 1, + _nativeXaiResponsesPassthrough: true, + }); +}); + +// ── Web search rewrite bypass (via existing nativeCodexPassthrough flag) ── + +test("web_search is NOT rewritten when native Responses passthrough flag is true", () => { + const input = { tools: [{ type: "web_search" }, { type: "x_search", from_date: "2026-05-01" }] }; + const { body, fallback } = prepareWebSearchFallbackBody(input, { + provider: "xai-oauth", + sourceFormat: "openai-responses", + targetFormat: "openai-responses", + nativeCodexPassthrough: true, + }); + assert.equal(fallback.enabled, false); + assert.deepEqual(body, input); +}); + +test("web_search IS rewritten for xAI when native passthrough flag is false", () => { + const { body, fallback } = prepareWebSearchFallbackBody( + { tools: [{ type: "web_search" }] }, + { + provider: "xai-oauth", + sourceFormat: "openai-responses", + targetFormat: "openai", + nativeCodexPassthrough: false, + } + ); + assert.equal(fallback.enabled, true); + assert.equal(fallback.toolName, OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME); + const tools = body.tools as Record[]; + const names = tools.map((t) => (t.function ? (t.function as { name?: string }).name : t.name)); + assert.ok(names.includes(OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME)); +}); + +test("interceptSearchOverride true still forces rewrite on passthrough path", () => { + assert.equal( + supportsNativeWebSearchFallbackBypass({ + provider: "xai-oauth", + sourceFormat: "openai-responses", + targetFormat: "openai-responses", + nativeCodexPassthrough: true, + interceptSearchOverride: true, + }), + false + ); +}); + +// ── Translator allowlist / Chat drop ─────────────────────────────────────── + +test("Responses→Chat validation accepts x_search (no unsupported_feature 400)", () => { + assert.doesNotThrow(() => { + openaiResponsesToOpenAIRequest( + "gpt-4o", + { + model: "gpt-4o", + input: "hi", + tools: [ + { type: "web_search" }, + { type: "x_search", from_date: "2026-05-01", to_date: "2026-08-01" }, + { + type: "function", + name: "lookup", + parameters: { type: "object", properties: {} }, + }, + ], + }, + false, + {} + ); + }); +}); + +test("Responses→Chat conversion drops x_search (no Chat equivalent)", () => { + const converted = openaiResponsesToOpenAIRequest( + "gpt-4o", + { + model: "gpt-4o", + input: "hi", + tools: [ + { type: "x_search", from_date: "2026-05-01" }, + { + type: "function", + name: "lookup", + parameters: { type: "object", properties: {} }, + }, + ], + }, + false, + {} + ) as { tools?: Array<{ type?: string; function?: { name?: string }; name?: string }> }; + + const tools = converted.tools || []; + assert.equal( + tools.some((t) => t.type === "x_search"), + false, + "x_search must not be forwarded on Chat Completions downgrade" + ); + assert.ok( + tools.some((t) => t.function?.name === "lookup" || t.name === "lookup"), + "function tools survive" + ); +}); + +// ── XaiExecutor URL + marker strip ───────────────────────────────────────── + +test("XaiExecutor.buildUrl uses responsesBaseUrl when requestEndpointPath is /v1/responses", () => { + const executor = new XaiExecutor("xai-oauth"); + const chatUrl = String(executor.buildUrl("grok-4.20-0309-reasoning", false, 0, null)); + assert.ok( + chatUrl.includes("chat/completions") || !chatUrl.includes("/responses"), + `default chat path expected, got ${chatUrl}` + ); + + const responsesUrl = String( + executor.buildUrl("grok-4.20-0309-reasoning", false, 0, { + requestEndpointPath: "/v1/responses", + } as never) + ); + assert.match(responsesUrl, /\/responses/); +}); + +test("XaiExecutor.transformRequest strips internal passthrough markers", () => { + const executor = new XaiExecutor("xai-oauth"); + const out = executor.transformRequest( + "grok-4.20-0309-reasoning", + { + model: "grok-4.20-0309-reasoning", + input: "hi", + tools: [{ type: "web_search" }, { type: "x_search" }], + _nativeXaiResponsesPassthrough: true, + _nativeCodexPassthrough: true, + }, + false, + {} as never + ) as Record; + + assert.equal(out._nativeXaiResponsesPassthrough, undefined); + assert.equal(out._nativeCodexPassthrough, undefined); + assert.ok(Array.isArray(out.tools)); + assert.equal((out.tools as unknown[]).length, 2); +}); + +// ── Composition invariant ────────────────────────────────────────────────── + +test("composition: flag → targetFormat Responses → no rewrite → credentials stamp → Responses URL", async () => { + const { resolveChatCoreRequestFormat } = + await import("../../open-sse/handlers/chatCore/requestFormat.ts"); + const { resolveChatCoreTargetFormat } = + await import("../../open-sse/handlers/chatCore/targetFormat.ts"); + const { resolveExecutionCredentials } = + await import("../../open-sse/handlers/chatCore/executionCredentials.ts"); + + const fmt = resolveChatCoreRequestFormat({ + body: { + input: "hi", + tools: [{ type: "web_search" }, { type: "x_search", from_date: "2026-05-01" }], + }, + provider: "xai-oauth", + userAgent: "unit-test", + clientRawRequest: { endpoint: "/v1/responses", headers: new Headers() }, + }); + assert.equal(fmt.nativeXaiResponsesPassthrough, true); + const nativeResponsesPassthrough = + fmt.nativeCodexPassthrough || fmt.nativeXaiResponsesPassthrough; + + const { targetFormat } = resolveChatCoreTargetFormat({ + provider: "xai-oauth", + resolvedModel: "grok-4.20-0309-reasoning", + apiFormat: undefined, + customModelTargetFormat: undefined, + providerSpecificData: undefined, + nativeXaiResponsesPassthrough: fmt.nativeXaiResponsesPassthrough, + }); + assert.equal(targetFormat, "openai-responses"); + + const { fallback } = prepareWebSearchFallbackBody( + { tools: [{ type: "web_search" }, { type: "x_search" }] }, + { + provider: "xai-oauth", + sourceFormat: fmt.sourceFormat, + targetFormat, + nativeCodexPassthrough: nativeResponsesPassthrough, + } + ); + assert.equal(fallback.enabled, false); + + const creds = resolveExecutionCredentials({ + credentials: {}, + nativeCodexPassthrough: nativeResponsesPassthrough, + endpointPath: fmt.endpointPath, + targetFormat, + provider: "xai-oauth", + ccSessionId: null, + }) as { requestEndpointPath?: string }; + assert.equal(creds.requestEndpointPath, "/v1/responses"); + + const url = String( + new XaiExecutor("xai-oauth").buildUrl("grok-4.20-0309-reasoning", false, 0, { + requestEndpointPath: creds.requestEndpointPath, + } as never) + ); + assert.match(url, /\/responses/); + assert.equal(url.includes("chat/completions"), false); +}); + +// ── Response fidelity ────────────────────────────────────────────────────── + +test("sanitizeResponsesApiResponse keeps xAI server-side tool usage + cost ticks", async () => { + const { sanitizeResponsesApiResponse } = + await import("../../open-sse/handlers/responseSanitizer.ts"); + const sanitized = sanitizeResponsesApiResponse({ + id: "resp_test", + object: "response", + created_at: 1, + model: "grok-4.20-0309-reasoning", + status: "completed", + output: [ + { + id: "ws_1", + type: "web_search_call", + status: "completed", + action: { type: "search", query: "stripe" }, + }, + { + id: "msg_1", + type: "message", + role: "assistant", + content: [{ type: "output_text", text: '{"ok":true}' }], + }, + ], + usage: { + input_tokens: 10, + output_tokens: 5, + total_tokens: 15, + cost_in_usd_ticks: 154733500, + server_side_tool_usage_details: { web_search_calls: 1, x_search_calls: 0 }, + }, + cost_in_usd_ticks: 154733500, + server_side_tool_usage_details: { web_search_calls: 1 }, + }) as Record; + + const usage = sanitized.usage as Record; + assert.equal(usage.cost_in_usd_ticks, 154733500); + assert.deepEqual(usage.server_side_tool_usage_details, { + web_search_calls: 1, + x_search_calls: 0, + }); + assert.equal(sanitized.cost_in_usd_ticks, 154733500); + assert.deepEqual(sanitized.server_side_tool_usage_details, { web_search_calls: 1 }); + assert.ok((sanitized.output as { type: string }[]).some((o) => o.type === "web_search_call")); +}); + +test("filterUsageForFormat(Responses) keeps cost ticks + server-side tool details", async () => { + const { filterUsageForFormat } = await import("../../open-sse/utils/usageTracking.ts"); + const filtered = filterUsageForFormat( + { + input_tokens: 10, + output_tokens: 5, + total_tokens: 15, + cost_in_usd_ticks: 154733500, + server_side_tool_usage_details: { web_search_calls: 1, x_search_calls: 0 }, + server_side_tool_usage: { web_search: 1 }, + x_groq: { should_not: "survive" }, + }, + "openai-responses" + ) as Record; + + assert.equal(filtered.cost_in_usd_ticks, 154733500); + assert.deepEqual(filtered.server_side_tool_usage_details, { + web_search_calls: 1, + x_search_calls: 0, + }); + assert.equal(filtered.x_groq, undefined); +});