diff --git a/packages/ai/README.md b/packages/ai/README.md index 16b3be096..e010fdf25 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -794,7 +794,7 @@ Many models support thinking/reasoning capabilities where they can show their in const model = models.getModel('anthropic', 'claude-sonnet-4-5')!; // or models.getModel('openai', 'gpt-5-mini'); // or models.getModel('google', 'gemini-2.5-flash'); -// or models.getModel('xai', 'grok-4.5'); +// or models.getModel('xai', 'grok-4.6'); // Check if model supports reasoning if (model.reasoning) { diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index e36d5551b..1f3a49bcb 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -410,7 +410,6 @@ const OPENAI_RESPONSES_NONE_REASONING_MODELS = new Set([ "gpt-5.6-terra", "gpt-5.6-luna", ]); -const XAI_RESPONSES_MODEL_ID = "grok-4.5"; const XAI_BUILTIN_EXCLUDED_MODEL_IDS = new Set([ "grok-3", "grok-3-fast", @@ -418,10 +417,6 @@ const XAI_BUILTIN_EXCLUDED_MODEL_IDS = new Set([ "grok-4.20-0309-reasoning", "grok-code-fast-1", ]); -const XAI_RESPONSES_EFFORT_LEVEL_MAP = { - off: null, - minimal: null, -} as const; const XAI_RESPONSES_COMPAT: OpenAIResponsesCompat = { supportsLongCacheRetention: false, }; @@ -823,8 +818,10 @@ function applyThinkingLevelMetadata(model: Model): void { ) { mergeThinkingLevelMap(model, { off: "none" }); } - if (model.provider === "xai" && model.api === "openai-responses" && model.id === XAI_RESPONSES_MODEL_ID) { - mergeThinkingLevelMap(model, XAI_RESPONSES_EFFORT_LEVEL_MAP); + // xAI models without verified effort options (e.g. grok-build-0.1) must not + // send the undocumented "none"/"minimal" efforts. + if (model.provider === "xai" && model.api === "openai-responses" && model.thinkingLevelMap === undefined) { + mergeThinkingLevelMap(model, { off: null, minimal: null }); } if (supportsOpenAiXhigh(model.id)) { mergeThinkingLevelMap(model, { xhigh: "xhigh" }); @@ -1632,15 +1629,14 @@ async function loadModelsDevData(): Promise[]> { for (const [modelId, model] of Object.entries(data.xai.models)) { const m = model as ModelsDevModel; if (m.tool_call !== true) continue; - const useResponsesApi = modelId === XAI_RESPONSES_MODEL_ID; models.push({ id: modelId, name: m.name || modelId, - api: useResponsesApi ? "openai-responses" : "openai-completions", + api: "openai-responses", provider: "xai", baseUrl: "https://api.x.ai/v1", - ...(useResponsesApi ? { compat: { ...XAI_RESPONSES_COMPAT } } : {}), + compat: { ...XAI_RESPONSES_COMPAT }, reasoning: m.reasoning === true, input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], cost: { @@ -1946,10 +1942,10 @@ async function loadModelsDevData(): Promise[]> { // Claude 4.x and 5.x models route to Anthropic Messages API const isCopilotClaude = /^claude-(haiku|sonnet|opus)-[45]([.\-]|$)/.test(modelId); - // Grok 4.5, gpt-5, oswe, and MAI-Code models are only served through + // Grok, gpt-5, oswe, and MAI-Code models are only served through // the Copilot /responses endpoint. const needsResponsesApi = - modelId === "grok-4.5" || + modelId.startsWith("grok-") || modelId.startsWith("gpt-5") || modelId.startsWith("oswe") || modelId.startsWith("mai-"); diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index c62ef9f87..268e4f4f8 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -39,6 +39,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { shortHash } from "../utils/hash.ts"; import { headersToRecord } from "../utils/headers.ts"; import { parseStreamingJson } from "../utils/json-parse.ts"; +import { forcePiUserAgent } from "../utils/pi-user-agent.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; import { retryProviderRequest } from "../utils/provider-retry.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; @@ -670,6 +671,10 @@ function createClient( Object.assign(headers, optionsHeaders); } + if (model.provider === "xai") { + forcePiUserAgent(headers); + } + return new OpenAI({ apiKey, baseURL: model.baseUrl, diff --git a/packages/ai/src/api/openai-responses.ts b/packages/ai/src/api/openai-responses.ts index 578398e8e..58d179c7c 100644 --- a/packages/ai/src/api/openai-responses.ts +++ b/packages/ai/src/api/openai-responses.ts @@ -19,6 +19,7 @@ import { splitDeferredTools } from "../utils/deferred-tools.ts"; import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; +import { forcePiUserAgent } from "../utils/pi-user-agent.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; import { retryProviderRequest } from "../utils/provider-retry.ts"; import { createGrammarToolInputProperties } from "./constrained-sampling.ts"; @@ -246,6 +247,10 @@ function createClient( Object.assign(headers, optionsHeaders); } + if (model.provider === "xai") { + forcePiUserAgent(headers); + } + return new OpenAI({ apiKey, baseURL: model.baseUrl, diff --git a/packages/ai/src/providers/xai.ts b/packages/ai/src/providers/xai.ts index c9fe6c349..8e2b179c2 100644 --- a/packages/ai/src/providers/xai.ts +++ b/packages/ai/src/providers/xai.ts @@ -1,11 +1,10 @@ -import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; import { openAIResponsesApi } from "../api/openai-responses.lazy.ts"; import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts"; import { loadXaiOAuth } from "../auth/oauth/load.ts"; import { createProvider, type Provider } from "../models.ts"; import { XAI_MODELS } from "./xai.models.ts"; -export function xaiProvider(): Provider<"openai-completions" | "openai-responses"> { +export function xaiProvider(): Provider<"openai-responses"> { return createProvider({ id: "xai", name: "xAI", @@ -20,9 +19,6 @@ export function xaiProvider(): Provider<"openai-completions" | "openai-responses }), }, models: Object.values(XAI_MODELS), - api: { - "openai-completions": openAICompletionsApi(), - "openai-responses": openAIResponsesApi(), - }, + api: openAIResponsesApi(), }); } diff --git a/packages/ai/src/utils/pi-user-agent.ts b/packages/ai/src/utils/pi-user-agent.ts index 93b23dd35..c36d5ffbc 100644 --- a/packages/ai/src/utils/pi-user-agent.ts +++ b/packages/ai/src/utils/pi-user-agent.ts @@ -1,4 +1,5 @@ import type * as NodeOs from "node:os"; +import type { ProviderHeaders } from "../types.ts"; type ProcessWithOsBuiltinModule = typeof process & { getBuiltinModule?: (id: "node:os") => typeof NodeOs; @@ -17,3 +18,10 @@ const nodeOs = loadNodeOs(); export function getPiUserAgent(): string { return nodeOs ? `pi (${nodeOs.platform()} ${nodeOs.release()}; ${nodeOs.arch()})` : "pi (browser)"; } + +export function forcePiUserAgent(headers: ProviderHeaders): void { + for (const name of Object.keys(headers)) { + if (name.toLowerCase() === "user-agent") delete headers[name]; + } + headers["User-Agent"] = getPiUserAgent(); +} diff --git a/packages/ai/test/model-catalog-types.test.ts b/packages/ai/test/model-catalog-types.test.ts index 0facda3db..0918228fe 100644 --- a/packages/ai/test/model-catalog-types.test.ts +++ b/packages/ai/test/model-catalog-types.test.ts @@ -6,7 +6,9 @@ it("derives model API, ID, and provider literals from grouped model data", () => expectTypeOf(XAI_MODELS["grok-4.5"].api).toEqualTypeOf<"openai-responses">(); expectTypeOf(XAI_MODELS["grok-4.5"].id).toEqualTypeOf<"grok-4.5">(); expectTypeOf(XAI_MODELS["grok-4.5"].provider).toEqualTypeOf<"xai">(); - expectTypeOf(XAI_MODELS["grok-4.3"].api).toEqualTypeOf<"openai-completions">(); + expectTypeOf(XAI_MODELS["grok-4.6"].api).toEqualTypeOf<"openai-responses">(); + expectTypeOf(XAI_MODELS["grok-4.6"].id).toEqualTypeOf<"grok-4.6">(); + expectTypeOf(XAI_MODELS["grok-4.3"].api).toEqualTypeOf<"openai-responses">(); }); it("routes GitHub Copilot Grok 4.5 through the Responses API", () => { diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index d3e7a0211..bf68823d2 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -542,7 +542,7 @@ describe("Generate E2E Tests", () => { }); }); - describe.skipIf(!process.env.XAI_API_KEY)("xAI Provider (grok-4.3 via OpenAI Completions)", () => { + describe.skipIf(!process.env.XAI_API_KEY)("xAI Provider (grok-4.3 via OpenAI Responses)", () => { const llm = getModel("xai", "grok-4.3"); it("should complete basic text generation", { retry: 3 }, async () => { diff --git a/packages/ai/test/supports-xhigh.test.ts b/packages/ai/test/supports-xhigh.test.ts index a1fd7ff8f..8d97b7607 100644 --- a/packages/ai/test/supports-xhigh.test.ts +++ b/packages/ai/test/supports-xhigh.test.ts @@ -147,6 +147,12 @@ describe("getSupportedThinkingLevels", () => { expect(getSupportedThinkingLevels(model!)).toContain("max"); }); + it("includes xhigh but not off or max for xAI Grok 4.6", () => { + const model = getModel("xai", "grok-4.6"); + expect(model).toBeDefined(); + expect(getSupportedThinkingLevels(model!)).toEqual(["low", "medium", "high", "xhigh"]); + }); + it("includes xhigh and max but not off for Bedrock Claude Fable 5", () => { const model = getModel("amazon-bedrock", "global.anthropic.claude-fable-5"); expect(model).toBeDefined(); diff --git a/packages/ai/test/xai-responses.test.ts b/packages/ai/test/xai-responses.test.ts index 92e464203..00e890815 100644 --- a/packages/ai/test/xai-responses.test.ts +++ b/packages/ai/test/xai-responses.test.ts @@ -1,10 +1,15 @@ +import { arch, platform, release } from "node:os"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts"; import type { OpenAIResponsesOptions } from "../src/api/openai-responses.ts"; +import { stream as streamOpenAIResponses } from "../src/api/openai-responses.ts"; import { getSupportedThinkingLevels } from "../src/models.ts"; import { XAI_MODELS } from "../src/providers/xai.models.ts"; import { xaiProvider } from "../src/providers/xai.ts"; import type { Context, Model } from "../src/types.ts"; +const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`; + type CapturedRequest = { url: string; headers: Headers; @@ -72,10 +77,14 @@ describe("xAI Responses provider", () => { } }); - it("uses Responses with low/medium/high efforts only for Grok 4.5", () => { - expect(XAI_MODELS["grok-4.5"].api).toBe("openai-responses"); + it("routes every built-in xAI model through Responses", () => { + for (const model of Object.values(XAI_MODELS)) { + expect(model.api, model.id).toBe("openai-responses"); + } expect(getSupportedThinkingLevels(XAI_MODELS["grok-4.5"])).toEqual(["low", "medium", "high"]); - expect(XAI_MODELS["grok-4.3"].api).toBe("openai-completions"); + expect(getSupportedThinkingLevels(XAI_MODELS["grok-4.6"])).toEqual(["low", "medium", "high", "xhigh"]); + expect(getSupportedThinkingLevels(XAI_MODELS["grok-4.3"])).toEqual(["off", "low", "medium", "high"]); + expect(getSupportedThinkingLevels(XAI_MODELS["grok-build-0.1"])).toEqual(["low", "medium", "high"]); }); it("uses /responses with bearer auth and xAI-compatible request fields", async () => { @@ -95,6 +104,7 @@ describe("xAI Responses provider", () => { expect(captured.url).toBe("https://api.x.ai/v1/responses"); expect(captured.headers.get("authorization")).toBe("Bearer xai-test-token"); + expect(captured.headers.get("user-agent")).toBe(PI_USER_AGENT); expect(captured.headers.get("session_id")).toBe("pi-session-123"); expect(captured.body).toMatchObject({ model: "grok-4.5", @@ -114,4 +124,132 @@ describe("xAI Responses provider", () => { ]), ); }); + + it("requests encrypted reasoning without an effort override", async () => { + const captured = await captureRequest( + XAI_MODELS["grok-4.5"], + { messages: [{ role: "user", content: "hello", timestamp: 1 }] }, + { apiKey: "xai-test-token" }, + ); + + expect(captured.body).toMatchObject({ + model: "grok-4.5", + store: false, + include: ["reasoning.encrypted_content"], + }); + expect(captured.body).not.toHaveProperty("reasoning"); + }); + + it("uses /responses for Grok 4.6 with xhigh effort and encrypted reasoning", async () => { + const captured = await captureRequest( + XAI_MODELS["grok-4.6"], + { + systemPrompt: "You are a careful coding assistant.", + messages: [{ role: "user", content: "hello", timestamp: 1 }], + }, + { + apiKey: "xai-test-token", + reasoningEffort: "xhigh", + }, + ); + + expect(captured.url).toBe("https://api.x.ai/v1/responses"); + expect(captured.body).toMatchObject({ + model: "grok-4.6", + store: false, + stream: true, + reasoning: { effort: "xhigh" }, + include: ["reasoning.encrypted_content"], + }); + }); + + it("uses /responses for Grok 4.3", async () => { + const captured = await captureRequest( + XAI_MODELS["grok-4.3"], + { + messages: [{ role: "user", content: "hello", timestamp: 1 }], + }, + { + apiKey: "xai-test-token", + reasoningEffort: "low", + }, + ); + + expect(captured.url).toBe("https://api.x.ai/v1/responses"); + expect(captured.body).toMatchObject({ + model: "grok-4.3", + store: false, + include: ["reasoning.encrypted_content"], + reasoning: { effort: "low" }, + }); + }); + + it("keeps the SDK User-Agent for non-xAI Responses requests", async () => { + let userAgent: string | null = null; + vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + userAgent = new Request(input, init).headers.get("user-agent"); + return completedResponse(); + }); + + const openaiModel: Model<"openai-responses"> = { + ...XAI_MODELS["grok-4.5"], + provider: "openai", + baseUrl: "https://api.openai.com/v1", + }; + const result = await streamOpenAIResponses( + openaiModel, + { messages: [{ role: "user", content: "hello", timestamp: 1 }] }, + { apiKey: "test-token" }, + ).result(); + + expect(result.stopReason, result.errorMessage).toBe("stop"); + expect(userAgent).not.toBeNull(); + expect(userAgent).not.toBe(PI_USER_AGENT); + }); + + it("forces pi's User-Agent on custom xAI Completions models over caller headers", async () => { + let userAgent: string | null = null; + vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + userAgent = new Request(input, init).headers.get("user-agent"); + const chunks = [ + { id: "chatcmpl-ua", choices: [{ delta: { content: "ok" }, finish_reason: null, index: 0 }] }, + { + id: "chatcmpl-ua", + choices: [{ delta: {}, finish_reason: "stop", index: 0 }], + usage: { + prompt_tokens: 1, + completion_tokens: 1, + prompt_tokens_details: { cached_tokens: 0 }, + completion_tokens_details: { reasoning_tokens: 0 }, + }, + }, + ]; + const body = `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}`).join("\n\n")}\n\ndata: [DONE]\n\n`; + return new Response(body, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }); + + const customModel: Model<"openai-completions"> = { + id: "grok-custom", + name: "Grok Custom", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 16384, + }; + const result = await streamOpenAICompletions( + customModel, + { messages: [{ role: "user", content: "hello", timestamp: 1 }] }, + { apiKey: "xai-test-token", headers: { "User-Agent": "custom-agent" } }, + ).result(); + + expect(result.stopReason, result.errorMessage).toBe("stop"); + expect(userAgent).toBe(PI_USER_AGENT); + }); }); diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index cfc36ec16..aea6717e1 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -32,7 +32,7 @@ export const defaultModelPerProvider: Record = { "github-copilot": "gpt-5.4", openrouter: "moonshotai/kimi-k2.6", "vercel-ai-gateway": "zai/glm-5.1", - xai: "grok-4.5", + xai: "grok-4.6", groq: "openai/gpt-oss-120b", cerebras: "zai-glm-4.7", zai: "glm-5.3", diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index ddf83c569..c38b59f04 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -723,6 +723,10 @@ describe("default model selection", () => { expect(defaultModelPerProvider["vercel-ai-gateway"]).toBe("zai/glm-5.1"); }); + test("xai default tracks current model", () => { + expect(defaultModelPerProvider.xai).toBe("grok-4.6"); + }); + test("qwen token plan individual default tracks current model", () => { expect(defaultModelPerProvider["qwen-token-plan-individual"]).toBe("qwen3.8-max"); });