mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(ai): route xAI models through Responses and default to Grok 4.6 (#8124)
* feat(ai): route xAI models through Responses and default to Grok 4.6 Send built-in xAI catalog models through the Responses API with store: false and include reasoning.encrypted_content so encrypted reasoning is requested and replayed, including future models.dev additions. Thinking levels come from models.dev reasoning_options (grok-4.6 exposes xhigh); models without verified options (grok-build-0.1) keep reasoning always on and never send unsupported "none"/"minimal" efforts. Narrow the xAI provider to the Responses API, send pi's runtime User-Agent on xAI requests for provider-side attribution, and make Grok 4.6 the default xAI model. * fix(ai): drop duplicate xAI encrypted-reasoning tests Azure already covers the shared replay path; keep the xAI-specific include-without-effort case next to the existing request tests. --------- Co-authored-by: Jaaneek <Jaaneek@users.noreply.github.com>
This commit is contained in:
parent
086c32e745
commit
70e878d4cf
12 changed files with 185 additions and 25 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<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" });
|
||||
|
|
@ -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: {
|
||||
|
|
@ -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-");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.3",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue