Files
openclaw/extensions/xai/api.ts
Jaaneek 5f1df99a9c 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
2026-05-18 02:43:12 +01:00

120 lines
3.4 KiB
TypeScript

import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
import {
normalizeOptionalLowercaseString,
readStringValue,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import {
applyXaiModelCompat,
HTML_ENTITY_TOOL_CALL_ARGUMENTS_ENCODING,
normalizeNativeXaiModelId,
resolveXaiModelCompatPatch,
XAI_TOOL_SCHEMA_PROFILE,
} from "./model-compat.js";
export { buildXaiProvider } from "./provider-catalog.js";
export { applyXaiConfig, applyXaiProviderConfig } from "./onboard.js";
export { buildXaiImageGenerationProvider } from "./image-generation-provider.js";
export {
buildXaiCatalogModels,
buildXaiModelDefinition,
resolveXaiCatalogEntry,
XAI_BASE_URL,
XAI_DEFAULT_CONTEXT_WINDOW,
XAI_DEFAULT_IMAGE_MODEL,
XAI_DEFAULT_MODEL_ID,
XAI_DEFAULT_MODEL_REF,
XAI_DEFAULT_MAX_TOKENS,
XAI_IMAGE_MODELS,
} from "./model-definitions.js";
export { isModernXaiModel, resolveXaiForwardCompatModel } from "./provider-models.js";
export { applyXaiRuntimeModelCompat } from "./runtime-model-compat.js";
export { applyXaiModelCompat, HTML_ENTITY_TOOL_CALL_ARGUMENTS_ENCODING, XAI_TOOL_SCHEMA_PROFILE };
export { resolveXaiModelCompatPatch };
const XAI_NATIVE_ENDPOINT_HOSTS = new Set(["api.x.ai"]);
function resolveHostname(value: string): string | undefined {
try {
return new URL(value).hostname.toLowerCase();
} catch {
return undefined;
}
}
function isXaiNativeEndpoint(baseUrl: unknown): boolean {
return (
typeof baseUrl === "string" && XAI_NATIVE_ENDPOINT_HOSTS.has(resolveHostname(baseUrl) ?? "")
);
}
export function isXaiModelHint(modelId: string): boolean {
return getModelProviderHint(modelId) === "x-ai";
}
export { normalizeNativeXaiModelId as normalizeXaiModelId };
function getModelProviderHint(modelId: string): string | null {
const trimmed = normalizeOptionalLowercaseString(modelId);
if (!trimmed) {
return null;
}
const slashIndex = trimmed.indexOf("/");
if (slashIndex <= 0) {
return null;
}
return trimmed.slice(0, slashIndex) || null;
}
function shouldUseXaiResponsesTransport(params: {
provider: string;
api?: unknown;
baseUrl?: unknown;
}): boolean {
if (params.api !== "openai-completions") {
return false;
}
if (isXaiNativeEndpoint(params.baseUrl)) {
return true;
}
return normalizeProviderId(params.provider) === "xai" && !params.baseUrl;
}
export function shouldContributeXaiCompat(params: {
modelId: string;
model: { api?: unknown; baseUrl?: unknown };
}): boolean {
if (params.model.api !== "openai-completions") {
return false;
}
return isXaiNativeEndpoint(params.model.baseUrl) || isXaiModelHint(params.modelId);
}
export function resolveXaiTransport(params: {
provider: string;
api?: unknown;
baseUrl?: unknown;
}): { api: "openai-responses"; baseUrl?: string } | undefined {
if (!shouldUseXaiResponsesTransport(params)) {
return undefined;
}
return {
api: "openai-responses",
baseUrl: readStringValue(params.baseUrl),
};
}
export function resolveXaiBaseUrl(baseUrlOrConfig?: unknown): string {
let candidate = baseUrlOrConfig;
if (
baseUrlOrConfig &&
typeof baseUrlOrConfig === "object" &&
!Array.isArray(baseUrlOrConfig) &&
"cfg" in baseUrlOrConfig
) {
candidate =
(baseUrlOrConfig as { cfg?: { models?: { providers?: { xai?: { baseUrl?: unknown } } } } })
.cfg?.models?.providers?.xai?.baseUrl ?? baseUrlOrConfig;
}
return readStringValue(candidate) || "https://api.x.ai/v1";
}