Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/ai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
20 changes: 8 additions & 12 deletions packages/ai/scripts/generate-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,18 +410,13 @@ 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",
"grok-4.20-0309-non-reasoning",
"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,
};
Expand Down Expand Up @@ -823,8 +818,10 @@ function applyThinkingLevelMetadata(model: Model<any>): 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" });
Expand Down Expand Up @@ -1632,15 +1629,14 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
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: {
Expand Down Expand Up @@ -1946,10 +1942,10 @@ async function loadModelsDevData(): Promise<Model<any>[]> {

// 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-");
Expand Down
5 changes: 5 additions & 0 deletions packages/ai/src/api/openai-completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -670,6 +671,10 @@ function createClient(
Object.assign(headers, optionsHeaders);
}

if (model.provider === "xai") {
forcePiUserAgent(headers);
}

return new OpenAI({
apiKey,
baseURL: model.baseUrl,
Expand Down
5 changes: 5 additions & 0 deletions packages/ai/src/api/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -246,6 +247,10 @@ function createClient(
Object.assign(headers, optionsHeaders);
}

if (model.provider === "xai") {
forcePiUserAgent(headers);
}

return new OpenAI({
apiKey,
baseURL: model.baseUrl,
Expand Down
8 changes: 2 additions & 6 deletions packages/ai/src/providers/xai.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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(),
});
}
8 changes: 8 additions & 0 deletions packages/ai/src/utils/pi-user-agent.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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();
}
4 changes: 3 additions & 1 deletion packages/ai/test/model-catalog-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/ai/test/stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
6 changes: 6 additions & 0 deletions packages/ai/test/supports-xhigh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
144 changes: 141 additions & 3 deletions packages/ai/test/xai-responses.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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",
Expand All @@ -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);
});
});
2 changes: 1 addition & 1 deletion packages/coding-agent/src/core/model-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const defaultModelPerProvider: Record<KnownProvider, string> = {
"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.1",
Expand Down
4 changes: 4 additions & 0 deletions packages/coding-agent/test/model-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,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");
});
Expand Down
Loading