mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 13:11:40 +00:00
refactor: move elevenlabs talk config into plugin
This commit is contained in:
8
extensions/elevenlabs/config-api.ts
Normal file
8
extensions/elevenlabs/config-api.ts
Normal 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";
|
||||
@@ -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,
|
||||
184
extensions/elevenlabs/config-compat.ts
Normal file
184
extensions/elevenlabs/config-compat.ts
Normal 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);
|
||||
}
|
||||
6
extensions/elevenlabs/contract-api.ts
Normal file
6
extensions/elevenlabs/contract-api.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
ELEVENLABS_TALK_PROVIDER_ID,
|
||||
legacyConfigRules,
|
||||
normalizeCompatibilityConfig,
|
||||
} from "./doctor-contract.js";
|
||||
export { migrateElevenLabsLegacyTalkConfig } from "./config-compat.js";
|
||||
34
extensions/elevenlabs/doctor-contract.ts
Normal file
34
extensions/elevenlabs/doctor-contract.ts
Normal 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 };
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user