diff --git a/docs/provider-endpoint-constraints.md b/docs/provider-endpoint-constraints.md index 0193702a2..44dbabe05 100644 --- a/docs/provider-endpoint-constraints.md +++ b/docs/provider-endpoint-constraints.md @@ -235,13 +235,16 @@ Reasoning fields are not interchangeable. - Compat needs both policies: disable reasoning for any tool choice, and disable reasoning only for forced tool choice. -### xAI Grok through Responses/SuperGrok +### xAI Grok through Responses (`xai` and `xai-oauth`) -Keep these independent: +Both the paid API-key provider (`xai` / `XAI_API_KEY`) and SuperGrok OAuth +(`xai-oauth`) chat over `https://api.x.ai/v1/responses`. Keep these independent: -- omit `reasoning.effort` -- include or drop encrypted reasoning replay -- filter reasoning-history wrappers +- omit `reasoning.effort` unless the model is on the Grok effort-capable allowlist +- omit `reasoning.summary` (the host rejects it; do not fall back to `"auto"`) +- omit presence/frequency penalties (`/v1/responses` rejects them for every Grok model) +- include `reasoning.encrypted_content` on the request +- replay encrypted reasoning items on later turns Some models reject only one of those fields; do not collapse them into one "Grok mode" branch. diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 08c36e86c..feedcbda3 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +### Fixed + +- Stopped treating `XAI_API_KEY` as SuperGrok (`xai-oauth`) sign-in for availability, so paid-key-only setups default to `xai/grok-4.5` instead of the zero-cost SuperGrok catalog path. Explicit `xai-oauth/…` selectors still accept the paid key via the existing env fallback. +- Omitted unsupported `reasoning.summary` on paid xAI Responses requests (`xai/grok-4.5`), matching SuperGrok, so a thinking level no longer serializes `summary: "auto"`. +- Omitted presence/frequency penalties on all first-party xAI Responses models, including non-reasoning ids such as `xai/grok-2`. + ## [17.3.4] - 2026-08-14 ### Fixed diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index 625f6b6d4..4748f28b1 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -2680,18 +2680,32 @@ export class AuthStorage { } /** - * Check if any form of auth is configured for a provider. - * Unlike getApiKey(), this doesn't refresh OAuth tokens. + * Dedicated auth for default-model availability (picker / `getAvailable`). + * Unlike {@link getApiKey}, this does not refresh OAuth tokens, and unlike + * {@link hasResolvableAuth} it ignores cross-provider env aliases so + * `XAI_API_KEY` does not auto-select SuperGrok (`xai-oauth`). */ hasAuth(provider: string): boolean { if (this.#runtimeOverrides.has(provider)) return true; if (this.#configOverrides.has(provider)) return true; if (this.#getCredentialsForProvider(provider).length > 0) return true; - if (getEnvApiKey(provider)) return true; + if (this.#hasDedicatedEnvAuth(provider)) return true; if (this.#fallbackResolver?.(provider)) return true; return false; } + /** + * Whether a request could resolve a key for this provider, including + * cross-provider env aliases (`xai-oauth` borrowing `XAI_API_KEY`). + * Use this for explicit model preflight (`xai-oauth/grok-4.5`); use + * {@link hasAuth} for auto-availability so the default picker stays on + * paid `xai` when only `XAI_API_KEY` is set. + */ + hasResolvableAuth(provider: string): boolean { + if (this.hasAuth(provider)) return true; + return Boolean(getEnvApiKey(provider)); + } + /** * True iff a dedicated, non-env credential source is configured for this * provider — i.e. anything in the cascade EXCEPT `getEnvApiKey(provider)`. @@ -2711,6 +2725,22 @@ export class AuthStorage { return false; } + /** + * Env auth that belongs to this provider, not a cross-provider alias. + * + * `getEnvApiKey("xai-oauth")` also accepts `XAI_API_KEY` so an explicit + * `xai-oauth/…` stream can still borrow the paid key. Availability and + * origin must not: otherwise an API-key-only setup marks SuperGrok as + * signed in and `pickDefaultAvailableModel` prefers `xai-oauth/grok-4.5` + * over paid `xai/grok-4.5`. + */ + #hasDedicatedEnvAuth(provider: string): boolean { + if (provider === "xai-oauth") { + return Boolean($env.XAI_OAUTH_TOKEN?.trim()); + } + return Boolean(getEnvApiKey(provider)); + } + /** * Classify where a provider's auth comes from, following the same precedence * as {@link AuthStorage.getApiKey}: runtime override → config override → @@ -2727,7 +2757,7 @@ export class AuthStorage { if (stored.some(credential => credential.type === "api_key" && credential.source === "login")) { return { kind: "api_key" }; } - if (getEnvApiKey(provider)) return { kind: "env", envVar: getEnvApiKeyName(provider) }; + if (this.#hasDedicatedEnvAuth(provider)) return { kind: "env", envVar: getEnvApiKeyName(provider) }; if (stored.some(credential => credential.type === "api_key")) return { kind: "api_key" }; if (this.#fallbackResolver?.(provider)) return { kind: "fallback" }; return undefined; diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index d60c1004c..2b262b130 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -1575,17 +1575,19 @@ function buildParams( if (options?.minP !== undefined) { params.min_p = options.minP; } - if (options?.presencePenalty !== undefined) { - params.presence_penalty = options.presencePenalty; - } - if (options?.repetitionPenalty !== undefined) { - params.repetition_penalty = options.repetitionPenalty; - } - if (options?.frequencyPenalty !== undefined) { - params.frequency_penalty = options.frequencyPenalty; + if (initialCompat.supportsPenaltyAndStopParams) { + if (options?.presencePenalty !== undefined) { + params.presence_penalty = options.presencePenalty; + } + if (options?.repetitionPenalty !== undefined) { + params.repetition_penalty = options.repetitionPenalty; + } + if (options?.frequencyPenalty !== undefined) { + params.frequency_penalty = options.frequencyPenalty; + } } } - if (options?.stopSequences?.length) { + if (options?.stopSequences?.length && initialCompat.supportsPenaltyAndStopParams) { const seqs = options.stopSequences; params.stop = seqs.length === 1 ? seqs[0] : seqs.slice(0, 4); } diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 21b587e7e..61055b2ab 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -1285,12 +1285,11 @@ export function buildParams( filterReasoningHistory: options?.filterReasoningHistory, omitReasoningEffort: options?.omitReasoningEffort, }); - const reasoningSummary = - model.provider === "xai-oauth" - ? options?.reasoning === undefined - ? undefined - : null - : options?.reasoningSummary; + const reasoningSummary = model.compat.supportsReasoningSummary + ? options?.reasoningSummary + : options?.reasoning === undefined + ? undefined + : null; applyResponsesCompatPolicy(params, reasoningPolicy, { reasoningSummary, forceReasoningOff: options?.forceReasoningOff, diff --git a/packages/ai/src/providers/openai-shared.ts b/packages/ai/src/providers/openai-shared.ts index 9d4f87861..f7b16f00e 100644 --- a/packages/ai/src/providers/openai-shared.ts +++ b/packages/ai/src/providers/openai-shared.ts @@ -3308,7 +3308,7 @@ export function applyCommonResponsesSamplingParams

& { - compat: Pick; + compat: Pick; }, ): void { if (options?.maxTokens && !model.omitMaxOutputTokens) { @@ -3325,8 +3325,10 @@ export function applyCommonResponsesSamplingParams

("xai", "grok-code-fast-1"); -if (!model) throw new Error("Expected bundled xAI Grok model"); -if (model.api !== "openai-completions") throw new Error(`Expected Chat Completions model, received ${model.api}`); const context: Context = { messages: [{ role: "user", content: "hello", timestamp: 0 }] }; const openAI56ResponsesModel = getBundledModel<"openai-responses">("openai", "gpt-5.6"); @@ -44,7 +41,7 @@ function chatCompletionsSse(): Response { id: "chatcmpl-affinity", object: "chat.completion.chunk", created: 0, - model: model.id, + model: openAI56CompletionsModel.id, choices: [{ index: 0, delta, finish_reason: finishReason }], }); @@ -56,7 +53,7 @@ function chatCompletionsSse(): Response { async function captureRequest( options: OpenAICompletionsOptions, - requestModel: Model<"openai-completions"> = model, + requestModel: Model<"openai-completions"> = openAI56CompletionsModel, requestContext: Context = context, ): Promise<{ headers: Headers; body: Record }> { let requestHeaders: Headers | undefined; @@ -83,7 +80,7 @@ async function captureRequest( async function captureSimpleRequest( options: SimpleStreamOptions, - requestModel: Model<"openai-completions"> = model, + requestModel: Model<"openai-completions"> = openAI56CompletionsModel, requestContext: Context = context, ): Promise<{ headers: Headers; body: Record }> { let requestHeaders: Headers | undefined; @@ -104,51 +101,6 @@ async function captureSimpleRequest( return { headers: requestHeaders, body }; } -describe("openai-completions xAI cache affinity", () => { - const cases: Array<{ - name: string; - options: OpenAICompletionsOptions; - expectedHeader: string | null; - }> = [ - { - name: "uses sessionId when no prompt cache key is provided", - options: { sessionId: "session-fallback" }, - expectedHeader: "session-fallback", - }, - { - name: "keeps the prompt cache key stable across a distinct side-channel session", - options: { promptCacheKey: "stable-cache-key", sessionId: "side-channel-session" }, - expectedHeader: "stable-cache-key", - }, - { - name: "omits automatic affinity when caching is disabled", - options: { - promptCacheKey: "disabled-cache-key", - sessionId: "disabled-session", - cacheRetention: "none", - }, - expectedHeader: null, - }, - { - name: "preserves a caller-provided mixed-case affinity header", - options: { - promptCacheKey: "automatic-cache-key", - sessionId: "automatic-session", - headers: { "X-Grok-Conv-Id": "caller-affinity" }, - }, - expectedHeader: "caller-affinity", - }, - ]; - - for (const { name, options, expectedHeader } of cases) { - it(name, async () => { - const { headers } = await captureRequest(options); - - expect(headers.get("x-grok-conv-id")).toBe(expectedHeader); - }); - } -}); - describe("OpenAI Chat Completions explicit prompt cache policy", () => { const historicalContext: Context = { messages: [ diff --git a/packages/ai/test/openai-completions-compat.test.ts b/packages/ai/test/openai-completions-compat.test.ts index d8bebd5d1..fec2436e4 100644 --- a/packages/ai/test/openai-completions-compat.test.ts +++ b/packages/ai/test/openai-completions-compat.test.ts @@ -210,6 +210,7 @@ describe("openai-completions compatibility", () => { toolStrictMode: "none", supportsReasoningParams: true, supportsSamplingParams: true, + supportsPenaltyAndStopParams: true, alwaysSendMaxTokens: false, isOpenRouterHost: false, isVercelGatewayHost: false, diff --git a/packages/ai/test/openai-completions-tool-result-images.test.ts b/packages/ai/test/openai-completions-tool-result-images.test.ts index 1219f8e30..8abf761b5 100644 --- a/packages/ai/test/openai-completions-tool-result-images.test.ts +++ b/packages/ai/test/openai-completions-tool-result-images.test.ts @@ -51,6 +51,7 @@ const compat: ResolvedOpenAICompat = { toolStrictMode: "none", supportsReasoningParams: true, supportsSamplingParams: true, + supportsPenaltyAndStopParams: true, alwaysSendMaxTokens: false, isOpenRouterHost: false, isVercelGatewayHost: false, diff --git a/packages/ai/test/openai-responses-cache-affinity.test.ts b/packages/ai/test/openai-responses-cache-affinity.test.ts index fd10d44cf..4eb32abca 100644 --- a/packages/ai/test/openai-responses-cache-affinity.test.ts +++ b/packages/ai/test/openai-responses-cache-affinity.test.ts @@ -57,6 +57,20 @@ const xaiOAuthResponsesModel: Model<"openai-responses"> = { reasoning: true, }), }; +const xaiApiKeyResponsesModel: Model<"openai-responses"> = { + ...model, + id: "grok-code-fast-1", + name: "Grok Code Fast 1", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + compat: buildOpenAIResponsesCompat({ + id: "grok-code-fast-1", + name: "Grok Code Fast 1", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + }), +}; const openAI56ResponsesModel: Model<"openai-responses"> = { ...model, @@ -675,6 +689,16 @@ describe("openai-responses cache affinity", () => { } }); + it("sets x-grok-conv-id cache affinity for paid xai Responses requests", async () => { + const captured = await captureDispatchedOpenAIResponseHeaders( + { sessionId: "session-fallback" }, + xaiApiKeyResponsesModel, + ); + + expect(getHeader(captured.headers, "x-grok-conv-id")).toBe("session-fallback"); + expect(captured.body?.prompt_cache_key).toBe("session-fallback"); + }); + it("sets OpenRouter Responses session_id from sessionId in the body", async () => { const captured = await captureOpenAIResponseHeaders( { sessionId: "workflow-123", promptCacheKey: "cache-key-123" }, diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index 0add12576..8017e13bd 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -1000,7 +1000,7 @@ describe("Generate E2E Tests", () => { ); }); - describe.skipIf(!e2eApiKey("XAI_API_KEY"))("xAI Provider (grok-code-fast-1 via OpenAI Completions)", () => { + describe.skipIf(!e2eApiKey("XAI_API_KEY"))("xAI Provider (grok-code-fast-1 via OpenAI Responses)", () => { const llm = getBundledModel("xai", "grok-code-fast-1"); it( diff --git a/packages/ai/test/xai-login.test.ts b/packages/ai/test/xai-login.test.ts index 1cdd2ca1d..3571b531a 100644 --- a/packages/ai/test/xai-login.test.ts +++ b/packages/ai/test/xai-login.test.ts @@ -29,6 +29,52 @@ describe("xAI API login wiring", () => { expect(getEnvApiKey("xai")).toBe("xai-env-key"); }); + test("XAI_API_KEY alone does not mark SuperGrok as available", async () => { + const originalOauthToken = Bun.env.XAI_OAUTH_TOKEN; + Bun.env.XAI_API_KEY = "xai-env-key"; + delete Bun.env.XAI_OAUTH_TOKEN; + const store = new SqliteAuthCredentialStore(new Database(":memory:")); + const storage = new AuthStorage(store); + await storage.reload(); + try { + expect(storage.hasAuth("xai")).toBe(true); + expect(storage.hasAuth("xai-oauth")).toBe(false); + expect(storage.hasResolvableAuth("xai")).toBe(true); + expect(storage.hasResolvableAuth("xai-oauth")).toBe(true); + expect(getEnvApiKey("xai-oauth")).toBe("xai-env-key"); + expect(storage.getCredentialOrigin("xai")).toEqual({ kind: "env", envVar: "XAI_API_KEY" }); + expect(storage.getCredentialOrigin("xai-oauth")).toBeUndefined(); + } finally { + if (originalOauthToken === undefined) { + delete Bun.env.XAI_OAUTH_TOKEN; + } else { + Bun.env.XAI_OAUTH_TOKEN = originalOauthToken; + } + store.close(); + } + }); + + test("XAI_OAUTH_TOKEN marks SuperGrok available without a paid API key", async () => { + const originalOauthToken = Bun.env.XAI_OAUTH_TOKEN; + delete Bun.env.XAI_API_KEY; + Bun.env.XAI_OAUTH_TOKEN = "xai-oauth-env"; + const store = new SqliteAuthCredentialStore(new Database(":memory:")); + const storage = new AuthStorage(store); + await storage.reload(); + try { + expect(storage.hasAuth("xai")).toBe(false); + expect(storage.hasAuth("xai-oauth")).toBe(true); + expect(storage.getCredentialOrigin("xai-oauth")).toEqual({ kind: "env" }); + } finally { + if (originalOauthToken === undefined) { + delete Bun.env.XAI_OAUTH_TOKEN; + } else { + Bun.env.XAI_OAUTH_TOKEN = originalOauthToken; + } + store.close(); + } + }); + test("AuthStorage.login('xai') validates against /models and stores the pasted key", async () => { const fetchCalls: Array<{ url: string; init: RequestInit | undefined }> = []; const fetchMock: FetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { diff --git a/packages/ai/test/xai-oauth-effort-strip.test.ts b/packages/ai/test/xai-oauth-effort-strip.test.ts index c84012057..6b7f99d89 100644 --- a/packages/ai/test/xai-oauth-effort-strip.test.ts +++ b/packages/ai/test/xai-oauth-effort-strip.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { buildParams } from "@oh-my-pi/pi-ai/providers/openai-responses"; -import type { Context } from "@oh-my-pi/pi-ai/types"; +import type { AssistantMessage, Context, Model } from "@oh-my-pi/pi-ai/types"; import { Effort } from "@oh-my-pi/pi-catalog/effort"; import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; @@ -38,6 +38,23 @@ describe("effort-dial-less reasoner encoding (regression)", () => { expect(grokR.thinking).toBeUndefined(); }); + test("paid xai/grok-code-fast-1 reasons but carries no thinking config", () => { + const grokCodeFast = getBundledModel("xai", "grok-code-fast-1"); + if (!grokCodeFast) throw new Error("xai/grok-code-fast-1 must be in bundled models.json"); + expect(grokCodeFast.api).toBe("openai-responses"); + expect(grokCodeFast.reasoning).toBe(true); + expect(grokCodeFast.thinking).toBeUndefined(); + expect(getSupportedEfforts(grokCodeFast)).toEqual([]); + }); + + test("paid xai/grok-4.3 keeps its effort dial", () => { + const grok43 = getBundledModel("xai", "grok-4.3"); + if (!grok43) throw new Error("xai/grok-4.3 must be in bundled models.json"); + expect(grok43.api).toBe("openai-responses"); + expect(grok43.thinking).toBeDefined(); + expect(getSupportedEfforts(grok43).length).toBeGreaterThan(0); + }); + test("the no-dial encoding stays scoped to openai-responses*", () => { const claude = getBundledModel("anthropic", "claude-sonnet-4-6"); if (!claude) throw new Error("anthropic/claude-sonnet-4-6 must be in bundled models.json"); @@ -57,6 +74,7 @@ describe("xAI OAuth Responses reasoning payload (regression)", () => { const { params } = buildParams(grok45, singleUserContext, undefined, undefined); expect(params.reasoning).toBeUndefined(); + expect(params.include).toContain("reasoning.encrypted_content"); }); test("xai-oauth/grok-4.5 omits unsupported reasoning summary", () => { @@ -66,5 +84,142 @@ describe("xAI OAuth Responses reasoning payload (regression)", () => { const { params } = buildParams(grok45, singleUserContext, { reasoning: Effort.High }, undefined); expect(params.reasoning).toEqual({ effort: "high" }); + expect(params.include).toContain("reasoning.encrypted_content"); + }); + + test("paid xai/grok-4.5 omits unsupported reasoning summary", () => { + const grok45 = getBundledModel<"openai-responses">("xai", "grok-4.5"); + if (!grok45) throw new Error("xai/grok-4.5 must be in bundled models.json"); + + const { params } = buildParams(grok45, singleUserContext, { reasoning: Effort.High }, undefined); + + expect(params.reasoning).toEqual({ effort: "high" }); + expect(params.include).toContain("reasoning.encrypted_content"); + }); + + test("paid xai/grok-4.5 requests encrypted reasoning content", () => { + const grok45 = getBundledModel<"openai-responses">("xai", "grok-4.5"); + if (!grok45) throw new Error("xai/grok-4.5 must be in bundled models.json"); + + const { params } = buildParams(grok45, singleUserContext, { reasoning: Effort.High }, undefined); + + expect(params.include).toContain("reasoning.encrypted_content"); + }); + + test("paid xai/grok-4.5 omits presence_penalty on reasoning models", () => { + const grok45 = getBundledModel<"openai-responses">("xai", "grok-4.5"); + if (!grok45) throw new Error("xai/grok-4.5 must be in bundled models.json"); + + const { params } = buildParams( + grok45, + singleUserContext, + { reasoning: Effort.High, presencePenalty: 0.4, temperature: 0.2 }, + undefined, + ); + + expect(params).not.toHaveProperty("presence_penalty"); + expect(params.temperature).toBe(0.2); + }); + + test("paid xai/grok-2 omits presence_penalty on non-reasoning Responses models", () => { + const grok2 = getBundledModel<"openai-responses">("xai", "grok-2"); + if (!grok2) throw new Error("xai/grok-2 must be in bundled models.json"); + + const { params } = buildParams(grok2, singleUserContext, { presencePenalty: 0.4, temperature: 0.2 }, undefined); + + expect(params).not.toHaveProperty("presence_penalty"); + expect(params.temperature).toBe(0.2); + }); + + test("paid xai/grok-4.5 clamps minimal reasoning effort to low", () => { + const grok45 = getBundledModel<"openai-responses">("xai", "grok-4.5"); + if (!grok45) throw new Error("xai/grok-4.5 must be in bundled models.json"); + + const { params } = buildParams(grok45, singleUserContext, { reasoning: Effort.Minimal }, undefined); + + expect(params.reasoning).toEqual({ effort: "low" }); + }); + + test("xai-oauth/grok-4.5 clamps minimal reasoning effort to low", () => { + const grok45 = getBundledModel<"openai-responses">("xai-oauth", "grok-4.5"); + if (!grok45) throw new Error("xai-oauth/grok-4.5 must be in bundled models.json"); + + const { params } = buildParams(grok45, singleUserContext, { reasoning: Effort.Minimal }, undefined); + + expect(params.reasoning).toEqual({ effort: "low" }); + }); + + test("xai-oauth/grok-4.5 replays encrypted reasoning on the next turn", () => { + const grok45 = getBundledModel<"openai-responses">("xai-oauth", "grok-4.5"); + if (!grok45) throw new Error("xai-oauth/grok-4.5 must be in bundled models.json"); + + const { params } = buildParams(grok45, followUpContextWithEncryptedReasoning(grok45), undefined, undefined); + + expect(params.include).toContain("reasoning.encrypted_content"); + expect(findEncryptedReasoning(params.input)).toEqual({ + type: "reasoning", + id: "rs_xai_next_turn", + encrypted_content: "enc_next_turn", + }); + }); + + test("paid xai/grok-4.5 replays encrypted reasoning on the next turn", () => { + const grok45 = getBundledModel<"openai-responses">("xai", "grok-4.5"); + if (!grok45) throw new Error("xai/grok-4.5 must be in bundled models.json"); + + const { params } = buildParams(grok45, followUpContextWithEncryptedReasoning(grok45), undefined, undefined); + + expect(findEncryptedReasoning(params.input)).toEqual({ + type: "reasoning", + id: "rs_xai_next_turn", + encrypted_content: "enc_next_turn", + }); }); }); + +function followUpContextWithEncryptedReasoning(model: Model<"openai-responses">): Context { + const assistant: AssistantMessage = { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "internal plan", + thinkingSignature: JSON.stringify({ + type: "reasoning", + id: "rs_xai_next_turn", + encrypted_content: "enc_next_turn", + }), + }, + { type: "text", text: "done" }, + ], + api: "openai-responses", + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: 1, + }; + return { + messages: [ + { role: "user", content: "first", timestamp: 0 }, + assistant, + { role: "user", content: "continue", timestamp: 2 }, + ], + }; +} + +function findEncryptedReasoning(input: unknown): Record | undefined { + if (!Array.isArray(input)) return undefined; + return input.find(item => { + if (!item || typeof item !== "object") return false; + const candidate = item as { type?: unknown; encrypted_content?: unknown }; + return candidate.type === "reasoning" && typeof candidate.encrypted_content === "string"; + }) as Record | undefined; +} diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index ebef9e027..94cac5bfd 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -5,6 +5,26 @@ ### Added - Added support for GLM-5.3 on the z.AI provider. GLM-5.3 introduces a uniform wire-exact `low`/`high`/`max` reasoning-effort ladder on every host (replacing GLM-5.2's host-specific dialects), makes thinking mandatory (`thinking.type` must always be `enabled`; disabling is no longer supported), and defaults to `max` effort. The model is pinned to 1M context and set as the z.AI provider default. +### Changed + +- Switched the paid xAI provider (`xai` / `XAI_API_KEY`) from Chat Completions to the OpenAI Responses API (`POST https://api.x.ai/v1/responses`), matching SuperGrok `xai-oauth`. Prompt-cache affinity (`x-grok-conv-id`), reasoning-effort allowlisting, and encrypted-reasoning replay rules are now shared across both first-party xAI hosts. +- Changed the paid xAI (`XAI_API_KEY`) default model from `grok-4-fast-non-reasoning` to `grok-4.5`. +- Changed the SuperGrok (`xai-oauth`) default model from `grok-4.3` to `grok-4.5`. +- Requested `reasoning.encrypted_content` on first-party xAI Responses calls (`xai` and `xai-oauth`) via the `include` parameter. +- Replayed xAI encrypted reasoning items on later Responses turns instead of stripping `type: "reasoning"` history. + +### Fixed + +- Invalidated stale paid-xAI model-cache rows written under Chat Completions so the Responses migration takes effect immediately instead of waiting for TTL expiry. +- Clamped paid xAI Responses `minimal` reasoning effort to `low` (same wire map as SuperGrok) so `xai/grok-4.5` does not 400. +- Suppressed presence/frequency penalties and stop sequences on xAI reasoning models so a configured `presencePenalty` does not 400 after the `grok-4.5` default change. +- Stopped emitting stale `thinking.efforts` dials on paid xAI Responses catalog rows that reject `reasoning.effort` (`grok-code-fast-1`, `grok-build-0.1`, `grok-4.20-0309-reasoning`, and other off-allowlist reasoners). +- Marked first-party xAI Responses hosts (`xai` and `xai-oauth`) as not supporting `reasoning.summary`, so paid `xai/grok-4.5` effort requests omit the unsupported field instead of sending `summary: "auto"`. +- Removed unsupported `xhigh` (and `max`) thinking tiers from first-party Grok 4.5 / 4.3 / 3-mini Responses rows; leftover `xhigh`/`max` requests clamp to `high`. `grok-4.6*` and `grok-4.20-multi-agent*` advertise unmapped `xhigh`. +- Stopped baking `reasoningEffortMap` on first-party xAI catalog rows that omit `reasoning.effort` (`omitReasoningEffort: true`). +- Suppressed presence/frequency penalties on every first-party xAI Responses model, including non-reasoning ids such as `grok-2`; xAI's `/v1/responses` marks those fields unsupported. +- Routed `grok-4.6` (added on main) through first-party xAI Responses and advertised its documented `xhigh` effort tier (4.5 stays 4-tier). + ## [17.3.4] - 2026-08-14 ### Added diff --git a/packages/catalog/scripts/generated-policies.ts b/packages/catalog/scripts/generated-policies.ts index abf8f82cc..58bbb0902 100644 --- a/packages/catalog/scripts/generated-policies.ts +++ b/packages/catalog/scripts/generated-policies.ts @@ -21,6 +21,7 @@ import { resolveModelThinking } from "../src/model-thinking"; import { isOllamaCloudOutputCapped, OLLAMA_CLOUD_MAX_OUTPUT_TOKENS } from "../src/provider-models/ollama"; import { ALIBABA_TOKEN_PLAN_STATIC_MODELS, + applyXaiResponsesThinkingPolicy, OPENAI_GPT_56_LONG_CONTEXT_COSTS, resolveWaferServerlessThinkingFormat, } from "../src/provider-models/openai-compat"; @@ -353,6 +354,10 @@ export function applyOllamaCloudOutputCap(models: ModelSpec[]): void { } function applyGeneratedModelPolicy(model: ModelSpec): void { + if ((model.provider === "xai" || model.provider === "xai-oauth") && model.api === "openai-responses") { + const updated = applyXaiResponsesThinkingPolicy(model as ModelSpec<"openai-responses">); + model.compat = updated.compat; + } const copilotLimits = model.provider === "github-copilot" ? COPILOT_GENERATED_LIMITS[model.id] : undefined; if (copilotLimits) { model.contextWindow = copilotLimits.contextWindow; diff --git a/packages/catalog/src/compat/openai.ts b/packages/catalog/src/compat/openai.ts index ad7ba06b0..2eeec29f1 100644 --- a/packages/catalog/src/compat/openai.ts +++ b/packages/catalog/src/compat/openai.ts @@ -16,6 +16,7 @@ import { isDeepseekModelIdOrName, isGlm52ReasoningEffortModelId, isGrokReasoningEffortCapable, + isGrokXHighEffortCapable, isKimiK3ModelId, isKimiK26ModelId, isKimiModelId, @@ -177,6 +178,22 @@ const MIMO_REASONING_EFFORT_MAP: NonNullable xhigh: "high", }; +/** Shared `minimal → low` clamp. xhigh-capable Grok keeps `xhigh` unmapped. */ +const XAI_RESPONSES_MINIMAL_EFFORT_MAP: NonNullable = { + minimal: "low", +}; +/** Grok 4.5 / 4.3 / 3-mini: leftover `xhigh`/`max` clamp to `high`. */ +const XAI_RESPONSES_CLAMPED_EFFORT_MAP: NonNullable = { + minimal: "low", + xhigh: "high", + max: "high", +}; + +/** Wire effort remap for first-party xAI Responses. */ +export function xaiResponsesReasoningEffortMap(modelId: string): NonNullable { + return isGrokXHighEffortCapable(modelId) ? XAI_RESPONSES_MINIMAL_EFFORT_MAP : XAI_RESPONSES_CLAMPED_EFFORT_MAP; +} + function mergeModelReasoningEffortMap( compat: ResolvedOpenAISharedCompat, modelId: string, @@ -471,6 +488,8 @@ export function buildOpenAICompat(spec: ModelSpec<"openai-completions">): Resolv // OpenAI proprietary reasoning models (o-series, gpt-5+) reject explicit // temperature/top_p/… with a 400 on every serving host (#5606). supportsSamplingParams: !isOpenAISamplingRestrictedModelId(spec.id), + // xAI reasoning models 400 on presence/frequency penalties and stop. + supportsPenaltyAndStopParams: !(isGrok && Boolean(spec.reasoning)), reasoningEffortMap: {}, supportsUsageInStreaming: !isCerebras, // Kimi (including via OpenRouter and Fireworks router-form IDs such as @@ -684,34 +703,46 @@ export function buildOpenAIResponsesCompat(spec: OpenAIResponsesSpecLike): Resol const isLocalServingBackend = (!PROXY_OPENAI_COMPAT_PROVIDERS.has(spec.provider) && LOCAL_OPENAI_COMPAT_PROVIDERS.has(spec.provider)) || hasLocalLoopbackBaseUrl(baseUrl); + const isXaiHost = modelMatchesHost({ provider: spec.provider, baseUrl }, "xai"); const compat: ResolvedOpenAIResponsesCompat = { supportsDeveloperRole: isAzure || isOpenAIUrl || hostMatchesUrl(baseUrl, "githubCopilot"), supportsStrictMode: isAzure || detectStrictModeSupport(spec.provider, baseUrl), - supportsReasoningEffort: spec.provider !== "xai-oauth" || isGrokReasoningEffortCapable(id), + // Paid `xai` and SuperGrok `xai-oauth` share api.x.ai `/v1/responses`. + // Only the Grok effort-capable allowlist accepts `reasoning.effort`; + // other reasoners (grok-build, grok-code-fast-1, …) 400 if it is sent. + supportsReasoningEffort: !isXaiHost || isGrokReasoningEffortCapable(id), supportsLongPromptCacheRetention: isOpenAIUrl, supportsPromptCacheBreakpoints, promptCacheBreakpointTtl: supportsPromptCacheBreakpoints ? "30m" : undefined, // Azure OpenAI and GitHub Copilot Responses paths require tool results // to strictly match prior tool calls when building Responses inputs. strictResponsesPairing: isAzure || spec.provider === "github-copilot", - // GitHub Copilot and xAI OAuth reject `detail: "original"` (400 / 422). - // Every other host preserves native-resolution frames (snapcompact relies - // on `original`). Detect Copilot by provider id or base-URL host so a - // model pointed at the Copilot host under a different provider id still - // clamps; xai-oauth is provider-id only (same host family as paid `xai`). + // GitHub Copilot and first-party xAI `/v1/responses` reject + // `detail: "original"` (400 / 422). Every other host preserves + // native-resolution frames (snapcompact relies on `original`). Detect + // Copilot by provider id or base-URL host so a model pointed at the + // Copilot host under a different provider id still clamps. supportsImageDetailOriginal: - spec.provider !== "xai-oauth" && !modelMatchesHost({ provider: spec.provider, baseUrl }, "githubCopilot"), - reasoningEffortMap: {}, + !isXaiHost && !modelMatchesHost({ provider: spec.provider, baseUrl }, "githubCopilot"), + // api.x.ai rejects `reasoning.summary` (SuperGrok and paid key alike). + supportsReasoningSummary: !isXaiHost, + reasoningEffortMap: isXaiHost ? { ...xaiResponsesReasoningEffortMap(id) } : {}, supportsReasoningParams: true, // OpenAI proprietary reasoning models (o-series, gpt-5+) reject explicit // temperature/top_p/… with a 400 on every serving host (#5606). supportsSamplingParams: !isOpenAISamplingRestrictedModelId(id), + // xAI `/v1/responses` rejects presence/frequency penalties for every + // model, not only reasoners (https://docs.x.ai/developers/rest-api-reference/inference/chat). + supportsPenaltyAndStopParams: !isXaiHost, thinkingFormat, reasoningDisableMode: resolveReasoningDisableMode(thinkingFormat), omitReasoningEffort: false, - includeEncryptedReasoning: spec.provider !== "xai-oauth", - filterReasoningHistory: spec.provider === "xai-oauth" || (isOpenRouter && isAnthropicModel), + // Ask xAI `/v1/responses` for `reasoning.encrypted_content` and replay + // those items on later turns. OpenRouter Anthropic still filters + // reasoning wrappers independently. + includeEncryptedReasoning: true, + filterReasoningHistory: isOpenRouter && isAnthropicModel, disableReasoningOnForcedToolChoice: isKimiModel, disableReasoningOnToolChoice: isDeepseekFamily && reasoningCapable && !isOpenRouter, supportsToolChoice: true, @@ -752,13 +783,24 @@ export function buildOpenAIResponsesCompat(spec: OpenAIResponsesSpecLike): Resol MINIMAX_PROVIDER_OR_ID_PATTERN.test(spec.provider) || (id ? MINIMAX_PROVIDER_OR_ID_PATTERN.test(id) : false), emptyLengthFinishIsContextError: spec.provider === "ollama", usesOpenAIToolCallIdLimit: spec.provider === "openai", - promptCacheSessionHeader: spec.provider === "xai-oauth" ? "x-grok-conv-id" : undefined, + promptCacheSessionHeader: isXaiHost ? "x-grok-conv-id" : undefined, streamFirstEventTimeoutMs: isLocalServingBackend ? 0 : spec.compat?.streamFirstEventTimeoutMs, streamIdleTimeoutMs: isLocalServingBackend ? LOCAL_OPENAI_COMPAT_STREAM_IDLE_TIMEOUT_MS : spec.compat?.streamIdleTimeoutMs, }; applyCompatOverrides(compat, spec.compat); + if (isXaiHost) { + const canonical = xaiResponsesReasoningEffortMap(id); + compat.reasoningEffortMap = { ...compat.reasoningEffortMap, ...canonical }; + // xhigh-capable Grok advertises unmapped `xhigh`; drop a stale clamp + // from previous snapshots so 4.6 / 16-agent mode is not rewritten to `high`. + for (const key of ["xhigh", "max"] as const) { + if (!(key in canonical)) { + delete compat.reasoningEffortMap[key]; + } + } + } if (spec.compat?.reasoningDisableMode === undefined) { compat.reasoningDisableMode = resolveReasoningDisableMode(compat.thinkingFormat); } @@ -776,6 +818,7 @@ function pickResponsesOnly(compat: ResolvedOpenAIResponsesCompat): ResponsesOnly strictResponsesPairing: compat.strictResponsesPairing, supportsImageDetailOriginal: compat.supportsImageDetailOriginal, supportsObfuscationOptOut: compat.supportsObfuscationOptOut, + supportsReasoningSummary: compat.supportsReasoningSummary, isVercelGatewayHost: compat.isVercelGatewayHost, } satisfies ResponsesOnlyCompat; } diff --git a/packages/catalog/src/hosts.ts b/packages/catalog/src/hosts.ts index 176f77125..5c7f3e2fb 100644 --- a/packages/catalog/src/hosts.ts +++ b/packages/catalog/src/hosts.ts @@ -47,7 +47,7 @@ export const KNOWN_HOSTS = { }, umans: { providers: ["umans"], urlMarkers: ["api.code.umans.ai"] }, xiaomi: { providers: ["xiaomi"], providerPrefixes: ["xiaomi-token-plan-"], urlMarkers: ["xiaomimimo.com"] }, - xai: { providers: ["xai"], urlMarkers: ["api.x.ai"] }, + xai: { providers: ["xai", "xai-oauth"], urlMarkers: ["api.x.ai"] }, mistral: { providers: ["mistral"], urlMarkers: ["mistral.ai"] }, together: { providers: ["together"], urlMarkers: ["api.together.xyz"] }, baseten: { providers: ["baseten"], urlMarkers: ["baseten.co"] }, diff --git a/packages/catalog/src/identity/family.ts b/packages/catalog/src/identity/family.ts index e57ca16f7..9395c3e13 100644 --- a/packages/catalog/src/identity/family.ts +++ b/packages/catalog/src/identity/family.ts @@ -110,7 +110,13 @@ export const isGrokModelId = memo((modelId: string): boolean => { return /(?:^|[./_-])grok(?:[-.]|$)/i.test(modelId); }); -const GROK_EFFORT_CAPABLE_PREFIXES = ["grok-3-mini", "grok-4.20-multi-agent", "grok-4.3", "grok-4.5"] as const; +const GROK_EFFORT_CAPABLE_PREFIXES = [ + "grok-3-mini", + "grok-4.20-multi-agent", + "grok-4.3", + "grok-4.5", + "grok-4.6", +] as const; /** * Grok SKUs that expose the wire `reasoning.effort` dial. Other Grok reasoners @@ -123,6 +129,27 @@ export const isGrokReasoningEffortCapable = memo((modelId: string): boolean => { return GROK_EFFORT_CAPABLE_PREFIXES.some(prefix => bare.startsWith(prefix)); }); +/** + * `grok-4.20-multi-agent*` uses `reasoning.effort` to pick agent count + * (`xhigh` is the 16-agent mode). Other first-party Grok effort SKUs stay on + * `low|medium|high` unless {@link isGrokXHighEffortCapable} (currently + * `grok-4.6*` plus multi-agent). + * https://docs.x.ai/developers/model-capabilities/text/reasoning + */ +export const isGrokMultiAgentModelId = memo((modelId: string): boolean => { + return bareModelId(modelId).trim().toLowerCase().startsWith("grok-4.20-multi-agent"); +}); + +/** + * First-party Grok SKUs whose Responses wire accepts `reasoning.effort: "xhigh"`. + * `grok-4.6*` documents xhigh as a reasoning depth; multi-agent uses it as + * 16-agent mode. `grok-4.5` / `grok-4.3` / `grok-3-mini` do not. + */ +export const isGrokXHighEffortCapable = memo((modelId: string): boolean => { + if (isGrokMultiAgentModelId(modelId)) return true; + return bareModelId(modelId).trim().toLowerCase().startsWith("grok-4.6"); +}); + /** * MiniMax M2-generation family (M2, M2.1, M2.5, M2.7, including `-highspeed`/ * `-lightning`/`-her`/`-turbo` variants, dotless aliases like `minimax-m21`, diff --git a/packages/catalog/src/model-thinking.ts b/packages/catalog/src/model-thinking.ts index 79ee1e372..6d0c40428 100644 --- a/packages/catalog/src/model-thinking.ts +++ b/packages/catalog/src/model-thinking.ts @@ -27,6 +27,7 @@ import { isDeepseekV4FlashModelId, isGlm52ReasoningEffortModelId, isGlm53ReasoningEffortModelId, + isGrokXHighEffortCapable, isKimiK3ModelId, isMimoModelIdOrName, isMinimaxM2FamilyModelId, @@ -404,6 +405,11 @@ function getModelDefinedEfforts( // Baseten's gpt-oss router mirrors its GLM route: high/max only. return HIGH_MAX_REASONING_EFFORTS; } + // First-party Grok: `grok-4.6*` and `grok-4.20-multi-agent*` advertise + // `xhigh`. Other effort-capable SKUs stay on `minimal/low/medium/high`. + if (modelMatchesHost({ provider: spec.provider, baseUrl: spec.baseUrl ?? "" }, "xai")) { + return isGrokXHighEffortCapable(spec.id) ? DEFAULT_REASONING_EFFORTS_WITH_XHIGH : DEFAULT_REASONING_EFFORTS; + } return isOpenAICompatReasoningApi(spec.api) && (isMinimaxM2FamilyModelId(spec.id) || isOpenAIGptOssModelId(spec.id) || diff --git a/packages/catalog/src/models.json b/packages/catalog/src/models.json index 6c62a3435..d7eae1742 100644 --- a/packages/catalog/src/models.json +++ b/packages/catalog/src/models.json @@ -103716,7 +103716,7 @@ "grok-2": { "id": "grok-2", "name": "Grok 2", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": false, @@ -103730,12 +103730,16 @@ "cacheWrite": 0 }, "contextWindow": 131072, - "maxTokens": 8192 + "maxTokens": 8192, + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true + } }, "grok-2-1212": { "id": "grok-2-1212", "name": "Grok 2 (1212)", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": false, @@ -103749,12 +103753,16 @@ "cacheWrite": 0 }, "contextWindow": 131072, - "maxTokens": 8192 + "maxTokens": 8192, + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true + } }, "grok-2-latest": { "id": "grok-2-latest", "name": "Grok 2 Latest", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": false, @@ -103768,12 +103776,16 @@ "cacheWrite": 0 }, "contextWindow": 131072, - "maxTokens": 8192 + "maxTokens": 8192, + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true + } }, "grok-2-vision": { "id": "grok-2-vision", "name": "Grok 2 Vision", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": false, @@ -103788,12 +103800,16 @@ "cacheWrite": 0 }, "contextWindow": 8192, - "maxTokens": 4096 + "maxTokens": 4096, + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true + } }, "grok-2-vision-1212": { "id": "grok-2-vision-1212", "name": "Grok 2 Vision (1212)", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": false, @@ -103808,12 +103824,16 @@ "cacheWrite": 0 }, "contextWindow": 8192, - "maxTokens": 4096 + "maxTokens": 4096, + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true + } }, "grok-2-vision-latest": { "id": "grok-2-vision-latest", "name": "Grok 2 Vision Latest", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": false, @@ -103828,12 +103848,16 @@ "cacheWrite": 0 }, "contextWindow": 8192, - "maxTokens": 4096 + "maxTokens": 4096, + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true + } }, "grok-3": { "id": "grok-3", "name": "Grok 3", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": false, @@ -103847,12 +103871,16 @@ "cacheWrite": 0 }, "contextWindow": 131072, - "maxTokens": 8192 + "maxTokens": 8192, + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true + } }, "grok-3-fast": { "id": "grok-3-fast", "name": "Grok 3 Fast", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": false, @@ -103866,12 +103894,16 @@ "cacheWrite": 0 }, "contextWindow": 131072, - "maxTokens": 8192 + "maxTokens": 8192, + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true + } }, "grok-3-fast-latest": { "id": "grok-3-fast-latest", "name": "Grok 3 Fast Latest", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": false, @@ -103885,12 +103917,16 @@ "cacheWrite": 0 }, "contextWindow": 131072, - "maxTokens": 8192 + "maxTokens": 8192, + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true + } }, "grok-3-latest": { "id": "grok-3-latest", "name": "Grok 3 Latest", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": false, @@ -103904,12 +103940,16 @@ "cacheWrite": 0 }, "contextWindow": 131072, - "maxTokens": 8192 + "maxTokens": 8192, + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true + } }, "grok-3-mini": { "id": "grok-3-mini", "name": "Grok 3 Mini", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": true, @@ -103931,13 +103971,25 @@ "low", "medium", "high" - ] + ], + "effortMap": { + "minimal": "low" + } + }, + "compat": { + "reasoningEffortMap": { + "minimal": "low", + "xhigh": "high", + "max": "high" + }, + "supportsReasoningEffort": true, + "omitReasoningEffort": false } }, "grok-3-mini-fast": { "id": "grok-3-mini-fast", "name": "Grok 3 Mini Fast", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": true, @@ -103959,13 +104011,25 @@ "low", "medium", "high" - ] + ], + "effortMap": { + "minimal": "low" + } + }, + "compat": { + "reasoningEffortMap": { + "minimal": "low", + "xhigh": "high", + "max": "high" + }, + "supportsReasoningEffort": true, + "omitReasoningEffort": false } }, "grok-3-mini-fast-latest": { "id": "grok-3-mini-fast-latest", "name": "Grok 3 Mini Fast Latest", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": true, @@ -103987,13 +104051,25 @@ "low", "medium", "high" - ] + ], + "effortMap": { + "minimal": "low" + } + }, + "compat": { + "reasoningEffortMap": { + "minimal": "low", + "xhigh": "high", + "max": "high" + }, + "supportsReasoningEffort": true, + "omitReasoningEffort": false } }, "grok-3-mini-latest": { "id": "grok-3-mini-latest", "name": "Grok 3 Mini Latest", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": true, @@ -104015,13 +104091,25 @@ "low", "medium", "high" - ] + ], + "effortMap": { + "minimal": "low" + } + }, + "compat": { + "reasoningEffortMap": { + "minimal": "low", + "xhigh": "high", + "max": "high" + }, + "supportsReasoningEffort": true, + "omitReasoningEffort": false } }, "grok-4": { "id": "grok-4", "name": "Grok 4", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": true, @@ -104036,20 +104124,15 @@ }, "contextWindow": 256000, "maxTokens": 64000, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high" - ] + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true } }, "grok-4-1-fast": { "id": "grok-4-1-fast", "name": "Grok 4.1 Fast", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": true, @@ -104065,20 +104148,15 @@ }, "contextWindow": 2000000, "maxTokens": 30000, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high" - ] + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true } }, "grok-4-1-fast-non-reasoning": { "id": "grok-4-1-fast-non-reasoning", "name": "Grok 4.1 Fast (Non-Reasoning)", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": false, @@ -104093,12 +104171,16 @@ "cacheWrite": 0 }, "contextWindow": 2000000, - "maxTokens": 30000 + "maxTokens": 30000, + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true + } }, "grok-4-fast": { "id": "grok-4-fast", "name": "Grok 4 Fast", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": true, @@ -104114,20 +104196,15 @@ }, "contextWindow": 2000000, "maxTokens": 30000, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high" - ] + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true } }, "grok-4-fast-non-reasoning": { "id": "grok-4-fast-non-reasoning", "name": "Grok 4 Fast (Non-Reasoning)", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": false, @@ -104142,12 +104219,16 @@ "cacheWrite": 0 }, "contextWindow": 2000000, - "maxTokens": 30000 + "maxTokens": 30000, + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true + } }, "grok-4.20-0309-non-reasoning": { "id": "grok-4.20-0309-non-reasoning", "name": "Grok 4.20 (Non-Reasoning)", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": false, @@ -104162,12 +104243,16 @@ "cacheWrite": 0 }, "contextWindow": 1000000, - "maxTokens": 30000 + "maxTokens": 30000, + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true + } }, "grok-4.20-0309-reasoning": { "id": "grok-4.20-0309-reasoning", "name": "Grok 4.20 (Reasoning)", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": true, @@ -104183,21 +104268,15 @@ }, "contextWindow": 1000000, "maxTokens": 30000, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high" - ], - "requiresEffort": true + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true } }, "grok-4.20-beta-latest-non-reasoning": { "id": "grok-4.20-beta-latest-non-reasoning", "name": "Grok 4.20 Beta (Non-Reasoning)", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": false, @@ -104212,12 +104291,16 @@ "cacheWrite": 0 }, "contextWindow": 2000000, - "maxTokens": 30000 + "maxTokens": 30000, + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true + } }, "grok-4.20-beta-latest-reasoning": { "id": "grok-4.20-beta-latest-reasoning", "name": "Grok 4.20 Beta (Reasoning)", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": true, @@ -104233,21 +104316,15 @@ }, "contextWindow": 2000000, "maxTokens": 30000, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high" - ], - "requiresEffort": true + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true } }, "grok-4.20-multi-agent-beta-latest": { "id": "grok-4.20-multi-agent-beta-latest", "name": "Grok 4.20 Multi-Agent Beta", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": true, @@ -104269,14 +104346,25 @@ "minimal", "low", "medium", - "high" - ] + "high", + "xhigh" + ], + "effortMap": { + "minimal": "low" + } + }, + "compat": { + "reasoningEffortMap": { + "minimal": "low" + }, + "supportsReasoningEffort": true, + "omitReasoningEffort": false } }, "grok-4.3": { "id": "grok-4.3", "name": "Grok 4.3", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": true, @@ -104299,13 +104387,25 @@ "low", "medium", "high" - ] + ], + "effortMap": { + "minimal": "low" + } + }, + "compat": { + "reasoningEffortMap": { + "minimal": "low", + "xhigh": "high", + "max": "high" + }, + "supportsReasoningEffort": true, + "omitReasoningEffort": false } }, "grok-4.5": { "id": "grok-4.5", "name": "Grok 4.5", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": true, @@ -104328,13 +104428,25 @@ "low", "medium", "high" - ] + ], + "effortMap": { + "minimal": "low" + } + }, + "compat": { + "reasoningEffortMap": { + "minimal": "low", + "xhigh": "high", + "max": "high" + }, + "supportsReasoningEffort": true, + "omitReasoningEffort": false } }, "grok-4.6": { "id": "grok-4.6", "name": "Grok 4.6", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": true, @@ -104356,14 +104468,25 @@ "minimal", "low", "medium", - "high" - ] + "high", + "xhigh" + ], + "effortMap": { + "minimal": "low" + } + }, + "compat": { + "reasoningEffortMap": { + "minimal": "low" + }, + "supportsReasoningEffort": true, + "omitReasoningEffort": false } }, "grok-beta": { "id": "grok-beta", "name": "Grok Beta", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": false, @@ -104377,12 +104500,16 @@ "cacheWrite": 0 }, "contextWindow": 131072, - "maxTokens": 4096 + "maxTokens": 4096, + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true + } }, "grok-build-0.1": { "id": "grok-build-0.1", "name": "Grok Build 0.1", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": true, @@ -104398,20 +104525,15 @@ }, "contextWindow": 256000, "maxTokens": 256000, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high" - ] + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true } }, "grok-code-fast-1": { "id": "grok-code-fast-1", "name": "Grok Code Fast 1", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": true, @@ -104426,20 +104548,15 @@ }, "contextWindow": 256000, "maxTokens": 10000, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high" - ] + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true } }, "grok-vision-beta": { "id": "grok-vision-beta", "name": "Grok Vision Beta", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": false, @@ -104454,7 +104571,11 @@ "cacheWrite": 0 }, "contextWindow": 8192, - "maxTokens": 4096 + "maxTokens": 4096, + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true + } } }, "xai-oauth": { @@ -104480,11 +104601,8 @@ "supportsComputerUse": false, "supportsComputerUseConfig": false, "compat": { - "reasoningEffortMap": { - "minimal": "low" - }, - "includeEncryptedReasoning": false, - "filterReasoningHistory": true, + "includeEncryptedReasoning": true, + "filterReasoningHistory": false, "supportsImageDetailOriginal": false, "omitReasoningEffort": true, "supportsReasoningEffort": false @@ -104512,11 +104630,8 @@ "supportsComputerUse": false, "supportsComputerUseConfig": false, "compat": { - "reasoningEffortMap": { - "minimal": "low" - }, - "includeEncryptedReasoning": false, - "filterReasoningHistory": true, + "includeEncryptedReasoning": true, + "filterReasoningHistory": false, "supportsImageDetailOriginal": false, "omitReasoningEffort": true, "supportsReasoningEffort": false @@ -104559,8 +104674,8 @@ "reasoningEffortMap": { "minimal": "low" }, - "includeEncryptedReasoning": false, - "filterReasoningHistory": true, + "includeEncryptedReasoning": true, + "filterReasoningHistory": false, "supportsImageDetailOriginal": false, "omitReasoningEffort": false, "supportsReasoningEffort": true @@ -104591,8 +104706,7 @@ "minimal", "low", "medium", - "high", - "xhigh" + "high" ], "effortMap": { "minimal": "low" @@ -104602,10 +104716,12 @@ "supportsComputerUseConfig": false, "compat": { "reasoningEffortMap": { - "minimal": "low" + "minimal": "low", + "xhigh": "high", + "max": "high" }, - "includeEncryptedReasoning": false, - "filterReasoningHistory": true, + "includeEncryptedReasoning": true, + "filterReasoningHistory": false, "supportsImageDetailOriginal": false, "omitReasoningEffort": false, "supportsReasoningEffort": true @@ -104630,6 +104746,52 @@ }, "contextWindow": 500000, "maxTokens": 500000, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ], + "effortMap": { + "minimal": "low" + } + }, + "supportsComputerUse": false, + "supportsComputerUseConfig": false, + "compat": { + "reasoningEffortMap": { + "minimal": "low", + "xhigh": "high", + "max": "high" + }, + "includeEncryptedReasoning": true, + "filterReasoningHistory": false, + "supportsImageDetailOriginal": false, + "omitReasoningEffort": false, + "supportsReasoningEffort": true + } + }, + "grok-4.6": { + "id": "grok-4.6", + "name": "Grok 4.6", + "api": "openai-responses", + "provider": "xai-oauth", + "baseUrl": "https://api.x.ai/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 500000, + "maxTokens": 500000, "thinking": { "mode": "effort", "efforts": [ @@ -104649,41 +104811,13 @@ "reasoningEffortMap": { "minimal": "low" }, - "includeEncryptedReasoning": false, - "filterReasoningHistory": true, + "includeEncryptedReasoning": true, + "filterReasoningHistory": false, "supportsImageDetailOriginal": false, "omitReasoningEffort": false, "supportsReasoningEffort": true } }, - "grok-4.6": { - "id": "grok-4.6", - "name": "Grok 4.6", - "api": "openai-responses", - "provider": "xai-oauth", - "baseUrl": "https://api.x.ai/v1", - "reasoning": true, - "input": [ - "text", - "image" - ], - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, - "contextWindow": 500000, - "maxTokens": 500000, - "supportsComputerUse": false, - "supportsComputerUseConfig": false, - "compat": { - "includeEncryptedReasoning": false, - "filterReasoningHistory": true, - "supportsImageDetailOriginal": false, - "omitReasoningEffort": true - } - }, "grok-build": { "id": "grok-build", "name": "Grok Build", @@ -104706,11 +104840,8 @@ "supportsComputerUse": false, "supportsComputerUseConfig": false, "compat": { - "reasoningEffortMap": { - "minimal": "low" - }, - "includeEncryptedReasoning": false, - "filterReasoningHistory": true, + "includeEncryptedReasoning": true, + "filterReasoningHistory": false, "supportsImageDetailOriginal": false, "omitReasoningEffort": true, "supportsReasoningEffort": false @@ -104738,11 +104869,8 @@ "supportsComputerUse": false, "supportsComputerUseConfig": false, "compat": { - "reasoningEffortMap": { - "minimal": "low" - }, - "includeEncryptedReasoning": false, - "filterReasoningHistory": true, + "includeEncryptedReasoning": true, + "filterReasoningHistory": false, "supportsImageDetailOriginal": false, "omitReasoningEffort": true, "supportsReasoningEffort": false @@ -104769,11 +104897,8 @@ "supportsComputerUse": false, "supportsComputerUseConfig": false, "compat": { - "reasoningEffortMap": { - "minimal": "low" - }, - "includeEncryptedReasoning": false, - "filterReasoningHistory": true, + "includeEncryptedReasoning": true, + "filterReasoningHistory": false, "supportsImageDetailOriginal": false, "omitReasoningEffort": true, "supportsReasoningEffort": false @@ -112025,4 +112150,4 @@ } } } -} \ No newline at end of file +} diff --git a/packages/catalog/src/provider-models/descriptors.ts b/packages/catalog/src/provider-models/descriptors.ts index 97f7ef54d..dc1d2afc3 100644 --- a/packages/catalog/src/provider-models/descriptors.ts +++ b/packages/catalog/src/provider-models/descriptors.ts @@ -478,13 +478,13 @@ export const CATALOG_PROVIDERS = [ }, { id: "xai", - defaultModel: "grok-4-fast-non-reasoning", + defaultModel: "grok-4.5", envVars: ["XAI_API_KEY"], createModelManagerOptions: (config: ModelManagerConfig) => xaiModelManagerOptions(config), }, { id: "xai-oauth", - defaultModel: "grok-4.3", + defaultModel: "grok-4.5", envVars: ["XAI_OAUTH_TOKEN", "XAI_API_KEY"], createModelManagerOptions: (config: ModelManagerConfig) => xaiOAuthModelManagerOptions(config), catalogDiscovery: { diff --git a/packages/catalog/src/provider-models/openai-compat.ts b/packages/catalog/src/provider-models/openai-compat.ts index bf985c9a5..347811244 100644 --- a/packages/catalog/src/provider-models/openai-compat.ts +++ b/packages/catalog/src/provider-models/openai-compat.ts @@ -1,5 +1,6 @@ import { USER_AGENT } from "@oh-my-pi/pi-utils"; import * as logger from "@oh-my-pi/pi-utils/logger"; +import { xaiResponsesReasoningEffortMap } from "../compat/openai"; import { DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS, fetchOpenAICompatibleModels, @@ -1257,8 +1258,23 @@ export interface XaiModelManagerConfig { fetch?: FetchImpl; } -export function xaiModelManagerOptions(config?: XaiModelManagerConfig): ModelManagerOptions<"openai-completions"> { - return createSimpleOpenAICompletionsOptions("xai", "https://api.x.ai/v1", config); +export function xaiModelManagerOptions(config?: XaiModelManagerConfig): ModelManagerOptions<"openai-responses"> { + return { + ...createOpenAICompatibleModelManagerOptions({ + api: "openai-responses", + providerId: "xai", + defaultBaseUrl: "https://api.x.ai/v1", + config, + requireApiKey: true, + mapModel: mapWithBundledReference, + }), + // Completions → Responses migration: a fresh authoritative cache written + // by the old resolver stores `api: "openai-completions"` for these ids. + // Without a drop list, `online-if-uncached` skips the network and + // `mergeDynamicModel` lets the cached api win over the new static + // Responses entries until TTL expiry. + dropCachedModelIdsOnStaticMismatch: getBundledModels("xai").map(model => model.id), + }; } export interface XaiOAuthModelManagerConfig { @@ -1316,6 +1332,7 @@ export const XAI_OAUTH_CURATED_MODELS: readonly XAICuratedModel[] = [ }, { id: "grok-4.3", contextWindow: 1_000_000, name: "Grok 4.3", input: ["text", "image"] }, { id: "grok-4.5", contextWindow: 500_000, name: "Grok 4.5", input: ["text", "image"] }, + { id: "grok-4.6", contextWindow: 500_000, name: "Grok 4.6", input: ["text", "image"] }, // grok-4.20-multi-agent-0309 is text-only per the bundled catalog; omit `input` for the default. { id: "grok-4.20-multi-agent-0309", contextWindow: 2_000_000, name: "Grok 4.20 (Multi-Agent)" }, { @@ -1352,21 +1369,47 @@ const XAI_NON_CHAT_PREFIXES = ["grok-imagine-", "grok-stt-", "grok-voice-"] as c function withXaiOAuthCompatDefaults(model: ModelSpec<"openai-responses">): ModelSpec<"openai-responses"> { const compat = { ...(model.compat ?? {}), - includeEncryptedReasoning: model.compat?.includeEncryptedReasoning ?? false, - filterReasoningHistory: model.compat?.filterReasoningHistory ?? true, + includeEncryptedReasoning: model.compat?.includeEncryptedReasoning ?? true, + filterReasoningHistory: model.compat?.filterReasoningHistory ?? false, supportsImageDetailOriginal: model.compat?.supportsImageDetailOriginal ?? false, omitReasoningEffort: model.compat?.omitReasoningEffort ?? !isGrokReasoningEffortCapable(model.id), }; return { ...model, compat }; } -// Hermes-agent parity: only the `minimal -> low` clamp is applied (see -// hermes-agent/agent/transports/codex.py:92 `_effort_clamp = {"minimal": -// "low"}`). Hermes sends `xhigh` to xAI verbatim and we match that contract -// — let xAI decide if the level is valid for the specific Grok model. -// `resolveModelThinking` folds this into `model.thinking.effortMap`, downstream -// of the omitReasoningEffort gate in pi-ai's stream.ts. -const XAI_REASONING_EFFORT_MAP = { minimal: "low" } as const; +// Hermes-agent parity for `minimal -> low` (see hermes-agent/agent/transports/ +// codex.py:92). Multi-agent Grok keeps `xhigh` unmapped (agent-count mode); +// other first-party SKUs clamp leftover `xhigh`/`max` to `high`. +// `resolveModelThinking` folds this into `model.thinking.effortMap`. + +/** + * Bake first-party xAI Responses effort-dial metadata onto a catalog spec. + * + * models.dev marks many Grok SKUs as reasoners and the thinking rebake would + * otherwise emit a default `minimal/low/medium/high` dial. api.x.ai only + * accepts `reasoning.effort` for {@link isGrokReasoningEffortCapable} ids — + * off-allowlist reasoners (`grok-code-fast-1`, `grok-build-0.1`, + * `grok-4.20-0309-reasoning`, …) 400 if the param is sent. SuperGrok + * (`xai-oauth`) already curates this via {@link mergeCuratedIntoModel}; paid + * `xai` rows come from stencil.so and need the same wire facts in the exported + * `models.json` so direct catalog readers do not present an unsupported dial. + * + * Explicit `compat.supportsReasoningEffort` / `omitReasoningEffort` win. + */ +export function applyXaiResponsesThinkingPolicy(model: ModelSpec<"openai-responses">): ModelSpec<"openai-responses"> { + const effortCapable = model.compat?.supportsReasoningEffort ?? isGrokReasoningEffortCapable(model.id); + const compat = { + ...(model.compat ?? {}), + supportsReasoningEffort: effortCapable, + omitReasoningEffort: model.compat?.omitReasoningEffort ?? !effortCapable, + }; + if (effortCapable) { + compat.reasoningEffortMap = { ...xaiResponsesReasoningEffortMap(model.id) }; + } else { + delete compat.reasoningEffortMap; + } + return { ...model, compat }; +} // xai-oauth's /v1/models exposes no per-request output limit on the OAuth // (Grok Build / SuperGrok) surface, so the curated catalog owns `maxTokens` @@ -1381,9 +1424,9 @@ const XAI_REASONING_EFFORT_MAP = { minimal: "low" } as const; // reasoning metadata and fetchOpenAICompatibleModels defaults reasoning to // false). Caller supplies a `base` Model (either a freshly synthesised seed // or a dynamic-fetched entry); the helper layers curated fields on top. -// The `minimal -> low` effort clamp (XAI_REASONING_EFFORT_MAP) is always -// merged in so dynamic-fetched models — which arrive without curated -// compat keys — still get the clamp applyResponsesReasoningParams expects. +// The effort remap from {@link xaiResponsesReasoningEffortMap} is merged +// only onto effort-capable rows. Off-allowlist reasoners omit the wire +// param, so a map on those specs is dead weight. // The effort-dial pair (`supportsReasoningEffort`/`omitReasoningEffort`) is // authoritative: a stale flag on `base` (previous snapshot or dynamic fetch) // must not outlive an allowlist change in identity/family.ts. @@ -1394,13 +1437,17 @@ function mergeCuratedIntoModel( const effortCapable = curated.supportsReasoningEffort ?? isGrokReasoningEffortCapable(curated.id); const compat = { ...(base.compat ?? {}), - reasoningEffortMap: { ...XAI_REASONING_EFFORT_MAP, ...(base.compat?.reasoningEffortMap ?? {}) }, - includeEncryptedReasoning: base.compat?.includeEncryptedReasoning ?? false, - filterReasoningHistory: base.compat?.filterReasoningHistory ?? true, + includeEncryptedReasoning: base.compat?.includeEncryptedReasoning ?? true, + filterReasoningHistory: false, supportsImageDetailOriginal: base.compat?.supportsImageDetailOriginal ?? false, omitReasoningEffort: !effortCapable, supportsReasoningEffort: effortCapable, }; + if (effortCapable) { + compat.reasoningEffortMap = { ...xaiResponsesReasoningEffortMap(curated.id) }; + } else { + delete compat.reasoningEffortMap; + } return { ...base, contextWindow: curated.contextWindow, @@ -1502,7 +1549,7 @@ export function buildXaiOAuthStaticSeed(baseUrl?: string): ModelSpec<"openai-res cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: curated.contextWindow, maxTokens: curated.contextWindow, - compat: { reasoningEffortMap: XAI_REASONING_EFFORT_MAP }, + compat: { reasoningEffortMap: xaiResponsesReasoningEffortMap(curated.id) }, }; return mergeCuratedIntoModel(base, curated); }); @@ -5713,6 +5760,15 @@ function openAiCompletionsDescriptor( return simpleModelsDevDescriptor(modelsDevKey, providerId, "openai-completions", baseUrl, options); } +function openAiResponsesDescriptor( + modelsDevKey: string, + providerId: string, + baseUrl: string, + options: Omit = {}, +): ModelsDevProviderDescriptor { + return simpleModelsDevDescriptor(modelsDevKey, providerId, "openai-responses", baseUrl, options); +} + function anthropicMessagesDescriptor( modelsDevKey: string, providerId: string, @@ -5837,7 +5893,9 @@ const MODELS_DEV_PROVIDER_DESCRIPTORS_CORE: readonly ModelsDevProviderDescriptor defaultContextWindow: 131072, }), // --- xAI --- - openAiCompletionsDescriptor("xai", "xai", "https://api.x.ai/v1"), + openAiResponsesDescriptor("xai", "xai", "https://api.x.ai/v1", { + transformModel: model => applyXaiResponsesThinkingPolicy(model as ModelSpec<"openai-responses">), + }), // --- DeepSeek --- openAiCompletionsDescriptor("deepseek", "deepseek", "https://api.deepseek.com", { // Only ship the v4 family as built-ins; older deepseek-chat / deepseek-reasoner diff --git a/packages/catalog/src/types.ts b/packages/catalog/src/types.ts index 729ab4d89..5760208f9 100644 --- a/packages/catalog/src/types.ts +++ b/packages/catalog/src/types.ts @@ -363,6 +363,13 @@ export interface OpenAICompat { * model id. Default: true. Issue #5606. */ supportsSamplingParams?: boolean; + /** + * Whether presence/frequency penalties and stop sequences may be sent. + * First-party xAI `/v1/responses` rejects penalty fields for every model. + * xAI reasoning models also reject them (and `stop`) on chat completions. + * When unset, auto-detected. Default: true. + */ + supportsPenaltyAndStopParams?: boolean; /** Always send a max-token field when the caller did not provide one. Default: auto-detected (Kimi-family models derive TPM limits from max_tokens). */ alwaysSendMaxTokens?: boolean; /** Whether Responses-API tool-call/result history must be strictly paired. Default: auto-detected (Azure OpenAI, GitHub Copilot). */ @@ -578,6 +585,7 @@ export interface ResolvedOpenAISharedCompat { reasoningEffortMap: Partial>; supportsReasoningParams: boolean; supportsSamplingParams: boolean; + supportsPenaltyAndStopParams: boolean; thinkingFormat: OpenAIReasoningFormat; /** Kimi Code transport selected by live per-model protocol metadata. */ kimiApiFormat?: OpenAICompat["kimiApiFormat"]; @@ -643,6 +651,7 @@ export type ResolvedOpenAICompat = ResolvedOpenAISharedCompat & | "reasoningEffortMap" | "supportsReasoningParams" | "supportsSamplingParams" + | "supportsPenaltyAndStopParams" | "thinkingFormat" | "kimiApiFormat" | "reasoningDisableMode" @@ -711,6 +720,12 @@ export interface ResolvedOpenAIResponsesCompat extends ResolvedOpenAISharedCompa strictResponsesPairing: boolean; supportsImageDetailOriginal: boolean; supportsObfuscationOptOut: boolean; + /** + * Whether `reasoning.summary` may be sent. First-party xAI `/v1/responses` + * rejects the field; handlers pass `null` so the wire omits it instead of + * filling `"auto"`. + */ + supportsReasoningSummary: boolean; streamIdleTimeoutMs?: number; vercelGatewayRouting?: OpenAICompat["vercelGatewayRouting"]; /** The model sits behind Vercel AI Gateway's Responses endpoint. */ diff --git a/packages/catalog/test/build.test.ts b/packages/catalog/test/build.test.ts index cf9a672d2..db22929aa 100644 --- a/packages/catalog/test/build.test.ts +++ b/packages/catalog/test/build.test.ts @@ -223,12 +223,15 @@ describe("buildModel", () => { }); }); -describe("xAI-OAuth Responses reasoning-effort suppression", () => { - const grokResponsesSpec = (id: string): ModelSpec<"openai-responses"> => ({ +describe("xAI Responses reasoning-effort suppression", () => { + const grokResponsesSpec = ( + id: string, + provider: "xai" | "xai-oauth" = "xai-oauth", + ): ModelSpec<"openai-responses"> => ({ id, name: id, api: "openai-responses", - provider: "xai-oauth", + provider, baseUrl: "https://api.x.ai/v1", reasoning: true, input: ["text"], @@ -248,6 +251,47 @@ describe("xAI-OAuth Responses reasoning-effort suppression", () => { expect(buildOpenAIResponsesCompat(grokResponsesSpec("grok-4.3")).supportsReasoningEffort).toBe(true); }); + it("applies the same Responses dialect to paid xai and xai-oauth", () => { + const paid = buildOpenAIResponsesCompat(grokResponsesSpec("grok-4.3", "xai")); + const oauth = buildOpenAIResponsesCompat(grokResponsesSpec("grok-4.3", "xai-oauth")); + expect(paid.promptCacheSessionHeader).toBe("x-grok-conv-id"); + expect(oauth.promptCacheSessionHeader).toBe("x-grok-conv-id"); + expect(paid.includeEncryptedReasoning).toBe(true); + expect(oauth.includeEncryptedReasoning).toBe(true); + expect(paid.filterReasoningHistory).toBe(false); + expect(oauth.filterReasoningHistory).toBe(false); + expect(paid.supportsImageDetailOriginal).toBe(false); + expect(oauth.supportsImageDetailOriginal).toBe(false); + expect(paid.supportsReasoningEffort).toBe(true); + expect(oauth.supportsReasoningEffort).toBe(true); + expect(paid.reasoningEffortMap).toEqual({ minimal: "low", xhigh: "high", max: "high" }); + expect(oauth.reasoningEffortMap).toEqual({ minimal: "low", xhigh: "high", max: "high" }); + expect( + buildOpenAIResponsesCompat(grokResponsesSpec("grok-4.20-multi-agent-0309", "xai")).reasoningEffortMap, + ).toEqual({ minimal: "low" }); + expect(paid.supportsPenaltyAndStopParams).toBe(false); + expect(oauth.supportsPenaltyAndStopParams).toBe(false); + expect(paid.supportsReasoningSummary).toBe(false); + expect(oauth.supportsReasoningSummary).toBe(false); + }); + + it("suppresses penalty params on every first-party xAI Responses model", () => { + const reasoning = buildOpenAIResponsesCompat(grokResponsesSpec("grok-4.5", "xai")); + const nonReasoning = buildOpenAIResponsesCompat({ + ...grokResponsesSpec("grok-2", "xai"), + reasoning: false, + }); + expect(reasoning.supportsPenaltyAndStopParams).toBe(false); + expect(nonReasoning.supportsPenaltyAndStopParams).toBe(false); + }); + + it("omits effort for paid xai models off the Grok allowlist", () => { + const compat = buildOpenAIResponsesCompat(grokResponsesSpec("grok-code-fast-1", "xai")); + expect(compat.supportsReasoningEffort).toBe(false); + expect(compat.omitReasoningEffort).toBe(true); + expect(buildModel(grokResponsesSpec("grok-code-fast-1", "xai")).thinking).toBeUndefined(); + }); + it("lets an explicit compat.supportsReasoningEffort override the allowlist default", () => { const compat = buildOpenAIResponsesCompat({ ...grokResponsesSpec("grok-build"), @@ -256,7 +300,7 @@ describe("xAI-OAuth Responses reasoning-effort suppression", () => { expect(compat.supportsReasoningEffort).toBe(true); }); - it("does not suppress effort for a non-xai-oauth provider with a grok-like id", () => { + it("does not suppress effort for a non-xAI provider with a grok-like id", () => { const compat = buildOpenAIResponsesCompat({ ...grokResponsesSpec("grok-build"), provider: "openai", diff --git a/packages/catalog/test/generated-policies.test.ts b/packages/catalog/test/generated-policies.test.ts index 041973480..84b69e6e5 100644 --- a/packages/catalog/test/generated-policies.test.ts +++ b/packages/catalog/test/generated-policies.test.ts @@ -501,6 +501,43 @@ describe("generated model policies", () => { expect(models[2]?.applyPatchToolType).toBeUndefined(); expect(models[3]?.applyPatchToolType).toBeUndefined(); }); + + it("strips paid xAI Responses effort dials for off-allowlist reasoners", () => { + const models: ModelSpec<"openai-responses">[] = [ + createSpec({ + id: "grok-code-fast-1", + api: "openai-responses", + provider: "xai", + thinking: { mode: "effort", efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High] }, + }), + createSpec({ + id: "grok-4.5", + api: "openai-responses", + provider: "xai", + thinking: { mode: "effort", efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High] }, + }), + createSpec({ + id: "grok-code-fast-1", + api: "openai-responses", + provider: "openrouter", + thinking: { mode: "effort", efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High] }, + }), + ]; + + applyGeneratedModelPolicies(models); + + expect(models[0]?.thinking).toBeUndefined(); + expect(models[0]?.compat).toMatchObject({ + supportsReasoningEffort: false, + omitReasoningEffort: true, + }); + expect(models[0]?.compat).not.toHaveProperty("reasoningEffortMap"); + expect(models[1]?.thinking?.efforts).toEqual([Effort.Minimal, Effort.Low, Effort.Medium, Effort.High]); + expect(models[1]?.compat?.supportsReasoningEffort).toBe(true); + // Non-xAI hosts are outside this policy — no baked no-dial compat. + expect(models[2]?.thinking).toBeDefined(); + expect(models[2]?.compat?.supportsReasoningEffort).toBeUndefined(); + }); }); describe("applyOllamaCloudOutputCap", () => { diff --git a/packages/catalog/test/identity-family.test.ts b/packages/catalog/test/identity-family.test.ts index ffba1b095..52b57dedd 100644 --- a/packages/catalog/test/identity-family.test.ts +++ b/packages/catalog/test/identity-family.test.ts @@ -5,7 +5,9 @@ import { isGeminiModelId, isGlmVisionModelId, isGrokModelId, + isGrokMultiAgentModelId, isGrokReasoningEffortCapable, + isGrokXHighEffortCapable, isKimiK26ModelId, isKimiModelId, isMinimaxM2FamilyModelId, @@ -339,6 +341,7 @@ describe("isGrokReasoningEffortCapable", () => { expect(isGrokReasoningEffortCapable("grok-4.20-multi-agent")).toBe(true); expect(isGrokReasoningEffortCapable("xai-oauth/grok-4.3")).toBe(true); expect(isGrokReasoningEffortCapable("xai-oauth/grok-4.5")).toBe(true); + expect(isGrokReasoningEffortCapable("xai-oauth/grok-4.6")).toBe(true); expect(isGrokReasoningEffortCapable("openrouter/xai/grok-3-mini")).toBe(true); }); @@ -349,3 +352,35 @@ describe("isGrokReasoningEffortCapable", () => { expect(isGrokReasoningEffortCapable("")).toBe(false); }); }); + +describe("isGrokMultiAgentModelId", () => { + test("matches grok-4.20-multi-agent SKUs across namespaces", () => { + expect(isGrokMultiAgentModelId("grok-4.20-multi-agent")).toBe(true); + expect(isGrokMultiAgentModelId("grok-4.20-multi-agent-0309")).toBe(true); + expect(isGrokMultiAgentModelId("xai/grok-4.20-multi-agent-beta-latest")).toBe(true); + }); + + test("rejects other Grok ids", () => { + expect(isGrokMultiAgentModelId("grok-4.5")).toBe(false); + expect(isGrokMultiAgentModelId("grok-4.6")).toBe(false); + expect(isGrokMultiAgentModelId("grok-4.20-0309-reasoning")).toBe(false); + expect(isGrokMultiAgentModelId("")).toBe(false); + }); +}); + +describe("isGrokXHighEffortCapable", () => { + test("matches grok-4.6 and multi-agent SKUs across namespaces", () => { + expect(isGrokXHighEffortCapable("grok-4.6")).toBe(true); + expect(isGrokXHighEffortCapable("xai/grok-4.6")).toBe(true); + expect(isGrokXHighEffortCapable("xai-oauth/grok-4.6")).toBe(true); + expect(isGrokXHighEffortCapable("grok-4.20-multi-agent-0309")).toBe(true); + }); + + test("rejects Grok SKUs that clamp leftover xhigh to high", () => { + expect(isGrokXHighEffortCapable("grok-4.5")).toBe(false); + expect(isGrokXHighEffortCapable("grok-4.3")).toBe(false); + expect(isGrokXHighEffortCapable("grok-3-mini")).toBe(false); + expect(isGrokXHighEffortCapable("grok-build")).toBe(false); + expect(isGrokXHighEffortCapable("")).toBe(false); + }); +}); diff --git a/packages/catalog/test/model-thinking.test.ts b/packages/catalog/test/model-thinking.test.ts index e05ec9865..e55e767c0 100644 --- a/packages/catalog/test/model-thinking.test.ts +++ b/packages/catalog/test/model-thinking.test.ts @@ -884,6 +884,65 @@ describe("model thinking runtime helpers", () => { expect(() => requireSupportedEffort(opus46, Effort.XHigh)).toThrow(/not supported/); }); + it("does not expose xhigh on first-party Grok 4.5 Responses models", () => { + const paid = createModel({ + id: "grok-4.5", + api: "openai-responses", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + }); + const oauth = createModel({ + id: "grok-4.5", + api: "openai-responses", + provider: "xai-oauth", + baseUrl: "https://api.x.ai/v1", + }); + + expect(paid.thinking?.efforts).toEqual([Effort.Minimal, Effort.Low, Effort.Medium, Effort.High]); + expect(oauth.thinking?.efforts).toEqual([Effort.Minimal, Effort.Low, Effort.Medium, Effort.High]); + expect(() => requireSupportedEffort(paid, Effort.XHigh)).toThrow(/not supported/); + }); + + it("exposes xhigh on first-party Grok 4.6 Responses models", () => { + const paid = createModel({ + id: "grok-4.6", + api: "openai-responses", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + }); + const oauth = createModel({ + id: "grok-4.6", + api: "openai-responses", + provider: "xai-oauth", + baseUrl: "https://api.x.ai/v1", + }); + + expect(paid.thinking?.efforts).toEqual([Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh]); + expect(oauth.thinking?.efforts).toEqual([Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh]); + expect(requireSupportedEffort(paid, Effort.XHigh)).toBe(Effort.XHigh); + expect(paid.compat.reasoningEffortMap?.xhigh).toBeUndefined(); + }); + + it("exposes xhigh on first-party Grok multi-agent Responses models", () => { + const paid = createModel({ + id: "grok-4.20-multi-agent-beta-latest", + api: "openai-responses", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + }); + const oauth = createModel({ + id: "grok-4.20-multi-agent-0309", + api: "openai-responses", + provider: "xai-oauth", + baseUrl: "https://api.x.ai/v1", + }); + + expect(paid.thinking?.efforts).toEqual([Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh]); + expect(oauth.thinking?.efforts).toEqual([Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh]); + expect(requireSupportedEffort(paid, Effort.XHigh)).toBe(Effort.XHigh); + expect(paid.compat.reasoningEffortMap?.xhigh).toBeUndefined(); + }); + it("rejects effort requests against un-built reasoning specs", () => { const spec = { id: "broken-reasoner", diff --git a/packages/catalog/test/xai-api-key-responses.test.ts b/packages/catalog/test/xai-api-key-responses.test.ts new file mode 100644 index 000000000..c11849cec --- /dev/null +++ b/packages/catalog/test/xai-api-key-responses.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { resolveProviderModels } from "@oh-my-pi/pi-catalog/model-manager"; +import { getBundledModels } from "@oh-my-pi/pi-catalog/models"; +import { CATALOG_PROVIDERS } from "@oh-my-pi/pi-catalog/provider-models/descriptors"; +import { xaiModelManagerOptions } from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; +import type { ModelSpec } from "@oh-my-pi/pi-catalog/types"; + +const XAI_RESPONSES_SPEC: ModelSpec<"openai-responses"> = { + id: "grok-4.5", + name: "Grok 4.5", + api: "openai-responses", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + input: ["text", "image"], + cost: { input: 2, output: 6, cacheRead: 0.3, cacheWrite: 0 }, + contextWindow: 500_000, + maxTokens: 500_000, +}; +const XAI_COMPLETIONS_SPEC: ModelSpec<"openai-completions"> = { + id: "grok-4.5", + name: "Grok 4.5", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + input: ["text", "image"], + cost: { input: 2, output: 6, cacheRead: 0.3, cacheWrite: 0 }, + contextWindow: 500_000, + maxTokens: 500_000, +}; + +describe("paid xai (XAI_API_KEY) Responses contract", () => { + it("registers xai on the catalog Responses discovery path", () => { + const entry = CATALOG_PROVIDERS.find(provider => provider.id === "xai"); + expect(entry, "xai catalog descriptor").toBeDefined(); + expect(entry!.defaultModel).toBe("grok-4.5"); + expect(entry!.envVars).toContain("XAI_API_KEY"); + const options = xaiModelManagerOptions({ apiKey: "test-key" }); + expect(options.providerId).toBe("xai"); + expect(options.fetchDynamicModels, "live /v1/models overlay").toBeTypeOf("function"); + expect(options.dropCachedModelIdsOnStaticMismatch).toEqual(getBundledModels("xai").map(model => model.id)); + expect(options.dropCachedModelIdsOnStaticMismatch).toContain("grok-4.5"); + }); + + it("bundles every paid xai chat model on openai-responses", () => { + const models = getBundledModels("xai"); + expect(models.length).toBeGreaterThan(0); + for (const model of models) { + expect(model.api, `${model.provider}/${model.id}`).toBe("openai-responses"); + expect(model.baseUrl).toBe("https://api.x.ai/v1"); + } + }); + + it("drops stale Chat Completions cache rows so Responses takes effect immediately", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-catalog-xai-completions-cache-")); + const dbPath = path.join(tempDir, "models.db"); + try { + await resolveProviderModels( + { + providerId: "xai", + staticModels: [XAI_COMPLETIONS_SPEC], + fetchDynamicModels: async () => [XAI_COMPLETIONS_SPEC], + cacheDbPath: dbPath, + }, + "online", + ); + + let fetches = 0; + const migrated = await resolveProviderModels( + { + ...xaiModelManagerOptions(), + staticModels: [XAI_RESPONSES_SPEC], + cacheDbPath: dbPath, + fetchDynamicModels: async () => { + fetches += 1; + return [XAI_RESPONSES_SPEC]; + }, + }, + "online-if-uncached", + ); + + expect(fetches).toBe(1); + expect(migrated.models.find(model => model.id === "grok-4.5")?.api).toBe("openai-responses"); + + const offline = await resolveProviderModels( + { + ...xaiModelManagerOptions(), + staticModels: [XAI_RESPONSES_SPEC], + cacheDbPath: dbPath, + fetchDynamicModels: async () => null, + }, + "offline", + ); + expect(offline.models.find(model => model.id === "grok-4.5")?.api).toBe("openai-responses"); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/catalog/test/xai-oauth-bundle.test.ts b/packages/catalog/test/xai-oauth-bundle.test.ts new file mode 100644 index 000000000..534cc488c --- /dev/null +++ b/packages/catalog/test/xai-oauth-bundle.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "bun:test"; +import MODELS_JSON from "@oh-my-pi/pi-catalog/models.json" with { type: "json" }; +import { CATALOG_PROVIDERS, DEFAULT_MODEL_PER_PROVIDER } from "@oh-my-pi/pi-catalog/provider-models/descriptors"; +import { buildXaiOAuthStaticSeed } from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; +import type { ModelSpec } from "@oh-my-pi/pi-catalog/types"; + +// Pins the invariant: bundled `models.json` carries every entry the runtime +// curated catalog (XAI_OAUTH_CURATED_MODELS, surfaced via +// buildXaiOAuthStaticSeed) emits. Without this, editing the curated list +// without regenerating `models.json` silently regresses the boot-time +// default-model resolver — the registry sees the runtime seed only after +// `refresh()`, but interactive boot resolves the persisted default +// synchronously from `#loadModels()`, which reads only `models.json`. +// +// Failure here means: run `bun run gen:models` and commit the diff. +describe("xai-oauth bundled catalog (regression)", () => { + const bundled = + (MODELS_JSON as unknown as Record>>)["xai-oauth"] ?? {}; + const seed = buildXaiOAuthStaticSeed(); + + it("defaults SuperGrok selection to grok-4.5", () => { + const entry = CATALOG_PROVIDERS.find(provider => provider.id === "xai-oauth"); + expect(entry?.defaultModel).toBe("grok-4.5"); + expect(DEFAULT_MODEL_PER_PROVIDER["xai-oauth"]).toBe("grok-4.5"); + expect(bundled["grok-4.5"], "xai-oauth/grok-4.5 must be bundled for the default").toBeDefined(); + }); + + it("bundles every curated id", () => { + const seededIds = seed.map(model => model.id).sort(); + const bundledIds = Object.keys(bundled).sort(); + expect(bundledIds).toEqual(seededIds); + }); + + for (const seededModel of seed) { + it(`matches contract for ${seededModel.id}`, () => { + const bundledEntry = bundled[seededModel.id]; + expect(bundledEntry, `xai-oauth/${seededModel.id} missing from models.json`).toBeDefined(); + expect(bundledEntry.id).toBe(seededModel.id); + expect(bundledEntry.name).toBe(seededModel.name); + expect(bundledEntry.provider).toBe("xai-oauth"); + expect(bundledEntry.api).toBe("openai-responses"); + expect(bundledEntry.contextWindow).toBe(seededModel.contextWindow); + expect(bundledEntry.reasoning).toBe(seededModel.reasoning); + // Input modality must survive both the curated seed and the bundle. + // Without this the static fallback used on offline boot strips + // vision capability silently (Codex PR #1127 review). + expect(bundledEntry.input).toEqual(seededModel.input); + expect(bundledEntry.compat?.supportsReasoningEffort).toBe(seededModel.compat?.supportsReasoningEffort); + }); + } + + // Absolute contract for the user-specified SuperGrok addition. The parity + // loop above can't catch a value typo (e.g. 2_000_000) or a flipped + // reasoning flag — both sides regenerate from the same seed together — so + // pin the literal attributes here. + it("exposes grok-composer-2.5-fast as a non-reasoning 200K text model", () => { + const composer = seed.find(model => model.id === "grok-composer-2.5-fast"); + expect(composer, "grok-composer-2.5-fast must be in the SuperGrok curated seed").toBeDefined(); + expect(composer!.reasoning).toBe(false); + expect(composer!.contextWindow).toBe(200_000); + expect(composer!.input).toEqual(["text"]); + // The bundled models.json entry is byte-identical to the generator's + // deterministic xai-oauth output: gen:models pushes + // buildXaiOAuthStaticSeed() (offline — xai-oauth has no upstream catalog + // source) and applyGeneratedModelPolicies(), so a regen reproduces these + // exact bytes; only unrelated other-provider network churn was excluded + // to keep the diff scoped. Pin its zero-cost invariant (overlay-stable + // for the SuperGrok subscription), which the parity loop above never + // compares. (maxTokens is pinned by the maxTokens-equals-contextWindow + // test below.) + expect(bundled["grok-composer-2.5-fast"]?.cost).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }); + }); + + // The OAuth surface's /v1/models reports no per-request output limit, so the + // curated catalog owns maxTokens — set to mirror each model's contextWindow + // (the openai-responses wire still clamps the actual request to + // OPENAI_MAX_OUTPUT_TOKENS). Pin maxTokens === contextWindow on both the + // static-seed and bundled paths so a null placeholder can + // never silently leak back into the bundle. + it("sets maxTokens equal to contextWindow for every xai-oauth model", () => { + for (const model of seed) { + expect(model.maxTokens, `seed ${model.id} maxTokens`).toBe(model.contextWindow); + expect(bundled[model.id]?.maxTokens, `bundled ${model.id} maxTokens`).toBe(model.contextWindow); + } + }); +}); diff --git a/packages/catalog/test/xai-responses-thinking-policy.test.ts b/packages/catalog/test/xai-responses-thinking-policy.test.ts new file mode 100644 index 000000000..2843552dc --- /dev/null +++ b/packages/catalog/test/xai-responses-thinking-policy.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "bun:test"; +import { Effort } from "@oh-my-pi/pi-catalog/effort"; +import MODELS_JSON from "@oh-my-pi/pi-catalog/models.json" with { type: "json" }; +import { + MODELS_DEV_PROVIDER_DESCRIPTORS, + mapModelsDevToModels, +} from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; +import type { ModelSpec } from "@oh-my-pi/pi-catalog/types"; +import { applyGeneratedModelPolicies } from "../scripts/generated-policies"; + +const XAI_MODELS_DEV_FIXTURE = { + xai: { + models: { + "grok-4.5": { + name: "Grok 4.5", + tool_call: true, + reasoning: true, + modalities: { input: ["text", "image"] }, + limit: { context: 500_000, output: 500_000 }, + cost: { input: 2, output: 6, cache_read: 0.3 }, + }, + "grok-4.6": { + name: "Grok 4.6", + tool_call: true, + reasoning: true, + modalities: { input: ["text", "image"] }, + limit: { context: 500_000, output: 500_000 }, + cost: { input: 2, output: 6, cache_read: 0.5 }, + }, + "grok-code-fast-1": { + name: "Grok Code Fast 1", + tool_call: true, + reasoning: true, + modalities: { input: ["text"] }, + limit: { context: 256_000, output: 10_000 }, + cost: { input: 0.2, output: 1.5 }, + }, + "grok-build-0.1": { + name: "Grok Build 0.1", + tool_call: true, + reasoning: true, + modalities: { input: ["text", "image"] }, + limit: { context: 256_000, output: 256_000 }, + cost: { input: 0, output: 0 }, + }, + "grok-4.20-0309-reasoning": { + name: "Grok 4.20 (Reasoning)", + tool_call: true, + reasoning: true, + modalities: { input: ["text", "image"] }, + limit: { context: 2_000_000, output: 64_000 }, + cost: { input: 2, output: 6 }, + }, + "grok-2": { + name: "Grok 2", + tool_call: true, + reasoning: false, + modalities: { input: ["text"] }, + limit: { context: 131_072, output: 8192 }, + cost: { input: 2, output: 10 }, + }, + "grok-4.20-multi-agent-beta-latest": { + name: "Grok 4.20 (Multi-Agent)", + tool_call: true, + reasoning: true, + modalities: { input: ["text"] }, + limit: { context: 2_000_000, output: 64_000 }, + cost: { input: 2, output: 6 }, + }, + }, + }, +}; + +describe("paid xAI Responses thinking policy", () => { + it("bakes the effort-dial allowlist on stencil.so → openai-responses mapping", () => { + const mapped = mapModelsDevToModels(XAI_MODELS_DEV_FIXTURE, MODELS_DEV_PROVIDER_DESCRIPTORS).filter( + model => model.provider === "xai", + ); + const byId = Object.fromEntries(mapped.map(model => [model.id, model])); + + expect(byId["grok-4.5"]?.api).toBe("openai-responses"); + expect(byId["grok-4.5"]?.compat).toMatchObject({ + supportsReasoningEffort: true, + omitReasoningEffort: false, + reasoningEffortMap: { minimal: "low" }, + }); + for (const id of ["grok-code-fast-1", "grok-build-0.1", "grok-4.20-0309-reasoning"] as const) { + expect(byId[id]?.reasoning, id).toBe(true); + expect(byId[id]?.compat, id).toMatchObject({ + supportsReasoningEffort: false, + omitReasoningEffort: true, + }); + expect(byId[id]?.compat, id).not.toHaveProperty("reasoningEffortMap"); + } + expect(byId["grok-2"]?.compat).toMatchObject({ + supportsReasoningEffort: false, + omitReasoningEffort: true, + }); + expect(byId["grok-2"]?.compat).not.toHaveProperty("reasoningEffortMap"); + }); + + it("strips stale thinking dials from off-allowlist paid xAI reasoners during generation", () => { + const mapped = mapModelsDevToModels(XAI_MODELS_DEV_FIXTURE, MODELS_DEV_PROVIDER_DESCRIPTORS).filter( + model => model.provider === "xai", + ); + // Snapshot-era Completions rows still carry a default effort ladder after the + // api flip; the generator must not re-emit that dial for Responses. + const snapshotStale = mapped.find(model => model.id === "grok-code-fast-1"); + expect(snapshotStale).toBeDefined(); + snapshotStale!.thinking = { mode: "effort", efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High] }; + + applyGeneratedModelPolicies(mapped); + const byId = Object.fromEntries(mapped.map(model => [model.id, model])); + + expect(byId["grok-4.5"]?.thinking).toEqual({ + mode: "effort", + efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High], + effortMap: { minimal: "low" }, + }); + expect(byId["grok-4.6"]?.thinking).toEqual({ + mode: "effort", + efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh], + effortMap: { minimal: "low" }, + }); + expect(byId["grok-4.6"]?.compat).toMatchObject({ + supportsReasoningEffort: true, + reasoningEffortMap: { minimal: "low" }, + }); + expect(byId["grok-4.6"]?.compat).not.toMatchObject({ + reasoningEffortMap: { xhigh: "high" }, + }); + expect(byId["grok-4.20-multi-agent-beta-latest"]?.thinking).toEqual({ + mode: "effort", + efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh], + effortMap: { minimal: "low" }, + }); + expect(byId["grok-4.20-multi-agent-beta-latest"]?.compat).toMatchObject({ + supportsReasoningEffort: true, + reasoningEffortMap: { minimal: "low" }, + }); + expect(byId["grok-4.20-multi-agent-beta-latest"]?.compat).not.toMatchObject({ + reasoningEffortMap: { xhigh: "high" }, + }); + for (const id of ["grok-code-fast-1", "grok-build-0.1", "grok-4.20-0309-reasoning"] as const) { + expect(byId[id]?.reasoning, id).toBe(true); + expect(byId[id]?.thinking, id).toBeUndefined(); + expect(byId[id]?.compat, id).toMatchObject({ supportsReasoningEffort: false }); + } + }); + + it("exports no-dial rows in the bundled models.json snapshot", () => { + const bundled = + (MODELS_JSON as unknown as Record>>).xai ?? {}; + for (const id of ["grok-code-fast-1", "grok-build-0.1", "grok-4.20-0309-reasoning"] as const) { + expect(bundled[id], `xai/${id} missing from models.json`).toBeDefined(); + expect(bundled[id]?.reasoning, id).toBe(true); + expect(bundled[id]?.thinking, id).toBeUndefined(); + expect(bundled[id]?.compat?.supportsReasoningEffort, id).toBe(false); + } + expect(bundled["grok-4.5"]?.thinking?.efforts).toEqual([Effort.Minimal, Effort.Low, Effort.Medium, Effort.High]); + expect(bundled["grok-4.5"]?.thinking?.efforts).not.toContain(Effort.XHigh); + expect(bundled["grok-4.5"]?.compat?.supportsReasoningEffort).toBe(true); + expect(bundled["grok-4.6"]?.thinking?.efforts).toContain(Effort.XHigh); + expect(bundled["grok-4.6"]?.compat).not.toMatchObject({ + reasoningEffortMap: { xhigh: "high" }, + }); + expect(bundled["grok-4.20-multi-agent-beta-latest"]?.thinking?.efforts).toContain(Effort.XHigh); + expect(bundled["grok-4.20-multi-agent-beta-latest"]?.compat).not.toMatchObject({ + reasoningEffortMap: { xhigh: "high" }, + }); + }); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5597d222b..f61f4fc3d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,16 @@ ## [Unreleased] +### Changed + +- Routed paid xAI models (`XAI_API_KEY` / `xai/…`) through the Responses API used by SuperGrok OAuth instead of Chat Completions. +- Changed the default model for `XAI_API_KEY` (`xai`) from `grok-4-fast-non-reasoning` to `grok-4.5`. +- Changed the default model for SuperGrok OAuth (`xai-oauth`) from `grok-4.3` to `grok-4.5`. +- Included `reasoning.encrypted_content` in Responses `include` for paid xAI and SuperGrok OAuth models. +- Replayed encrypted xAI reasoning on follow-up Responses turns for `xai` and `xai-oauth`. +- Kept automatic model selection on paid `xai/grok-4.5` when only `XAI_API_KEY` is set, instead of preferring SuperGrok `xai-oauth/grok-4.5`. Explicit `xai-oauth/grok-4.5` still works with that paid key. +- Stopped sending presence/frequency penalties and stop sequences to xAI reasoning models such as `grok-4.5`, which reject them. + ## [17.3.4] - 2026-08-14 ### Changed diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index f2dd6f1b5..50aa6e713 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -1667,17 +1667,22 @@ export class ModelRegistry { * * Side-effect-free and synchronous: a command-backed key (`!cmd`) counts as * configured by its presence alone — the program is NOT executed — and OAuth - * tokens are NOT refreshed (`authStorage.hasAuth`). This is what keeps the + * tokens are NOT refreshed (`authStorage.hasResolvableAuth`). This is what keeps the * model-switch pre-flight off the event loop's hot path; the real key * (command execution + OAuth refresh) is resolved lazily per request via * {@link ModelRegistry.resolver}. + * + * Cross-provider env aliases count here (`xai-oauth` can borrow `XAI_API_KEY`) + * so an explicit `xai-oauth/…` selector does not fail with "No API key". + * Default-model availability still uses {@link AuthStorage.hasAuth}, which + * ignores that alias so SuperGrok is not auto-selected from a paid key. */ hasConfiguredAuth(model: Model): boolean { const keyConfig = this.#customProviderApiKeys.get(model.provider); return ( isCommandConfigValue(keyConfig) || this.#keylessProviders.has(model.provider) || - this.authStorage.hasAuth(model.provider) + this.authStorage.hasResolvableAuth(model.provider) ); } diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index f8f665441..6d0e2d86d 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -437,6 +437,36 @@ describe("pickDefaultAvailableModel", () => { expect(result?.provider).toBe("zhipu-coding-plan"); expect(result?.id).toBe("glm-5.1"); }); + + test("prefers SuperGrok over paid xAI when both defaults are present", () => { + const paid = buildModel({ + id: "grok-4.5", + name: "Grok 4.5", + api: "openai-responses", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + input: ["text", "image"], + cost: { input: 2, output: 6, cacheRead: 0.3, cacheWrite: 0 }, + contextWindow: 500000, + maxTokens: 500000, + }); + const oauth = buildModel({ + id: "grok-4.5", + name: "Grok 4.5", + api: "openai-responses", + provider: "xai-oauth", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 500000, + maxTokens: 500000, + }); + + expect(pickDefaultAvailableModel([paid, oauth])?.provider).toBe("xai-oauth"); + expect(pickDefaultAvailableModel([paid])?.provider).toBe("xai"); + }); }); describe("parseModelPattern", () => {