fix(xai): remove synthetic batch STT model (#103419)

This commit is contained in:
Peter Steinberger
2026-07-10 06:32:45 +01:00
committed by GitHub
parent dc783bbde8
commit e7b652798f
13 changed files with 243 additions and 33 deletions

View File

@@ -351,9 +351,10 @@ stale context metadata on active 4.20 rows. It does not pin active 4.20
The bundled `xai` plugin registers batch speech-to-text through OpenClaw's
media-understanding transcription surface.
- Default model: `grok-stt`
- Endpoint: xAI REST `/v1/stt`
- Input path: multipart audio file upload
- Model selection: xAI chooses the transcription model internally; the
endpoint has no model selector
- Used wherever inbound audio transcription reads `tools.media.audio`,
including Discord voice-channel segments and channel audio attachments
@@ -368,7 +369,6 @@ stale context metadata on active 4.20 rows. It does not pin active 4.20
{
type: "provider",
provider: "xai",
model: "grok-stt",
},
],
},
@@ -379,9 +379,8 @@ stale context metadata on active 4.20 rows. It does not pin active 4.20
Language can be supplied through the shared audio media config or per-call
transcription request. Prompt hints are accepted by the shared OpenClaw
surface, but the xAI REST STT integration forwards only file, model, and
language because those map cleanly to the current public xAI
endpoint.
surface, but the xAI REST STT integration forwards only file and language
because those map to the current public xAI endpoint.
</Accordion>

View File

@@ -109,6 +109,70 @@ describe("xAI doctor contract", () => {
expect(normalizeCompatibilityConfig({ cfg: config })).toEqual({ config, changes: [] });
});
it("removes only the obsolete xAI STT model selector from media entries", () => {
const config = {
tools: {
media: {
models: [
{
provider: " xAI ",
model: " GROK-STT ",
language: "en",
timeoutSeconds: 30,
},
{ type: "provider", provider: "xai", model: "custom-stt" },
{ type: "provider", provider: "openai", model: "grok-stt" },
{ type: "cli", provider: "xai", model: "grok-stt", command: "whisper" },
{ provider: "x-ai", model: "grok-stt" },
{ provider: "xai" },
],
audio: {
models: [
{
type: "provider",
provider: "xai",
model: "grok-stt",
profile: "speech",
timeoutSeconds: 45,
},
],
},
},
},
} as unknown as OpenClawConfig;
expect(
legacyConfigRules.filter((rule) => rule.match(readPathForTest(config, rule.path))),
).toHaveLength(2);
const result = normalizeCompatibilityConfig({ cfg: config });
expect(result.changes).toHaveLength(2);
expect(result.config).not.toBe(config);
expect(result.config.tools?.media?.models).toEqual([
{ provider: " xAI ", language: "en", timeoutSeconds: 30 },
{ type: "provider", provider: "xai", model: "custom-stt" },
{ type: "provider", provider: "openai", model: "grok-stt" },
{ type: "cli", provider: "xai", model: "grok-stt", command: "whisper" },
{ provider: "x-ai", model: "grok-stt" },
{ provider: "xai" },
]);
expect(result.config.tools?.media?.audio?.models).toEqual([
{
type: "provider",
provider: "xai",
profile: "speech",
timeoutSeconds: 45,
},
]);
expect(config.tools?.media?.models?.[0]).toHaveProperty("model", " GROK-STT ");
expect(config.tools?.media?.audio?.models?.[0]).toHaveProperty("model", "grok-stt");
expect(normalizeCompatibilityConfig({ cfg: result.config })).toEqual({
config: result.config,
changes: [],
});
});
});
function readPathForTest(root: unknown, path: readonly (string | number)[]): unknown {

View File

@@ -54,6 +54,10 @@ const PLUGIN_MODEL_MIGRATIONS: PluginModelMigration[] = [
["plugins", "entries", "xai", "config", "xSearch"],
].map((path) => ({ path, retiredModels: RETIRED_CODE_MODELS, targetModel: "grok-build-0.1" })),
];
const XAI_STT_MODEL_LIST_PATHS = [
["tools", "media", "models"],
["tools", "media", "audio", "models"],
] as const;
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
@@ -81,12 +85,34 @@ function hasLegacyBuiltinCatalogRows(value: unknown): boolean {
return Array.isArray(value) && value.some((model) => isLegacyXaiBuiltinModel(model));
}
function isLegacyXaiSttEntry(value: unknown): boolean {
const entry = asRecord(value);
if (!entry || (entry.type !== undefined && entry.type !== "provider")) {
return false;
}
return (
typeof entry.provider === "string" &&
entry.provider.trim().toLowerCase() === "xai" &&
typeof entry.model === "string" &&
entry.model.trim().toLowerCase() === "grok-stt"
);
}
function hasLegacyXaiSttEntries(value: unknown): boolean {
return Array.isArray(value) && value.some(isLegacyXaiSttEntry);
}
export const legacyConfigRules: LegacyConfigRule[] = [
...PLUGIN_MODEL_MIGRATIONS.map((migration) => ({
path: migration.path,
message: `${migration.path.join(".")}.model uses a retired xAI model; run "openclaw doctor --fix" to use ${migration.targetModel}.`,
match: (value: unknown) => isRetiredToolModel(value, migration.retiredModels),
})),
...XAI_STT_MODEL_LIST_PATHS.map((path) => ({
path: [...path],
message: `${path.join(".")} contains the obsolete xAI grok-stt model selector; run "openclaw doctor --fix" to remove it.`,
match: hasLegacyXaiSttEntries,
})),
{
path: ["models", "providers", "xai", "models"],
message:
@@ -121,6 +147,30 @@ export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }):
);
}
for (const path of XAI_STT_MODEL_LIST_PATHS) {
if (!hasLegacyXaiSttEntries(readPath(next, path))) {
continue;
}
if (next === cfg) {
next = structuredClone(cfg);
}
const entries = readPath(next, path);
if (!Array.isArray(entries)) {
continue;
}
let removed = 0;
for (const entry of entries) {
if (!isLegacyXaiSttEntry(entry)) {
continue;
}
delete asRecord(entry)?.model;
removed += 1;
}
changes.push(
`Removed the obsolete xAI grok-stt model selector from ${removed} ${path.join(".")} entr${removed === 1 ? "y" : "ies"}.`,
);
}
const modelsPath = ["models", "providers", "xai", "models"];
const configuredModels = readPath(next, modelsPath);
if (hasLegacyBuiltinCatalogRows(configuredModels)) {

View File

@@ -488,7 +488,7 @@ describe("xai provider plugin", () => {
const mediaProvider = requireEntry(mediaProviders, "xai");
expect(mediaProvider.capabilities).toEqual(["audio"]);
expect(mediaProvider.defaultModels).toEqual({ audio: "grok-stt" });
expect(mediaProvider.defaultModels).toBeUndefined();
const realtimeProvider = requireEntry(realtimeTranscriptionProviders, "xai");
expect(realtimeProvider.label).toBe("xAI Realtime Transcription");
expect(realtimeProvider.aliases).toContain("xai-realtime");

View File

@@ -195,9 +195,6 @@
"mediaUnderstandingProviderMetadata": {
"xai": {
"capabilities": ["audio"],
"defaultModels": {
"audio": "grok-stt"
},
"autoPriority": {
"audio": 25
}

View File

@@ -11,6 +11,7 @@ const manifest = JSON.parse(
modelCatalog?: {
suppressions?: Array<{ provider?: string; model?: string }>;
};
mediaUnderstandingProviderMetadata?: Record<string, { defaultModels?: Record<string, string> }>;
};
const XAI_MULTI_AGENT_MODELS = [
@@ -68,4 +69,8 @@ describe("xAI plugin manifest", () => {
expect(suppressionRefs).toContain(`xai/${model}`);
}
});
it("does not advertise a batch STT model selector", () => {
expect(manifest.mediaUnderstandingProviderMetadata?.xai?.defaultModels).toBeUndefined();
});
});

View File

@@ -1,10 +1,6 @@
// Xai tests cover stt plugin behavior.
import { describe, expect, it, vi } from "vitest";
import {
buildXaiMediaUnderstandingProvider,
transcribeXaiAudio,
XAI_DEFAULT_STT_MODEL,
} from "./stt.js";
import { buildXaiMediaUnderstandingProvider, transcribeXaiAudio } from "./stt.js";
const { postTranscriptionRequestMock } = vi.hoisted(() => ({
postTranscriptionRequestMock: vi.fn(
@@ -59,13 +55,13 @@ describe("xai stt", () => {
mime: "audio/wav",
apiKey: "xai-key",
baseUrl: "https://api.x.ai/v1/",
model: XAI_DEFAULT_STT_MODEL,
model: "grok-4.3",
language: "en",
prompt: "ignored provider hint",
timeoutMs: 10_000,
});
expect(result).toEqual({ text: "hello from audio", model: XAI_DEFAULT_STT_MODEL });
expect(result).toEqual({ text: "hello from audio" });
const call = requireLastPostTranscriptionCall();
expect(call.url).toBe("https://api.x.ai/v1/stt");
expect(call.timeoutMs).toBe(10_000);
@@ -73,7 +69,7 @@ describe("xai stt", () => {
expect(call.headers.get("authorization")).toBe("Bearer xai-key");
expect(call.body).toBeInstanceOf(FormData);
const form = call.body as FormData;
expect(form.get("model")).toBe(XAI_DEFAULT_STT_MODEL);
expect(form.get("model")).toBeNull();
expect(form.get("language")).toBe("en");
expect(form.get("prompt")).toBeNull();
expect(form.get("file")).toBeInstanceOf(Blob);
@@ -83,7 +79,7 @@ describe("xai stt", () => {
const provider = buildXaiMediaUnderstandingProvider();
expect(provider.id).toBe("xai");
expect(provider.capabilities).toEqual(["audio"]);
expect(provider.defaultModels).toEqual({ audio: XAI_DEFAULT_STT_MODEL });
expect(provider.defaultModels).toBeUndefined();
expect(provider.autoPriority).toEqual({ audio: 25 });
});
@@ -98,7 +94,7 @@ describe("xai stt", () => {
mime: "audio/wav",
apiKey: "core-resolved-bearer",
baseUrl: "https://api.x.ai/v1/",
model: XAI_DEFAULT_STT_MODEL,
model: "grok-4.3",
timeoutMs: 10_000,
});
const call = requireLastPostTranscriptionCall();

View File

@@ -15,8 +15,6 @@ import {
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { XAI_BASE_URL } from "./model-definitions.js";
export const XAI_DEFAULT_STT_MODEL = "grok-stt";
type XaiSttResponse = {
text?: string;
};
@@ -44,14 +42,12 @@ export async function transcribeXaiAudio(
transport: "media-understanding",
});
const model = normalizeOptionalString(params.model);
const language = normalizeOptionalString(params.language);
const form = buildAudioTranscriptionFormData({
buffer: params.buffer,
fileName: params.fileName,
mime: params.mime,
fields: {
model,
language,
},
});
@@ -72,7 +68,6 @@ export async function transcribeXaiAudio(
const payload = await readProviderJsonResponse<XaiSttResponse>(response, "xai.stt");
return {
text: requireTranscriptionText(payload.text, "xAI transcription response missing text"),
...(model ? { model } : {}),
};
} finally {
await release();
@@ -86,7 +81,6 @@ export function buildXaiMediaUnderstandingProvider(): MediaUnderstandingProvider
return {
id: "xai",
capabilities: ["audio"],
defaultModels: { audio: XAI_DEFAULT_STT_MODEL },
autoPriority: { audio: 25 },
transcribeAudio: transcribeXaiAudio,
};

View File

@@ -19,7 +19,6 @@ import { isBillingErrorMessage } from "openclaw/plugin-sdk/test-env";
import { describe, expect, it } from "vitest";
import { createCodeExecutionTool } from "./code-execution.js";
import plugin from "./index.js";
import { XAI_DEFAULT_STT_MODEL } from "./stt.js";
const XAI_API_KEY = process.env.XAI_API_KEY ?? "";
const LIVE_IMAGE_MODEL = process.env.OPENCLAW_LIVE_XAI_IMAGE_MODEL?.trim() || "grok-imagine-image";
@@ -306,12 +305,11 @@ describeLive("xai plugin live", () => {
mime: "audio/mpeg",
apiKey: XAI_API_KEY,
baseUrl: "https://api.x.ai/v1",
model: XAI_DEFAULT_STT_MODEL,
timeoutMs: 90_000,
});
const normalized = transcript?.text.toLowerCase() ?? "";
expect(transcript?.model).toBe(XAI_DEFAULT_STT_MODEL);
expect(transcript?.model).toBeUndefined();
expectOpenClawLiveTranscriptMarker(normalized);
expect(normalized).toContain("speech");
expect(normalized).toContain("text");

View File

@@ -290,6 +290,55 @@ describe("runCapability auto audio entries", () => {
expect(seenModel).toBe("gpt-4o-transcribe");
});
it("does not leak the active xAI chat model into model-less batch STT", async () => {
let runResult: Awaited<ReturnType<typeof runCapability>> | undefined;
let seenModel: string | undefined;
await withAudioFixture("openclaw-auto-audio-xai", async ({ ctx, media, cache }) => {
const providerRegistry = createProviderRegistry({
xai: {
id: "xai",
capabilities: ["audio"],
transcribeAudio: async (req) => {
seenModel = req.model;
return { text: "xai audio" };
},
},
});
const cfg = {
models: {
providers: {
xai: {
apiKey: "xai-test-key", // pragma: allowlist secret
models: [],
},
},
},
} as unknown as OpenClawConfig;
runResult = await runCapability({
capability: "audio",
cfg,
ctx,
attachments: cache,
media,
providerRegistry,
activeModel: { provider: "xai", model: "grok-4.3" },
});
});
if (!runResult) {
throw new Error("expected xAI audio result");
}
expect(requireCapabilityOutput(runResult, 0)).toEqual({
kind: "audio.transcription",
attachmentIndex: 0,
provider: "xai",
text: "xai audio",
});
expect(seenModel).toBeUndefined();
});
it("prefers provider keys over auto-detected local whisper", async () => {
const binDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auto-audio-bin-"));
try {

View File

@@ -623,6 +623,8 @@ async function resolveKeyEntry(params: {
) {
return null;
}
// The supplied model can belong to the active chat route. Audio providers
// use their own default or stay model-less; explicit media entries bypass this auto path.
const resolvedModel =
capability === "image"
? await resolveAutoImageModelId({
@@ -632,14 +634,18 @@ async function resolveKeyEntry(params: {
explicitModel: model,
workspaceDir,
})
: capability === "video"
? (model ??
: capability === "audio"
? resolveDefaultMediaModelFromRegistry({
providerId,
capability: "audio",
providerRegistry,
})
: (model ??
resolveDefaultMediaModelFromRegistry({
providerId,
capability: "video",
providerRegistry,
}))
: model;
}));
if (capability === "image" && !resolvedModel) {
return null;
}
@@ -922,7 +928,7 @@ async function resolveActiveModelEntry(params: {
providerRegistry: params.providerRegistry,
});
}
if ((params.capability === "image" || params.capability === "audio") && !model) {
if (params.capability === "image" && !model) {
return null;
}
return {

View File

@@ -391,6 +391,27 @@ describe("doctor-contract-registry module loader", () => {
).toEqual(["ollama-cloud"]);
});
it("collects provider ids from media model entries", () => {
const raw = {
tools: {
media: {
models: [{ provider: " xAI " }, { provider: " " }],
audio: { models: [{ provider: "XAI", model: "grok-stt" }] },
image: { models: [{ provider: "openai", model: "gpt-5.5" }] },
video: { models: [{ provider: "gemini", model: "veo" }] },
},
},
};
expect(collectRelevantDoctorPluginIds(raw)).toEqual(["gemini", "openai", "xai"]);
expect(
collectRelevantDoctorPluginIdsForTouchedPaths({
raw,
touchedPaths: [["tools", "media", "audio", "models", "0", "model"]],
}),
).toEqual(["gemini", "openai", "xai"]);
});
it("loads a plugin doctor contract when scoped by a contributed provider id", () => {
const pluginRoot = makeTempDir();
fs.writeFileSync(path.join(pluginRoot, "doctor-contract-api.ts"), "export {};\n", "utf-8");

View File

@@ -2,6 +2,7 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import { asNullableRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeTrimmedStringList } from "@openclaw/normalization-core/string-normalization";
import type { LegacyConfigRule } from "../config/legacy.shared.js";
@@ -235,6 +236,30 @@ function hasLegacyElevenLabsTalkFields(raw: unknown): boolean {
);
}
function collectMediaProviderIds(root: Record<string, unknown>, ids: Set<string>): void {
const media = asNullableRecord(asNullableRecord(root.tools)?.media);
if (!media) {
return;
}
const modelLists = [
media.models,
asNullableRecord(media.audio)?.models,
asNullableRecord(media.image)?.models,
asNullableRecord(media.video)?.models,
];
for (const models of modelLists) {
if (!Array.isArray(models)) {
continue;
}
for (const model of models) {
const provider = asNullableRecord(model)?.provider;
if (typeof provider === "string" && provider.trim()) {
ids.add(normalizeProviderId(provider));
}
}
}
}
export function collectRelevantDoctorPluginIds(raw: unknown): string[] {
const ids = new Set<string>();
const root = asNullableRecord(raw);
@@ -265,6 +290,8 @@ export function collectRelevantDoctorPluginIds(raw: unknown): string[] {
}
}
collectMediaProviderIds(root, ids);
if (hasLegacyElevenLabsTalkFields(root)) {
ids.add("elevenlabs");
}
@@ -307,6 +334,10 @@ export function collectRelevantDoctorPluginIdsForTouchedPaths(params: {
ids.add(third);
continue;
}
if (first === "tools" && second === "media") {
collectMediaProviderIds(root, ids);
continue;
}
if (first === "talk" && hasLegacyElevenLabsTalkFields(root)) {
ids.add("elevenlabs");
}