diff --git a/src/providers/registry.ts b/src/providers/registry.ts index d188185ed3..f5f0129281 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -992,6 +992,12 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ oauthId: "xai", jawcodeBundle: "xai", note: "Log in with your Grok account", + // xAI Priority Processing is documented on the canonical API-key transport + // (https://api.x.ai/v1). The OAuth/CLI transport (https://cli-chat-proxy.grok.com/v1) + // is not established by public docs, so this opt-in is gated by auth mode in + // serviceTierSupportForModel (issue #1875): Fast is advertised/forwarded only in + // key mode, never on the unverified OAuth transport. + chatServiceTier: true, // Parallel tool calls: officially supported and default-on per docs.x.ai function-calling // (verified 260709, devlog/_plan/260709_parallel_tool_calls). Streamed calls arrive whole // per chunk, so the buffered parser assembles them losslessly. diff --git a/src/providers/service-tier.ts b/src/providers/service-tier.ts index aa4fab044a..5c06700cd2 100644 --- a/src/providers/service-tier.ts +++ b/src/providers/service-tier.ts @@ -6,6 +6,14 @@ import { getProviderRegistryEntry, providerModelWireDefault, type InboundWire } export const SERVICE_TIER_ADAPTERS = new Set(["openai-chat", "openai-responses"]); export type CapturedServiceTierAdapterAuthority = Readonly>; +/** + * xAI multiplexes two transports under one provider id: an API-key mode that stays on + * https://api.x.ai/v1 (where Priority Processing is documented) and an OAuth/CLI mode that + * resolves to https://cli-chat-proxy.grok.com/v1 (unverified by public docs). The built-in + * xai preset opts into chatServiceTier, but that opt-in must only arm when the effective + * transport is the canonical API-key path (issue #1875). + */ +const XAI_CHAT_PRIORITY_PROVIDER = "xai"; const capturedAdapterAuthority = new WeakMap(); @@ -14,6 +22,11 @@ type ServiceTierCapabilityProvider = Pick< "adapter" | "supportsServiceTier" | "modelSupportsServiceTier" | "modelAdapters" | "baseUrl" | "authMode" | "chatServiceTier" >; +type ChatServiceTierProvider = Pick< + OcxProviderConfig, + "supportsServiceTier" | "modelSupportsServiceTier" | "chatServiceTier" +> & Partial>; + /** * Read a model map by exact model identity. Service-tier capability is deliberately * stricter than the older model metadata maps: a family key or a colon-qualified @@ -34,6 +47,29 @@ function exactModelValue( return undefined; } +function isXaiPriorityProvider(providerName: string | undefined): boolean { + return providerName?.trim().toLowerCase() === XAI_CHAT_PRIORITY_PROVIDER; +} + +function isCanonicalXaiPriorityTransport(provider: ChatServiceTierProvider): boolean { + if (provider.authMode !== "key") return false; + if (provider.adapter?.trim().toLowerCase() !== "openai-chat") return false; + if (typeof provider.baseUrl !== "string") return false; + try { + const url = new URL(provider.baseUrl.trim()); + return url.protocol === "https:" + && url.username === "" + && url.password === "" + && url.hostname.toLowerCase() === "api.x.ai" + && url.port === "" + && (url.pathname === "/v1" || url.pathname === "/v1/") + && url.search === "" + && url.hash === ""; + } catch { + return false; + } +} + /** * Resolve the declared provider/model capability. An explicit provider-level false is a * fail-closed boundary and cannot be reopened by a model map. Otherwise an exact model @@ -50,13 +86,19 @@ export function supportsServiceTierForModel( ?? provider.supportsServiceTier; } -/** Whether the Chat serializer may emit a tier for this exact model. */ +/** Whether the Chat serializer may emit a tier for this exact model and effective transport. */ export function canSerializeServiceTierForChatModel( - provider: Pick, + provider: ChatServiceTierProvider, modelId: string, + providerName?: string, ): boolean { const exact = exactModelValue(provider.modelSupportsServiceTier, modelId); - if (provider.supportsServiceTier === false || exact === false) return false; + if (provider.supportsServiceTier === false || provider.chatServiceTier === false || exact === false) { + return false; + } + if (isXaiPriorityProvider(providerName) && !isCanonicalXaiPriorityTransport(provider)) { + return false; + } return provider.chatServiceTier === true || exact === true; } @@ -135,9 +177,17 @@ export function serviceTierSupportForModel( ? provider.adapter : serviceTierAdapterForModel(providerName, provider, modelId, inbound); if (!SERVICE_TIER_ADAPTERS.has(adapter)) return false; + // xAI Fast is verified only on its canonical API-key Chat transport. Keep every other xAI + // wire fail-closed even if it happens to use another OpenAI-compatible adapter. + if (isXaiPriorityProvider(providerName)) { + if (adapter !== "openai-chat") return false; + return canSerializeServiceTierForChatModel(provider, modelId, providerName) ? true : false; + } // Treat the Chat serializer decision as authoritative so catalog metadata, routing // evidence, fast-mode injection, and caller-tier stripping cannot claim support that the - // final request builder will omit. A provider-wide false and an exact false stay closed. - if (adapter === "openai-chat" && !canSerializeServiceTierForChatModel(provider, modelId)) return false; + // final request builder will omit. + if (adapter === "openai-chat") { + return canSerializeServiceTierForChatModel(provider, modelId, providerName) ? true : false; + } return supportsServiceTierForModel(provider, modelId); } diff --git a/src/server/chat-native.ts b/src/server/chat-native.ts index 5ba9c9d067..7407c17cab 100644 --- a/src/server/chat-native.ts +++ b/src/server/chat-native.ts @@ -26,6 +26,7 @@ import { rateLimitRetryPolicyFor, rotateProviderTransportOn429, } from "../providers/key-failover"; +import { canSerializeServiceTierForChatModel } from "../providers/service-tier"; import type { RouteResult } from "../router"; import type { OcxConfig, OcxProviderConfig } from "../types"; import { fetchWithHeaderTimeout, providerFetch, safeHostLabel } from "./responses/fetch-helpers"; @@ -49,6 +50,23 @@ function isRec(value: unknown): value is Rec { return value !== null && typeof value === "object" && !Array.isArray(value); } +/** + * Bind native Chat serialization to the same provider/model/transport capability decision + * used by catalog projection and Responses-to-Chat translation. The passthrough builder owns + * field serialization, so a shallow provider view is enough to make its chatServiceTier gate + * reflect the resolved capability without mutating the routed provider or its auth transport. + */ +export function providerForNativeChatSerialization( + providerName: string, + provider: OcxProviderConfig, + modelId: string, +): OcxProviderConfig { + const chatServiceTier = canSerializeServiceTierForChatModel(provider, modelId, providerName); + return provider.chatServiceTier === chatServiceTier + ? provider + : { ...provider, chatServiceTier }; +} + export function isNativeChatRouteEligible(route: RouteResult, rawBody: Rec): boolean { const provider = route.provider; if (provider.adapter !== "openai-chat") return false; @@ -138,7 +156,12 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio retainedRequestBytes = bytes; }; try { - activeRequest = buildOpenAIChatPassthroughRequest(activeProvider, options.chatBody, route.modelId, requestedStream); + activeRequest = buildOpenAIChatPassthroughRequest( + providerForNativeChatSerialization(route.providerName, activeProvider, route.modelId), + options.chatBody, + route.modelId, + requestedStream, + ); retainRequest(activeRequest); } catch (error) { releaseRetainedRequest(); @@ -177,7 +200,6 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio request.releaseBodyObservation?.(); } }; - let response: Response; try { response = await send(activeRequest); @@ -205,7 +227,12 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio activeProvider = rotated; activeAdapter = createOpenAIChatAdapter(activeProvider); releaseRetainedRequest(); - activeRequest = buildOpenAIChatPassthroughRequest(activeProvider, options.chatBody, route.modelId, requestedStream); + activeRequest = buildOpenAIChatPassthroughRequest( + providerForNativeChatSerialization(route.providerName, activeProvider, route.modelId), + options.chatBody, + route.modelId, + requestedStream, + ); retainRequest(activeRequest); response = await send(activeRequest, "key-429"); } @@ -368,4 +395,4 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio status: 200, headers: { "Content-Type": "application/json" }, }); -} +} \ No newline at end of file diff --git a/tests/service-tier-capability.test.ts b/tests/service-tier-capability.test.ts index ceaa46c2ad..be564353c8 100644 --- a/tests/service-tier-capability.test.ts +++ b/tests/service-tier-capability.test.ts @@ -11,7 +11,7 @@ import { providerConfigSeed, enrichProviderFromRegistry } from "../src/providers import { getProviderRegistryEntry } from "../src/providers/registry"; import type { RequestLogContext } from "../src/server/request-log"; import { applyServiceTierGate, handleResponses } from "../src/server/responses/core"; -import { canForwardServiceTierForModel, supportsServiceTierForModel } from "../src/providers/service-tier"; +import { canForwardServiceTierForModel, serviceTierSupportForModel, supportsServiceTierForModel } from "../src/providers/service-tier"; import { serviceTierAdapterForModel } from "../src/providers/service-tier"; import { candidateCapabilityEvidence } from "../src/routing/capability"; import { resolveProductionBehaviorValues } from "../src/routing/compatibility/behavior"; @@ -310,4 +310,44 @@ describe("the gate fires on the live handleResponses path", () => { const undeclared = await drive("custom-chat", custom(), "undeclared-model", { service_tier: "priority" }); expect(undeclared).not.toHaveProperty("service_tier"); }); + + test("xai API-key transport injects priority with fastMode and strips it when disabled", async () => { + const xaiKey = (): OcxProviderConfig => ({ ...providerConfigSeed(getProviderRegistryEntry("xai")!), apiKey: "sk-test", authMode: "key" }); + const on = await drive("xai", xaiKey(), "grok-4.6", {}, true); + expect(on.service_tier).toBe("priority"); + const off = await drive("xai", xaiKey(), "grok-4.6", { service_tier: "priority" }, false); + expect(off).not.toHaveProperty("service_tier"); + }); + + test("xai OAuth transport never receives service_tier", async () => { + const xaiOauth = (): OcxProviderConfig => ({ ...providerConfigSeed(getProviderRegistryEntry("xai")!), apiKey: "sk-test", authMode: "oauth" }); + const injected = await drive("xai", xaiOauth(), "grok-4.6", {}, true); + expect(injected).not.toHaveProperty("service_tier"); + const caller = await drive("xai", xaiOauth(), "grok-4.6", { service_tier: "priority" }); + expect(caller).not.toHaveProperty("service_tier"); + }); +}); + +describe("xai Priority (Fast) is transport-sensitive (issue #1875)", () => { + const xaiKey = (): OcxProviderConfig => + ({ ...providerConfigSeed(getProviderRegistryEntry("xai")!), apiKey: "sk-test", authMode: "key" }); + const xaiOauth = (): OcxProviderConfig => + ({ ...providerConfigSeed(getProviderRegistryEntry("xai")!), apiKey: "sk-test", authMode: "oauth" }); + + test("API-key transport arms the Chat opt-in and forwards priority", () => { + const provider = xaiKey(); + expect(serviceTierSupportForModel(provider, "grok-4.6", "xai")).toBe(true); + expect(canForwardServiceTierForModel(provider, "grok-4.6", "xai")).toBe(true); + }); + + test("OAuth/CLI transport stays unarmed despite the static registry flag", () => { + const provider = xaiOauth(); + expect(serviceTierSupportForModel(provider, "grok-4.6", "xai")).toBe(false); + expect(canForwardServiceTierForModel(provider, "grok-4.6", "xai")).toBe(false); + }); + + test("an exact model denial still wins over the API-key transport", () => { + const provider = { ...xaiKey(), modelSupportsServiceTier: { "grok-4.6": false } }; + expect(serviceTierSupportForModel(provider, "grok-4.6", "xai")).toBe(false); + }); }); diff --git a/tests/xai-service-tier-transport.test.ts b/tests/xai-service-tier-transport.test.ts new file mode 100644 index 0000000000..a392bf9c86 --- /dev/null +++ b/tests/xai-service-tier-transport.test.ts @@ -0,0 +1,166 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { buildOpenAIChatPassthroughRequest } from "../src/adapters/openai-chat"; +import { canSerializeServiceTierForChatModel, serviceTierSupportForModel } from "../src/providers/service-tier"; +import { providerForNativeChatSerialization } from "../src/server/chat-native"; +import { handleResponses } from "../src/server/responses/core"; +import type { OcxConfig, OcxProviderConfig } from "../src/types"; + +const MODEL_ID = "grok-4"; + +function xaiProvider(overrides: Partial = {}): OcxProviderConfig { + return { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + apiKey: "xai-test", + authMode: "key", + chatServiceTier: true, + ...overrides, + }; +} + +function directChatBody(provider: OcxProviderConfig): Record { + const serializationProvider = providerForNativeChatSerialization("xai", provider, MODEL_ID); + const request = buildOpenAIChatPassthroughRequest( + serializationProvider, + { + messages: [{ role: "user", content: "hello" }], + service_tier: "priority", + }, + MODEL_ID, + false, + ); + return JSON.parse(request.body) as Record; +} + +describe("xAI Priority Processing transport boundary", () => { + test("canonical API-key transport is the only built-in xAI Chat transport that advertises Fast", () => { + const canonical = xaiProvider(); + expect(canSerializeServiceTierForChatModel(canonical, MODEL_ID, "xai")).toBe(true); + expect(serviceTierSupportForModel(canonical, MODEL_ID, "xai")).toBe(true); + + const trailingSlash = xaiProvider({ baseUrl: "https://api.x.ai/v1/" }); + expect(canSerializeServiceTierForChatModel(trailingSlash, MODEL_ID, "xai")).toBe(true); + + for (const provider of [ + xaiProvider({ authMode: "oauth", baseUrl: "https://cli-chat-proxy.grok.com/v1" }), + xaiProvider({ baseUrl: "https://relay.example.test/v1" }), + xaiProvider({ baseUrl: "https://api.x.ai.evil.example/v1" }), + xaiProvider({ baseUrl: "https://api.x.ai/v1?proxy=1" }), + xaiProvider({ adapter: "openai-responses" }), + ]) { + expect(canSerializeServiceTierForChatModel(provider, MODEL_ID, "xai")).toBe(false); + expect(serviceTierSupportForModel(provider, MODEL_ID, "xai")).toBe(false); + } + }); + + test("xAI-like custom provider ids retain the generic explicit Chat capability contract", () => { + const custom = xaiProvider({ baseUrl: "https://relay.example.test/v1" }); + expect(canSerializeServiceTierForChatModel(custom, MODEL_ID, "x-ai")).toBe(true); + expect(serviceTierSupportForModel(custom, MODEL_ID, "x-ai")).toBe(true); + }); + + test("explicit provider and model opt-outs remain fail-closed on canonical xAI", () => { + const chatOptOut = xaiProvider({ + chatServiceTier: false, + modelSupportsServiceTier: { [MODEL_ID]: true }, + }); + expect(canSerializeServiceTierForChatModel(chatOptOut, MODEL_ID, "xai")).toBe(false); + expect(serviceTierSupportForModel(chatOptOut, MODEL_ID, "xai")).toBe(false); + + const providerOptOut = xaiProvider({ supportsServiceTier: false }); + expect(canSerializeServiceTierForChatModel(providerOptOut, MODEL_ID, "xai")).toBe(false); + expect(serviceTierSupportForModel(providerOptOut, MODEL_ID, "xai")).toBe(false); + + const modelOptOut = xaiProvider({ modelSupportsServiceTier: { [MODEL_ID]: false } }); + expect(canSerializeServiceTierForChatModel(modelOptOut, MODEL_ID, "xai")).toBe(false); + expect(serviceTierSupportForModel(modelOptOut, MODEL_ID, "xai")).toBe(false); + }); + + test("native Chat serialization consumes the same transport-aware decision", () => { + expect(directChatBody(xaiProvider()).service_tier).toBe("priority"); + + for (const provider of [ + xaiProvider({ authMode: "oauth", baseUrl: "https://cli-chat-proxy.grok.com/v1" }), + xaiProvider({ baseUrl: "https://relay.example.test/v1" }), + xaiProvider({ chatServiceTier: false }), + xaiProvider({ supportsServiceTier: false }), + xaiProvider({ modelSupportsServiceTier: { [MODEL_ID]: false } }), + ]) { + expect(directChatBody(provider)).not.toHaveProperty("service_tier"); + } + }); +}); + +describe("xAI transport gate on the live Responses path", () => { + const originalFetch = globalThis.fetch; + afterEach(() => { globalThis.fetch = originalFetch; }); + + type CapturedRequest = { + url: string; + body: Record; + }; + + async function responsesRequest( + provider: OcxProviderConfig, + rawBody: Record = {}, + fastMode?: boolean, + ): Promise { + const requests: CapturedRequest[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input instanceof Request + ? input.url + : input instanceof URL + ? input.toString() + : String(input); + requests.push({ + url, + body: JSON.parse(String(init?.body ?? "{}")) as Record, + }); + return new Response("data: [DONE]\n\n", { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }) as typeof fetch; + + const config = { + providers: { xai: provider }, + ...(fastMode === undefined ? {} : { fastMode }), + } as unknown as OcxConfig; + await handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: `xai/${MODEL_ID}`, + input: "ping", + stream: true, + ...rawBody, + }), + }), + config, + { model: "", provider: "" }, + {}, + ); + return requests[0] ?? { url: "", body: {} }; + } + + test("a same-named xAI baseUrl override is pinned canonical before Fast gating", async () => { + const configured = xaiProvider({ baseUrl: "https://relay.example.test/v1" }); + + const fast = await responsesRequest(configured, {}, true); + expect(fast.url).toBe("https://api.x.ai/v1/chat/completions"); + expect(fast.body.service_tier).toBe("priority"); + + const caller = await responsesRequest(configured, { service_tier: "priority" }); + expect(caller.url).toBe("https://api.x.ai/v1/chat/completions"); + expect(caller.body.service_tier).toBe("priority"); + }); + + test("canonical xAI opt-outs strip Fast before the request is serialized", async () => { + const chatOptOut = xaiProvider({ chatServiceTier: false }); + expect((await responsesRequest(chatOptOut, {}, true)).body).not.toHaveProperty("service_tier"); + + const modelOptOut = xaiProvider({ modelSupportsServiceTier: { [MODEL_ID]: false } }); + expect((await responsesRequest(modelOptOut, { service_tier: "priority" }, true)).body).not.toHaveProperty("service_tier"); + }); +});