mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 07:51:35 +00:00
fix(xai): follow OAuth default model automatically (#115617)
* fix(xai): default OAuth login to Grok 4.5 * fix(xai): follow OAuth default model automatically * fix(xai): send OAuth proxy request headers * fix(xai): avoid provider API shadowing * fix(qa): restore current main validation gates --------- Co-authored-by: echeung <echeung@agentsonly.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
This commit is contained in:
@@ -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/<vendor>/<model>` namespace (for example `nvidia/nvidia/nemotron-...`); pickers preserve the literal `<provider>/<model-id>` composition while the canonical key sent to the API stays single-prefixed.
|
||||
</Accordion>
|
||||
<Accordion title="xAI">
|
||||
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/<model>"].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/<model>"].params.tool_stream=false`.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
|
||||
@@ -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.
|
||||
</Step>
|
||||
<Step title="API-key path">
|
||||
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.
|
||||
<Step title="Pick a model">
|
||||
```json5
|
||||
{
|
||||
agents: { defaults: { model: { primary: "xai/grok-4.3" } } },
|
||||
agents: { defaults: { model: { primary: "xai/auto" } } },
|
||||
}
|
||||
```
|
||||
</Step>
|
||||
@@ -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` |
|
||||
|
||||
<Tip>
|
||||
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.
|
||||
</Tip>
|
||||
|
||||
Catalog context and token-cost metadata follows xAI's live
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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<string | undefined> {
|
||||
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<ModelProviderConfig> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -148,6 +148,70 @@ async function captureXaiResponsesPayloadWithThinking(
|
||||
}
|
||||
|
||||
describe("xai stream wrappers", () => {
|
||||
it("adds the Grok OAuth proxy request contract", () => {
|
||||
let capturedHeaders: Record<string, string> | undefined;
|
||||
const baseStreamFn: StreamFn = (_model, _context, options) => {
|
||||
capturedHeaders = options?.headers;
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
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<string, string> | undefined;
|
||||
const baseStreamFn: StreamFn = (_model, _context, options) => {
|
||||
capturedHeaders = options?.headers;
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
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(
|
||||
|
||||
@@ -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<string, string>([
|
||||
@@ -18,6 +19,36 @@ const XAI_FAST_MODEL_IDS = new Map<string, string>([
|
||||
]);
|
||||
type DynamicFastMode = boolean | (() => boolean | undefined);
|
||||
|
||||
function isXaiGrokOAuthProxyModel(model: Parameters<StreamFn>[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<string, unknown> | 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))),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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<ProviderAut
|
||||
progress.stop("xAI OAuth complete");
|
||||
return buildOauthProviderAuthResult({
|
||||
providerId: PROVIDER_ID,
|
||||
defaultModel: XAI_DEFAULT_MODEL_REF,
|
||||
defaultModel: XAI_OAUTH_DEFAULT_MODEL_REF,
|
||||
access: tokens.accessToken,
|
||||
refresh: tokens.refreshToken,
|
||||
expires: tokens.expires,
|
||||
email: identity.email,
|
||||
displayName: identity.displayName,
|
||||
profileName: identity.email ?? identity.accountId,
|
||||
configPatch: applyXaiConfig(ctx.config),
|
||||
configPatch: applyXaiOAuthConfig(ctx.config),
|
||||
credentialExtra: {
|
||||
tokenEndpoint: discovery.tokenEndpoint,
|
||||
deviceAuthorizationEndpoint: discovery.deviceAuthorizationEndpoint,
|
||||
|
||||
Reference in New Issue
Block a user