refactor: move elevenlabs talk config into plugin

This commit is contained in:
Peter Steinberger
2026-04-05 14:10:48 +01:00
parent a705845e18
commit 9ddc3576d1
14 changed files with 428 additions and 273 deletions

View File

@@ -0,0 +1,8 @@
// Narrow barrel for ElevenLabs config compatibility helpers consumed outside the plugin.
// Keep this separate from runtime exports so doctor/config code stays lightweight.
export {
ELEVENLABS_TALK_PROVIDER_ID,
migrateElevenLabsLegacyTalkConfig,
resolveElevenLabsApiKeyWithProfileFallback,
} from "./config-compat.js";

View File

@@ -2,15 +2,45 @@ import type fs from "node:fs";
import type os from "node:os";
import type path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { resolveTalkApiKey } from "./talk.js";
import {
migrateElevenLabsLegacyTalkConfig,
resolveElevenLabsApiKeyWithProfileFallback,
} from "./config-compat.js";
describe("elevenlabs config compat", () => {
it("moves legacy talk fields into talk.providers.elevenlabs", () => {
const result = migrateElevenLabsLegacyTalkConfig({
talk: {
voiceId: "voice-123",
modelId: "eleven_v3",
outputFormat: "pcm_44100",
apiKey: "secret-key", // pragma: allowlist secret
},
});
expect(result.changes).toEqual([
"Moved talk legacy fields (voiceId, modelId, outputFormat, apiKey) → talk.providers.elevenlabs (filled missing provider fields only).",
]);
expect(result.config).toEqual({
talk: {
providers: {
elevenlabs: {
voiceId: "voice-123",
modelId: "eleven_v3",
outputFormat: "pcm_44100",
apiKey: "secret-key", // pragma: allowlist secret
},
},
},
});
});
describe("talk api key fallback", () => {
it("reads ELEVENLABS_API_KEY from profile when env is missing", () => {
const existsSync = vi.fn((candidate: string) => candidate.endsWith(".profile"));
const readFileSync = vi.fn(() => "export ELEVENLABS_API_KEY=profile-key\n");
const homedir = vi.fn(() => "/tmp/home");
const value = resolveTalkApiKey(
const value = resolveElevenLabsApiKeyWithProfileFallback(
{},
{
fs: { existsSync, readFileSync } as unknown as typeof fs,
@@ -29,7 +59,7 @@ describe("talk api key fallback", () => {
});
const readFileSync = vi.fn(() => "");
const value = resolveTalkApiKey(
const value = resolveElevenLabsApiKeyWithProfileFallback(
{ ELEVENLABS_API_KEY: "env-key" },
{
fs: { existsSync, readFileSync } as unknown as typeof fs,

View File

@@ -0,0 +1,184 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const ELEVENLABS_API_KEY_ENV = "ELEVENLABS_API_KEY";
const PROFILE_CANDIDATES = [".profile", ".zprofile", ".zshrc", ".bashrc"] as const;
const LEGACY_TALK_FIELD_KEYS = [
"voiceId",
"voiceAliases",
"modelId",
"outputFormat",
"apiKey",
] as const;
type JsonRecord = Record<string, unknown>;
type ElevenLabsApiKeyDeps = {
fs?: typeof fs;
os?: typeof os;
path?: typeof path;
};
export const ELEVENLABS_TALK_PROVIDER_ID = "elevenlabs";
function isRecord(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function getRecord(value: unknown): JsonRecord | null {
return isRecord(value) ? value : null;
}
function ensureRecord(root: JsonRecord, key: string): JsonRecord {
const existing = getRecord(root[key]);
if (existing) {
return existing;
}
const next: JsonRecord = {};
root[key] = next;
return next;
}
function isBlockedObjectKey(key: string): boolean {
return key === "__proto__" || key === "prototype" || key === "constructor";
}
function mergeMissing(target: JsonRecord, source: JsonRecord): void {
for (const [key, value] of Object.entries(source)) {
if (value === undefined || isBlockedObjectKey(key)) {
continue;
}
const existing = target[key];
if (existing === undefined) {
target[key] = value;
continue;
}
if (isRecord(existing) && isRecord(value)) {
mergeMissing(existing, value);
}
}
}
function hasLegacyTalkFields(value: unknown): value is JsonRecord {
const talk = getRecord(value);
if (!talk) {
return false;
}
return LEGACY_TALK_FIELD_KEYS.some((key) => Object.prototype.hasOwnProperty.call(talk, key));
}
function resolveTalkMigrationTargetProviderId(talk: JsonRecord): string | null {
const explicitProvider =
typeof talk.provider === "string" && talk.provider.trim() ? talk.provider.trim() : null;
const providers = getRecord(talk.providers);
if (explicitProvider) {
if (isBlockedObjectKey(explicitProvider)) {
return null;
}
return explicitProvider;
}
if (!providers) {
return ELEVENLABS_TALK_PROVIDER_ID;
}
const providerIds = Object.keys(providers).filter((key) => !isBlockedObjectKey(key));
if (providerIds.length === 0) {
return ELEVENLABS_TALK_PROVIDER_ID;
}
if (providerIds.length === 1) {
return providerIds[0] ?? null;
}
return null;
}
export function migrateElevenLabsLegacyTalkConfig<T>(raw: T): { config: T; changes: string[] } {
if (!isRecord(raw)) {
return { config: raw, changes: [] };
}
const talk = getRecord(raw.talk);
if (!talk || !hasLegacyTalkFields(talk)) {
return { config: raw, changes: [] };
}
const providerId = resolveTalkMigrationTargetProviderId(talk);
if (!providerId) {
return {
config: raw,
changes: [
"Skipped talk legacy field migration because talk.providers defines multiple providers and talk.provider is unset; move talk.voiceId/talk.voiceAliases/talk.modelId/talk.outputFormat/talk.apiKey under the intended provider manually.",
],
};
}
const nextRoot = structuredClone(raw) as JsonRecord;
const nextTalk = ensureRecord(nextRoot, "talk");
const providers = ensureRecord(nextTalk, "providers");
const existingProvider = getRecord(providers[providerId]) ?? {};
const migratedProvider = structuredClone(existingProvider);
const legacyFields: JsonRecord = {};
const movedKeys: string[] = [];
for (const key of LEGACY_TALK_FIELD_KEYS) {
if (!Object.prototype.hasOwnProperty.call(nextTalk, key)) {
continue;
}
legacyFields[key] = nextTalk[key];
delete nextTalk[key];
movedKeys.push(key);
}
if (movedKeys.length === 0) {
return { config: raw, changes: [] };
}
mergeMissing(migratedProvider, legacyFields);
providers[providerId] = migratedProvider;
nextTalk.providers = providers;
nextRoot.talk = nextTalk;
return {
config: nextRoot as T,
changes: [
`Moved talk legacy fields (${movedKeys.join(", ")}) → talk.providers.${providerId} (filled missing provider fields only).`,
],
};
}
function readApiKeyFromProfile(deps: ElevenLabsApiKeyDeps = {}): string | null {
const fsImpl = deps.fs ?? fs;
const osImpl = deps.os ?? os;
const pathImpl = deps.path ?? path;
const home = osImpl.homedir();
for (const candidate of PROFILE_CANDIDATES) {
const fullPath = pathImpl.join(home, candidate);
if (!fsImpl.existsSync(fullPath)) {
continue;
}
try {
const text = fsImpl.readFileSync(fullPath, "utf-8");
const match = text.match(
/(?:^|\n)\s*(?:export\s+)?ELEVENLABS_API_KEY\s*=\s*["']?([^\n"']+)["']?/,
);
const value = match?.[1]?.trim();
if (value) {
return value;
}
} catch {
// Ignore profile read errors.
}
}
return null;
}
export function resolveElevenLabsApiKeyWithProfileFallback(
env: NodeJS.ProcessEnv = process.env,
deps: ElevenLabsApiKeyDeps = {},
): string | null {
const envValue = (env[ELEVENLABS_API_KEY_ENV] ?? "").trim();
if (envValue) {
return envValue;
}
return readApiKeyFromProfile(deps);
}

View File

@@ -0,0 +1,6 @@
export {
ELEVENLABS_TALK_PROVIDER_ID,
legacyConfigRules,
normalizeCompatibilityConfig,
} from "./doctor-contract.js";
export { migrateElevenLabsLegacyTalkConfig } from "./config-compat.js";

View File

@@ -0,0 +1,34 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { ELEVENLABS_TALK_PROVIDER_ID, migrateElevenLabsLegacyTalkConfig } from "./config-compat.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function hasLegacyTalkFields(value: unknown): boolean {
const talk = isRecord(value) ? value : null;
if (!talk) {
return false;
}
return ["voiceId", "voiceAliases", "modelId", "outputFormat", "apiKey"].some((key) =>
Object.prototype.hasOwnProperty.call(talk, key),
);
}
export const legacyConfigRules = [
{
path: ["talk"],
message:
"talk.voiceId/talk.voiceAliases/talk.modelId/talk.outputFormat/talk.apiKey are legacy; use talk.providers.<provider> (auto-migrated on load).",
match: hasLegacyTalkFields,
},
] as const;
export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): {
config: OpenClawConfig;
changes: string[];
} {
return migrateElevenLabsLegacyTalkConfig(cfg);
}
export { ELEVENLABS_TALK_PROVIDER_ID };

View File

@@ -12,6 +12,7 @@ import {
normalizeSeed,
requireInRange,
} from "openclaw/plugin-sdk/speech";
import { resolveElevenLabsApiKeyWithProfileFallback } from "./config-api.js";
import { elevenLabsTTS } from "./tts.js";
const DEFAULT_ELEVENLABS_BASE_URL = "https://api.elevenlabs.io";
@@ -350,16 +351,16 @@ export function buildElevenLabsSpeechProvider(): SpeechProviderPlugin {
resolveTalkConfig: ({ baseTtsConfig, talkProviderConfig }) => {
const base = normalizeElevenLabsProviderConfig(baseTtsConfig);
const talkVoiceSettings = asObject(talkProviderConfig.voiceSettings);
const resolvedTalkApiKey =
talkProviderConfig.apiKey === undefined
? (resolveElevenLabsApiKeyWithProfileFallback() ?? undefined)
: normalizeResolvedSecretInputString({
value: talkProviderConfig.apiKey,
path: "talk.providers.elevenlabs.apiKey",
});
return {
...base,
...(talkProviderConfig.apiKey === undefined
? {}
: {
apiKey: normalizeResolvedSecretInputString({
value: talkProviderConfig.apiKey,
path: "talk.providers.elevenlabs.apiKey",
}),
}),
...(resolvedTalkApiKey === undefined ? {} : { apiKey: resolvedTalkApiKey }),
...(trimToUndefined(talkProviderConfig.baseUrl) == null
? {}
: { baseUrl: normalizeElevenLabsBaseUrl(trimToUndefined(talkProviderConfig.baseUrl)) }),
@@ -443,7 +444,10 @@ export function buildElevenLabsSpeechProvider(): SpeechProviderPlugin {
? readElevenLabsProviderConfig(req.providerConfig)
: undefined;
const apiKey =
req.apiKey || config?.apiKey || process.env.ELEVENLABS_API_KEY || process.env.XI_API_KEY;
req.apiKey ||
config?.apiKey ||
resolveElevenLabsApiKeyWithProfileFallback() ||
process.env.XI_API_KEY;
if (!apiKey) {
throw new Error("ElevenLabs API key missing");
}
@@ -455,13 +459,14 @@ export function buildElevenLabsSpeechProvider(): SpeechProviderPlugin {
isConfigured: ({ providerConfig }) =>
Boolean(
readElevenLabsProviderConfig(providerConfig).apiKey ||
process.env.ELEVENLABS_API_KEY ||
resolveElevenLabsApiKeyWithProfileFallback() ||
process.env.XI_API_KEY,
),
synthesize: async (req) => {
const config = readElevenLabsProviderConfig(req.providerConfig);
const overrides = req.providerOverrides ?? {};
const apiKey = config.apiKey || process.env.ELEVENLABS_API_KEY || process.env.XI_API_KEY;
const apiKey =
config.apiKey || resolveElevenLabsApiKeyWithProfileFallback() || process.env.XI_API_KEY;
if (!apiKey) {
throw new Error("ElevenLabs API key missing");
}
@@ -515,7 +520,8 @@ export function buildElevenLabsSpeechProvider(): SpeechProviderPlugin {
},
synthesizeTelephony: async (req) => {
const config = readElevenLabsProviderConfig(req.providerConfig);
const apiKey = config.apiKey || process.env.ELEVENLABS_API_KEY || process.env.XI_API_KEY;
const apiKey =
config.apiKey || resolveElevenLabsApiKeyWithProfileFallback() || process.env.XI_API_KEY;
if (!apiKey) {
throw new Error("ElevenLabs API key missing");
}

View File

@@ -1,5 +1,9 @@
import { isDeepStrictEqual } from "node:util";
import { migrateAmazonBedrockLegacyConfig } from "../../extensions/amazon-bedrock/config-api.js";
import {
ELEVENLABS_TALK_PROVIDER_ID,
normalizeCompatibilityConfig as normalizeElevenLabsCompatibilityConfig,
} from "../../extensions/elevenlabs/contract-api.js";
import { migrateVoiceCallLegacyConfigInput } from "../../extensions/voice-call/config-api.js";
import { normalizeProviderId } from "../agents/model-selection.js";
import { shouldMoveSingleAccountChannelKey } from "../channels/plugins/setup-helpers.js";
@@ -8,7 +12,7 @@ import { resolveNormalizedProviderModelMaxTokens } from "../config/defaults.js";
import { migrateLegacyWebFetchConfig } from "../config/legacy-web-fetch.js";
import { migrateLegacyWebSearchConfig } from "../config/legacy-web-search.js";
import { migrateLegacyXSearchConfig } from "../config/legacy-x-search.js";
import { LEGACY_TALK_PROVIDER_ID, normalizeTalkSection } from "../config/talk.js";
import { normalizeTalkSection } from "../config/talk.js";
import { DEFAULT_GOOGLE_API_BASE_URL } from "../infra/google-api-base-url.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
@@ -388,6 +392,13 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): {
return;
}
const legacyMigration = normalizeElevenLabsCompatibilityConfig({ cfg: next });
if (legacyMigration.changes.length > 0) {
next = legacyMigration.config;
changes.push(...legacyMigration.changes);
return;
}
const normalizedTalk = normalizeTalkSection(rawTalk as OpenClawConfig["talk"]);
if (!normalizedTalk) {
return;
@@ -411,7 +422,7 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): {
return;
}
changes.push(`Moved legacy talk flat fields → talk.providers.${LEGACY_TALK_PROVIDER_ID}.`);
changes.push(`Moved legacy talk flat fields → talk.providers.${ELEVENLABS_TALK_PROVIDER_ID}.`);
};
const normalizeLegacyCrossContextMessageConfig = () => {

View File

@@ -3,15 +3,9 @@ import { normalizeProviderId } from "../agents/model-selection.js";
import { normalizeProviderSpecificConfig } from "../agents/models-config.providers.policy.js";
import { applyProviderConfigDefaultsWithPlugin } from "../plugins/provider-runtime.js";
import { DEFAULT_AGENT_MAX_CONCURRENT, DEFAULT_SUBAGENT_MAX_CONCURRENT } from "./agent-limits.js";
import {
LEGACY_TALK_PROVIDER_ID,
normalizeTalkConfig,
resolveActiveTalkProviderConfig,
resolveTalkApiKey,
} from "./talk.js";
import { normalizeTalkConfig } from "./talk.js";
import type { OpenClawConfig } from "./types.js";
import type { ModelDefinitionConfig } from "./types.models.js";
import { hasConfiguredSecretInput } from "./types.secrets.js";
type WarnState = { warned: boolean };
@@ -133,40 +127,6 @@ export function applySessionDefaults(
return next;
}
export function applyTalkApiKey(config: OpenClawConfig): OpenClawConfig {
const normalized = normalizeTalkConfig(config);
const resolved = resolveTalkApiKey();
if (!resolved) {
return normalized;
}
const talk = normalized.talk;
const active = resolveActiveTalkProviderConfig(talk);
if (!active || active.provider !== LEGACY_TALK_PROVIDER_ID) {
return normalized;
}
const existingProviderApiKeyConfigured = hasConfiguredSecretInput(active?.config?.apiKey);
if (existingProviderApiKeyConfigured) {
return normalized;
}
const providerId = active.provider;
const providers = { ...talk?.providers };
const providerConfig = { ...providers[providerId], apiKey: resolved };
providers[providerId] = providerConfig;
const nextTalk = {
...talk,
providers,
};
return {
...normalized,
talk: nextTalk,
};
}
export function applyTalkConfigNormalization(config: OpenClawConfig): OpenClawConfig {
return normalizeTalkConfig(config);
}

View File

@@ -1,3 +1,4 @@
import { migrateElevenLabsLegacyTalkConfig } from "../../extensions/elevenlabs/contract-api.js";
import {
buildDefaultControlUiAllowedOrigins,
hasConfiguredControlUiAllowedOrigins,
@@ -15,7 +16,6 @@ import {
} from "./legacy.shared.js";
import { DEFAULT_GATEWAY_PORT } from "./paths.js";
import { isBlockedObjectKey } from "./prototype-keys.js";
import { LEGACY_TALK_PROVIDER_ID } from "./talk.js";
const AGENT_HEARTBEAT_KEYS = new Set([
"every",
@@ -37,13 +37,6 @@ const AGENT_HEARTBEAT_KEYS = new Set([
const CHANNEL_HEARTBEAT_KEYS = new Set(["showOk", "showAlerts", "useIndicator"]);
const LEGACY_TTS_PROVIDER_KEYS = ["openai", "elevenlabs", "microsoft", "edge"] as const;
const LEGACY_TTS_PLUGIN_IDS = new Set(["voice-call"]);
const LEGACY_TALK_FIELD_KEYS = [
"voiceId",
"voiceAliases",
"modelId",
"outputFormat",
"apiKey",
] as const;
function sandboxScopeFromPerSession(perSession: boolean): "session" | "shared" {
return perSession ? "session" : "shared";
@@ -152,7 +145,9 @@ function hasLegacyTalkFields(value: unknown): boolean {
if (!talk) {
return false;
}
return LEGACY_TALK_FIELD_KEYS.some((key) => Object.prototype.hasOwnProperty.call(talk, key));
return ["voiceId", "voiceAliases", "modelId", "outputFormat", "apiKey"].some((key) =>
Object.prototype.hasOwnProperty.call(talk, key),
);
}
function hasLegacySandboxPerSession(value: unknown): boolean {
@@ -167,68 +162,19 @@ function hasLegacyAgentListSandboxPerSession(value: unknown): boolean {
return value.some((agent) => hasLegacySandboxPerSession(getRecord(agent)?.sandbox));
}
function resolveTalkMigrationTargetProviderId(talk: Record<string, unknown>): string | null {
const explicitProvider =
typeof talk.provider === "string" && talk.provider.trim() ? talk.provider.trim() : null;
const providers = getRecord(talk.providers);
if (explicitProvider) {
if (isBlockedObjectKey(explicitProvider)) {
return null;
}
return explicitProvider;
}
if (!providers) {
return LEGACY_TALK_PROVIDER_ID;
}
const providerIds = Object.keys(providers).filter((key) => !isBlockedObjectKey(key));
if (providerIds.length === 0) {
return LEGACY_TALK_PROVIDER_ID;
}
if (providerIds.length === 1) {
return providerIds[0] ?? null;
}
return null;
}
function migrateLegacyTalkFields(raw: Record<string, unknown>, changes: string[]): void {
const talk = getRecord(raw.talk);
if (!talk || !hasLegacyTalkFields(talk)) {
if (!hasLegacyTalkFields(raw.talk)) {
return;
}
const providerId = resolveTalkMigrationTargetProviderId(talk);
if (!providerId) {
changes.push(
"Skipped talk legacy field migration because talk.providers defines multiple providers and talk.provider is unset; move talk.voiceId/talk.voiceAliases/talk.modelId/talk.outputFormat/talk.apiKey under the intended provider manually.",
);
const migrated = migrateElevenLabsLegacyTalkConfig(raw);
if (migrated.changes.length === 0) {
return;
}
const providers = ensureRecord(talk, "providers");
const existingProvider = getRecord(providers[providerId]) ?? {};
const migratedProvider = structuredClone(existingProvider);
const legacyFields: Record<string, unknown> = {};
const movedKeys: string[] = [];
for (const key of LEGACY_TALK_FIELD_KEYS) {
if (!Object.prototype.hasOwnProperty.call(talk, key)) {
continue;
}
legacyFields[key] = talk[key];
delete talk[key];
movedKeys.push(key);
for (const key of Object.keys(raw)) {
delete raw[key];
}
if (movedKeys.length === 0) {
return;
}
mergeMissing(migratedProvider, legacyFields);
providers[providerId] = migratedProvider;
talk.providers = providers;
raw.talk = talk;
changes.push(
`Moved talk legacy fields (${movedKeys.join(", ")}) → talk.providers.${providerId} (filled missing provider fields only).`,
);
Object.assign(raw, migrated.config);
changes.push(...migrated.changes);
}
function hasLegacyPluginEntryTtsProviderKeys(value: unknown): boolean {

View File

@@ -6,7 +6,6 @@ import {
applyMessageDefaults,
applyModelDefaults,
applySessionDefaults,
applyTalkApiKey,
applyTalkConfigNormalization,
} from "./defaults.js";
import { normalizeExecSafeBinProfilesInConfig } from "./normalize-exec-safe-bin.js";
@@ -16,7 +15,6 @@ import type { OpenClawConfig, ResolvedSourceConfig, RuntimeConfig } from "./type
export type ConfigMaterializationMode = "load" | "missing" | "snapshot";
type MaterializationProfile = {
includeTalkApiKey: boolean;
includeCompactionDefaults: boolean;
includeContextPruningDefaults: boolean;
includeLoggingDefaults: boolean;
@@ -25,21 +23,18 @@ type MaterializationProfile = {
const MATERIALIZATION_PROFILES: Record<ConfigMaterializationMode, MaterializationProfile> = {
load: {
includeTalkApiKey: false,
includeCompactionDefaults: true,
includeContextPruningDefaults: true,
includeLoggingDefaults: true,
normalizePaths: true,
},
missing: {
includeTalkApiKey: true,
includeCompactionDefaults: true,
includeContextPruningDefaults: true,
includeLoggingDefaults: false,
normalizePaths: false,
},
snapshot: {
includeTalkApiKey: true,
includeCompactionDefaults: false,
includeContextPruningDefaults: false,
includeLoggingDefaults: true,
@@ -74,9 +69,6 @@ export function materializeRuntimeConfig(
}
next = applyModelDefaults(next);
next = applyTalkConfigNormalization(next);
if (profile.includeTalkApiKey) {
next = applyTalkApiKey(next);
}
if (profile.normalizePaths) {
normalizeConfigPaths(next);
}

View File

@@ -2,13 +2,9 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withEnvAsync } from "../test-utils/env.js";
import { createConfigIO } from "./io.js";
import { buildTalkConfigResponse, normalizeTalkSection } from "./talk.js";
const envVar = (...parts: string[]) => parts.join("_");
const elevenLabsApiKeyEnv = ["ELEVENLABS_API", "KEY"].join("_");
async function withTempConfig(
config: unknown,
run: (configPath: string) => Promise<void>,
@@ -125,76 +121,20 @@ describe("talk normalization", () => {
});
});
it("merges ELEVENLABS_API_KEY into normalized defaults for legacy configs", async () => {
// pragma: allowlist secret
const elevenLabsApiKey = "env-eleven-key"; // pragma: allowlist secret
await withEnvAsync({ [elevenLabsApiKeyEnv]: elevenLabsApiKey }, async () => {
await withTempConfig(
{
talk: {
voiceId: "voice-123",
},
it("does not inject provider apiKey defaults during snapshot materialization", async () => {
await withTempConfig(
{
talk: {
voiceId: "voice-123",
},
async (configPath) => {
const io = createConfigIO({ configPath });
const snapshot = await io.readConfigFileSnapshot();
expect(snapshot.config.talk?.provider).toBeUndefined();
expect(snapshot.config.talk?.providers?.elevenlabs?.voiceId).toBe("voice-123");
expect(snapshot.config.talk?.providers?.elevenlabs?.apiKey).toBe(elevenLabsApiKey);
},
);
});
});
it("does not apply ELEVENLABS_API_KEY when active provider is not elevenlabs", async () => {
const elevenLabsApiKey = "env-eleven-key"; // pragma: allowlist secret
await withEnvAsync({ [elevenLabsApiKeyEnv]: elevenLabsApiKey }, async () => {
await withTempConfig(
{
talk: {
provider: "acme",
providers: {
acme: {
voiceId: "acme-voice",
},
},
},
},
async (configPath) => {
const io = createConfigIO({ configPath });
const snapshot = await io.readConfigFileSnapshot();
expect(snapshot.config.talk?.provider).toBe("acme");
expect(snapshot.config.talk?.providers?.acme?.voiceId).toBe("acme-voice");
expect(snapshot.config.talk?.providers?.acme?.apiKey).toBeUndefined();
},
);
});
});
it("does not inject ELEVENLABS_API_KEY fallback when talk.apiKey is SecretRef", async () => {
await withEnvAsync({ [envVar("ELEVENLABS", "API", "KEY")]: "env-eleven-key" }, async () => {
await withTempConfig(
{
talk: {
provider: "elevenlabs",
apiKey: { source: "env", provider: "default", id: "ELEVENLABS_API_KEY" },
providers: {
elevenlabs: {
voiceId: "voice-123",
},
},
},
},
async (configPath) => {
const io = createConfigIO({ configPath });
const snapshot = await io.readConfigFileSnapshot();
expect(snapshot.config.talk?.providers?.elevenlabs?.apiKey).toEqual({
source: "env",
provider: "default",
id: "ELEVENLABS_API_KEY",
});
},
);
});
},
async (configPath) => {
const io = createConfigIO({ configPath });
const snapshot = await io.readConfigFileSnapshot();
expect(snapshot.config.talk?.provider).toBeUndefined();
expect(snapshot.config.talk?.providers?.elevenlabs?.voiceId).toBe("voice-123");
expect(snapshot.config.talk?.providers?.elevenlabs?.apiKey).toBeUndefined();
},
);
});
});

View File

@@ -1,6 +1,3 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type {
ResolvedTalkConfig,
TalkConfig,
@@ -10,12 +7,6 @@ import type {
import type { OpenClawConfig } from "./types.js";
import { coerceSecretRef } from "./types.secrets.js";
type TalkApiKeyDeps = {
fs?: typeof fs;
os?: typeof os;
path?: typeof path;
};
export const LEGACY_TALK_PROVIDER_ID = "elevenlabs";
function isPlainObject(value: unknown): value is Record<string, unknown> {
@@ -237,43 +228,3 @@ export function buildTalkConfigResponse(value: unknown): TalkConfigResponse | un
return Object.keys(payload).length > 0 ? payload : undefined;
}
export function readTalkApiKeyFromProfile(deps: TalkApiKeyDeps = {}): string | null {
const fsImpl = deps.fs ?? fs;
const osImpl = deps.os ?? os;
const pathImpl = deps.path ?? path;
const home = osImpl.homedir();
const candidates = [".profile", ".zprofile", ".zshrc", ".bashrc"].map((name) =>
pathImpl.join(home, name),
);
for (const candidate of candidates) {
if (!fsImpl.existsSync(candidate)) {
continue;
}
try {
const text = fsImpl.readFileSync(candidate, "utf-8");
const match = text.match(
/(?:^|\n)\s*(?:export\s+)?ELEVENLABS_API_KEY\s*=\s*["']?([^\n"']+)["']?/,
);
const value = match?.[1]?.trim();
if (value) {
return value;
}
} catch {
// Ignore profile read errors.
}
}
return null;
}
export function resolveTalkApiKey(
env: NodeJS.ProcessEnv = process.env,
deps: TalkApiKeyDeps = {},
): string | null {
const envValue = (env.ELEVENLABS_API_KEY ?? "").trim();
if (envValue) {
return envValue;
}
return readTalkApiKeyFromProfile(deps);
}

View File

@@ -1,7 +1,11 @@
import { loadConfig, readConfigFileSnapshot } from "../../config/config.js";
import { redactConfigObject } from "../../config/redact-snapshot.js";
import { buildTalkConfigResponse, resolveActiveTalkProviderConfig } from "../../config/talk.js";
import type { TalkProviderConfig } from "../../config/types.gateway.js";
import {
buildTalkConfigResponse,
normalizeTalkSection,
resolveActiveTalkProviderConfig,
} from "../../config/talk.js";
import type { TalkConfigResponse, TalkProviderConfig } from "../../config/types.gateway.js";
import type { OpenClawConfig, TtsConfig, TtsProviderConfigMap } from "../../config/types.js";
import { canonicalizeSpeechProviderId, getSpeechProvider } from "../../tts/provider-registry.js";
import { synthesizeSpeech, type TtsDirectiveOverrides } from "../../tts/tts.js";
@@ -218,6 +222,60 @@ function inferMimeType(
return undefined;
}
function resolveTalkResponseFromConfig(params: {
includeSecrets: boolean;
sourceConfig: OpenClawConfig;
runtimeConfig: OpenClawConfig;
}): TalkConfigResponse | undefined {
const normalizedTalk = normalizeTalkSection(params.sourceConfig.talk);
if (!normalizedTalk) {
return undefined;
}
const payload = buildTalkConfigResponse(normalizedTalk);
if (!payload) {
return undefined;
}
if (params.includeSecrets) {
return payload;
}
const sourceResolved = resolveActiveTalkProviderConfig(normalizedTalk);
const runtimeResolved = resolveActiveTalkProviderConfig(params.runtimeConfig.talk);
const activeProviderId = sourceResolved?.provider ?? runtimeResolved?.provider;
const provider = canonicalizeSpeechProviderId(activeProviderId, params.runtimeConfig);
if (!provider) {
return payload;
}
const speechProvider = getSpeechProvider(provider, params.runtimeConfig);
const sourceBaseTts = asRecord(params.sourceConfig.messages?.tts) ?? {};
const runtimeBaseTts = asRecord(params.runtimeConfig.messages?.tts) ?? {};
const talkProviderConfig = sourceResolved?.config ?? runtimeResolved?.config ?? {};
const resolvedConfig =
speechProvider?.resolveTalkConfig?.({
cfg: params.runtimeConfig,
baseTtsConfig: Object.keys(sourceBaseTts).length > 0 ? sourceBaseTts : runtimeBaseTts,
talkProviderConfig,
timeoutMs:
typeof sourceBaseTts.timeoutMs === "number"
? sourceBaseTts.timeoutMs
: typeof runtimeBaseTts.timeoutMs === "number"
? runtimeBaseTts.timeoutMs
: 30_000,
}) ?? talkProviderConfig;
return {
...payload,
provider,
resolved: {
provider,
config: resolvedConfig,
},
};
}
export const talkHandlers: GatewayRequestHandlers = {
"talk.config": async ({ params, respond, client }) => {
if (!validateTalkConfigParams(params)) {
@@ -243,14 +301,16 @@ export const talkHandlers: GatewayRequestHandlers = {
}
const snapshot = await readConfigFileSnapshot();
const runtimeConfig = loadConfig();
const configPayload: Record<string, unknown> = {};
const talkSource = includeSecrets
? snapshot.config.talk
: redactConfigObject(snapshot.config.talk);
const talk = buildTalkConfigResponse(talkSource);
const talk = resolveTalkResponseFromConfig({
includeSecrets,
sourceConfig: snapshot.config,
runtimeConfig,
});
if (talk) {
configPayload.talk = talk;
configPayload.talk = includeSecrets ? talk : redactConfigObject(talk);
}
const sessionMainKey = snapshot.config.session?.mainKey;

View File

@@ -30,7 +30,7 @@ type TalkConfigPayload = {
talk?: {
provider?: string;
providers?: {
elevenlabs?: { voiceId?: string; apiKey?: string | SecretRef };
[providerId: string]: { voiceId?: string; apiKey?: string | SecretRef } | undefined;
};
resolved?: {
provider?: string;
@@ -147,22 +147,22 @@ async function invokeTalkSpeakDirect(params: Record<string, unknown>) {
return response;
}
function expectElevenLabsTalkConfig(
function expectTalkConfig(
talk: TalkConfig | undefined,
expected: {
provider?: string;
provider: string;
voiceId?: string;
apiKey?: string | SecretRef;
silenceTimeoutMs?: number;
},
) {
expect(talk?.provider).toBe(expected.provider ?? "elevenlabs");
expect(talk?.providers?.elevenlabs?.voiceId).toBe(expected.voiceId);
expect(talk?.resolved?.provider).toBe("elevenlabs");
expect(talk?.provider).toBe(expected.provider);
expect(talk?.providers?.[expected.provider]?.voiceId).toBe(expected.voiceId);
expect(talk?.resolved?.provider).toBe(expected.provider);
expect(talk?.resolved?.config?.voiceId).toBe(expected.voiceId);
if ("apiKey" in expected) {
expect(talk?.providers?.elevenlabs?.apiKey).toEqual(expected.apiKey);
expect(talk?.providers?.[expected.provider]?.apiKey).toEqual(expected.apiKey);
expect(talk?.resolved?.config?.apiKey).toEqual(expected.apiKey);
}
if ("silenceTimeoutMs" in expected) {
@@ -195,7 +195,7 @@ describe("gateway talk.config", () => {
await connectOperator(ws, ["operator.read"]);
const res = await fetchTalkConfig(ws);
expect(res.ok).toBe(true);
expectElevenLabsTalkConfig(res.payload?.config?.talk, {
expectTalkConfig(res.payload?.config?.talk, {
provider: "elevenlabs",
voiceId: "voice-123",
apiKey: "__OPENCLAW_REDACTED__",
@@ -238,7 +238,7 @@ describe("gateway talk.config", () => {
await connectOperator(ws, [...scopes]);
const res = await fetchTalkConfig(ws, { includeSecrets: true });
expect(res.ok).toBe(true);
expectElevenLabsTalkConfig(res.payload?.config?.talk, {
expectTalkConfig(res.payload?.config?.talk, {
provider: "elevenlabs",
apiKey: "secret-key-abc",
});
@@ -265,7 +265,7 @@ describe("gateway talk.config", () => {
provider: "default",
id: "ELEVENLABS_API_KEY",
} satisfies SecretRef;
expectElevenLabsTalkConfig(res.payload?.config?.talk, {
expectTalkConfig(res.payload?.config?.talk, {
provider: "elevenlabs",
apiKey: secretRef,
});
@@ -273,6 +273,33 @@ describe("gateway talk.config", () => {
});
});
it("resolves plugin-owned Talk defaults before redaction", async () => {
const { writeConfigFile } = await import("../config/config.js");
await writeConfigFile({
talk: {
provider: "elevenlabs",
providers: {
elevenlabs: {
voiceId: "voice-from-config",
},
},
},
});
await withEnvAsync({ ELEVENLABS_API_KEY: "env-elevenlabs-key" }, async () => {
await withServer(async (ws) => {
await connectOperator(ws, ["operator.read"]);
const res = await fetchTalkConfig(ws);
expect(res.ok, JSON.stringify(res.error)).toBe(true);
expectTalkConfig(res.payload?.config?.talk, {
provider: "elevenlabs",
voiceId: "voice-from-config",
apiKey: "__OPENCLAW_REDACTED__",
});
});
});
});
it("returns canonical provider talk payloads", async () => {
const { writeConfigFile } = await import("../config/config.js");
await writeConfigFile({
@@ -290,7 +317,7 @@ describe("gateway talk.config", () => {
await connectOperator(ws, ["operator.read"]);
const res = await fetchTalkConfig(ws);
expect(res.ok).toBe(true);
expectElevenLabsTalkConfig(res.payload?.config?.talk, {
expectTalkConfig(res.payload?.config?.talk, {
provider: "elevenlabs",
voiceId: "voice-normalized",
});