mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
refactor: add canonical talk config payload
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user