Skip to content

Commit 5f1df99

Browse files
Jaaneeksteipete
authored andcommitted
xai: OAuth login fixes plus openclaw User-Agent attribution
OAuth login flow ---------------- - Hard-require refresh_token after the authorization-code exchange in xai-oauth.ts. Access-only responses persisted credentials that the downstream usability check later rejected; the new requireRefreshToken option fails the exchange instead. Error wording explains the missing refresh_token in OIDC scope terms (offline_access scope rejected), not a "grant". - Derive token expiry from the access-token JWT exp claim when expires_in is missing. id_token exp is intentionally not used as a fallback because id_token lifetime tracks the OIDC session, not the access token, and would defer refresh past actual expiry. - Handle CORS preflight OPTIONS on the loopback OAuth callback in src/plugin-sdk/provider-auth-runtime.ts. The previous handler treated any non-callback request as a failed GET, returned "Missing code or state", and tore the server down before the real GET arrived. The CORS allowlist is now an optional `corsOriginAllowlist` parameter on waitForLocalOAuthCallback so the SDK helper stays generic. The xAI plugin passes ["auth.x.ai", "accounts.x.ai"] from loginXaiOAuth. Sidecar surfaces ---------------- - speech-provider.ts (POST /v1/tts) honors the xAI OAuth profile in addition to provider config and XAI_API_KEY. isConfigured now also reports true when an xAI auth profile is configured (via isProviderAuthProfileConfigured), so OAuth-only users are no longer silently filtered out by the selection layer. The bearer resolver threads req.cfg into resolveApiKeyForProvider so the right xAI auth profile is picked when a user has multiple. - realtime-transcription-provider.ts (WSS /stt) gets the same isConfigured fix, and the lazy headers() resolver threads req.cfg into the OAuth bearer lookup. createSession stays sync per its plugin contract. - stt.ts: drop the plugin-side OAuth fallback. The media-understanding core already resolves auth (cfg/agentDir-aware) via resolveProviderExecutionContext before calling transcribeAudio, so the wrapper was redundant. transcribeAudio is now the registered hook directly. User-Agent attribution ---------------------- - New buildXaiAttributionPolicy in src/agents/provider-attribution.ts injects User-Agent: openclaw/<version>, originator, and version on /v1/responses and /v1/chat/completions traffic that goes through resolveProviderRequestHeaders. Gated to xai-native and default endpoint classes; custom proxy baseUrls remain withheld. reviewNote is honest about which headers are spec-verified vs mirrored. - Shared extensions/xai/src/xai-user-agent.ts helper exports xaiUserAgentHeaderFor(baseUrl) which only emits the User-Agent when the resolved baseUrl points at the xAI-native API host. Threaded through TTS and realtime STT (WS upgrade headers) so user-configured proxy baseUrls do not receive the openclaw identity. OAuth discovery and token endpoints still send User-Agent unconditionally because isTrustedXaiOAuthEndpoint already restricts those URLs to *.x.ai. - Image gen, batch STT, and video gen rely on the attribution policy alone (no manual User-Agent in defaultHeaders), so attribution withholding on user-configured proxy baseUrls is preserved end-to-end. - UA is bearer-agnostic: same value whether the bearer comes from an xAI API key or the xAI OAuth flow. Drop dead api.grok.x.ai alias ----------------------------- - xAI retired the api.grok.x.ai alias; DNS now returns NXDOMAIN from xAI's own authoritative nameservers. Drop it from the xai-native endpoint host set in extensions/xai/openclaw.plugin.json, extensions/xai/api.ts, extensions/xai/tts.ts, and the openai-responses payload policy. Update the attribution test to classify api.grok.x.ai as "custom" (no live user can reach it; the classification keeps documenting the host's status). Video generation now matches xAI's actual API behavior ------------------------------------------------------ Previously, real video generation requests failed with "xAI video generation response malformed" because the poll-status handler validated against a closed enum that did not match what the xAI service actually returns. Four fixes: - Loosen the poll-status handler. xAI returns intermediate strings outside `["queued", "processing", "done", "failed", "expired"]` (commonly `submitted`, `pending`, `in_progress`, ...). Treat `done` as terminal-success, `["failed", "error", "expired", "cancelled"]` as terminal-failure, and any other string (including empty) as continue-polling. Also accept `cancelled` as a terminal failure. - Send default duration/aspect_ratio/resolution on every generate and reference-image submit. xAI rejects bodies that omit these fields. Defaults: duration=8s, aspect_ratio="16:9", resolution="720p". - Accept lowercase resolution input ("480p"/"720p"/"1080p") in addition to uppercase, normalize to lowercase on the wire. - Add an `x-idempotency-key` header (fresh `crypto.randomUUID()`) on every submit so a network retry does not double-charge the user. Polls intentionally reuse the unmodified `headers` without the key. Ergonomics ---------- - All "missing xAI credentials" errors (code_execution, lazy code_execution fallback in extensions/xai/index.ts, x_search, web_search grok in web-search-provider.runtime.ts, TTS, batch STT, realtime STT) now mention `openclaw onboard --auth-choice xai-oauth` first. - Dedupe the Grok model-id alias table: model-compat.ts re-exports normalizeXaiModelId from model-id.ts as normalizeNativeXaiModelId. Test coverage ------------- - src/plugin-sdk/provider-auth-runtime.test.ts: locks the new pure buildOAuthCallbackOriginResolver gate (allowlist match, case-normalization, https-only, non-allowlisted hosts dropped, multi-Origin handling). - extensions/xai/xai-oauth.test.ts: locks XAI_OAUTH_CALLBACK_CORS_ORIGIN_ALLOWLIST so loginXaiOAuth keeps threading the right hosts to the SDK helper. - extensions/xai/speech-provider.test.ts: OAuth-only auth profile flips isConfigured to true; cfg threads into the OAuth fallback resolver. - extensions/xai/realtime-transcription-provider.test.ts: same + upgrade headers carry the OAuth bearer end-to-end. - extensions/xai/stt.test.ts: explicit assertion that transcribeAudio trusts the core-resolved apiKey (no plugin-side wrapper). Verification ------------ - pnpm install: clean - 154/154 vitest tests pass across 13 touched test files - pnpm check:changed: typecheck core/ext + tests, oxlint core/ext, runtime guards, dependency pin guard, package patch guard, runtime import cycles, sidecar loader guard - all green - pnpm build: 0 errors, 0 [INEFFECTIVE_DYNAMIC_IMPORT] warnings
1 parent b504696 commit 5f1df99

27 files changed

Lines changed: 819 additions & 150 deletions

extensions/xai/api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export { applyXaiRuntimeModelCompat } from "./runtime-model-compat.js";
3131
export { applyXaiModelCompat, HTML_ENTITY_TOOL_CALL_ARGUMENTS_ENCODING, XAI_TOOL_SCHEMA_PROFILE };
3232
export { resolveXaiModelCompatPatch };
3333

34-
const XAI_NATIVE_ENDPOINT_HOSTS = new Set(["api.x.ai", "api.grok.x.ai"]);
34+
const XAI_NATIVE_ENDPOINT_HOSTS = new Set(["api.x.ai"]);
3535

3636
function resolveHostname(value: string): string | undefined {
3737
try {

extensions/xai/code-execution.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ export function createCodeExecutionTool(options?: {
108108
return jsonResult({
109109
error: "missing_xai_api_key",
110110
message:
111-
"code_execution needs an xAI API key. Run openclaw onboard --auth-choice xai-api-key, set XAI_API_KEY in the Gateway environment, or configure plugins.entries.xai.config.webSearch.apiKey.",
111+
"code_execution needs xAI credentials. Run `openclaw onboard --auth-choice xai-oauth` to sign in with Grok, run `openclaw onboard --auth-choice xai-api-key`, set `XAI_API_KEY` in the Gateway environment, or configure `plugins.entries.xai.config.webSearch.apiKey`.",
112112
docs: "https://docs.openclaw.ai/tools/code-execution",
113113
});
114114
}

extensions/xai/image-generation-provider.test.ts

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,22 @@ const {
1717
postJsonRequestMock: vi.fn(),
1818
postMultipartRequestMock: vi.fn(),
1919
assertOkOrThrowHttpErrorMock: vi.fn(async () => {}),
20-
resolveProviderHttpRequestConfigMock: vi.fn((params: Record<string, unknown>) => ({
21-
baseUrl: params.baseUrl ?? params.defaultBaseUrl ?? "https://api.x.ai/v1",
22-
allowPrivateNetwork: false,
23-
headers: new Headers(params.defaultHeaders as HeadersInit | undefined),
24-
dispatcherPolicy: undefined,
25-
})),
20+
resolveProviderHttpRequestConfigMock: vi.fn((params: Record<string, unknown>) => {
21+
const headers = new Headers(params.defaultHeaders as HeadersInit | undefined);
22+
// Stub mirroring the xAI attribution policy headers (real wire is locked in provider-attribution.test.ts).
23+
if (params.provider === "xai") {
24+
const version = process.env.OPENCLAW_VERSION?.trim() || "unknown";
25+
headers.set("User-Agent", `openclaw/${version}`);
26+
headers.set("originator", "openclaw");
27+
headers.set("version", version);
28+
}
29+
return {
30+
baseUrl: params.baseUrl ?? params.defaultBaseUrl ?? "https://api.x.ai/v1",
31+
allowPrivateNetwork: false,
32+
headers,
33+
dispatcherPolicy: undefined,
34+
};
35+
}),
2636
createProviderOperationDeadlineMock: vi.fn((params: Record<string, unknown>) => ({
2737
timeoutMs: params.timeoutMs,
2838
label: params.label,
@@ -62,12 +72,14 @@ function requirePostJsonCall(index = 0): {
6272
url?: string;
6373
timeoutMs?: number;
6474
body?: Record<string, unknown>;
75+
headers?: Headers;
6576
} {
6677
const params = (postJsonRequestMock.mock.calls as unknown as Array<[unknown]>)[index]?.[0] as
6778
| {
6879
url?: string;
6980
timeoutMs?: number;
7081
body?: Record<string, unknown>;
82+
headers?: Headers;
7183
}
7284
| undefined;
7385
if (!params) {
@@ -215,6 +227,32 @@ describe("xai image generation provider", () => {
215227
expect(request.body?.response_format).toBe("b64_json");
216228
});
217229

230+
it("forwards xAI attribution User-Agent through the SDK image request", async () => {
231+
vi.stubEnv("OPENCLAW_VERSION", "2026.3.22");
232+
postJsonRequestMock.mockResolvedValue({
233+
response: {
234+
json: async () => ({
235+
data: [{ b64_json: Buffer.from("ua-png").toString("base64") }],
236+
}),
237+
},
238+
release: vi.fn(async () => {}),
239+
});
240+
241+
const provider = buildXaiImageGenerationProvider();
242+
await provider.generateImage({
243+
provider: "xai",
244+
model: "grok-imagine-image",
245+
prompt: "ua check",
246+
cfg: {},
247+
} as any);
248+
249+
const request = requirePostJsonCall();
250+
expect(request.headers?.get("user-agent")).toBe("openclaw/2026.3.22");
251+
expect(request.headers?.get("originator")).toBe("openclaw");
252+
expect(request.headers?.get("version")).toBe("2026.3.22");
253+
vi.unstubAllEnvs();
254+
});
255+
218256
it("uses the plural xAI images payload for multiple edit inputs", async () => {
219257
postJsonRequestMock.mockResolvedValue({
220258
response: {

extensions/xai/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ function createLazyCodeExecutionTool(ctx: {
125125
return jsonResult({
126126
error: "missing_xai_api_key",
127127
message:
128-
"code_execution needs an xAI API key. Run openclaw onboard --auth-choice xai-api-key, set XAI_API_KEY in the Gateway environment, or configure plugins.entries.xai.config.webSearch.apiKey.",
128+
"code_execution needs xAI credentials. Run `openclaw onboard --auth-choice xai-oauth` to sign in with Grok, run `openclaw onboard --auth-choice xai-api-key`, set `XAI_API_KEY` in the Gateway environment, or configure `plugins.entries.xai.config.webSearch.apiKey`.",
129129
docs: "https://docs.openclaw.ai/tools/code-execution",
130130
});
131131
}

extensions/xai/model-compat.ts

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import {
33
type ModelCompatConfig,
44
} from "openclaw/plugin-sdk/provider-model-shared";
55

6+
export { normalizeXaiModelId as normalizeNativeXaiModelId } from "./model-id.js";
7+
68
export const XAI_TOOL_SCHEMA_PROFILE = "xai";
79
export const HTML_ENTITY_TOOL_CALL_ARGUMENTS_ENCODING = "html-entities";
810

@@ -30,25 +32,3 @@ export function applyXaiModelCompat<T extends { compat?: unknown }>(model: T): T
3032
resolveXaiModelCompatPatch(),
3133
) as T;
3234
}
33-
34-
export function normalizeNativeXaiModelId(id: string): string {
35-
if (id === "grok-4-fast-reasoning") {
36-
return "grok-4-fast";
37-
}
38-
if (id === "grok-4-1-fast-reasoning") {
39-
return "grok-4-1-fast";
40-
}
41-
if (id === "grok-4.20-experimental-beta-0304-reasoning") {
42-
return "grok-4.20-beta-latest-reasoning";
43-
}
44-
if (id === "grok-4.20-experimental-beta-0304-non-reasoning") {
45-
return "grok-4.20-beta-latest-non-reasoning";
46-
}
47-
if (id === "grok-4.20-reasoning") {
48-
return "grok-4.20-beta-latest-reasoning";
49-
}
50-
if (id === "grok-4.20-non-reasoning") {
51-
return "grok-4.20-beta-latest-non-reasoning";
52-
}
53-
return id;
54-
}

extensions/xai/openclaw.plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"providerEndpoints": [
2424
{
2525
"endpointClass": "xai-native",
26-
"hosts": ["api.x.ai", "api.grok.x.ai"]
26+
"hosts": ["api.x.ai"]
2727
}
2828
],
2929
"providerRequest": {

extensions/xai/realtime-transcription-provider.test.ts

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,36 @@ import type WebSocket from "ws";
55
import { WebSocketServer } from "ws";
66
import { buildXaiRealtimeTranscriptionProvider } from "./realtime-transcription-provider.js";
77

8+
const { isProviderAuthProfileConfiguredMock, resolveApiKeyForProviderMock } = vi.hoisted(() => ({
9+
isProviderAuthProfileConfiguredMock: vi.fn(() => false),
10+
resolveApiKeyForProviderMock: vi.fn(
11+
async (): Promise<{ apiKey: string | undefined }> => ({ apiKey: undefined }),
12+
),
13+
}));
14+
15+
vi.mock("openclaw/plugin-sdk/provider-auth", () => ({
16+
isProviderAuthProfileConfigured: isProviderAuthProfileConfiguredMock,
17+
}));
18+
19+
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
20+
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
21+
}));
22+
823
let cleanup: (() => Promise<void>) | undefined;
924

1025
afterEach(async () => {
1126
await cleanup?.();
1227
cleanup = undefined;
28+
isProviderAuthProfileConfiguredMock.mockReset();
29+
isProviderAuthProfileConfiguredMock.mockReturnValue(false);
30+
resolveApiKeyForProviderMock.mockReset();
31+
resolveApiKeyForProviderMock.mockResolvedValue({ apiKey: undefined });
32+
delete process.env.XAI_API_KEY;
33+
vi.unstubAllEnvs();
1334
});
1435

1536
async function createRealtimeSttServer(params?: {
16-
onRequest?: (url: URL) => void;
37+
onRequest?: (url: URL, headers: Record<string, string | string[] | undefined>) => void;
1738
onBinary?: (audio: Buffer) => void;
1839
initialEvent?: unknown;
1940
}) {
@@ -28,7 +49,7 @@ async function createRealtimeSttServer(params?: {
2849

2950
server.on("upgrade", (request, socket, head) => {
3051
const url = new URL(request.url ?? "/", "http://127.0.0.1");
31-
params?.onRequest?.(url);
52+
params?.onRequest?.(url, request.headers);
3253
wss.handleUpgrade(request, socket, head, (ws) => {
3354
clients.add(ws);
3455
ws.on("close", () => clients.delete(ws));
@@ -122,10 +143,15 @@ describe("xai realtime transcription provider", () => {
122143
});
123144

124145
it("streams raw binary audio and maps partial and final transcript events", async () => {
146+
vi.stubEnv("OPENCLAW_VERSION", "2026.3.22");
125147
const binaryFrames: Buffer[] = [];
126148
const requestUrls: URL[] = [];
149+
const upgradeHeaders: Array<Record<string, string | string[] | undefined>> = [];
127150
const server = await createRealtimeSttServer({
128-
onRequest: (url) => requestUrls.push(url),
151+
onRequest: (url, headers) => {
152+
requestUrls.push(url);
153+
upgradeHeaders.push(headers);
154+
},
129155
onBinary: (audio) => binaryFrames.push(audio),
130156
});
131157
const provider = buildXaiRealtimeTranscriptionProvider();
@@ -166,10 +192,13 @@ describe("xai realtime transcription provider", () => {
166192
expect(requestUrls[0]?.searchParams.get("encoding")).toBe("pcm");
167193
expect(requestUrls[0]?.searchParams.get("interim_results")).toBe("true");
168194
expect(requestUrls[0]?.searchParams.get("endpointing")).toBe("500");
195+
expect(upgradeHeaders[0]?.["user-agent"]).toBeUndefined();
196+
expect(upgradeHeaders[0]?.authorization).toBe("Bearer xai-test-key");
169197
expect(Buffer.concat(binaryFrames).toString()).toContain("queued-before-ready");
170198
expect(Buffer.concat(binaryFrames).toString()).toContain("after-ready");
171199
expect(onSpeechStart).toHaveBeenCalled();
172200
expect(onPartial).toHaveBeenCalledWith("hello openclaw");
201+
vi.unstubAllEnvs();
173202
});
174203

175204
it("rejects setup errors before the stream is ready", async () => {
@@ -203,4 +232,42 @@ describe("xai realtime transcription provider", () => {
203232
expect(provider.aliases).toContain("xai-realtime");
204233
expect(provider.aliases).toContain("grok-stt-streaming");
205234
});
235+
236+
it("reports configured when an xAI auth profile exists, even without env or config apiKey", () => {
237+
delete process.env.XAI_API_KEY;
238+
isProviderAuthProfileConfiguredMock.mockReturnValue(true);
239+
const provider = buildXaiRealtimeTranscriptionProvider();
240+
expect(provider.isConfigured({ cfg: {}, providerConfig: {} })).toBe(true);
241+
expect(isProviderAuthProfileConfiguredMock).toHaveBeenCalledWith({
242+
provider: "xai",
243+
cfg: {},
244+
});
245+
});
246+
247+
it("threads cfg into the lazy WebSocket bearer resolver", async () => {
248+
delete process.env.XAI_API_KEY;
249+
resolveApiKeyForProviderMock.mockResolvedValue({ apiKey: "oauth-bearer" });
250+
const upgradeHeaders: Array<Record<string, string | string[] | undefined>> = [];
251+
const server = await createRealtimeSttServer({
252+
onRequest: (_url, headers) => {
253+
upgradeHeaders.push(headers);
254+
},
255+
});
256+
257+
const provider = buildXaiRealtimeTranscriptionProvider();
258+
const cfg = { agents: { defaults: {} } };
259+
const session = provider.createSession({
260+
cfg,
261+
providerConfig: {
262+
baseUrl: server.baseUrl,
263+
},
264+
});
265+
266+
await session.connect();
267+
session.close();
268+
await server.donePromise;
269+
270+
expect(resolveApiKeyForProviderMock).toHaveBeenCalledWith({ provider: "xai", cfg });
271+
expect(upgradeHeaders[0]?.authorization).toBe("Bearer oauth-bearer");
272+
});
206273
});

extensions/xai/realtime-transcription-provider.ts

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
import {
2+
isProviderAuthProfileConfigured,
3+
type OpenClawConfig,
4+
} from "openclaw/plugin-sdk/provider-auth";
5+
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
16
import {
27
createRealtimeTranscriptionWebSocketSession,
38
type RealtimeTranscriptionProviderConfig,
@@ -9,6 +14,7 @@ import {
914
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
1015
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
1116
import { XAI_BASE_URL } from "./model-definitions.js";
17+
import { xaiUserAgentHeaderFor } from "./src/xai-user-agent.js";
1218

1319
type XaiRealtimeTranscriptionEncoding = "pcm" | "mulaw" | "alaw";
1420

@@ -24,6 +30,8 @@ type XaiRealtimeTranscriptionProviderConfig = {
2430

2531
type XaiRealtimeTranscriptionSessionConfig = RealtimeTranscriptionSessionCreateRequest & {
2632
apiKey: string;
33+
// Late-bound bearer; called per (re)connect.
34+
resolveApiKey?: () => Promise<string>;
2735
baseUrl: string;
2836
sampleRate: number;
2937
encoding: XaiRealtimeTranscriptionEncoding;
@@ -219,7 +227,13 @@ function createXaiRealtimeTranscriptionSession(
219227
providerId: "xai",
220228
callbacks: config,
221229
url: () => toXaiRealtimeWsUrl(config),
222-
headers: { Authorization: `Bearer ${config.apiKey}` },
230+
headers: async () => {
231+
const apiKey = config.resolveApiKey ? await config.resolveApiKey() : config.apiKey;
232+
return {
233+
Authorization: `Bearer ${apiKey}`,
234+
...xaiUserAgentHeaderFor(config.baseUrl),
235+
};
236+
},
223237
connectTimeoutMs: XAI_REALTIME_STT_CONNECT_TIMEOUT_MS,
224238
closeTimeoutMs: XAI_REALTIME_STT_CLOSE_TIMEOUT_MS,
225239
maxReconnectAttempts: XAI_REALTIME_STT_MAX_RECONNECT_ATTEMPTS,
@@ -245,17 +259,18 @@ export function buildXaiRealtimeTranscriptionProvider(): RealtimeTranscriptionPr
245259
aliases: ["xai-realtime", "grok-stt-streaming"],
246260
autoSelectOrder: 25,
247261
resolveConfig: ({ rawConfig }) => normalizeProviderConfig(rawConfig),
248-
isConfigured: ({ providerConfig }) =>
249-
Boolean(normalizeProviderConfig(providerConfig).apiKey || process.env.XAI_API_KEY),
262+
isConfigured: ({ providerConfig, cfg }) =>
263+
Boolean(normalizeProviderConfig(providerConfig).apiKey || process.env.XAI_API_KEY) ||
264+
isProviderAuthProfileConfigured({ provider: "xai", cfg }),
250265
createSession: (req) => {
251266
const config = normalizeProviderConfig(req.providerConfig);
252-
const apiKey = config.apiKey || process.env.XAI_API_KEY;
253-
if (!apiKey) {
254-
throw new Error("xAI API key missing");
255-
}
267+
// createSession must stay sync per RealtimeTranscriptionProviderPlugin; bearer is resolved lazily in headers().
268+
const seedApiKey =
269+
normalizeOptionalString(config.apiKey) ?? normalizeOptionalString(process.env.XAI_API_KEY);
256270
return createXaiRealtimeTranscriptionSession({
257271
...req,
258-
apiKey,
272+
apiKey: seedApiKey ?? "",
273+
resolveApiKey: () => resolveXaiRealtimeApiKey(config.apiKey, req.cfg),
259274
baseUrl: normalizeXaiRealtimeBaseUrl(config.baseUrl),
260275
sampleRate: config.sampleRate ?? XAI_REALTIME_STT_DEFAULT_SAMPLE_RATE,
261276
encoding: config.encoding ?? XAI_REALTIME_STT_DEFAULT_ENCODING,
@@ -266,3 +281,26 @@ export function buildXaiRealtimeTranscriptionProvider(): RealtimeTranscriptionPr
266281
},
267282
};
268283
}
284+
285+
// Resolve an xAI bearer for the realtime `/stt` WebSocket:
286+
// 1. Configured `plugins.entries.voice-call.config.streaming.providers.xai.apiKey`
287+
// 2. `XAI_API_KEY` env var
288+
// 3. xAI OAuth auth profile (cfg-scoped)
289+
async function resolveXaiRealtimeApiKey(
290+
configApiKey: string | undefined,
291+
cfg: OpenClawConfig | undefined,
292+
): Promise<string> {
293+
const direct =
294+
normalizeOptionalString(configApiKey) ?? normalizeOptionalString(process.env.XAI_API_KEY);
295+
if (direct) {
296+
return direct;
297+
}
298+
const auth = await resolveApiKeyForProvider({ provider: "xai", cfg });
299+
const oauthKey = normalizeOptionalString(auth?.apiKey);
300+
if (oauthKey) {
301+
return oauthKey;
302+
}
303+
throw new Error(
304+
"xAI credentials missing for realtime STT. Sign in with `openclaw onboard --auth-choice xai-oauth`, or run `openclaw onboard --auth-choice xai-api-key`, or set XAI_API_KEY.",
305+
);
306+
}

0 commit comments

Comments
 (0)