refactor: move talk config contract under plugin

This commit is contained in:
Peter Steinberger
2026-04-05 14:26:25 +01:00
parent d842251ef8
commit 9a0d88a868
21 changed files with 102 additions and 180 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -1,5 +1,7 @@
export {
ELEVENLABS_TALK_PROVIDER_ID,
ELEVENLABS_TALK_LEGACY_CONFIG_RULES,
hasLegacyTalkFields,
legacyConfigRules,
normalizeCompatibilityConfig,
} from "./doctor-contract.js";

View File

@@ -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<string, unknown> {
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.<provider> (auto-migrated on load).",
match: hasLegacyTalkFields,
},
] as const;
];
export const ELEVENLABS_TALK_LEGACY_CONFIG_RULES = legacyConfigRules;
export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): {
config: OpenClawConfig;

View File

@@ -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"

View File

@@ -71,6 +71,9 @@ export const pluginSdkDocMetadata = {
"provider-onboard": {
category: "provider",
},
elevenlabs: {
category: "provider",
},
"runtime-store": {
category: "runtime",
},

View File

@@ -75,6 +75,7 @@
"allowlist-config-edit",
"bluebubbles",
"bluebubbles-policy",
"elevenlabs",
"browser-config-support",
"browser-support",
"boolean-param",

View File

@@ -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", () => {

View File

@@ -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 = () => {

View File

@@ -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<string, unknown>, 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.<provider> 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.<provider>",
legacyRules: [TALK_RULE],
legacyRules: ELEVENLABS_TALK_LEGACY_CONFIG_RULES,
apply: (raw, changes) => {
migrateLegacyTalkFields(raw, changes);
},

View File

@@ -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": {

View File

@@ -146,10 +146,7 @@ export const FIELD_HELP: Record<string, string> = {
"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.",

View File

@@ -743,10 +743,7 @@ export const FIELD_LABELS: Record<string, string> = {
"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",

View File

@@ -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,
});

View File

@@ -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<string, unknown> {
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<string, string> | undefined {
if (!isPlainObject(value)) {
return undefined;
}
const aliases: Record<string, string> = {};
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<string, TalkProviderConf
return Object.keys(providers).length > 0 ? providers : undefined;
}
function legacyProviderConfigFromTalk(
source: Record<string, unknown>,
): 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<string, unknown>;
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;
}

View File

@@ -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<string, string>;
/** 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;
};

View File

@@ -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());

View File

@@ -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),
};

View File

@@ -52,6 +52,20 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
: undefined;
}
function asStringRecord(value: unknown): Record<string, string> | undefined {
const record = asRecord(value);
if (!record) {
return undefined;
}
const next: Record<string, string> = {};
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;
}

View File

@@ -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";

View File

@@ -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",