fix(openai): support the current speech model snapshot (#118475)

* fix(openai): support the current speech model snapshot

* docs(openai): clarify speech instructions apply to the model family
This commit is contained in:
Peter Steinberger
2026-08-02 23:00:46 -07:00
committed by GitHub
parent 2e46ab1f23
commit 5de84bfc4d
4 changed files with 111 additions and 6 deletions
+5 -4
View File
@@ -806,15 +806,16 @@ compatibility fallback when the shared
| Model | `tts.providers.openai.model` | `gpt-4o-mini-tts` |
| Voice | `tts.providers.openai.speakerVoice` | `coral` |
| Speed | `tts.providers.openai.speed` | (unset) |
| Instructions | `tts.providers.openai.instructions` | (unset, `gpt-4o-mini-tts` only) |
| Instructions | `tts.providers.openai.instructions` | (unset, `gpt-4o-mini-tts` family only) |
| Format | `tts.providers.openai.responseFormat` | `opus` for voice notes, `mp3` for files |
| API key | `tts.providers.openai.apiKey` | Falls back to `OPENAI_API_KEY` |
| Base URL | `tts.providers.openai.baseUrl` | `https://api.openai.com/v1` |
| Extra body | `tts.providers.openai.extraBody` / `extra_body` | (unset) |
Available models: `gpt-4o-mini-tts`, `tts-1`, `tts-1-hd`. Available voices:
`alloy`, `ash`, `ballad`, `cedar`, `coral`, `echo`, `fable`, `juniper`,
`marin`, `onyx`, `nova`, `sage`, `shimmer`, `verse`.
Available models: `gpt-4o-mini-tts`, `gpt-4o-mini-tts-2025-12-15`, `tts-1`,
`tts-1-hd`. Available voices: `alloy`, `ash`, `ballad`, `cedar`, `coral`,
`echo`, `fable`, `juniper`, `marin`, `onyx`, `nova`, `sage`, `shimmer`,
`verse`.
`extraBody` is merged into `/audio/speech` request JSON after OpenClaw's
generated fields, so use it for OpenAI-compatible endpoints that require
+97
View File
@@ -1,7 +1,10 @@
// Openai tests cover speech provider plugin behavior.
import { createServer } from "node:http";
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildOpenAISpeechProvider } from "./speech-provider.js";
const OPENAI_TTS_SNAPSHOT = "gpt-4o-mini-tts-2025-12-15";
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
fetchWithSsrFGuard: async ({
url,
@@ -62,6 +65,13 @@ describe("buildOpenAISpeechProvider", () => {
vi.restoreAllMocks();
});
it("advertises official speech snapshots without changing the default model", () => {
const provider = buildOpenAISpeechProvider();
expect(provider.defaultModel).toBe("gpt-4o-mini-tts");
expect(provider.models).toContain(OPENAI_TTS_SNAPSHOT);
});
it("normalizes provider-owned speech config from raw provider config", () => {
const provider = buildOpenAISpeechProvider();
const resolved = provider.resolveConfig?.({
@@ -177,6 +187,23 @@ describe("buildOpenAISpeechProvider", () => {
overrides: { voice: "alloy" },
});
expect(
provider.parseDirectiveToken?.({
key: "openai_model",
value: OPENAI_TTS_SNAPSHOT,
policy: {
allowVoice: true,
allowModelId: true,
},
providerConfig: {
baseUrl: "https://api.openai.com/v1/",
},
} as never),
).toEqual({
handled: true,
overrides: { model: OPENAI_TTS_SNAPSHOT },
});
expect(
provider.parseDirectiveToken?.({
key: "model",
@@ -194,6 +221,76 @@ describe("buildOpenAISpeechProvider", () => {
});
});
it("sends dated speech snapshots through a real loopback HTTP request", async () => {
const provider = buildOpenAISpeechProvider();
let receivedRequest:
| {
method: string | undefined;
url: string | undefined;
body: unknown;
}
| undefined;
const server = createServer((request, response) => {
const chunks: Buffer[] = [];
request.on("data", (chunk: Buffer) => chunks.push(chunk));
request.on("end", () => {
receivedRequest = {
method: request.method,
url: request.url,
body: JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown,
};
response.writeHead(200, { "content-type": "audio/mpeg" });
response.end(Buffer.from("snapshot-audio"));
});
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.removeListener("error", reject);
resolve();
});
});
try {
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("expected a loopback server address");
}
const result = await provider.synthesize({
text: "snapshot request",
cfg: {} as never,
providerConfig: {
apiKey: "sk-test",
baseUrl: `http://127.0.0.1:${address.port}/v1`,
model: OPENAI_TTS_SNAPSHOT,
voice: "alloy",
instructions: " Speak warmly ",
},
target: "audio-file",
timeoutMs: 1_000,
});
expect(receivedRequest).toEqual({
method: "POST",
url: "/v1/audio/speech",
body: {
model: OPENAI_TTS_SNAPSHOT,
input: "snapshot request",
voice: "alloy",
response_format: "mp3",
instructions: "Speak warmly",
},
});
expect(result.audioBuffer).toEqual(Buffer.from("snapshot-audio"));
} finally {
server.closeAllConnections();
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
it("parses preferred-OpenAI speed directive within the supported range", () => {
const provider = buildOpenAISpeechProvider();
+3 -1
View File
@@ -101,13 +101,15 @@ describe("openai tts", () => {
describe("isValidOpenAIModel", () => {
it("matches the supported model set and rejects unsupported values", () => {
expect(OPENAI_TTS_MODELS).toContain("gpt-4o-mini-tts");
expect(OPENAI_TTS_MODELS).toContain("gpt-4o-mini-tts-2025-12-15");
expect(OPENAI_TTS_MODELS).toContain("tts-1");
expect(OPENAI_TTS_MODELS).toContain("tts-1-hd");
expect(OPENAI_TTS_MODELS).toHaveLength(3);
expect(OPENAI_TTS_MODELS).toHaveLength(4);
expect(Array.isArray(OPENAI_TTS_MODELS)).toBe(true);
expect(OPENAI_TTS_MODELS.length).toBeGreaterThan(0);
const cases = [
{ model: "gpt-4o-mini-tts", expected: true },
{ model: "gpt-4o-mini-tts-2025-12-15", expected: true },
{ model: "tts-1", expected: true },
{ model: "tts-1-hd", expected: true },
{ model: "invalid", expected: false },
+6 -1
View File
@@ -17,7 +17,12 @@ import {
export const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
const DEFAULT_TTS_MAX_BYTES = 16 * 1024 * 1024;
export const OPENAI_TTS_MODELS = ["gpt-4o-mini-tts", "tts-1", "tts-1-hd"] as const;
export const OPENAI_TTS_MODELS = [
"gpt-4o-mini-tts",
"gpt-4o-mini-tts-2025-12-15",
"tts-1",
"tts-1-hd",
] as const;
export const OPENAI_TTS_VOICES = [
"alloy",