refactor: add canonical talk config payload

This commit is contained in:
Peter Steinberger
2026-03-08 14:47:29 +00:00
parent 4f482d2a2b
commit 4e2290b841
10 changed files with 171 additions and 16 deletions

View File

@@ -77,8 +77,19 @@ class TalkModeManager(
return trimmed.takeIf { it.isNotEmpty() } return trimmed.takeIf { it.isNotEmpty() }
} }
private fun selectResolvedTalkProviderConfig(talk: JsonObject): TalkProviderConfigSelection? {
val resolved = talk["resolved"].asObjectOrNull() ?: return null
val providerId = normalizeTalkProviderId(resolved["provider"].asStringOrNull()) ?: return null
return TalkProviderConfigSelection(
provider = providerId,
config = resolved["config"].asObjectOrNull() ?: buildJsonObject {},
normalizedPayload = true,
)
}
internal fun selectTalkProviderConfig(talk: JsonObject?): TalkProviderConfigSelection? { internal fun selectTalkProviderConfig(talk: JsonObject?): TalkProviderConfigSelection? {
if (talk == null) return null if (talk == null) return null
selectResolvedTalkProviderConfig(talk)?.let { return it }
val rawProvider = talk["provider"].asStringOrNull() val rawProvider = talk["provider"].asStringOrNull()
val rawProviders = talk["providers"].asObjectOrNull() val rawProviders = talk["providers"].asObjectOrNull()
val hasNormalizedPayload = rawProvider != null || rawProviders != null val hasNormalizedPayload = rawProvider != null || rawProviders != null

View File

@@ -13,6 +13,36 @@ import org.junit.Test
class TalkModeConfigParsingTest { class TalkModeConfigParsingTest {
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
@Test
fun prefersCanonicalResolvedTalkProviderPayload() {
val talk =
json.parseToJsonElement(
"""
{
"resolved": {
"provider": "elevenlabs",
"config": {
"voiceId": "voice-resolved"
}
},
"provider": "elevenlabs",
"providers": {
"elevenlabs": {
"voiceId": "voice-normalized"
}
}
}
""".trimIndent(),
)
.jsonObject
val selection = TalkModeManager.selectTalkProviderConfig(talk)
assertNotNull(selection)
assertEquals("elevenlabs", selection?.provider)
assertTrue(selection?.normalizedPayload == true)
assertEquals("voice-resolved", selection?.config?.get("voiceId")?.jsonPrimitive?.content)
}
@Test @Test
fun prefersNormalizedTalkProviderPayload() { fun prefersNormalizedTalkProviderPayload() {
val talk = val talk =

View File

@@ -23,6 +23,9 @@ public enum TalkConfigParsing {
allowLegacyFallback: Bool = true, allowLegacyFallback: Bool = true,
) -> TalkProviderConfigSelection? { ) -> TalkProviderConfigSelection? {
guard let talk else { return nil } guard let talk else { return nil }
if let resolvedSelection = self.resolvedProviderConfig(talk) {
return resolvedSelection
}
let rawProvider = talk["provider"]?.stringValue let rawProvider = talk["provider"]?.stringValue
let rawProviders = talk["providers"] let rawProviders = talk["providers"]
let hasNormalizedPayload = rawProvider != nil || rawProviders != nil let hasNormalizedPayload = rawProvider != nil || rawProviders != nil
@@ -68,6 +71,19 @@ public enum TalkConfigParsing {
return trimmed.isEmpty ? nil : trimmed return trimmed.isEmpty ? nil : trimmed
} }
private static func resolvedProviderConfig(
_ talk: [String: AnyCodable]
) -> TalkProviderConfigSelection? {
guard
let resolved = talk["resolved"]?.dictionaryValue,
let providerID = self.normalizedTalkProviderID(resolved["provider"]?.stringValue)
else { return nil }
return TalkProviderConfigSelection(
provider: providerID,
config: resolved["config"]?.dictionaryValue ?? [:],
normalizedPayload: true)
}
private static func normalizedTalkProviders(_ raw: AnyCodable?) -> [String: [String: AnyCodable]] { private static func normalizedTalkProviders(_ raw: AnyCodable?) -> [String: [String: AnyCodable]] {
guard let providerMap = raw?.dictionaryValue else { return [:] } guard let providerMap = raw?.dictionaryValue else { return [:] }
return providerMap.reduce(into: [String: [String: AnyCodable]]()) { acc, entry in return providerMap.reduce(into: [String: [String: AnyCodable]]()) { acc, entry in

View File

@@ -2,6 +2,28 @@ import OpenClawKit
import Testing import Testing
struct TalkConfigParsingTests { struct TalkConfigParsingTests {
@Test func prefersCanonicalResolvedTalkProviderPayload() {
let talk: [String: AnyCodable] = [
"resolved": AnyCodable([
"provider": "elevenlabs",
"config": [
"voiceId": "voice-resolved",
],
]),
"provider": AnyCodable("elevenlabs"),
"providers": AnyCodable([
"elevenlabs": [
"voiceId": "voice-normalized",
],
]),
]
let selection = TalkConfigParsing.selectProviderConfig(talk, defaultProvider: "elevenlabs")
#expect(selection?.provider == "elevenlabs")
#expect(selection?.normalizedPayload == true)
#expect(selection?.config["voiceId"]?.stringValue == "voice-resolved")
}
@Test func prefersNormalizedTalkProviderPayload() { @Test func prefersNormalizedTalkProviderPayload() {
let talk: [String: AnyCodable] = [ let talk: [String: AnyCodable] = [
"provider": AnyCodable("elevenlabs"), "provider": AnyCodable("elevenlabs"),

View File

@@ -178,17 +178,17 @@ export function applyTalkApiKey(config: OpenClawConfig): OpenClawConfig {
const talk = normalized.talk; const talk = normalized.talk;
const active = resolveActiveTalkProviderConfig(talk); const active = resolveActiveTalkProviderConfig(talk);
if (active.provider && active.provider !== DEFAULT_TALK_PROVIDER) { if (active?.provider && active.provider !== DEFAULT_TALK_PROVIDER) {
return normalized; return normalized;
} }
const existingProviderApiKeyConfigured = hasConfiguredSecretInput(active.config?.apiKey); const existingProviderApiKeyConfigured = hasConfiguredSecretInput(active?.config?.apiKey);
const existingLegacyApiKeyConfigured = hasConfiguredSecretInput(talk?.apiKey); const existingLegacyApiKeyConfigured = hasConfiguredSecretInput(talk?.apiKey);
if (existingProviderApiKeyConfigured || existingLegacyApiKeyConfigured) { if (existingProviderApiKeyConfigured || existingLegacyApiKeyConfigured) {
return normalized; return normalized;
} }
const providerId = active.provider ?? DEFAULT_TALK_PROVIDER; const providerId = active?.provider ?? DEFAULT_TALK_PROVIDER;
const providers = { ...talk?.providers }; const providers = { ...talk?.providers };
const providerConfig = { ...providers[providerId], apiKey: resolved }; const providerConfig = { ...providers[providerId], apiKey: resolved };
providers[providerId] = providerConfig; providers[providerId] = providerConfig;

View File

@@ -4,7 +4,7 @@ import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { withEnvAsync } from "../test-utils/env.js"; import { withEnvAsync } from "../test-utils/env.js";
import { createConfigIO } from "./io.js"; import { createConfigIO } from "./io.js";
import { normalizeTalkSection } from "./talk.js"; import { buildTalkConfigResponse, normalizeTalkSection } from "./talk.js";
const envVar = (...parts: string[]) => parts.join("_"); const envVar = (...parts: string[]) => parts.join("_");
const elevenLabsApiKeyEnv = ["ELEVENLABS_API", "KEY"].join("_"); const elevenLabsApiKeyEnv = ["ELEVENLABS_API", "KEY"].join("_");
@@ -82,6 +82,40 @@ describe("talk normalization", () => {
}); });
}); });
it("builds a canonical resolved talk payload for clients", () => {
const payload = buildTalkConfigResponse({
provider: "acme",
providers: {
acme: {
voiceId: "acme-voice",
modelId: "acme-model",
},
},
voiceId: "legacy-voice",
interruptOnSpeech: true,
});
expect(payload).toEqual({
provider: "acme",
providers: {
acme: {
voiceId: "acme-voice",
modelId: "acme-model",
},
},
resolved: {
provider: "acme",
config: {
voiceId: "acme-voice",
modelId: "acme-model",
},
},
voiceId: "acme-voice",
modelId: "acme-model",
interruptOnSpeech: true,
});
});
it("preserves SecretRef apiKey values during normalization", () => { it("preserves SecretRef apiKey values during normalization", () => {
const normalized = normalizeTalkSection({ const normalized = normalizeTalkSection({
provider: "elevenlabs", provider: "elevenlabs",

View File

@@ -1,7 +1,12 @@
import fs from "node:fs"; import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import type { TalkConfig, TalkProviderConfig } from "./types.gateway.js"; import type {
ResolvedTalkConfig,
TalkConfig,
TalkConfigResponse,
TalkProviderConfig,
} from "./types.gateway.js";
import type { OpenClawConfig } from "./types.js"; import type { OpenClawConfig } from "./types.js";
import { coerceSecretRef } from "./types.secrets.js"; import { coerceSecretRef } from "./types.secrets.js";
@@ -247,25 +252,24 @@ export function normalizeTalkConfig(config: OpenClawConfig): OpenClawConfig {
}; };
} }
export function resolveActiveTalkProviderConfig(talk: TalkConfig | undefined): { export function resolveActiveTalkProviderConfig(
provider?: string; talk: TalkConfig | undefined,
config?: TalkProviderConfig; ): ResolvedTalkConfig | undefined {
} {
const normalizedTalk = normalizeTalkSection(talk); const normalizedTalk = normalizeTalkSection(talk);
if (!normalizedTalk) { if (!normalizedTalk) {
return {}; return undefined;
} }
const provider = activeProviderFromTalk(normalizedTalk); const provider = activeProviderFromTalk(normalizedTalk);
if (!provider) { if (!provider) {
return {}; return undefined;
} }
return { return {
provider, provider,
config: normalizedTalk.providers?.[provider], config: normalizedTalk.providers?.[provider] ?? {},
}; };
} }
export function buildTalkConfigResponse(value: unknown): TalkConfig | undefined { export function buildTalkConfigResponse(value: unknown): TalkConfigResponse | undefined {
if (!isPlainObject(value)) { if (!isPlainObject(value)) {
return undefined; return undefined;
} }
@@ -274,7 +278,7 @@ export function buildTalkConfigResponse(value: unknown): TalkConfig | undefined
return undefined; return undefined;
} }
const payload: TalkConfig = {}; const payload: TalkConfigResponse = {};
if (typeof normalized.interruptOnSpeech === "boolean") { if (typeof normalized.interruptOnSpeech === "boolean") {
payload.interruptOnSpeech = normalized.interruptOnSpeech; payload.interruptOnSpeech = normalized.interruptOnSpeech;
} }
@@ -288,8 +292,12 @@ export function buildTalkConfigResponse(value: unknown): TalkConfig | undefined
payload.provider = normalized.provider; payload.provider = normalized.provider;
} }
const activeProvider = activeProviderFromTalk(normalized); const resolved = resolveActiveTalkProviderConfig(normalized);
const providerConfig = activeProvider ? normalized.providers?.[activeProvider] : undefined; if (resolved) {
payload.resolved = resolved;
}
const providerConfig = resolved?.config;
const providerCompatibilityLegacy = legacyTalkFieldsFromProviderConfig(providerConfig); const providerCompatibilityLegacy = legacyTalkFieldsFromProviderConfig(providerConfig);
const compatibilityLegacy = const compatibilityLegacy =
Object.keys(providerCompatibilityLegacy).length > 0 Object.keys(providerCompatibilityLegacy).length > 0

View File

@@ -63,6 +63,13 @@ export type TalkProviderConfig = {
[key: string]: unknown; [key: string]: unknown;
}; };
export type ResolvedTalkConfig = {
/** Active Talk TTS provider resolved from the current config payload. */
provider: string;
/** Provider config for the active Talk provider. */
config: TalkProviderConfig;
};
export type TalkConfig = { export type TalkConfig = {
/** Active Talk TTS provider (for example "elevenlabs"). */ /** Active Talk TTS provider (for example "elevenlabs"). */
provider?: string; provider?: string;
@@ -84,6 +91,11 @@ export type TalkConfig = {
apiKey?: SecretInput; apiKey?: SecretInput;
}; };
export type TalkConfigResponse = TalkConfig & {
/** Canonical active Talk payload for clients. */
resolved?: ResolvedTalkConfig;
};
export type GatewayControlUiConfig = { export type GatewayControlUiConfig = {
/** If false, the Gateway will not serve the Control UI (default /). */ /** If false, the Gateway will not serve the Control UI (default /). */
enabled?: boolean; enabled?: boolean;

View File

@@ -27,6 +27,14 @@ const TalkProviderConfigSchema = Type.Object(
{ additionalProperties: true }, { additionalProperties: true },
); );
const ResolvedTalkConfigSchema = Type.Object(
{
provider: Type.String(),
config: TalkProviderConfigSchema,
},
{ additionalProperties: false },
);
export const TalkConfigResultSchema = Type.Object( export const TalkConfigResultSchema = Type.Object(
{ {
config: Type.Object( config: Type.Object(
@@ -36,6 +44,7 @@ export const TalkConfigResultSchema = Type.Object(
{ {
provider: Type.Optional(Type.String()), provider: Type.Optional(Type.String()),
providers: Type.Optional(Type.Record(Type.String(), TalkProviderConfigSchema)), providers: Type.Optional(Type.Record(Type.String(), TalkProviderConfigSchema)),
resolved: Type.Optional(ResolvedTalkConfigSchema),
voiceId: Type.Optional(Type.String()), voiceId: Type.Optional(Type.String()),
voiceAliases: Type.Optional(Type.Record(Type.String(), Type.String())), voiceAliases: Type.Optional(Type.Record(Type.String(), Type.String())),
modelId: Type.Optional(Type.String()), modelId: Type.Optional(Type.String()),

View File

@@ -91,6 +91,10 @@ describe("gateway talk.config", () => {
providers?: { providers?: {
elevenlabs?: { voiceId?: string; apiKey?: string }; elevenlabs?: { voiceId?: string; apiKey?: string };
}; };
resolved?: {
provider?: string;
config?: { voiceId?: string; apiKey?: string };
};
apiKey?: string; apiKey?: string;
voiceId?: string; voiceId?: string;
silenceTimeoutMs?: number; silenceTimeoutMs?: number;
@@ -103,6 +107,9 @@ describe("gateway talk.config", () => {
expect(res.payload?.config?.talk?.providers?.elevenlabs?.apiKey).toBe( expect(res.payload?.config?.talk?.providers?.elevenlabs?.apiKey).toBe(
"__OPENCLAW_REDACTED__", "__OPENCLAW_REDACTED__",
); );
expect(res.payload?.config?.talk?.resolved?.provider).toBe("elevenlabs");
expect(res.payload?.config?.talk?.resolved?.config?.voiceId).toBe("voice-123");
expect(res.payload?.config?.talk?.resolved?.config?.apiKey).toBe("__OPENCLAW_REDACTED__");
expect(res.payload?.config?.talk?.voiceId).toBe("voice-123"); expect(res.payload?.config?.talk?.voiceId).toBe("voice-123");
expect(res.payload?.config?.talk?.apiKey).toBe("__OPENCLAW_REDACTED__"); expect(res.payload?.config?.talk?.apiKey).toBe("__OPENCLAW_REDACTED__");
expect(res.payload?.config?.talk?.silenceTimeoutMs).toBe(1500); expect(res.payload?.config?.talk?.silenceTimeoutMs).toBe(1500);
@@ -156,6 +163,10 @@ describe("gateway talk.config", () => {
providers?: { providers?: {
elevenlabs?: { voiceId?: string }; elevenlabs?: { voiceId?: string };
}; };
resolved?: {
provider?: string;
config?: { voiceId?: string };
};
voiceId?: string; voiceId?: string;
}; };
}; };
@@ -163,6 +174,8 @@ describe("gateway talk.config", () => {
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
expect(res.payload?.config?.talk?.provider).toBe("elevenlabs"); expect(res.payload?.config?.talk?.provider).toBe("elevenlabs");
expect(res.payload?.config?.talk?.providers?.elevenlabs?.voiceId).toBe("voice-normalized"); expect(res.payload?.config?.talk?.providers?.elevenlabs?.voiceId).toBe("voice-normalized");
expect(res.payload?.config?.talk?.resolved?.provider).toBe("elevenlabs");
expect(res.payload?.config?.talk?.resolved?.config?.voiceId).toBe("voice-normalized");
expect(res.payload?.config?.talk?.voiceId).toBe("voice-normalized"); expect(res.payload?.config?.talk?.voiceId).toBe("voice-normalized");
}); });
}); });