From 9a0d88a868c1c004e5afe4f4da6cfb30bfbac8fe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Apr 2026 14:26:25 +0100 Subject: [PATCH] refactor: move talk config contract under plugin --- docs/.generated/config-baseline.sha256 | 4 +- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- extensions/elevenlabs/contract-api.ts | 2 + extensions/elevenlabs/doctor-contract.ts | 9 ++- package.json | 4 + scripts/lib/plugin-sdk-doc-metadata.ts | 3 + scripts/lib/plugin-sdk-entrypoints.json | 1 + .../doctor-legacy-config.migrations.test.ts | 4 +- src/commands/doctor-legacy-config.ts | 17 +---- src/config/legacy.migrations.runtime.ts | 27 ++----- src/config/schema.base.generated.ts | 74 ++++++++----------- src/config/schema.help.ts | 5 +- src/config/schema.labels.ts | 5 +- src/config/talk.normalize.test.ts | 11 +-- src/config/talk.ts | 63 ++-------------- src/config/types.gateway.ts | 10 +-- src/config/zod-schema.ts | 4 - src/gateway/protocol/schema/channels.ts | 4 - src/gateway/server-methods/talk.ts | 16 +++- src/plugin-sdk/elevenlabs.ts | 11 +++ .../config-footprint-guardrails.test.ts | 4 + 21 files changed, 102 insertions(+), 180 deletions(-) create mode 100644 src/plugin-sdk/elevenlabs.ts diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index f951490ad92..c2f659947b8 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -d5a737eb69a2b2b64526fa0197ef9fe576b1d5d4b949a5c610a8457d5f5706cd config-baseline.json +1dc927cd4be5a0ef6e17958a53ceb6df155107ca8100cdb4d417003483f17990 config-baseline.json b1a181b667568b5860a80945837d544fdec4f946fba34e871936ce0cd3eb689b config-baseline.core.json 3c999707b167138de34f6255e3488b99e404c5132d3fc5879a1fa12d815c31f5 config-baseline.channel.json -031b237717ca108ea2cd314413db4c91edfdfea55f808179e3066331f41af134 config-baseline.plugin.json +fcf32a00815f392ceda9195b8c2af82ae7e88da333feaacee9296f7d5921e73f config-baseline.plugin.json diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 5e64a230326..4dd1fa4d311 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -eedd483d35cebcdf261d0b550185e57aeb23a36446c89f5c76a038d6e6d2651a plugin-sdk-api-baseline.json -7713278ccd37a88115baac658ae9cb381bdaac8ad0bc2b7b79956b83819c9973 plugin-sdk-api-baseline.jsonl +924468503f0a1b9d6338dcf086c556db83c479ea78c5d9fb6ee7f36434bb3425 plugin-sdk-api-baseline.json +24220dbc9f18c092304321fd3064de39254e87848a6f5ba673b5652f986e36e0 plugin-sdk-api-baseline.jsonl diff --git a/extensions/elevenlabs/contract-api.ts b/extensions/elevenlabs/contract-api.ts index 42223a4488a..f3486226b8c 100644 --- a/extensions/elevenlabs/contract-api.ts +++ b/extensions/elevenlabs/contract-api.ts @@ -1,5 +1,7 @@ export { ELEVENLABS_TALK_PROVIDER_ID, + ELEVENLABS_TALK_LEGACY_CONFIG_RULES, + hasLegacyTalkFields, legacyConfigRules, normalizeCompatibilityConfig, } from "./doctor-contract.js"; diff --git a/extensions/elevenlabs/doctor-contract.ts b/extensions/elevenlabs/doctor-contract.ts index 404a67d594c..0df768bd959 100644 --- a/extensions/elevenlabs/doctor-contract.ts +++ b/extensions/elevenlabs/doctor-contract.ts @@ -1,3 +1,4 @@ +import type { ChannelDoctorLegacyConfigRule } from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { ELEVENLABS_TALK_PROVIDER_ID, migrateElevenLabsLegacyTalkConfig } from "./config-compat.js"; @@ -5,7 +6,7 @@ function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } -function hasLegacyTalkFields(value: unknown): boolean { +export function hasLegacyTalkFields(value: unknown): boolean { const talk = isRecord(value) ? value : null; if (!talk) { return false; @@ -15,14 +16,16 @@ function hasLegacyTalkFields(value: unknown): boolean { ); } -export const legacyConfigRules = [ +export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [ { 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 const ELEVENLABS_TALK_LEGACY_CONFIG_RULES = legacyConfigRules; export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): { config: OpenClawConfig; diff --git a/package.json b/package.json index c5574528190..bd90fff8452 100644 --- a/package.json +++ b/package.json @@ -335,6 +335,10 @@ "types": "./dist/plugin-sdk/bluebubbles-policy.d.ts", "default": "./dist/plugin-sdk/bluebubbles-policy.js" }, + "./plugin-sdk/elevenlabs": { + "types": "./dist/plugin-sdk/elevenlabs.d.ts", + "default": "./dist/plugin-sdk/elevenlabs.js" + }, "./plugin-sdk/browser-config-support": { "types": "./dist/plugin-sdk/browser-config-support.d.ts", "default": "./dist/plugin-sdk/browser-config-support.js" diff --git a/scripts/lib/plugin-sdk-doc-metadata.ts b/scripts/lib/plugin-sdk-doc-metadata.ts index 0ac926a2e23..9904372e22a 100644 --- a/scripts/lib/plugin-sdk-doc-metadata.ts +++ b/scripts/lib/plugin-sdk-doc-metadata.ts @@ -71,6 +71,9 @@ export const pluginSdkDocMetadata = { "provider-onboard": { category: "provider", }, + elevenlabs: { + category: "provider", + }, "runtime-store": { category: "runtime", }, diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index bb12195c970..80b69a672d9 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -75,6 +75,7 @@ "allowlist-config-edit", "bluebubbles", "bluebubbles-policy", + "elevenlabs", "browser-config-support", "browser-support", "boolean-param", diff --git a/src/commands/doctor-legacy-config.migrations.test.ts b/src/commands/doctor-legacy-config.migrations.test.ts index 72bf4a2581d..b6200982dfe 100644 --- a/src/commands/doctor-legacy-config.migrations.test.ts +++ b/src/commands/doctor-legacy-config.migrations.test.ts @@ -761,7 +761,9 @@ describe("normalizeCompatibilityConfigValues", () => { interruptOnSpeech: false, silenceTimeoutMs: 1500, }); - expect(res.changes).toEqual(["Moved legacy talk flat fields → talk.providers.elevenlabs."]); + expect(res.changes).toEqual([ + "Moved talk legacy fields (voiceId, voiceAliases, modelId, outputFormat, apiKey) → talk.providers.elevenlabs (filled missing provider fields only).", + ]); }); it("normalizes talk provider ids without overriding explicit provider config", () => { diff --git a/src/commands/doctor-legacy-config.ts b/src/commands/doctor-legacy-config.ts index 9420c5cd47b..ed28ed13fc7 100644 --- a/src/commands/doctor-legacy-config.ts +++ b/src/commands/doctor-legacy-config.ts @@ -1,9 +1,5 @@ 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"; @@ -14,6 +10,7 @@ import { migrateLegacyWebSearchConfig } from "../config/legacy-web-search.js"; import { migrateLegacyXSearchConfig } from "../config/legacy-x-search.js"; import { normalizeTalkSection } from "../config/talk.js"; import { DEFAULT_GOOGLE_API_BASE_URL } from "../infra/google-api-base-url.js"; +import { normalizeCompatibilityConfig as normalizeElevenLabsCompatibilityConfig } from "../plugin-sdk/elevenlabs.js"; import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): { @@ -409,20 +406,14 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): { return; } - const hasProviderShape = typeof rawTalk.provider === "string" || isRecord(rawTalk.providers); next = { ...next, talk: normalizedTalk, }; - if (hasProviderShape) { - changes.push( - "Normalized talk.provider/providers shape (trimmed provider ids and merged missing compatibility fields).", - ); - return; - } - - changes.push(`Moved legacy talk flat fields → talk.providers.${ELEVENLABS_TALK_PROVIDER_ID}.`); + changes.push( + "Normalized talk.provider/providers shape (trimmed provider ids and merged missing compatibility fields).", + ); }; const normalizeLegacyCrossContextMessageConfig = () => { diff --git a/src/config/legacy.migrations.runtime.ts b/src/config/legacy.migrations.runtime.ts index 1e2173e9842..22513895fe3 100644 --- a/src/config/legacy.migrations.runtime.ts +++ b/src/config/legacy.migrations.runtime.ts @@ -1,4 +1,7 @@ -import { migrateElevenLabsLegacyTalkConfig } from "../../extensions/elevenlabs/contract-api.js"; +import { + ELEVENLABS_TALK_LEGACY_CONFIG_RULES, + migrateElevenLabsLegacyTalkConfig, +} from "../plugin-sdk/elevenlabs.js"; import { buildDefaultControlUiAllowedOrigins, hasConfiguredControlUiAllowedOrigins, @@ -140,16 +143,6 @@ function hasLegacyTtsProviderKeys(value: unknown): boolean { return LEGACY_TTS_PROVIDER_KEYS.some((key) => Object.prototype.hasOwnProperty.call(tts, key)); } -function hasLegacyTalkFields(value: unknown): boolean { - const talk = getRecord(value); - if (!talk) { - return false; - } - return ["voiceId", "voiceAliases", "modelId", "outputFormat", "apiKey"].some((key) => - Object.prototype.hasOwnProperty.call(talk, key), - ); -} - function hasLegacySandboxPerSession(value: unknown): boolean { const sandbox = getRecord(value); return Boolean(sandbox && Object.prototype.hasOwnProperty.call(sandbox, "perSession")); @@ -163,9 +156,6 @@ function hasLegacyAgentListSandboxPerSession(value: unknown): boolean { } function migrateLegacyTalkFields(raw: Record, changes: string[]): void { - if (!hasLegacyTalkFields(raw.talk)) { - return; - } const migrated = migrateElevenLabsLegacyTalkConfig(raw); if (migrated.changes.length === 0) { return; @@ -284,13 +274,6 @@ const LEGACY_TTS_RULES: LegacyConfigRule[] = [ }, ]; -const TALK_RULE: LegacyConfigRule = { - path: ["talk"], - message: - "talk.voiceId/talk.voiceAliases/talk.modelId/talk.outputFormat/talk.apiKey are legacy; use talk.providers. instead (auto-migrated on load).", - match: (value) => hasLegacyTalkFields(value), -}; - const LEGACY_SANDBOX_SCOPE_RULES: LegacyConfigRule[] = [ { path: ["agents", "defaults", "sandbox"], @@ -361,7 +344,7 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME: LegacyConfigMigrationSpec[] = [ defineLegacyConfigMigration({ id: "talk.legacy-fields->talk.providers", describe: "Move legacy Talk flat fields into talk.providers.", - legacyRules: [TALK_RULE], + legacyRules: ELEVENLABS_TALK_LEGACY_CONFIG_RULES, apply: (raw, changes) => { migrateLegacyTalkFields(raw, changes); }, diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 20626f24a61..256aba18c89 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -17086,6 +17086,21 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { }, additionalProperties: false, }, + experimental: { + type: "object", + properties: { + planTool: { + type: "boolean", + title: "Enable Structured Plan Tool", + description: + "Enable the experimental structured `update_plan` tool for non-trivial multi-step work tracking across all providers. OpenAI and OpenAI Codex runs auto-enable it even when this flag is unset.", + }, + }, + additionalProperties: false, + title: "Experimental Tools", + description: + "Experimental built-in tool flags. Keep these off by default and enable only when you are intentionally testing a preview surface.", + }, }, additionalProperties: false, title: "Tools", @@ -20166,32 +20181,6 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { additionalProperties: { type: "object", properties: { - voiceId: { - type: "string", - title: "Talk Provider Voice ID", - description: "Provider default voice ID for Talk mode.", - }, - voiceAliases: { - type: "object", - propertyNames: { - type: "string", - }, - additionalProperties: { - type: "string", - }, - title: "Talk Provider Voice Aliases", - description: "Optional provider voice alias map for Talk directives.", - }, - modelId: { - type: "string", - title: "Talk Provider Model ID", - description: "Provider default model ID for Talk mode.", - }, - outputFormat: { - type: "string", - title: "Talk Provider Output Format", - description: "Provider default output format for Talk mode.", - }, apiKey: { anyOf: [ { @@ -20262,6 +20251,8 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { }, }, additionalProperties: {}, + title: "Talk Provider Config", + description: "Provider-owned Talk config fields for the matching provider id.", }, title: "Talk Provider Settings", description: @@ -23466,6 +23457,16 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { help: "Allowlist of target agent IDs permitted for agent_to_agent calls when orchestration is enabled. Use explicit allowlists to avoid uncontrolled cross-agent call graphs.", tags: ["access", "tools"], }, + "tools.experimental": { + label: "Experimental Tools", + help: "Experimental built-in tool flags. Keep these off by default and enable only when you are intentionally testing a preview surface.", + tags: ["security", "tools", "advanced"], + }, + "tools.experimental.planTool": { + label: "Enable Structured Plan Tool", + help: "Enable the experimental structured `update_plan` tool for non-trivial multi-step work tracking across all providers. OpenAI and OpenAI Codex runs auto-enable it even when this flag is unset.", + tags: ["security", "tools", "advanced"], + }, "tools.elevated": { label: "Elevated Tool Access", help: "Elevated tool access controls for privileged command surfaces that should only be reachable from trusted senders. Keep disabled unless operator workflows explicitly require elevated actions.", @@ -26118,24 +26119,9 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { help: "Provider-specific Talk settings keyed by provider id. During migration, prefer this over legacy talk.* keys.", tags: ["media"], }, - "talk.providers.*.voiceId": { - label: "Talk Provider Voice ID", - help: "Provider default voice ID for Talk mode.", - tags: ["media"], - }, - "talk.providers.*.voiceAliases": { - label: "Talk Provider Voice Aliases", - help: "Optional provider voice alias map for Talk directives.", - tags: ["media"], - }, - "talk.providers.*.modelId": { - label: "Talk Provider Model ID", - help: "Provider default model ID for Talk mode.", - tags: ["models", "media"], - }, - "talk.providers.*.outputFormat": { - label: "Talk Provider Output Format", - help: "Provider default output format for Talk mode.", + "talk.providers.*": { + label: "Talk Provider Config", + help: "Provider-owned Talk config fields for the matching provider id.", tags: ["media"], }, "talk.providers.*.apiKey": { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index ca60838b75a..86a399c01de 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -146,10 +146,7 @@ export const FIELD_HELP: Record = { "talk.provider": 'Active Talk provider id (for example "elevenlabs").', "talk.providers": "Provider-specific Talk settings keyed by provider id. During migration, prefer this over legacy talk.* keys.", - "talk.providers.*.voiceId": "Provider default voice ID for Talk mode.", - "talk.providers.*.voiceAliases": "Optional provider voice alias map for Talk directives.", - "talk.providers.*.modelId": "Provider default model ID for Talk mode.", - "talk.providers.*.outputFormat": "Provider default output format for Talk mode.", + "talk.providers.*": "Provider-owned Talk config fields for the matching provider id.", "talk.providers.*.apiKey": "Provider API key for Talk mode.", // pragma: allowlist secret "talk.interruptOnSpeech": "If true (default), stop assistant speech when the user starts speaking in Talk mode. Keep enabled for conversational turn-taking.", diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 9cc61651779..e63becba62f 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -743,10 +743,7 @@ export const FIELD_LABELS: Record = { "messages.tts.providers.*.apiKey": "TTS Provider API Key", // pragma: allowlist secret "talk.provider": "Talk Active Provider", "talk.providers": "Talk Provider Settings", - "talk.providers.*.voiceId": "Talk Provider Voice ID", - "talk.providers.*.voiceAliases": "Talk Provider Voice Aliases", - "talk.providers.*.modelId": "Talk Provider Model ID", - "talk.providers.*.outputFormat": "Talk Provider Output Format", + "talk.providers.*": "Talk Provider Config", "talk.providers.*.apiKey": "Talk Provider API Key", // pragma: allowlist secret channels: "Channels", "channels.defaults": "Channel Defaults", diff --git a/src/config/talk.normalize.test.ts b/src/config/talk.normalize.test.ts index 20837299038..4720f892c61 100644 --- a/src/config/talk.normalize.test.ts +++ b/src/config/talk.normalize.test.ts @@ -20,7 +20,7 @@ async function withTempConfig( } describe("talk normalization", () => { - it("maps legacy ElevenLabs fields into provider/providers", () => { + it("keeps core Talk normalization generic and ignores legacy provider-flat fields", () => { const normalized = normalizeTalkSection({ voiceId: "voice-123", voiceAliases: { Clawd: "EXAVITQu4vr4xnSDxMaL" }, // pragma: allowlist secret @@ -32,15 +32,6 @@ describe("talk normalization", () => { } as unknown as never); expect(normalized).toEqual({ - providers: { - elevenlabs: { - voiceId: "voice-123", - voiceAliases: { Clawd: "EXAVITQu4vr4xnSDxMaL" }, - modelId: "eleven_v3", - outputFormat: "pcm_44100", - apiKey: "secret-key", // pragma: allowlist secret - }, - }, interruptOnSpeech: false, silenceTimeoutMs: 1500, }); diff --git a/src/config/talk.ts b/src/config/talk.ts index d042f9cfe78..44de9dca070 100644 --- a/src/config/talk.ts +++ b/src/config/talk.ts @@ -7,8 +7,6 @@ import type { import type { OpenClawConfig } from "./types.js"; import { coerceSecretRef } from "./types.secrets.js"; -export const LEGACY_TALK_PROVIDER_ID = "elevenlabs"; - function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -21,20 +19,6 @@ function normalizeString(value: unknown): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } -function normalizeVoiceAliases(value: unknown): Record | undefined { - if (!isPlainObject(value)) { - return undefined; - } - const aliases: Record = {}; - for (const [alias, rawId] of Object.entries(value)) { - if (typeof rawId !== "string") { - continue; - } - aliases[alias] = rawId; - } - return Object.keys(aliases).length > 0 ? aliases : undefined; -} - function normalizeTalkSecretInput(value: unknown): TalkProviderConfig["apiKey"] | undefined { if (typeof value === "string") { const trimmed = value.trim(); @@ -60,13 +44,6 @@ function normalizeTalkProviderConfig(value: unknown): TalkProviderConfig | undef if (raw === undefined) { continue; } - if (key === "voiceAliases") { - const aliases = normalizeVoiceAliases(raw); - if (aliases) { - provider.voiceAliases = aliases; - } - continue; - } if (key === "apiKey") { const normalized = normalizeTalkSecretInput(raw); if (normalized !== undefined) { @@ -74,13 +51,6 @@ function normalizeTalkProviderConfig(value: unknown): TalkProviderConfig | undef } continue; } - if (key === "voiceId" || key === "modelId" || key === "outputFormat") { - const normalized = normalizeString(raw); - if (normalized) { - provider[key] = normalized; - } - continue; - } provider[key] = raw; } @@ -106,18 +76,6 @@ function normalizeTalkProviders(value: unknown): Record 0 ? providers : undefined; } -function legacyProviderConfigFromTalk( - source: Record, -): TalkProviderConfig | undefined { - return normalizeTalkProviderConfig({ - voiceId: source.voiceId, - voiceAliases: source.voiceAliases, - modelId: source.modelId, - outputFormat: source.outputFormat, - apiKey: source.apiKey, - }); -} - function activeProviderFromTalk(talk: TalkConfig): string | undefined { const provider = normalizeString(talk.provider); const providers = talk.providers; @@ -137,7 +95,6 @@ export function normalizeTalkSection(value: TalkConfig | undefined): TalkConfig } const source = value as Record; - const hasNormalizedShape = typeof source.provider === "string" || isPlainObject(source.providers); const normalized: TalkConfig = {}; if (typeof source.interruptOnSpeech === "boolean") { normalized.interruptOnSpeech = source.interruptOnSpeech; @@ -147,21 +104,13 @@ export function normalizeTalkSection(value: TalkConfig | undefined): TalkConfig normalized.silenceTimeoutMs = silenceTimeoutMs; } - if (hasNormalizedShape) { - const providers = normalizeTalkProviders(source.providers); - const provider = normalizeString(source.provider); - if (providers) { - normalized.providers = providers; - } - if (provider) { - normalized.provider = provider; - } - return Object.keys(normalized).length > 0 ? normalized : undefined; + const providers = normalizeTalkProviders(source.providers); + const provider = normalizeString(source.provider); + if (providers) { + normalized.providers = providers; } - - const legacyProviderConfig = legacyProviderConfigFromTalk(source); - if (legacyProviderConfig) { - normalized.providers = { [LEGACY_TALK_PROVIDER_ID]: legacyProviderConfig }; + if (provider) { + normalized.provider = provider; } return Object.keys(normalized).length > 0 ? normalized : undefined; } diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index caa25a4e98c..3025baed9fd 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -49,17 +49,9 @@ export type CanvasHostConfig = { }; export type TalkProviderConfig = { - /** Default voice ID for the provider's Talk mode implementation. */ - voiceId?: string; - /** Optional voice name -> provider voice ID map. */ - voiceAliases?: Record; - /** Default provider model ID for Talk mode. */ - modelId?: string; - /** Default provider output format (for example pcm_44100). */ - outputFormat?: string; /** Provider API key (optional; provider-specific env fallback may apply). */ apiKey?: SecretInput; - /** Provider-specific extensions. */ + /** Provider-owned Talk config fields. */ [key: string]: unknown; }; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 4f0e43b3e3f..ccdac3cf4c6 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -169,10 +169,6 @@ const PluginEntrySchema = z const TalkProviderEntrySchema = z .object({ - voiceId: z.string().optional(), - voiceAliases: z.record(z.string(), z.string()).optional(), - modelId: z.string().optional(), - outputFormat: z.string().optional(), apiKey: SecretInputSchema.optional().register(sensitive), }) .catchall(z.unknown()); diff --git a/src/gateway/protocol/schema/channels.ts b/src/gateway/protocol/schema/channels.ts index 8a72b67ca85..8903460da69 100644 --- a/src/gateway/protocol/schema/channels.ts +++ b/src/gateway/protocol/schema/channels.ts @@ -37,10 +37,6 @@ export const TalkSpeakParamsSchema = Type.Object( ); const talkProviderFieldSchemas = { - voiceId: Type.Optional(Type.String()), - voiceAliases: Type.Optional(Type.Record(Type.String(), Type.String())), - modelId: Type.Optional(Type.String()), - outputFormat: Type.Optional(Type.String()), apiKey: Type.Optional(SecretInputSchema), }; diff --git a/src/gateway/server-methods/talk.ts b/src/gateway/server-methods/talk.ts index 5d8d7881da5..419c5bbee4b 100644 --- a/src/gateway/server-methods/talk.ts +++ b/src/gateway/server-methods/talk.ts @@ -52,6 +52,20 @@ function asRecord(value: unknown): Record | undefined { : undefined; } +function asStringRecord(value: unknown): Record | undefined { + const record = asRecord(value); + if (!record) { + return undefined; + } + const next: Record = {}; + for (const [key, entryValue] of Object.entries(record)) { + if (typeof entryValue === "string") { + next[key] = entryValue; + } + } + return Object.keys(next).length > 0 ? next : undefined; +} + function normalizeAliasKey(value: string): string { return value.trim().toLowerCase(); } @@ -63,7 +77,7 @@ function resolveTalkVoiceId( if (!requested) { return undefined; } - const aliases = providerConfig.voiceAliases; + const aliases = asStringRecord(providerConfig.voiceAliases); if (!aliases) { return requested; } diff --git a/src/plugin-sdk/elevenlabs.ts b/src/plugin-sdk/elevenlabs.ts new file mode 100644 index 00000000000..288a9710653 --- /dev/null +++ b/src/plugin-sdk/elevenlabs.ts @@ -0,0 +1,11 @@ +// Private helper surface for the bundled ElevenLabs speech plugin. +// Keep this surface narrow and limited to config/doctor compatibility. + +export { + ELEVENLABS_TALK_PROVIDER_ID, + ELEVENLABS_TALK_LEGACY_CONFIG_RULES, + hasLegacyTalkFields, + legacyConfigRules, + migrateElevenLabsLegacyTalkConfig, + normalizeCompatibilityConfig, +} from "../../extensions/elevenlabs/contract-api.js"; diff --git a/src/plugins/contracts/config-footprint-guardrails.test.ts b/src/plugins/contracts/config-footprint-guardrails.test.ts index 6c69dd51f11..0962ae93378 100644 --- a/src/plugins/contracts/config-footprint-guardrails.test.ts +++ b/src/plugins/contracts/config-footprint-guardrails.test.ts @@ -60,6 +60,10 @@ describe("config footprint guardrails", () => { "talk.modelId", "talk.outputFormat", "talk.apiKey", + "talk.providers.*.voiceId", + "talk.providers.*.voiceAliases", + "talk.providers.*.modelId", + "talk.providers.*.outputFormat", "agents.defaults.sandbox.perSession", "hooks.internal.handlers", "channels.telegram.groupMentionsOnly",