Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
12
extensions/gradium/README.md
Normal file
12
extensions/gradium/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# OpenClaw Gradium Plugin
|
||||
|
||||
Official OpenClaw plugin for Gradium.
|
||||
|
||||
Install from OpenClaw:
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/gradium-speech
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
See <https://docs.openclaw.ai/providers/gradium> for setup and configuration.
|
||||
43
extensions/gradium/gradium.live.test.ts
Normal file
43
extensions/gradium/gradium.live.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// Gradium tests cover gradium plugin behavior.
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
registerProviderPlugin,
|
||||
requireRegisteredProvider,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { isLiveTestEnabled } from "openclaw/plugin-sdk/test-env";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import plugin from "./index.js";
|
||||
|
||||
const LIVE = isLiveTestEnabled();
|
||||
const GRADIUM_API_KEY = process.env.GRADIUM_API_KEY?.trim() ?? "";
|
||||
|
||||
const registerGradiumPlugin = () =>
|
||||
registerProviderPlugin({
|
||||
plugin,
|
||||
id: "gradium",
|
||||
name: "Gradium Speech",
|
||||
});
|
||||
|
||||
describe.skipIf(!LIVE || !GRADIUM_API_KEY)("gradium live", () => {
|
||||
it("synthesizes speech through the registered provider", async () => {
|
||||
const { speechProviders } = await registerGradiumPlugin();
|
||||
const provider = requireRegisteredProvider(speechProviders, "gradium");
|
||||
|
||||
const result = await provider.synthesize({
|
||||
text: "Hello, this is a test of Gradium text to speech.",
|
||||
cfg: { plugins: { enabled: true } } as never,
|
||||
providerConfig: { apiKey: GRADIUM_API_KEY },
|
||||
target: "audio-file",
|
||||
timeoutMs: 45_000,
|
||||
});
|
||||
|
||||
expect(result.outputFormat).toBe("wav");
|
||||
expect(result.audioBuffer.byteLength).toBeGreaterThan(512);
|
||||
|
||||
const outPath = join(tmpdir(), "gradium-live-test.wav");
|
||||
writeFileSync(outPath, result.audioBuffer);
|
||||
console.log(`Audio written to ${outPath}`);
|
||||
}, 60_000);
|
||||
});
|
||||
12
extensions/gradium/index.ts
Normal file
12
extensions/gradium/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Gradium plugin entrypoint registers its OpenClaw integration.
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { buildGradiumSpeechProvider } from "./speech-provider.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "gradium",
|
||||
name: "Gradium Speech",
|
||||
description: "Bundled Gradium speech provider",
|
||||
register(api) {
|
||||
api.registerSpeechProvider(buildGradiumSpeechProvider());
|
||||
},
|
||||
});
|
||||
12
extensions/gradium/npm-shrinkwrap.json
generated
Normal file
12
extensions/gradium/npm-shrinkwrap.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/gradium-speech",
|
||||
"version": "2026.6.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/gradium-speech",
|
||||
"version": "2026.6.11"
|
||||
}
|
||||
}
|
||||
}
|
||||
22
extensions/gradium/openclaw.plugin.json
Normal file
22
extensions/gradium/openclaw.plugin.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"id": "gradium",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"setup": {
|
||||
"providers": [
|
||||
{
|
||||
"id": "gradium",
|
||||
"envVars": ["GRADIUM_API_KEY"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"contracts": {
|
||||
"speechProviders": ["gradium"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
35
extensions/gradium/package.json
Normal file
35
extensions/gradium/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@openclaw/gradium-speech",
|
||||
"version": "2026.6.11",
|
||||
"description": "OpenClaw Gradium speech plugin.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"install": {
|
||||
"clawhubSpec": "clawhub:@openclaw/gradium-speech",
|
||||
"npmSpec": "@openclaw/gradium-speech",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.6.8"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.11"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.11",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
}
|
||||
18
extensions/gradium/shared.ts
Normal file
18
extensions/gradium/shared.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// Gradium plugin module implements shared behavior.
|
||||
const DEFAULT_GRADIUM_BASE_URL = "https://api.gradium.ai";
|
||||
export const DEFAULT_GRADIUM_VOICE_ID = "YTpq7expH9539ERJ";
|
||||
|
||||
export const GRADIUM_VOICES = [
|
||||
{ id: "YTpq7expH9539ERJ", name: "Emma" },
|
||||
{ id: "LFZvm12tW_z0xfGo", name: "Kent" },
|
||||
{ id: "Eu9iL_CYe8N-Gkx_", name: "Tiffany" },
|
||||
{ id: "2H4HY2CBNyJHBCrP", name: "Christina" },
|
||||
{ id: "jtEKaLYNn6iif5PR", name: "Sydney" },
|
||||
{ id: "KWJiFWu2O9nMPYcR", name: "John" },
|
||||
{ id: "3jUdJyOi9pgbxBTK", name: "Arthur" },
|
||||
] as const;
|
||||
|
||||
export function normalizeGradiumBaseUrl(baseUrl?: string): string {
|
||||
const trimmed = baseUrl?.trim();
|
||||
return trimmed?.replace(/\/+$/, "") || DEFAULT_GRADIUM_BASE_URL;
|
||||
}
|
||||
174
extensions/gradium/speech-provider.test.ts
Normal file
174
extensions/gradium/speech-provider.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
// Gradium tests cover speech provider plugin behavior.
|
||||
import { installPinnedHostnameTestHooks } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildGradiumSpeechProvider } from "./speech-provider.js";
|
||||
|
||||
describe("gradium speech provider", () => {
|
||||
installPinnedHostnameTestHooks();
|
||||
|
||||
const provider = buildGradiumSpeechProvider();
|
||||
|
||||
const firstFetchCall = (fetchMock: ReturnType<typeof vi.fn>): [string, RequestInit] => {
|
||||
const call = fetchMock.mock.calls[0] as [string, RequestInit] | undefined;
|
||||
if (!call) {
|
||||
throw new Error("expected Gradium fetch call");
|
||||
}
|
||||
return call;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("reports configured when GRADIUM_API_KEY is set", () => {
|
||||
const original = process.env.GRADIUM_API_KEY;
|
||||
try {
|
||||
process.env.GRADIUM_API_KEY = "gsk_test";
|
||||
expect(provider.isConfigured({ providerConfig: {}, timeoutMs: 5_000 })).toBe(true);
|
||||
} finally {
|
||||
if (original === undefined) {
|
||||
delete process.env.GRADIUM_API_KEY;
|
||||
} else {
|
||||
process.env.GRADIUM_API_KEY = original;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("reports not configured when no key is available", () => {
|
||||
const original = process.env.GRADIUM_API_KEY;
|
||||
try {
|
||||
delete process.env.GRADIUM_API_KEY;
|
||||
expect(provider.isConfigured({ providerConfig: {}, timeoutMs: 5_000 })).toBe(false);
|
||||
} finally {
|
||||
if (original !== undefined) {
|
||||
process.env.GRADIUM_API_KEY = original;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("synthesizes audio via the Gradium TTS endpoint", async () => {
|
||||
const audioData = Buffer.from("wav-audio-data");
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(audioData, { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await provider.synthesize({
|
||||
text: "OpenClaw test",
|
||||
cfg: {} as never,
|
||||
providerConfig: { apiKey: "gsk_test123" },
|
||||
target: "audio-file",
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = firstFetchCall(fetchMock);
|
||||
expect(url).toBe("https://api.gradium.ai/api/post/speech/tts");
|
||||
const headers = new Headers(init.headers);
|
||||
expect(headers.get("x-api-key")).toBe("gsk_test123");
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
text: "OpenClaw test",
|
||||
voice_id: "YTpq7expH9539ERJ",
|
||||
only_audio: true,
|
||||
output_format: "wav",
|
||||
json_config: '{"padding_bonus":0}',
|
||||
});
|
||||
expect(result.outputFormat).toBe("wav");
|
||||
expect(result.fileExtension).toBe(".wav");
|
||||
expect(result.voiceCompatible).toBe(false);
|
||||
expect(result.audioBuffer).toEqual(audioData);
|
||||
});
|
||||
|
||||
it("uses opus and voiceCompatible for voice-note target", async () => {
|
||||
const audioData = Buffer.from("opus-audio-data");
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(audioData, { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await provider.synthesize({
|
||||
text: "Voice note test",
|
||||
cfg: {} as never,
|
||||
providerConfig: { apiKey: "gsk_test123" },
|
||||
target: "voice-note",
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
|
||||
const [, init] = firstFetchCall(fetchMock);
|
||||
expect(JSON.parse(init.body as string).output_format).toBe("opus");
|
||||
expect(result.outputFormat).toBe("opus");
|
||||
expect(result.fileExtension).toBe(".opus");
|
||||
expect(result.voiceCompatible).toBe(true);
|
||||
expect(result.audioBuffer).toEqual(audioData);
|
||||
});
|
||||
|
||||
it("applies the configured media byte cap to synthesized audio", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(new Response(new Uint8Array(2048), { status: 200 })),
|
||||
);
|
||||
|
||||
await expect(
|
||||
provider.synthesize({
|
||||
text: "OpenClaw test",
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
mediaMaxMb: 0.001,
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
providerConfig: { apiKey: "gsk_test123" },
|
||||
target: "audio-file",
|
||||
timeoutMs: 30_000,
|
||||
}),
|
||||
).rejects.toThrow("Gradium TTS audio response exceeds");
|
||||
});
|
||||
|
||||
it("uses ulaw_8000 for telephony synthesis", async () => {
|
||||
const audioData = Buffer.from("ulaw-audio-data");
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(audioData, { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const synthesizeTelephony = provider.synthesizeTelephony;
|
||||
if (!synthesizeTelephony) {
|
||||
throw new Error("Expected Gradium provider synthesizeTelephony");
|
||||
}
|
||||
|
||||
const result = await synthesizeTelephony({
|
||||
text: "Telephony test",
|
||||
cfg: {} as never,
|
||||
providerConfig: { apiKey: "gsk_test123", voiceId: "default-voice" },
|
||||
providerOverrides: { voiceId: "override-voice" },
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
|
||||
const [, init] = firstFetchCall(fetchMock);
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
text: "Telephony test",
|
||||
voice_id: "override-voice",
|
||||
only_audio: true,
|
||||
output_format: "ulaw_8000",
|
||||
json_config: '{"padding_bonus":0}',
|
||||
});
|
||||
expect(result.outputFormat).toBe("ulaw_8000");
|
||||
expect(result.sampleRate).toBe(8_000);
|
||||
expect(result.audioBuffer).toEqual(audioData);
|
||||
});
|
||||
|
||||
it("throws when no API key is available", async () => {
|
||||
const original = process.env.GRADIUM_API_KEY;
|
||||
try {
|
||||
delete process.env.GRADIUM_API_KEY;
|
||||
await expect(
|
||||
provider.synthesize({
|
||||
text: "test",
|
||||
cfg: {} as never,
|
||||
providerConfig: {},
|
||||
target: "audio-file",
|
||||
timeoutMs: 5_000,
|
||||
}),
|
||||
).rejects.toThrow("Gradium API key missing");
|
||||
} finally {
|
||||
if (original !== undefined) {
|
||||
process.env.GRADIUM_API_KEY = original;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
132
extensions/gradium/speech-provider.ts
Normal file
132
extensions/gradium/speech-provider.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
// Gradium provider module implements model/runtime integration.
|
||||
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
|
||||
import type {
|
||||
SpeechDirectiveTokenParseContext,
|
||||
SpeechProviderConfig,
|
||||
SpeechProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/speech";
|
||||
import { asObject, trimToUndefined } from "openclaw/plugin-sdk/speech";
|
||||
import { DEFAULT_GRADIUM_VOICE_ID, GRADIUM_VOICES, normalizeGradiumBaseUrl } from "./shared.js";
|
||||
import { gradiumTTS } from "./tts.js";
|
||||
|
||||
const DEFAULT_GENERATED_AUDIO_MAX_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
type GradiumProviderConfig = {
|
||||
apiKey?: string;
|
||||
baseUrl: string;
|
||||
voiceId: string;
|
||||
};
|
||||
|
||||
function normalizeGradiumProviderConfig(rawConfig: Record<string, unknown>): GradiumProviderConfig {
|
||||
const providers = asObject(rawConfig.providers);
|
||||
const raw = asObject(providers?.gradium) ?? asObject(rawConfig.gradium);
|
||||
return {
|
||||
apiKey: normalizeResolvedSecretInputString({
|
||||
value: raw?.apiKey,
|
||||
path: "messages.tts.providers.gradium.apiKey",
|
||||
}),
|
||||
baseUrl: normalizeGradiumBaseUrl(trimToUndefined(raw?.baseUrl)),
|
||||
voiceId: trimToUndefined(raw?.voiceId) ?? DEFAULT_GRADIUM_VOICE_ID,
|
||||
};
|
||||
}
|
||||
|
||||
function readGradiumProviderConfig(config: SpeechProviderConfig): GradiumProviderConfig {
|
||||
const defaults = normalizeGradiumProviderConfig({});
|
||||
return {
|
||||
apiKey: trimToUndefined(config.apiKey) ?? defaults.apiKey,
|
||||
baseUrl: normalizeGradiumBaseUrl(trimToUndefined(config.baseUrl) ?? defaults.baseUrl),
|
||||
voiceId: trimToUndefined(config.voiceId) ?? defaults.voiceId,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveGeneratedAudioMaxBytes(req: {
|
||||
cfg: { agents?: { defaults?: { mediaMaxMb?: number } } };
|
||||
}): number {
|
||||
const configured = req.cfg.agents?.defaults?.mediaMaxMb;
|
||||
if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) {
|
||||
return Math.floor(configured * 1024 * 1024);
|
||||
}
|
||||
return DEFAULT_GENERATED_AUDIO_MAX_BYTES;
|
||||
}
|
||||
|
||||
function parseDirectiveToken(ctx: SpeechDirectiveTokenParseContext): {
|
||||
handled: boolean;
|
||||
overrides?: Record<string, unknown>;
|
||||
warnings?: string[];
|
||||
} {
|
||||
switch (ctx.key) {
|
||||
case "voice":
|
||||
case "voice_id":
|
||||
case "voiceid":
|
||||
case "gradium_voice":
|
||||
case "gradiumvoice":
|
||||
if (!ctx.policy.allowVoice) {
|
||||
return { handled: true };
|
||||
}
|
||||
return {
|
||||
handled: true,
|
||||
overrides: { ...ctx.currentOverrides, voiceId: ctx.value },
|
||||
};
|
||||
default:
|
||||
return { handled: false };
|
||||
}
|
||||
}
|
||||
|
||||
export function buildGradiumSpeechProvider(): SpeechProviderPlugin {
|
||||
return {
|
||||
id: "gradium",
|
||||
label: "Gradium",
|
||||
autoSelectOrder: 30,
|
||||
voices: GRADIUM_VOICES.map((v) => v.id),
|
||||
resolveConfig: ({ rawConfig }) => normalizeGradiumProviderConfig(rawConfig),
|
||||
parseDirectiveToken,
|
||||
listVoices: async () => GRADIUM_VOICES.map((v) => ({ id: v.id, name: v.name })),
|
||||
isConfigured: ({ providerConfig }) =>
|
||||
Boolean(readGradiumProviderConfig(providerConfig).apiKey || process.env.GRADIUM_API_KEY),
|
||||
synthesize: async (req) => {
|
||||
const config = readGradiumProviderConfig(req.providerConfig);
|
||||
const overrides = req.providerOverrides ?? {};
|
||||
const apiKey = config.apiKey || process.env.GRADIUM_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("Gradium API key missing");
|
||||
}
|
||||
const wantsVoiceNote = req.target === "voice-note";
|
||||
const outputFormat = wantsVoiceNote ? "opus" : "wav";
|
||||
const audioBuffer = await gradiumTTS({
|
||||
text: req.text,
|
||||
apiKey,
|
||||
baseUrl: config.baseUrl,
|
||||
voiceId: trimToUndefined(overrides.voiceId) ?? config.voiceId,
|
||||
outputFormat,
|
||||
timeoutMs: req.timeoutMs,
|
||||
maxBytes: resolveGeneratedAudioMaxBytes(req),
|
||||
});
|
||||
return {
|
||||
audioBuffer,
|
||||
outputFormat,
|
||||
fileExtension: wantsVoiceNote ? ".opus" : ".wav",
|
||||
voiceCompatible: wantsVoiceNote,
|
||||
};
|
||||
},
|
||||
synthesizeTelephony: async (req) => {
|
||||
const config = readGradiumProviderConfig(req.providerConfig);
|
||||
const overrides = req.providerOverrides ?? {};
|
||||
const apiKey = config.apiKey || process.env.GRADIUM_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("Gradium API key missing");
|
||||
}
|
||||
const outputFormat = "ulaw_8000";
|
||||
const sampleRate = 8_000;
|
||||
const audioBuffer = await gradiumTTS({
|
||||
text: req.text,
|
||||
apiKey,
|
||||
baseUrl: config.baseUrl,
|
||||
voiceId: trimToUndefined(overrides.voiceId) ?? config.voiceId,
|
||||
outputFormat,
|
||||
timeoutMs: req.timeoutMs,
|
||||
maxBytes: resolveGeneratedAudioMaxBytes(req),
|
||||
});
|
||||
return { audioBuffer, outputFormat, sampleRate };
|
||||
},
|
||||
};
|
||||
}
|
||||
16
extensions/gradium/tsconfig.json
Normal file
16
extensions/gradium/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
169
extensions/gradium/tts.test.ts
Normal file
169
extensions/gradium/tts.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
// Gradium tests cover tts plugin behavior.
|
||||
import { installPinnedHostnameTestHooks } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { gradiumTTS } from "./tts.js";
|
||||
|
||||
describe("gradium tts diagnostics", () => {
|
||||
installPinnedHostnameTestHooks();
|
||||
|
||||
function createStreamingErrorResponse(params: {
|
||||
status: number;
|
||||
chunkCount: number;
|
||||
chunkSize: number;
|
||||
byte: number;
|
||||
}): { response: Response; getReadCount: () => number } {
|
||||
let reads = 0;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (reads >= params.chunkCount) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
reads += 1;
|
||||
controller.enqueue(new Uint8Array(params.chunkSize).fill(params.byte));
|
||||
},
|
||||
});
|
||||
return {
|
||||
response: new Response(stream, { status: params.status }),
|
||||
getReadCount: () => reads,
|
||||
};
|
||||
}
|
||||
|
||||
function createStreamingAudioResponse(params: {
|
||||
chunkCount: number;
|
||||
chunkSize: number;
|
||||
byte: number;
|
||||
}): { response: Response; getReadCount: () => number } {
|
||||
return createStreamingErrorResponse({ ...params, status: 200 });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("includes parsed provider detail and request id for JSON API errors", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
message: "Invalid API key",
|
||||
}),
|
||||
{
|
||||
status: 401,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-request-id": "grad_req_123",
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
gradiumTTS({
|
||||
text: "hello",
|
||||
apiKey: "bad-key",
|
||||
baseUrl: "https://api.gradium.ai",
|
||||
voiceId: "YTpq7expH9539ERJ",
|
||||
outputFormat: "wav",
|
||||
timeoutMs: 5_000,
|
||||
}),
|
||||
).rejects.toThrow("Gradium API error (401): Invalid API key [request_id=grad_req_123]");
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("falls back to raw body text when the error body is non-JSON", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(new Response("service unavailable", { status: 503 })),
|
||||
);
|
||||
|
||||
await expect(
|
||||
gradiumTTS({
|
||||
text: "hello",
|
||||
apiKey: "test-key",
|
||||
baseUrl: "https://api.gradium.ai",
|
||||
voiceId: "YTpq7expH9539ERJ",
|
||||
outputFormat: "wav",
|
||||
timeoutMs: 5_000,
|
||||
}),
|
||||
).rejects.toThrow("Gradium API error (503): service unavailable");
|
||||
});
|
||||
|
||||
it("caps streamed non-JSON error reads instead of consuming full response bodies", async () => {
|
||||
const streamed = createStreamingErrorResponse({
|
||||
status: 503,
|
||||
chunkCount: 200,
|
||||
chunkSize: 1024,
|
||||
byte: 121,
|
||||
});
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(streamed.response));
|
||||
|
||||
await expect(
|
||||
gradiumTTS({
|
||||
text: "hello",
|
||||
apiKey: "test-key",
|
||||
baseUrl: "https://api.gradium.ai",
|
||||
voiceId: "YTpq7expH9539ERJ",
|
||||
outputFormat: "wav",
|
||||
timeoutMs: 5_000,
|
||||
}),
|
||||
).rejects.toThrow("Gradium API error (503)");
|
||||
|
||||
expect(streamed.getReadCount()).toBeLessThan(200);
|
||||
});
|
||||
|
||||
it("sends the correct request payload", async () => {
|
||||
const audioData = Buffer.from("fake-wav-data");
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(audioData, { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await gradiumTTS({
|
||||
text: "Hello world",
|
||||
apiKey: "gsk_test123",
|
||||
baseUrl: "https://api.gradium.ai",
|
||||
voiceId: "YTpq7expH9539ERJ",
|
||||
outputFormat: "wav",
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe("https://api.gradium.ai/api/post/speech/tts");
|
||||
expect(init.method).toBe("POST");
|
||||
const headers = new Headers(init.headers);
|
||||
expect(headers.get("x-api-key")).toBe("gsk_test123");
|
||||
expect(headers.get("content-type")).toBe("application/json");
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
text: "Hello world",
|
||||
voice_id: "YTpq7expH9539ERJ",
|
||||
only_audio: true,
|
||||
output_format: "wav",
|
||||
json_config: '{"padding_bonus":0}',
|
||||
});
|
||||
expect(result).toEqual(audioData);
|
||||
});
|
||||
|
||||
it("caps streamed audio responses instead of buffering oversized TTS output", async () => {
|
||||
const streamed = createStreamingAudioResponse({
|
||||
chunkCount: 20,
|
||||
chunkSize: 1024,
|
||||
byte: 121,
|
||||
});
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(streamed.response));
|
||||
|
||||
await expect(
|
||||
gradiumTTS({
|
||||
text: "hello",
|
||||
apiKey: "test-key",
|
||||
baseUrl: "https://api.gradium.ai",
|
||||
voiceId: "YTpq7expH9539ERJ",
|
||||
outputFormat: "wav",
|
||||
timeoutMs: 5_000,
|
||||
maxBytes: 2048,
|
||||
}),
|
||||
).rejects.toThrow("Gradium TTS audio response exceeds 2048 bytes");
|
||||
|
||||
expect(streamed.getReadCount()).toBeLessThan(20);
|
||||
});
|
||||
});
|
||||
62
extensions/gradium/tts.ts
Normal file
62
extensions/gradium/tts.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// Gradium plugin module implements tts behavior.
|
||||
import { assertOkOrThrowProviderError } from "openclaw/plugin-sdk/provider-http";
|
||||
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { normalizeGradiumBaseUrl } from "./shared.js";
|
||||
|
||||
const DEFAULT_TTS_MAX_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
export async function gradiumTTS(params: {
|
||||
text: string;
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
voiceId: string;
|
||||
outputFormat: "wav" | "opus" | "ulaw_8000" | "pcm" | "pcm_24000" | "alaw_8000";
|
||||
timeoutMs: number;
|
||||
maxBytes?: number;
|
||||
}): Promise<Buffer> {
|
||||
const {
|
||||
text,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
voiceId,
|
||||
outputFormat,
|
||||
timeoutMs,
|
||||
maxBytes = DEFAULT_TTS_MAX_BYTES,
|
||||
} = params;
|
||||
const normalizedBaseUrl = normalizeGradiumBaseUrl(baseUrl);
|
||||
const url = `${normalizedBaseUrl}/api/post/speech/tts`;
|
||||
const hostname = new URL(normalizedBaseUrl).hostname;
|
||||
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-api-key": apiKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
voice_id: voiceId,
|
||||
only_audio: true,
|
||||
output_format: outputFormat,
|
||||
json_config: JSON.stringify({ padding_bonus: 0 }),
|
||||
}),
|
||||
},
|
||||
timeoutMs,
|
||||
policy: { hostnameAllowlist: [hostname] },
|
||||
auditContext: "gradium.tts",
|
||||
});
|
||||
|
||||
try {
|
||||
await assertOkOrThrowProviderError(response, "Gradium API error");
|
||||
|
||||
return await readResponseWithLimit(response, maxBytes, {
|
||||
onOverflow: ({ maxBytes: maxBytesLocal }) =>
|
||||
new Error(`Gradium TTS audio response exceeds ${maxBytesLocal} bytes`),
|
||||
});
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user