feat(talk): add provider-agnostic config with legacy compatibility

This commit is contained in:
Nimrod Gutman
2026-02-21 21:47:39 +02:00
committed by Peter Steinberger
parent d1f28c954e
commit d58f71571a
19 changed files with 1003 additions and 109 deletions

View File

@@ -16,6 +16,17 @@ export const TalkConfigParamsSchema = Type.Object(
{ additionalProperties: false },
);
const TalkProviderConfigSchema = Type.Object(
{
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(Type.String()),
},
{ additionalProperties: true },
);
export const TalkConfigResultSchema = Type.Object(
{
config: Type.Object(
@@ -23,6 +34,8 @@ export const TalkConfigResultSchema = Type.Object(
talk: Type.Optional(
Type.Object(
{
provider: Type.Optional(Type.String()),
providers: Type.Optional(Type.Record(Type.String(), TalkProviderConfigSchema)),
voiceId: Type.Optional(Type.String()),
voiceAliases: Type.Optional(Type.Record(Type.String(), Type.String())),
modelId: Type.Optional(Type.String()),

View File

@@ -1,5 +1,6 @@
import { readConfigFileSnapshot } from "../../config/config.js";
import { redactConfigObject } from "../../config/redact-snapshot.js";
import { buildTalkConfigResponse } from "../../config/talk.js";
import {
ErrorCodes,
errorShape,
@@ -17,46 +18,6 @@ function canReadTalkSecrets(client: { connect?: { scopes?: string[] } } | null):
return scopes.includes(ADMIN_SCOPE) || scopes.includes(TALK_SECRETS_SCOPE);
}
function normalizeTalkConfigSection(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const source = value as Record<string, unknown>;
const talk: Record<string, unknown> = {};
if (typeof source.voiceId === "string") {
talk.voiceId = source.voiceId;
}
if (
source.voiceAliases &&
typeof source.voiceAliases === "object" &&
!Array.isArray(source.voiceAliases)
) {
const aliases: Record<string, string> = {};
for (const [alias, id] of Object.entries(source.voiceAliases as Record<string, unknown>)) {
if (typeof id !== "string") {
continue;
}
aliases[alias] = id;
}
if (Object.keys(aliases).length > 0) {
talk.voiceAliases = aliases;
}
}
if (typeof source.modelId === "string") {
talk.modelId = source.modelId;
}
if (typeof source.outputFormat === "string") {
talk.outputFormat = source.outputFormat;
}
if (typeof source.apiKey === "string") {
talk.apiKey = source.apiKey;
}
if (typeof source.interruptOnSpeech === "boolean") {
talk.interruptOnSpeech = source.interruptOnSpeech;
}
return Object.keys(talk).length > 0 ? talk : undefined;
}
export const talkHandlers: GatewayRequestHandlers = {
"talk.config": async ({ params, respond, client }) => {
if (!validateTalkConfigParams(params)) {
@@ -87,7 +48,7 @@ export const talkHandlers: GatewayRequestHandlers = {
const talkSource = includeSecrets
? snapshot.config.talk
: redactConfigObject(snapshot.config.talk);
const talk = normalizeTalkConfigSection(talkSource);
const talk = buildTalkConfigResponse(talkSource);
if (talk) {
configPayload.talk = talk;
}

View File

@@ -79,12 +79,24 @@ describe("gateway talk.config", () => {
await withServer(async (ws) => {
await connectOperator(ws, ["operator.read"]);
const res = await rpcReq<{ config?: { talk?: { apiKey?: string; voiceId?: string } } }>(
ws,
"talk.config",
{},
);
const res = await rpcReq<{
config?: {
talk?: {
provider?: string;
providers?: {
elevenlabs?: { voiceId?: string; apiKey?: string };
};
apiKey?: string;
voiceId?: string;
};
};
}>(ws, "talk.config", {});
expect(res.ok).toBe(true);
expect(res.payload?.config?.talk?.provider).toBe("elevenlabs");
expect(res.payload?.config?.talk?.providers?.elevenlabs?.voiceId).toBe("voice-123");
expect(res.payload?.config?.talk?.providers?.elevenlabs?.apiKey).toBe(
"__OPENCLAW_REDACTED__",
);
expect(res.payload?.config?.talk?.voiceId).toBe("voice-123");
expect(res.payload?.config?.talk?.apiKey).toBe("__OPENCLAW_REDACTED__");
});
@@ -113,4 +125,38 @@ describe("gateway talk.config", () => {
expect(res.payload?.config?.talk?.apiKey).toBe("secret-key-abc");
});
});
it("prefers normalized provider payload over conflicting legacy talk keys", async () => {
const { writeConfigFile } = await import("../config/config.js");
await writeConfigFile({
talk: {
provider: "elevenlabs",
providers: {
elevenlabs: {
voiceId: "voice-normalized",
},
},
voiceId: "voice-legacy",
},
});
await withServer(async (ws) => {
await connectOperator(ws, ["operator.read"]);
const res = await rpcReq<{
config?: {
talk?: {
provider?: string;
providers?: {
elevenlabs?: { voiceId?: string };
};
voiceId?: string;
};
};
}>(ws, "talk.config", {});
expect(res.ok).toBe(true);
expect(res.payload?.config?.talk?.provider).toBe("elevenlabs");
expect(res.payload?.config?.talk?.providers?.elevenlabs?.voiceId).toBe("voice-normalized");
expect(res.payload?.config?.talk?.voiceId).toBe("voice-normalized");
});
});
});