From 9ddc3576d1aedd753f3805b68032f7e64c68c09a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Apr 2026 14:10:48 +0100 Subject: [PATCH] refactor: move elevenlabs talk config into plugin --- extensions/elevenlabs/config-api.ts | 8 + .../elevenlabs/config-compat.test.ts | 38 +++- extensions/elevenlabs/config-compat.ts | 184 ++++++++++++++++++ extensions/elevenlabs/contract-api.ts | 6 + extensions/elevenlabs/doctor-contract.ts | 34 ++++ extensions/elevenlabs/speech-provider.ts | 30 +-- src/commands/doctor-legacy-config.ts | 15 +- src/config/defaults.ts | 42 +--- src/config/legacy.migrations.runtime.ts | 76 ++------ src/config/materialize.ts | 8 - src/config/talk.normalize.test.ts | 88 ++------- src/config/talk.ts | 49 ----- src/gateway/server-methods/talk.ts | 74 ++++++- src/gateway/server.talk-config.test.ts | 49 +++-- 14 files changed, 428 insertions(+), 273 deletions(-) create mode 100644 extensions/elevenlabs/config-api.ts rename src/config/config.talk-api-key-fallback.test.ts => extensions/elevenlabs/config-compat.test.ts (56%) create mode 100644 extensions/elevenlabs/config-compat.ts create mode 100644 extensions/elevenlabs/contract-api.ts create mode 100644 extensions/elevenlabs/doctor-contract.ts diff --git a/extensions/elevenlabs/config-api.ts b/extensions/elevenlabs/config-api.ts new file mode 100644 index 00000000000..f8c330359e0 --- /dev/null +++ b/extensions/elevenlabs/config-api.ts @@ -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"; diff --git a/src/config/config.talk-api-key-fallback.test.ts b/extensions/elevenlabs/config-compat.test.ts similarity index 56% rename from src/config/config.talk-api-key-fallback.test.ts rename to extensions/elevenlabs/config-compat.test.ts index e16526b3410..a9803cd1a99 100644 --- a/src/config/config.talk-api-key-fallback.test.ts +++ b/extensions/elevenlabs/config-compat.test.ts @@ -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, diff --git a/extensions/elevenlabs/config-compat.ts b/extensions/elevenlabs/config-compat.ts new file mode 100644 index 00000000000..6e69642f24f --- /dev/null +++ b/extensions/elevenlabs/config-compat.ts @@ -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; + +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(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); +} diff --git a/extensions/elevenlabs/contract-api.ts b/extensions/elevenlabs/contract-api.ts new file mode 100644 index 00000000000..42223a4488a --- /dev/null +++ b/extensions/elevenlabs/contract-api.ts @@ -0,0 +1,6 @@ +export { + ELEVENLABS_TALK_PROVIDER_ID, + legacyConfigRules, + normalizeCompatibilityConfig, +} from "./doctor-contract.js"; +export { migrateElevenLabsLegacyTalkConfig } from "./config-compat.js"; diff --git a/extensions/elevenlabs/doctor-contract.ts b/extensions/elevenlabs/doctor-contract.ts new file mode 100644 index 00000000000..404a67d594c --- /dev/null +++ b/extensions/elevenlabs/doctor-contract.ts @@ -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 { + 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. (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 }; diff --git a/extensions/elevenlabs/speech-provider.ts b/extensions/elevenlabs/speech-provider.ts index 527406cfc37..10c853718b3 100644 --- a/extensions/elevenlabs/speech-provider.ts +++ b/extensions/elevenlabs/speech-provider.ts @@ -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"); } diff --git a/src/commands/doctor-legacy-config.ts b/src/commands/doctor-legacy-config.ts index 008d92696ca..9420c5cd47b 100644 --- a/src/commands/doctor-legacy-config.ts +++ b/src/commands/doctor-legacy-config.ts @@ -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 = () => { diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 2b36d325353..44410669cb2 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -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); } diff --git a/src/config/legacy.migrations.runtime.ts b/src/config/legacy.migrations.runtime.ts index f865a69efaa..1e2173e9842 100644 --- a/src/config/legacy.migrations.runtime.ts +++ b/src/config/legacy.migrations.runtime.ts @@ -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 | 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, 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 = {}; - 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 { diff --git a/src/config/materialize.ts b/src/config/materialize.ts index 0578218608d..82a813f59c6 100644 --- a/src/config/materialize.ts +++ b/src/config/materialize.ts @@ -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 = { 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); } diff --git a/src/config/talk.normalize.test.ts b/src/config/talk.normalize.test.ts index 7f438f6acae..20837299038 100644 --- a/src/config/talk.normalize.test.ts +++ b/src/config/talk.normalize.test.ts @@ -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, @@ -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(); + }, + ); }); }); diff --git a/src/config/talk.ts b/src/config/talk.ts index de3c0d0e9fa..d042f9cfe78 100644 --- a/src/config/talk.ts +++ b/src/config/talk.ts @@ -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 { @@ -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); -} diff --git a/src/gateway/server-methods/talk.ts b/src/gateway/server-methods/talk.ts index 0bf7c542c5a..5d8d7881da5 100644 --- a/src/gateway/server-methods/talk.ts +++ b/src/gateway/server-methods/talk.ts @@ -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 = {}; - 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; diff --git a/src/gateway/server.talk-config.test.ts b/src/gateway/server.talk-config.test.ts index f7ce1ae8a9a..858f2deb497 100644 --- a/src/gateway/server.talk-config.test.ts +++ b/src/gateway/server.talk-config.test.ts @@ -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) { 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", });