diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index 0f2ae0229dc2..8e799ae96c33 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -328,7 +328,7 @@ messages and normalizes `stats.cached` into `cacheRead`; legacy | Venice | `venice` | `VENICE_API_KEY` | - | | Vercel AI Gateway | `vercel-ai-gateway` | `AI_GATEWAY_API_KEY` | `vercel-ai-gateway/anthropic/claude-opus-4.6` | | Volcano Engine (Doubao) | `volcengine` / `volcengine-plan` | `VOLCANO_ENGINE_API_KEY` | `volcengine-plan/ark-code-latest` | -| xAI | `xai` | SuperGrok/X Premium OAuth or `XAI_API_KEY` | `xai/grok-4.3` | +| xAI | `xai` | SuperGrok/X Premium OAuth or `XAI_API_KEY` | OAuth: `xai/auto`; API key: `xai/grok-4.3` | | Xiaomi | `xiaomi` / `xiaomi-token-plan` | `XIAOMI_API_KEY` / `XIAOMI_TOKEN_PLAN_API_KEY` | `xiaomi/mimo-v2.5` / `xiaomi-token-plan/mimo-v2.5-pro` | #### Quirks worth knowing @@ -347,7 +347,7 @@ messages and normalizes `stats.cached` into `cacheRead`; legacy Model ids use a `nvidia//` namespace (for example `nvidia/nvidia/nemotron-...`); pickers preserve the literal `/` composition while the canonical key sent to the API stays single-prefixed. - Uses the xAI Responses path. The recommended path is SuperGrok/X Premium OAuth; API keys still work via `XAI_API_KEY` or plugin config, and Grok `web_search` reuses the same auth profile before API-key fallback. Grok 4.5 is selectable for chat, coding, and agentic work where available; `grok-4.3` remains the regional-safe bundled default. Older `/fast` and `params.fastMode: true` configurations still resolve through xAI's Grok 4.3 compatibility redirects, but new configurations should select a current model directly. `tool_stream` defaults on; disable via `agents.defaults.models["xai/"].params.tool_stream=false`. + Uses the xAI Responses path. The recommended path is SuperGrok/X Premium OAuth; fresh setup selects `xai/auto`, which follows xAI's authenticated default model without an OpenClaw update. Existing concrete model ids stay pinned. API keys still work via `XAI_API_KEY` or plugin config and keep `grok-4.3` as the regional-safe setup default. Grok `web_search` reuses the same auth profile before API-key fallback. Older `/fast` and `params.fastMode: true` configurations still resolve through xAI's Grok 4.3 compatibility redirects, but new configurations should select a current model directly. `tool_stream` defaults on; disable via `agents.defaults.models["xai/"].params.tool_stream=false`. diff --git a/docs/providers/xai.md b/docs/providers/xai.md index bd80cde18dea..d6144e9c911b 100644 --- a/docs/providers/xai.md +++ b/docs/providers/xai.md @@ -41,10 +41,13 @@ OAuth client. openclaw models auth login --provider xai --method oauth ``` - Apply Grok as the default model separately: + With no existing primary model, OAuth setup selects `xai/auto`. The plugin + resolves that stable ref from xAI's authenticated model catalog and remote + default, so future xAI default changes do not require an OpenClaw update. + It preserves an existing primary; opt in explicitly when needed: ```bash - openclaw models set xai/grok-4.3 + openclaw models set xai/auto ``` Rerun full onboarding only if you intentionally want to change Gateway, @@ -53,7 +56,8 @@ OAuth client. API-key setup still works for xAI Console keys and for media surfaces - that need key-backed provider config: + that need key-backed provider config. It keeps Grok 4.3 as the + regional-safe setup default: ```bash openclaw models auth login --provider xai --method api-key @@ -64,7 +68,7 @@ OAuth client. ```json5 { - agents: { defaults: { model: { primary: "xai/grok-4.3" } } }, + agents: { defaults: { model: { primary: "xai/auto" } } }, } ``` @@ -85,7 +89,8 @@ bundled xAI model provider reuses it as a fallback too. `openclaw models auth login --provider xai --method oauth`; it uses device-code verification, not a localhost callback. - If sign-in succeeds but Grok is not the default model, run - `openclaw models set xai/grok-4.3`. + `openclaw models set xai/auto`. OAuth login preserves an existing + primary model unless you explicitly change it. - Inspect saved xAI auth profiles: ```bash @@ -116,9 +121,10 @@ see [legacy compatibility and moving aliases](#legacy-compatibility-and-moving-a | Grok 4.20 | `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning` | -Use `grok-4.5` for general chat, coding, and agentic work where it is available. -Grok 4.3 remains the regional-safe setup default; `grok-build-0.1` and both -dated Grok 4.20 variants remain selectable. +Use `xai/auto` to follow xAI's authenticated OAuth default, or select a concrete +id such as `xai/grok-4.5` to remain pinned. API-key setup keeps Grok 4.3 as the +regional-safe default; `grok-build-0.1` and both dated Grok 4.20 variants remain +selectable. Catalog context and token-cost metadata follows xAI's live diff --git a/extensions/memory-core/doctor-contract-api.test.ts b/extensions/memory-core/doctor-contract-api.test.ts index 905a920718a3..0313a36d9c61 100644 --- a/extensions/memory-core/doctor-contract-api.test.ts +++ b/extensions/memory-core/doctor-contract-api.test.ts @@ -99,6 +99,18 @@ function vectorToBlob(embedding: number[]): Buffer { return Buffer.from(new Float32Array(embedding).buffer); } +function insertCanonicalChunkProvenance( + db: DatabaseSync, + chunkId: string, + observedAt: number, +): void { + db.prepare( + `INSERT INTO memory_index_chunk_provenance ( + chunk_id, origin_class, session_kind, observed_at + ) VALUES (?, 'agent', 'unknown', ?)`, + ).run(chunkId, observedAt); +} + async function writeLegacyMemorySidecar( legacyPath: string, params: { @@ -222,6 +234,7 @@ async function createCanonicalMemoryIndex(agentPath: string, text: string): Prom db.prepare( "INSERT INTO memory_index_chunks_fts (text, id, path, source, model, start_line, end_line) VALUES (?, ?, ?, ?, ?, ?, ?)", ).run(text, "canonical-chunk", "MEMORY.md", "memory", "embed-model", 1, 1); + insertCanonicalChunkProvenance(db, "canonical-chunk", 31); } finally { db.close(); } @@ -271,6 +284,7 @@ async function createUnrelatedCanonicalMemoryIndex( 1, 1, ); + insertCanonicalChunkProvenance(db, "canonical-other-chunk", 31); } finally { db.close(); } @@ -309,6 +323,7 @@ async function createCanonicalLegacyMemoryRowsWithFts(agentPath: string, ftsText db.prepare( "INSERT INTO memory_index_chunks_fts (text, id, path, source, model, start_line, end_line) VALUES (?, ?, ?, ?, ?, ?, ?)", ).run(ftsText, "chunk-1", "MEMORY.md", "memory", "embed-model", 1, 2); + insertCanonicalChunkProvenance(db, "chunk-1", 30); } finally { db.close(); } @@ -1415,7 +1430,7 @@ describe("memory-core doctor dreaming migration", () => { expect.stringContaining("Archived Memory Core legacy memory index sidecar"), ]); expect(readMemoryRows(agentPath)).toEqual({ - sources: [{ path: "MEMORY.md", source: "memory", hash: "file-hash" }], + sources: [{ path: "MEMORY.md", source: "memory", hash: "" }], chunks: [{ id: "chunk-1", text: "remember this" }], cache: [{ provider: "openai", hash: "chunk-hash" }], }); @@ -1483,7 +1498,7 @@ describe("memory-core doctor dreaming migration", () => { expect.stringContaining("Archived Memory Core legacy memory index sidecar"), ]); expect(readMemoryRows(agentPath)).toEqual({ - sources: [{ path: "MEMORY.md", source: "memory", hash: "file-hash" }], + sources: [{ path: "MEMORY.md", source: "memory", hash: "" }], chunks: [{ id: "chunk-1", text: "remember this" }], cache: [{ provider: "openai", hash: "chunk-hash" }], }); @@ -1658,7 +1673,7 @@ describe("memory-core doctor dreaming migration", () => { ]); for (const agentPath of [mainAgentPath, workAgentPath]) { expect(readMemoryRows(agentPath)).toEqual({ - sources: [{ path: "MEMORY.md", source: "memory", hash: "file-hash" }], + sources: [{ path: "MEMORY.md", source: "memory", hash: "" }], chunks: [{ id: "chunk-1", text: "remember this" }], cache: [{ provider: "openai", hash: "chunk-hash" }], }); @@ -1781,7 +1796,7 @@ describe("memory-core doctor dreaming migration", () => { "Migrated Memory Core legacy memory index for agent main -> per-agent SQLite (1 source(s), 1 chunk(s), 1 cache row(s))", ]); expect(readMemoryRows(agentPath)).toEqual({ - sources: [{ path: "MEMORY.md", source: "memory", hash: "file-hash" }], + sources: [{ path: "MEMORY.md", source: "memory", hash: "" }], chunks: [{ id: "chunk-1", text: "remember this" }], cache: [{ provider: "openai", hash: "chunk-hash" }], }); @@ -1970,24 +1985,15 @@ describe("memory-core doctor dreaming migration", () => { expect(result.changes).toContain( `Copied Memory Core legacy memory index sidecar retry path -> ${alternateRetryPath}`, ); - expect(retryPreview?.preview).toEqual( - expect.arrayContaining([ - `- Memory Core legacy memory index: ${retryPath} -> ${path.join( - stateDir, - "agents", - "main", - "agent", - "openclaw-agent.sqlite", - )}`, - `- Memory Core legacy memory index: ${alternateRetryPath} -> ${path.join( - stateDir, - "agents", - "main", - "agent", - "openclaw-agent.sqlite", - )}`, - ]), - ); + expect(retryPreview?.preview).toEqual([ + `- Memory Core legacy memory index: ${alternateRetryPath} -> ${path.join( + stateDir, + "agents", + "main", + "agent", + "openclaw-agent.sqlite", + )}`, + ]); await expect(fs.access(legacyPath)).resolves.toBeUndefined(); await expect(fs.access(alternateRetryPath)).resolves.toBeUndefined(); @@ -2005,7 +2011,7 @@ describe("memory-core doctor dreaming migration", () => { expect.stringContaining("Copied Memory Core legacy memory index sidecar retry path"), ]), ); - expect(retryEntriesAfter).toEqual(retryEntriesBefore); + expect(retryEntriesAfter).toEqual(retryEntriesBefore.map((entry) => `${entry}.migrated`)); }); it("keeps canonical rows and archives a conflicting derived legacy index", async () => { @@ -2192,7 +2198,7 @@ describe("memory-core doctor dreaming migration", () => { ]); expect(readMemoryRows(agentPath)).toEqual({ sources: [ - { path: "MEMORY.md", source: "memory", hash: "file-hash" }, + { path: "MEMORY.md", source: "memory", hash: "" }, { path: "OTHER.md", source: "memory", hash: "canonical-other-file-hash" }, ], chunks: [ @@ -2259,7 +2265,7 @@ describe("memory-core doctor dreaming migration", () => { ]); expect(readMemoryRows(agentPath)).toEqual({ sources: [ - { path: "MEMORY.md", source: "memory", hash: "file-hash" }, + { path: "MEMORY.md", source: "memory", hash: "" }, { path: "OTHER.md", source: "memory", hash: "canonical-other-file-hash" }, ], chunks: [ diff --git a/extensions/memory-core/src/memory/manager-search.test.ts b/extensions/memory-core/src/memory/manager-search.test.ts index c99ccc4bd1f7..a0ab92d910a8 100644 --- a/extensions/memory-core/src/memory/manager-search.test.ts +++ b/extensions/memory-core/src/memory/manager-search.test.ts @@ -69,10 +69,10 @@ describe("memory search provenance", () => { endLine: 1, }); db.prepare( - `UPDATE memory_index_chunk_provenance - SET origin_class = ?, session_kind = ?, observed_at = ?, supersedes_key = ? - WHERE chunk_id = ?`, - ).run("owner", "interactive", 1234, "tea-preference", "provenance-hit"); + `INSERT INTO memory_index_chunk_provenance ( + chunk_id, origin_class, session_kind, observed_at, supersedes_key + ) VALUES (?, ?, ?, ?, ?)`, + ).run("provenance-hit", "owner", "interactive", 1234, "tea-preference"); const results = await searchKeyword({ db, diff --git a/extensions/qa-lab/src/test-file-scenario-runner.test.ts b/extensions/qa-lab/src/test-file-scenario-runner.test.ts index 3f3d763ca005..c7bc85230128 100644 --- a/extensions/qa-lab/src/test-file-scenario-runner.test.ts +++ b/extensions/qa-lab/src/test-file-scenario-runner.test.ts @@ -535,7 +535,7 @@ describe("qa test file scenario runner", () => { it.each([ { evidence: "missing", expectedFailure: /without writing fresh producer QA evidence/u }, - { evidence: "stale", expectedFailure: /not written by the current scenario run/u }, + { evidence: "stale", expectedFailure: /without writing fresh producer QA evidence/u }, { evidence: "empty", expectedFailure: /without reporting an executed producer check/u }, { evidence: "malformed", expectedFailure: /invalid JSON/u }, { evidence: "outside", expectedFailure: /inside its scenario output directory/u }, diff --git a/extensions/qa-lab/src/test-file-scenario-runner.ts b/extensions/qa-lab/src/test-file-scenario-runner.ts index 36b40e0732ed..152ba80291e4 100644 --- a/extensions/qa-lab/src/test-file-scenario-runner.ts +++ b/extensions/qa-lab/src/test-file-scenario-runner.ts @@ -354,16 +354,12 @@ async function runQaTestFileScenario(params: { }) { const requiresProducerEvidence = params.scenario.execution.kind === "script" && !isDockerE2eScenario(params.scenario); - let producerEvidenceStartedAtMs: number | undefined; if (requiresProducerEvidence) { const scenarioOutputDir = path.join(params.outputDir, params.scenario.id); - // Both producer indexes belong to one command invocation. Clear them before - // launch so a successful no-op cannot authenticate an earlier scenario run. - await Promise.all([ - fs.rm(path.join(scenarioOutputDir, "latest-run.json"), { force: true }), - fs.rm(path.join(scenarioOutputDir, QA_EVIDENCE_FILENAME), { force: true }), - ]); - producerEvidenceStartedAtMs = Date.now(); + // The whole producer artifact root belongs to one command invocation. Clear + // it so neither a stale index nor a stale bundle can authenticate a no-op. + await fs.rm(scenarioOutputDir, { force: true, recursive: true }); + await fs.mkdir(scenarioOutputDir, { recursive: true }); } const definition = testFileRunnerDefinitions[params.scenario.execution.kind]; const result = await runScenarioCommandSteps({ @@ -379,7 +375,7 @@ async function runQaTestFileScenario(params: { outputDir: params.outputDir, repoRoot: params.repoRoot, scenario: params.scenario, - producerEvidenceStartedAtMs, + requireCurrentRunEvidence: requiresProducerEvidence, }); } catch (error) { if (result.status !== "pass") { diff --git a/extensions/qa-lab/src/test-file-scenario-script-evidence.ts b/extensions/qa-lab/src/test-file-scenario-script-evidence.ts index bd53c114101a..15eed2fc92ab 100644 --- a/extensions/qa-lab/src/test-file-scenario-script-evidence.ts +++ b/extensions/qa-lab/src/test-file-scenario-script-evidence.ts @@ -84,14 +84,14 @@ function assertScenarioOwnsEvidencePath(scenarioOutputDir: string, evidencePath: export async function readScriptProducerEvidence(params: { outputDir: string; - producerEvidenceStartedAtMs?: number; + requireCurrentRunEvidence?: boolean; repoRoot: string; scenario: { id: string }; }): Promise<{ producerEvidence?: QaEvidenceSummaryJson }> { const scenarioOutputDir = path.join(params.outputDir, params.scenario.id); const latestRun = await readJsonFileIfExists(path.join(scenarioOutputDir, "latest-run.json")); if ( - params.producerEvidenceStartedAtMs !== undefined && + params.requireCurrentRunEvidence === true && latestRun !== undefined && (latestRun === null || typeof latestRun !== "object" || @@ -117,7 +117,7 @@ export async function readScriptProducerEvidence(params: { const evidencePath = path.isAbsolute(candidate) ? candidate : path.join(scenarioOutputDir, candidate); - if (params.producerEvidenceStartedAtMs !== undefined) { + if (params.requireCurrentRunEvidence === true) { assertScenarioOwnsEvidencePath(scenarioOutputDir, evidencePath); const evidenceStat = await fs.stat(evidencePath).catch((error: unknown) => { if ((error as NodeJS.ErrnoException).code === "ENOENT") { @@ -128,9 +128,6 @@ export async function readScriptProducerEvidence(params: { if (!evidenceStat) { continue; } - if (evidenceStat.mtimeMs < params.producerEvidenceStartedAtMs) { - throw new Error("producer evidence was not written by the current scenario run"); - } assertScenarioOwnsEvidencePath( await fs.realpath(scenarioOutputDir), await fs.realpath(evidencePath), diff --git a/extensions/xai/index.test.ts b/extensions/xai/index.test.ts index a3e970f4045f..a05cd2b74b58 100644 --- a/extensions/xai/index.test.ts +++ b/extensions/xai/index.test.ts @@ -20,7 +20,7 @@ vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => providerAuthRuntimeMo import plugin from "./index.js"; import manifest from "./openclaw.plugin.json" with { type: "json" }; -import { buildLiveXaiProvider } from "./provider-catalog.js"; +import { buildLiveXaiOAuthProvider, buildLiveXaiProvider } from "./provider-catalog.js"; import setupPlugin from "./setup-api.js"; import { createXaiPayloadCaptureStream, @@ -192,8 +192,12 @@ describe("xai provider plugin", () => { source: "profile:xai-profile", profileId: "xai-profile", }); - const fetchMock = vi.fn(async () => - Response.json({ + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith("/settings")) { + return Response.json({ default_model: "grok-build" }); + } + return Response.json({ data: [ { id: "grok-composer-2.5-fast", @@ -216,8 +220,8 @@ describe("xai provider plugin", () => { api_backend: "image", }, ], - }), - ); + }); + }); vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); const provider = await registerSingleProviderPlugin(plugin); @@ -246,9 +250,18 @@ describe("xai provider plugin", () => { expect(result.provider.auth).toBe("oauth"); expect(result.provider.apiKey).toBeUndefined(); expect(result.provider.models.map((model) => model.id)).toEqual([ + "auto", "grok-composer-2.5-fast", "grok-build", ]); + const auto = result.provider.models.find((model) => model.id === "auto"); + expect(auto?.params?.canonicalModelId).toBe("grok-build"); + const normalizedAuto = provider.normalizeResolvedModel?.({ + provider: "xai", + modelId: "auto", + model: { ...auto, provider: "xai" }, + } as never); + expect(normalizedAuto?.id).toBe("grok-build"); const composer = result.provider.models.find((model) => model.id === "grok-composer-2.5-fast"); if (!composer) { throw new Error("expected OAuth Composer model"); @@ -284,11 +297,53 @@ describe("xai provider plugin", () => { profileId: "xai-profile", lockedProfile: true, }); - const fetchCall = fetchMock.mock.calls[0] as unknown as [string, RequestInit] | undefined; - expect(fetchCall?.[0]).toBe("https://cli-chat-proxy.grok.com/v1/models"); - expect(new Headers(fetchCall?.[1]?.headers).get("Authorization")).toBe( + const modelFetchCall = fetchMock.mock.calls.find( + ([input]) => input === "https://cli-chat-proxy.grok.com/v1/models", + ) as unknown as [string, RequestInit] | undefined; + const settingsFetchCall = fetchMock.mock.calls.find( + ([input]) => input === "https://cli-chat-proxy.grok.com/v1/settings", + ) as unknown as [string, RequestInit] | undefined; + expect(new Headers(modelFetchCall?.[1]?.headers).get("Authorization")).toBe( "Bearer xai-oauth-token", ); + expect(new Headers(settingsFetchCall?.[1]?.headers).get("Authorization")).toBe( + "Bearer xai-oauth-token", + ); + }); + + it("updates xAI OAuth auto from remote settings without a catalog code change", async () => { + let remoteDefault = "grok-4-5"; + const release = vi.fn(async () => undefined); + const fetchGuard: LiveModelCatalogFetchGuard = vi.fn(async ({ url }) => ({ + response: url.endsWith("/settings") + ? Response.json({ default_model: remoteDefault }) + : Response.json({ + data: [ + { id: "grok-4.5", api_backend: "responses" }, + { id: "grok-next", api_backend: "responses" }, + ], + }), + finalUrl: url, + release, + })); + + const first = await buildLiveXaiOAuthProvider({ + discoveryApiKey: "xai-oauth-token", + fetchGuard, + }); + expect(first.models.find((model) => model.id === "auto")?.params?.canonicalModelId).toBe( + "grok-4.5", + ); + + clearLiveCatalogCacheForTests(); + remoteDefault = "grok-next"; + const next = await buildLiveXaiOAuthProvider({ + discoveryApiKey: "xai-oauth-token", + fetchGuard, + }); + expect(next.models.find((model) => model.id === "auto")?.params?.canonicalModelId).toBe( + "grok-next", + ); }); it("uses runtime OAuth profiles when xAI catalog auth resolution is empty", async () => { @@ -328,7 +383,8 @@ describe("xai provider plugin", () => { } expect(result.provider.baseUrl).toBe("https://cli-chat-proxy.grok.com/v1"); expect(result.provider.auth).toBe("oauth"); - expect(result.provider.models.map((model) => model.id)).toEqual(["grok-build"]); + expect(result.provider.models.map((model) => model.id)).toEqual(["auto", "grok-build"]); + expect(result.provider.models[0]?.params?.canonicalModelId).toBe("grok-build"); expect(providerAuthRuntimeMocks.resolveApiKeyForProvider).toHaveBeenCalledWith({ provider: "xai", cfg: { models: {} }, @@ -376,6 +432,7 @@ describe("xai provider plugin", () => { expect(result.provider.baseUrl).toBe("https://cli-chat-proxy.grok.com/v1"); expect(result.provider.auth).toBe("oauth"); expect(result.provider.apiKey).toBeUndefined(); + expect(result.provider.models.map((model) => model.id)).toContain("auto"); expect(result.provider.models.map((model) => model.id)).toContain("grok-build-0.1"); }); @@ -415,6 +472,8 @@ describe("xai provider plugin", () => { expect(result.provider.baseUrl).toBe("https://api.x.ai/v1"); expect(result.provider.apiKey).toBe("env-xai-key"); expect(result.provider.auth).toBeUndefined(); + expect(result.provider.models.map((model) => model.id)).toEqual(["grok-4.3"]); + expect(result.provider.models.some((model) => model.id === "auto")).toBe(false); const fetchCall = fetchMock.mock.calls[0] as unknown as [string, RequestInit] | undefined; expect(fetchCall?.[0]).toBe("https://api.x.ai/v1/models"); expect(new Headers(fetchCall?.[1]?.headers).get("Authorization")).toBe("Bearer env-xai-key"); @@ -769,6 +828,7 @@ describe("xai provider plugin", () => { modelId: "grok-4.3", model: createProviderModel({ id: "grok-4.3" }), } as never); + expect(normalized?.id).toBe("grok-4.3"); expect(normalized?.thinkingLevelMap).toEqual({ off: "none", minimal: "low", diff --git a/extensions/xai/index.ts b/extensions/xai/index.ts index 2a139d1b5af3..ba50572ff912 100644 --- a/extensions/xai/index.ts +++ b/extensions/xai/index.ts @@ -6,7 +6,6 @@ import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-mod import { defaultToolStreamExtraParams } from "openclaw/plugin-sdk/provider-stream-shared"; import { jsonResult } from "openclaw/plugin-sdk/provider-web-search"; import { - applyXaiRuntimeModelCompat, buildXaiImageGenerationProvider, normalizeXaiModelId, resolveXaiTransport, @@ -22,7 +21,11 @@ import { buildXaiProvider, } from "./provider-catalog.js"; import { isXaiProviderId } from "./provider-id.js"; -import { isModernXaiModel, resolveXaiForwardCompatModel } from "./provider-models.js"; +import { + isModernXaiModel, + normalizeXaiResolvedModel, + resolveXaiForwardCompatModel, +} from "./provider-models.js"; import { resolveThinkingProfile } from "./provider-policy-api.js"; import { buildXaiRealtimeTranscriptionProvider } from "./realtime-transcription-provider.js"; import { buildXaiRealtimeVoiceProvider } from "./realtime-voice-provider.js"; @@ -172,7 +175,7 @@ export default defineSingleProviderPluginEntry({ id: "xai", name: "xAI Plugin", description: "Bundled xAI plugin", - provider: { + provider: (pluginApi) => ({ label: "xAI", aliases: ["x-ai"], docsPath: "/providers/xai", @@ -251,7 +254,10 @@ export default defineSingleProviderPluginEntry({ }, ...buildProviderReplayFamilyHooks({ family: "openai-compatible" }), prepareExtraParams: (ctx) => defaultToolStreamExtraParams(ctx.extraParams), - wrapStreamFn: wrapXaiProviderStream, + wrapStreamFn: (ctx) => + wrapXaiProviderStream(ctx, { + clientVersion: pluginApi.runtime.version, + }), // Provider-specific fallback auth stays owned by the xAI plugin so core // auth/discovery code can consume it generically without parsing xAI's // private config layout. Callers may receive a real key from the active @@ -267,7 +273,7 @@ export default defineSingleProviderPluginEntry({ mode: "api-key" as const, }; }, - normalizeResolvedModel: ({ model }) => applyXaiRuntimeModelCompat(model), + normalizeResolvedModel: ({ model }) => normalizeXaiResolvedModel(model), normalizeTransport: ({ provider, api, baseUrl }) => resolveXaiTransport({ provider, api, baseUrl }), normalizeModelId: ({ modelId }) => normalizeXaiModelId(modelId), @@ -276,7 +282,7 @@ export default defineSingleProviderPluginEntry({ resolveThinkingProfile, isModernModelRef: ({ modelId }) => isModernXaiModel(modelId), classifyFailoverReason: ({ errorMessage }) => classifyXaiFailoverReason(errorMessage), - }, + }), register(api) { api.registerWebSearchProvider(createXaiWebSearchProvider()); api.registerMediaUnderstandingProvider(buildXaiMediaUnderstandingProvider()); diff --git a/extensions/xai/model-id.ts b/extensions/xai/model-id.ts index 51088ea0b454..b6c1813de1d7 100644 --- a/extensions/xai/model-id.ts +++ b/extensions/xai/model-id.ts @@ -1,4 +1,6 @@ // Xai plugin module implements model id behavior. +export const XAI_OAUTH_AUTO_MODEL_ID = "auto"; + export function normalizeXaiModelId(id: string): string { if (id === "grok-4.3-latest") { return "grok-4.3"; diff --git a/extensions/xai/onboard.test.ts b/extensions/xai/onboard.test.ts index dc8caaaa84e9..6aa5e10fefbd 100644 --- a/extensions/xai/onboard.test.ts +++ b/extensions/xai/onboard.test.ts @@ -9,13 +9,20 @@ import { EXPECTED_FALLBACKS, } from "openclaw/plugin-sdk/provider-test-contracts"; import { describe, expect, it } from "vitest"; -import { applyXaiConfig, applyXaiProviderConfig, XAI_DEFAULT_MODEL_REF } from "./onboard.js"; +import { + applyXaiConfig, + applyXaiOAuthConfig, + applyXaiProviderConfig, + XAI_DEFAULT_MODEL_REF, + XAI_OAUTH_DEFAULT_MODEL_REF, +} from "./onboard.js"; describe("xai onboard", () => { it("adds xAI provider with correct settings", () => { const cfg = applyXaiConfig({}); expect(cfg.models?.providers?.xai?.baseUrl).toBe("https://api.x.ai/v1"); expect(cfg.models?.providers?.xai?.api).toBe("openai-responses"); + expect(XAI_DEFAULT_MODEL_REF).toBe("xai/grok-4.3"); expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(XAI_DEFAULT_MODEL_REF); }); @@ -102,6 +109,14 @@ describe("xai onboard", () => { expect(cfg.agents?.defaults?.models?.[XAI_DEFAULT_MODEL_REF]?.alias).toBe("Grok"); }); + it("persists the provider-owned auto ref for OAuth setup", () => { + const cfg = applyXaiOAuthConfig({}); + + expect(XAI_OAUTH_DEFAULT_MODEL_REF).toBe("xai/auto"); + expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe("xai/auto"); + expect(cfg.agents?.defaults?.models?.["xai/auto"]?.alias).toBe("Grok"); + }); + it("preserves existing model fallbacks", () => { const cfg = applyXaiConfig(createConfigWithFallbacks()); expect(resolveAgentModelFallbackValues(cfg.agents?.defaults?.model)).toEqual([ diff --git a/extensions/xai/onboard.ts b/extensions/xai/onboard.ts index 0132fd19515d..f0e9c712008b 100644 --- a/extensions/xai/onboard.ts +++ b/extensions/xai/onboard.ts @@ -9,21 +9,28 @@ import { XAI_BASE_URL, XAI_DEFAULT_MODEL_ID, } from "./model-definitions.js"; +import { XAI_OAUTH_AUTO_MODEL_ID } from "./model-id.js"; export const XAI_DEFAULT_MODEL_REF = `xai/${XAI_DEFAULT_MODEL_ID}`; +// OAuth resolves this stable ref against xAI's authenticated catalog and +// remote default setting. API-key setup stays on the pinned default above. +export const XAI_OAUTH_DEFAULT_MODEL_REF = `xai/${XAI_OAUTH_AUTO_MODEL_ID}`; -const xaiPresetAppliers = createModelCatalogPresetAppliers< - ["openai-completions" | "openai-responses"] ->({ - primaryModelRef: XAI_DEFAULT_MODEL_REF, - resolveParams: (_cfg: OpenClawConfig, api) => ({ - providerId: "xai", - api, - baseUrl: XAI_BASE_URL, - catalogModels: buildXaiCatalogModels(), - aliases: [{ modelRef: XAI_DEFAULT_MODEL_REF, alias: "Grok" }], - }), -}); +function createXaiPresetAppliers(primaryModelRef: string) { + return createModelCatalogPresetAppliers<["openai-completions" | "openai-responses"]>({ + primaryModelRef, + resolveParams: (_cfg: OpenClawConfig, api) => ({ + providerId: "xai", + api, + baseUrl: XAI_BASE_URL, + catalogModels: buildXaiCatalogModels(), + aliases: [{ modelRef: primaryModelRef, alias: "Grok" }], + }), + }); +} + +const xaiPresetAppliers = createXaiPresetAppliers(XAI_DEFAULT_MODEL_REF); +const xaiOAuthPresetAppliers = createXaiPresetAppliers(XAI_OAUTH_DEFAULT_MODEL_REF); function pruneRetiredXaiBuiltinModels(cfg: OpenClawConfig): OpenClawConfig { const provider = cfg.models?.providers?.xai; @@ -59,3 +66,7 @@ export function applyXaiProviderConfig(cfg: OpenClawConfig): OpenClawConfig { export function applyXaiConfig(cfg: OpenClawConfig): OpenClawConfig { return xaiPresetAppliers.applyConfig(pruneRetiredXaiBuiltinModels(cfg), "openai-responses"); } + +export function applyXaiOAuthConfig(cfg: OpenClawConfig): OpenClawConfig { + return xaiOAuthPresetAppliers.applyConfig(pruneRetiredXaiBuiltinModels(cfg), "openai-responses"); +} diff --git a/extensions/xai/provider-catalog.ts b/extensions/xai/provider-catalog.ts index fd535cae8a42..d69ac4b92871 100644 --- a/extensions/xai/provider-catalog.ts +++ b/extensions/xai/provider-catalog.ts @@ -1,6 +1,7 @@ // Xai provider module implements model/runtime integration. import { buildLiveModelProviderConfig, + getCachedLiveProviderModelRows, type LiveModelCatalogFetchGuard, } from "openclaw/plugin-sdk/provider-catalog-live-runtime"; import type { @@ -15,11 +16,13 @@ import { XAI_IMAGE_MODELS, XAI_DEFAULT_MAX_TOKENS, } from "./model-definitions.js"; +import { XAI_OAUTH_AUTO_MODEL_ID } from "./model-id.js"; const PROVIDER_ID = "xai"; const XAI_MODELS_ENDPOINT = `${XAI_BASE_URL}/models`; -const XAI_GROK_OAUTH_BASE_URL = "https://cli-chat-proxy.grok.com/v1"; +export const XAI_GROK_OAUTH_BASE_URL = "https://cli-chat-proxy.grok.com/v1"; const XAI_GROK_OAUTH_MODELS_ENDPOINT = `${XAI_GROK_OAUTH_BASE_URL}/models`; +const XAI_GROK_OAUTH_SETTINGS_ENDPOINT = `${XAI_GROK_OAUTH_BASE_URL}/settings`; const XAI_MODELS_CACHE_TTL_MS = 60_000; const XAI_GROK_OAUTH_MODELS_CACHE_TTL_MS = 60_000; // Composer emits replayable Responses reasoning, but the OAuth catalog omits that capability. @@ -51,6 +54,100 @@ function buildXaiOAuthFallbackProvider(): ModelProviderConfig { }; } +function normalizeXaiOAuthModelSelector(value: string): string { + return value.trim().toLowerCase().replace(/[._]+/g, "-"); +} + +function resolveXaiOAuthAutoTarget( + models: readonly ModelDefinitionConfig[], + preferredModelId: string | undefined, +): ModelDefinitionConfig | undefined { + const candidates = models.filter((model) => model.id !== XAI_OAUTH_AUTO_MODEL_ID); + if (preferredModelId) { + const exact = candidates.find((model) => model.id === preferredModelId); + if (exact) { + return exact; + } + const selector = normalizeXaiOAuthModelSelector(preferredModelId); + const normalizedMatches = candidates.filter( + (model) => normalizeXaiOAuthModelSelector(model.id) === selector, + ); + if (normalizedMatches.length === 1) { + return normalizedMatches[0]; + } + } + // Match Grok Build's fallback when its remote default is absent or stale: + // use the first auth-visible model returned by the provider catalog. + return candidates[0]; +} + +function withXaiOAuthAutoModel( + provider: ModelProviderConfig, + preferredModelId: string | undefined, +): ModelProviderConfig { + const target = resolveXaiOAuthAutoTarget(provider.models, preferredModelId); + if (!target) { + return provider; + } + return { + ...provider, + models: [ + { + ...target, + id: XAI_OAUTH_AUTO_MODEL_ID, + params: { + ...target.params, + canonicalModelId: target.id, + }, + }, + ...provider.models.filter((model) => model.id !== XAI_OAUTH_AUTO_MODEL_ID), + ], + }; +} + +function readXaiOAuthDefaultModelId(value: unknown): string | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + return readLiveModelString(value, "default_model"); +} + +async function fetchXaiOAuthDefaultModelId(params: { + discoveryApiKey: string; + fetchGuard?: LiveModelCatalogFetchGuard; + signal?: AbortSignal; +}): Promise { + try { + const rows = await getCachedLiveProviderModelRows({ + providerId: PROVIDER_ID, + endpoint: XAI_GROK_OAUTH_SETTINGS_ENDPOINT, + discoveryApiKey: params.discoveryApiKey, + fetchGuard: params.fetchGuard, + signal: params.signal, + ttlMs: XAI_GROK_OAUTH_MODELS_CACHE_TTL_MS, + auditContext: "xai-grok-oauth-settings-discovery", + cacheKeyParts: [ + PROVIDER_ID, + "grok-oauth-settings", + XAI_GROK_OAUTH_SETTINGS_ENDPOINT, + params.discoveryApiKey, + ], + readRows: (body) => { + if (!body || typeof body !== "object" || Array.isArray(body)) { + throw new Error("xAI OAuth settings response must be an object"); + } + return [body]; + }, + shouldCacheRows: (candidateRows) => + readXaiOAuthDefaultModelId(candidateRows[0]) !== undefined, + }); + return readXaiOAuthDefaultModelId(rows[0]); + } catch { + // Remote settings are advisory. Catalog order remains the provider-owned fallback. + return undefined; + } +} + export async function buildLiveXaiProvider(params: { apiKey?: string; discoveryApiKey?: string; @@ -177,29 +274,33 @@ export async function buildLiveXaiOAuthProvider(params: { signal?: AbortSignal; }): Promise { const fallback = buildXaiOAuthFallbackProvider(); - return await buildLiveModelProviderConfig({ - providerId: PROVIDER_ID, - endpoint: XAI_GROK_OAUTH_MODELS_ENDPOINT, - providerConfig: { - baseUrl: fallback.baseUrl, - api: fallback.api, - auth: fallback.auth, - }, - models: fallback.models, - discoveryApiKey: params.discoveryApiKey, - fetchGuard: params.fetchGuard, - signal: params.signal, - ttlMs: XAI_GROK_OAUTH_MODELS_CACHE_TTL_MS, - auditContext: "xai-grok-oauth-model-discovery", - cacheKeyParts: [ - PROVIDER_ID, - "grok-oauth-model-rows", - XAI_GROK_OAUTH_MODELS_ENDPOINT, - params.discoveryApiKey, - ], - projectRows: (rows) => - rows - .map(buildXaiOauthModelFromLiveRow) - .filter((model): model is ModelDefinitionConfig => Boolean(model)), - }); + const [provider, preferredModelId] = await Promise.all([ + buildLiveModelProviderConfig({ + providerId: PROVIDER_ID, + endpoint: XAI_GROK_OAUTH_MODELS_ENDPOINT, + providerConfig: { + baseUrl: fallback.baseUrl, + api: fallback.api, + auth: fallback.auth, + }, + models: fallback.models, + discoveryApiKey: params.discoveryApiKey, + fetchGuard: params.fetchGuard, + signal: params.signal, + ttlMs: XAI_GROK_OAUTH_MODELS_CACHE_TTL_MS, + auditContext: "xai-grok-oauth-model-discovery", + cacheKeyParts: [ + PROVIDER_ID, + "grok-oauth-model-rows", + XAI_GROK_OAUTH_MODELS_ENDPOINT, + params.discoveryApiKey, + ], + projectRows: (rows) => + rows + .map(buildXaiOauthModelFromLiveRow) + .filter((model): model is ModelDefinitionConfig => Boolean(model)), + }), + fetchXaiOAuthDefaultModelId(params), + ]); + return withXaiOAuthAutoModel(provider, preferredModelId); } diff --git a/extensions/xai/provider-models.ts b/extensions/xai/provider-models.ts index 607c21fad0f7..a2f87b9bc57b 100644 --- a/extensions/xai/provider-models.ts +++ b/extensions/xai/provider-models.ts @@ -9,7 +9,7 @@ import { normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveXaiCatalogEntry, XAI_BASE_URL } from "./model-definitions.js"; -import { normalizeXaiModelId } from "./model-id.js"; +import { normalizeXaiModelId, XAI_OAUTH_AUTO_MODEL_ID } from "./model-id.js"; import { applyXaiRuntimeModelCompat } from "./runtime-model-compat.js"; const XAI_MODERN_MODEL_PREFIXES = ["grok-4.5", "grok-build-0.1", "grok-4.3", "grok-4.20"] as const; @@ -47,3 +47,15 @@ export function resolveXaiForwardCompatModel(params: { } as ProviderRuntimeModel), ); } + +export function normalizeXaiResolvedModel(model: ProviderRuntimeModel): ProviderRuntimeModel { + const canonicalModelId = + typeof model.params?.canonicalModelId === "string" + ? model.params.canonicalModelId.trim() + : undefined; + const resolved = + model.id === XAI_OAUTH_AUTO_MODEL_ID && canonicalModelId + ? { ...model, id: canonicalModelId } + : model; + return applyXaiRuntimeModelCompat(resolved); +} diff --git a/extensions/xai/stream.test.ts b/extensions/xai/stream.test.ts index 64fb6afdee10..2439cd3e5729 100644 --- a/extensions/xai/stream.test.ts +++ b/extensions/xai/stream.test.ts @@ -148,6 +148,70 @@ async function captureXaiResponsesPayloadWithThinking( } describe("xai stream wrappers", () => { + it("adds the Grok OAuth proxy request contract", () => { + let capturedHeaders: Record | undefined; + const baseStreamFn: StreamFn = (_model, _context, options) => { + capturedHeaders = options?.headers; + return {} as ReturnType; + }; + const wrapped = wrapXaiProviderStream( + { + streamFn: baseStreamFn, + extraParams: { tool_stream: false }, + } as never, + { clientVersion: "2026.7.2" }, + ); + + void wrapped?.( + { + api: "openai-responses", + provider: "xai", + id: "grok-4.5", + baseUrl: "https://cli-chat-proxy.grok.com/v1", + } as Model<"openai-responses">, + { messages: [] } as Context, + { headers: { "X-XAI-Token-Auth": "operator-value", "X-Existing": "kept" } }, + ); + + expect(capturedHeaders).toEqual({ + "x-existing": "kept", + "x-grok-client-version": "2026.7.2", + "x-grok-model-override": "grok-4.5", + "x-xai-token-auth": "xai-grok-cli", + }); + }); + + it.each([ + ["the public API-key endpoint", "xai", "https://api.x.ai/v1"], + ["a different provider", "other", "https://cli-chat-proxy.grok.com/v1"], + ])("does not add Grok OAuth headers for %s", (_label, provider, baseUrl) => { + let capturedHeaders: Record | undefined; + const baseStreamFn: StreamFn = (_model, _context, options) => { + capturedHeaders = options?.headers; + return {} as ReturnType; + }; + const wrapped = wrapXaiProviderStream( + { + streamFn: baseStreamFn, + extraParams: { tool_stream: false }, + } as never, + { clientVersion: "2026.7.2" }, + ); + + void wrapped?.( + { + api: "openai-responses", + provider, + id: "grok-4.5", + baseUrl, + } as Model<"openai-responses">, + { messages: [] } as Context, + { headers: { "X-Existing": "kept" } }, + ); + + expect(capturedHeaders).toEqual({ "X-Existing": "kept" }); + }); + it("rewrites supported Grok models to fast variants when fast mode is enabled", () => { expect(captureWrappedModelId({ modelId: "grok-3", fastMode: true })).toBe("grok-3-fast"); expect( diff --git a/extensions/xai/stream.ts b/extensions/xai/stream.ts index eb0991e2aef5..595daf3d7869 100644 --- a/extensions/xai/stream.ts +++ b/extensions/xai/stream.ts @@ -8,6 +8,7 @@ import { createPlainTextToolCallCompatWrapper, createToolStreamWrapper, } from "openclaw/plugin-sdk/provider-stream-shared"; +import { XAI_GROK_OAUTH_BASE_URL } from "./provider-catalog.js"; import { isXaiProviderId } from "./provider-id.js"; const XAI_FAST_MODEL_IDS = new Map([ @@ -18,6 +19,36 @@ const XAI_FAST_MODEL_IDS = new Map([ ]); type DynamicFastMode = boolean | (() => boolean | undefined); +function isXaiGrokOAuthProxyModel(model: Parameters[0]): boolean { + return ( + isXaiProviderId(model.provider) && + model.baseUrl?.trim().replace(/\/+$/u, "") === XAI_GROK_OAUTH_BASE_URL + ); +} + +function createXaiGrokOAuthHeadersWrapper( + baseStreamFn: StreamFn | undefined, + clientVersion: string | undefined, +): StreamFn { + const underlying = baseStreamFn ?? streamSimple; + const normalizedClientVersion = clientVersion?.trim(); + return (model, context, options) => { + if (!normalizedClientVersion || !isXaiGrokOAuthProxyModel(model)) { + return underlying(model, context, options); + } + const headers = new Headers(options?.headers); + // The Grok OAuth proxy requires its CLI identity and a concrete catalog model. + // Keep these proxy-only so ordinary xAI API-key traffic retains its public contract. + headers.set("X-XAI-Token-Auth", "xai-grok-cli"); + headers.set("x-grok-client-version", normalizedClientVersion); + headers.set("x-grok-model-override", model.id); + return underlying(model, context, { + ...options, + headers: Object.fromEntries(headers.entries()), + }); + }; +} + function resolveXaiFastModelId(modelId: unknown): string | undefined { if (typeof modelId !== "string") { return undefined; @@ -270,11 +301,15 @@ function hasXaiFastModeParam(extraParams: Record | undefined): ); } -export function wrapXaiProviderStream(ctx: ProviderWrapStreamFnContext): StreamFn | undefined { +export function wrapXaiProviderStream( + ctx: ProviderWrapStreamFnContext, + runtime?: { clientVersion?: string }, +): StreamFn | undefined { const extraParams = ctx.extraParams; const toolStreamEnabled = extraParams?.tool_stream !== false; return composeProviderStreamWrappers( ctx.streamFn, + (streamFn) => createXaiGrokOAuthHeadersWrapper(streamFn, runtime?.clientVersion), createXaiToolPayloadCompatibilityWrapper, hasXaiFastModeParam(extraParams) && ((streamFn) => createXaiFastModeWrapper(streamFn, () => resolveXaiFastMode(extraParams))), diff --git a/extensions/xai/xai-oauth.test.ts b/extensions/xai/xai-oauth.test.ts index ae68c3947916..aced32f0855a 100644 --- a/extensions/xai/xai-oauth.test.ts +++ b/extensions/xai/xai-oauth.test.ts @@ -429,6 +429,11 @@ describe("xAI OAuth", () => { accountId: "acct-1", access: expect.any(String), }); + expect(result.defaultModel).toBe("xai/auto"); + expect(result.configPatch?.agents?.defaults?.model).toEqual({ + primary: "xai/auto", + }); + expect(result.configPatch?.agents?.defaults?.models?.["xai/auto"]?.alias).toBe("Grok"); expect(progress.update).toHaveBeenCalledWith("Waiting for xAI device authorization..."); expect(progress.stop).toHaveBeenCalledWith("xAI OAuth complete"); }); diff --git a/extensions/xai/xai-oauth.ts b/extensions/xai/xai-oauth.ts index d7a345843594..2831c2020263 100644 --- a/extensions/xai/xai-oauth.ts +++ b/extensions/xai/xai-oauth.ts @@ -14,7 +14,7 @@ import { } from "openclaw/plugin-sdk/provider-auth"; import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; import { sleep } from "openclaw/plugin-sdk/runtime-env"; -import { applyXaiConfig, XAI_DEFAULT_MODEL_REF } from "./onboard.js"; +import { applyXaiOAuthConfig, XAI_OAUTH_DEFAULT_MODEL_REF } from "./onboard.js"; import { xaiUserAgent } from "./src/xai-user-agent.js"; const PROVIDER_ID = "xai"; @@ -651,14 +651,14 @@ async function loginXaiDeviceCode(ctx: ProviderAuthContext): Promise