mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
refactor(voice-call): retire runtime config compatibility (#105476)
* refactor(voice-call): rename config migration module * refactor(voice-call): retire runtime config compatibility
This commit is contained in:
@@ -101,7 +101,7 @@ Notes:
|
||||
- Twilio defaults to US1. For a non-US Region, set `twilio.region` to `ie1` or `au1` and use credentials created in that Region; see [Twilio's regional REST API guide](https://www.twilio.com/docs/global-infrastructure/using-the-twilio-rest-api-in-a-non-us-region).
|
||||
- `mock` is a local dev provider (no network calls).
|
||||
- Telnyx requires `telnyx.publicKey` (or `TELNYX_PUBLIC_KEY`) unless `skipSignatureVerification` is true.
|
||||
- If older configs still use `provider: "log"`, `twilio.from`, or legacy `streaming.*` OpenAI keys, run `openclaw doctor --fix` to rewrite them.
|
||||
- Runtime accepts canonical config only. If older configs still use `provider: "log"`, `twilio.from`, or legacy `streaming.*` OpenAI keys, run `openclaw doctor --fix` to rewrite them.
|
||||
- advanced webhook, streaming, and tunnel notes: `https://docs.openclaw.ai/plugins/voice-call`
|
||||
- `responseModel` is optional. When unset, voice responses use the runtime default model.
|
||||
- `sessionScope` defaults to `per-phone`, preserving caller memory across calls. Use `per-call` for reception, booking, IVR, and bridge flows where each carrier call should start fresh.
|
||||
@@ -163,4 +163,3 @@ Actions:
|
||||
- Outbound conversation calls suppress barge-in only while the initial greeting is actively speaking, then re-enable normal interruption.
|
||||
- Twilio stream disconnect auto-end uses a short grace window so quick reconnects do not end the call.
|
||||
- Realtime provider selection is generic. Configure `streaming.provider` / `realtime.provider` and put provider-owned options under `providers.<id>`.
|
||||
- Runtime fallback still accepts the old voice-call keys for now, but migration is a doctor step and the compat shim is scheduled to go away in a future release.
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
// Narrow barrel for config compatibility helpers consumed outside the plugin.
|
||||
// Keep this separate from api.ts so config migration code does not pull in the
|
||||
// full runtime-oriented voice-call surface.
|
||||
|
||||
export {
|
||||
VOICE_CALL_LEGACY_CONFIG_REMOVAL_VERSION,
|
||||
collectVoiceCallLegacyConfigIssues,
|
||||
formatVoiceCallLegacyConfigWarnings,
|
||||
migrateVoiceCallLegacyConfigInput,
|
||||
normalizeVoiceCallLegacyConfigInput,
|
||||
parseVoiceCallPluginConfig,
|
||||
} from "./src/config-compat.js";
|
||||
@@ -261,6 +261,33 @@ describe("voice-call plugin", () => {
|
||||
];
|
||||
});
|
||||
|
||||
it("defaults canonical plugin config to an enabled mock runtime", async () => {
|
||||
const { service } = setup({});
|
||||
|
||||
await service?.start(createServiceContext());
|
||||
|
||||
expect(createVoiceCallRuntime).toHaveBeenCalledTimes(1);
|
||||
expect(firstRuntimeConfig()).toMatchObject({
|
||||
enabled: true,
|
||||
provider: "mock",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["provider log", { enabled: true, provider: "log" }],
|
||||
[
|
||||
"twilio.from",
|
||||
{
|
||||
enabled: true,
|
||||
provider: "mock",
|
||||
twilio: { from: "+15550001234" },
|
||||
},
|
||||
],
|
||||
])("rejects legacy %s config instead of normalizing it at runtime", (_label, config) => {
|
||||
expect(() => setup(config)).toThrow();
|
||||
expect(createVoiceCallRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses a started runtime across plugin registration contexts", async () => {
|
||||
const first = setup({ provider: "mock" });
|
||||
const second = setup({ provider: "mock" });
|
||||
@@ -730,40 +757,6 @@ describe("voice-call plugin", () => {
|
||||
expect(error?.message).not.toContain("endedAt=");
|
||||
});
|
||||
|
||||
it("normalizes legacy config through runtime creation and warns to run doctor", async () => {
|
||||
const { methods } = setup({
|
||||
enabled: true,
|
||||
provider: "log",
|
||||
twilio: {
|
||||
from: "+15550001234",
|
||||
},
|
||||
streaming: {
|
||||
enabled: true,
|
||||
sttProvider: "openai",
|
||||
openaiApiKey: "sk-test", // pragma: allowlist secret
|
||||
},
|
||||
});
|
||||
const handler = methods.get("voicecall.status") as
|
||||
| ((ctx: {
|
||||
params: Record<string, unknown>;
|
||||
respond: ReturnType<typeof vi.fn>;
|
||||
}) => Promise<void>)
|
||||
| undefined;
|
||||
const respond = vi.fn();
|
||||
|
||||
await handler?.({ params: { callId: "call-1" }, respond });
|
||||
|
||||
expect(vi.mocked(createVoiceCallRuntime)).toHaveBeenCalledTimes(1);
|
||||
const runtimeConfig = firstRuntimeConfig();
|
||||
expect(runtimeConfig?.enabled).toBe(true);
|
||||
expect(runtimeConfig?.provider).toBe("mock");
|
||||
expect(runtimeConfig?.fromNumber).toBe("+15550001234");
|
||||
expect(runtimeConfig?.streaming?.enabled).toBe(true);
|
||||
expect(runtimeConfig?.streaming?.provider).toBe("openai");
|
||||
expect(runtimeConfig?.streaming?.providers?.openai?.apiKey).toBe("sk-test");
|
||||
expectWarningIncludes('Run "openclaw doctor --fix"');
|
||||
});
|
||||
|
||||
it("freezes the invoking agent on tool-created calls", async () => {
|
||||
const { tools } = setup({ provider: "mock" }, { agentId: "support" });
|
||||
const tool = tools[0] as {
|
||||
|
||||
@@ -3,7 +3,10 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { ErrorCodes, errorShape } from "openclaw/plugin-sdk/gateway-runtime";
|
||||
import { timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
asOptionalRecord,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { jsonResult as json } from "openclaw/plugin-sdk/tool-results";
|
||||
import { Type } from "typebox";
|
||||
import {
|
||||
@@ -14,11 +17,7 @@ import {
|
||||
import { createVoiceCallRuntime, type VoiceCallRuntime } from "./runtime-entry.js";
|
||||
import { registerVoiceCallCli } from "./src/cli.js";
|
||||
import {
|
||||
formatVoiceCallLegacyConfigWarnings,
|
||||
normalizeVoiceCallLegacyConfigInput,
|
||||
parseVoiceCallPluginConfig,
|
||||
} from "./src/config-compat.js";
|
||||
import {
|
||||
VoiceCallConfigSchema,
|
||||
resolveVoiceCallConfig,
|
||||
validateProviderConfig,
|
||||
type VoiceCallConfig,
|
||||
@@ -32,12 +31,12 @@ const VOICE_CALL_READ_METHOD_SCOPE = { scope: "operator.read" as const };
|
||||
|
||||
const voiceCallConfigSchema = {
|
||||
parse(value: unknown): VoiceCallConfig {
|
||||
const normalized = normalizeVoiceCallLegacyConfigInput(value);
|
||||
const enabled = typeof normalized.enabled === "boolean" ? normalized.enabled : true;
|
||||
return parseVoiceCallPluginConfig({
|
||||
...normalized,
|
||||
const config = asOptionalRecord(value) ?? {};
|
||||
const enabled = typeof config.enabled === "boolean" ? config.enabled : true;
|
||||
return VoiceCallConfigSchema.parse({
|
||||
...config,
|
||||
enabled,
|
||||
provider: normalized.provider ?? (enabled ? "mock" : undefined),
|
||||
provider: config.provider ?? (enabled ? "mock" : undefined),
|
||||
});
|
||||
},
|
||||
uiHints: {
|
||||
@@ -290,16 +289,6 @@ export default definePluginEntry({
|
||||
const config = resolveVoiceCallConfig(voiceCallConfigSchema.parse(api.pluginConfig));
|
||||
const validation = validateProviderConfig(config);
|
||||
|
||||
if (api.pluginConfig && typeof api.pluginConfig === "object") {
|
||||
for (const warning of formatVoiceCallLegacyConfigWarnings({
|
||||
value: api.pluginConfig,
|
||||
configPathPrefix: "plugins.entries.voice-call.config",
|
||||
doctorFixCommand: "openclaw doctor --fix",
|
||||
})) {
|
||||
api.logger.warn(warning);
|
||||
}
|
||||
}
|
||||
|
||||
const runtimeState = getVoiceCallRuntimeGlobalState();
|
||||
const continueOperationStore = createVoiceCallContinueOperationStore({
|
||||
config,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { migrateVoiceCallLegacyConfigInput } from "./config-api.js";
|
||||
import { migrateVoiceCallLegacyConfigInput } from "./src/config-migration.js";
|
||||
|
||||
// Setup-time entrypoint for voice-call config migrations.
|
||||
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
// Voice Call tests cover config compat plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
VOICE_CALL_LEGACY_CONFIG_REMOVAL_VERSION,
|
||||
collectVoiceCallLegacyConfigIssues,
|
||||
formatVoiceCallLegacyConfigWarnings,
|
||||
migrateVoiceCallLegacyConfigInput,
|
||||
normalizeVoiceCallLegacyConfigInput,
|
||||
parseVoiceCallPluginConfig,
|
||||
} from "./config-compat.js";
|
||||
|
||||
describe("voice-call config compatibility", () => {
|
||||
it("maps deprecated provider and twilio.from fields into canonical config", () => {
|
||||
const parsed = parseVoiceCallPluginConfig({
|
||||
enabled: true,
|
||||
provider: "log",
|
||||
twilio: {
|
||||
from: "+15550001234",
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.provider).toBe("mock");
|
||||
expect(parsed.fromNumber).toBe("+15550001234");
|
||||
});
|
||||
|
||||
it("moves legacy streaming OpenAI fields into streaming.providers.openai", () => {
|
||||
const normalized = normalizeVoiceCallLegacyConfigInput({
|
||||
streaming: {
|
||||
enabled: true,
|
||||
sttProvider: "openai",
|
||||
openaiApiKey: "sk-test", // pragma: allowlist secret
|
||||
sttModel: "gpt-4o-transcribe",
|
||||
silenceDurationMs: 700,
|
||||
vadThreshold: 0.4,
|
||||
},
|
||||
});
|
||||
|
||||
const streaming = normalized.streaming as
|
||||
| {
|
||||
enabled?: boolean;
|
||||
provider?: string;
|
||||
providers?: {
|
||||
openai?: {
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
silenceDurationMs?: number;
|
||||
vadThreshold?: number;
|
||||
};
|
||||
};
|
||||
openaiApiKey?: unknown;
|
||||
sttModel?: unknown;
|
||||
}
|
||||
| undefined;
|
||||
expect(streaming?.enabled).toBe(true);
|
||||
expect(streaming?.provider).toBe("openai");
|
||||
expect(streaming?.providers?.openai).toEqual({
|
||||
apiKey: "sk-test",
|
||||
model: "gpt-4o-transcribe",
|
||||
silenceDurationMs: 700,
|
||||
vadThreshold: 0.4,
|
||||
});
|
||||
expect(streaming?.openaiApiKey).toBeUndefined();
|
||||
expect(streaming?.sttModel).toBeUndefined();
|
||||
});
|
||||
|
||||
it("removes legacy realtime agentContext system prompt toggle", () => {
|
||||
const normalized = normalizeVoiceCallLegacyConfigInput({
|
||||
realtime: {
|
||||
agentContext: {
|
||||
enabled: true,
|
||||
includeSystemPrompt: false,
|
||||
includeWorkspaceFiles: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const agentContext = (
|
||||
normalized.realtime as
|
||||
| {
|
||||
agentContext?: {
|
||||
enabled?: boolean;
|
||||
includeSystemPrompt?: unknown;
|
||||
includeWorkspaceFiles?: boolean;
|
||||
};
|
||||
}
|
||||
| undefined
|
||||
)?.agentContext;
|
||||
|
||||
expect(agentContext).toEqual({
|
||||
enabled: true,
|
||||
includeWorkspaceFiles: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not migrate non-finite legacy streaming numbers", () => {
|
||||
const migration = migrateVoiceCallLegacyConfigInput({
|
||||
value: {
|
||||
streaming: {
|
||||
silenceDurationMs: Number.NaN,
|
||||
vadThreshold: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
},
|
||||
configPathPrefix: "plugins.entries.voice-call.config",
|
||||
});
|
||||
const streaming = migration.config.streaming as
|
||||
| {
|
||||
providers?: {
|
||||
openai?: {
|
||||
silenceDurationMs?: number;
|
||||
vadThreshold?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
|
||||
expect(streaming?.providers?.openai).toBeUndefined();
|
||||
expect(migration.changes).toEqual([
|
||||
"Removed invalid plugins.entries.voice-call.config.streaming.silenceDurationMs.",
|
||||
"Removed invalid plugins.entries.voice-call.config.streaming.vadThreshold.",
|
||||
]);
|
||||
expect(migration.issues.map((issue) => issue.path)).toEqual([
|
||||
"streaming.silenceDurationMs",
|
||||
"streaming.vadThreshold",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports doctor-oriented legacy issues and warnings", () => {
|
||||
const raw = {
|
||||
provider: "log",
|
||||
twilio: {
|
||||
from: "+15550001234",
|
||||
},
|
||||
streaming: {
|
||||
sttProvider: "openai",
|
||||
openaiApiKey: "sk-test", // pragma: allowlist secret
|
||||
},
|
||||
realtime: {
|
||||
agentContext: {
|
||||
includeSystemPrompt: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(collectVoiceCallLegacyConfigIssues(raw)).toEqual([
|
||||
{
|
||||
path: "provider",
|
||||
replacement: "provider",
|
||||
message: 'Replace provider "log" with "mock".',
|
||||
},
|
||||
{
|
||||
path: "twilio.from",
|
||||
replacement: "fromNumber",
|
||||
message: "Move twilio.from to fromNumber.",
|
||||
},
|
||||
{
|
||||
path: "streaming.sttProvider",
|
||||
replacement: "streaming.provider",
|
||||
message: "Move streaming.sttProvider to streaming.provider.",
|
||||
},
|
||||
{
|
||||
path: "streaming.openaiApiKey",
|
||||
replacement: "streaming.providers.openai.apiKey",
|
||||
message: "Move streaming.openaiApiKey to streaming.providers.openai.apiKey.",
|
||||
},
|
||||
{
|
||||
path: "realtime.agentContext.includeSystemPrompt",
|
||||
replacement: "realtime.agentContext",
|
||||
message:
|
||||
"Remove realtime.agentContext.includeSystemPrompt; realtime context now uses the generated agent prompt.",
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
formatVoiceCallLegacyConfigWarnings({
|
||||
value: raw,
|
||||
configPathPrefix: "plugins.entries.voice-call.config",
|
||||
doctorFixCommand: "openclaw doctor --fix",
|
||||
}),
|
||||
).toEqual([
|
||||
`[voice-call] legacy config keys detected under plugins.entries.voice-call.config; runtime loading will not rewrite them, and support for the legacy shape will be removed in ${VOICE_CALL_LEGACY_CONFIG_REMOVAL_VERSION}. Run "openclaw doctor --fix".`,
|
||||
'[voice-call] plugins.entries.voice-call.config.provider: Replace provider "log" with "mock".',
|
||||
"[voice-call] plugins.entries.voice-call.config.twilio.from: Move twilio.from to fromNumber.",
|
||||
"[voice-call] plugins.entries.voice-call.config.streaming.sttProvider: Move streaming.sttProvider to streaming.provider.",
|
||||
"[voice-call] plugins.entries.voice-call.config.streaming.openaiApiKey: Move streaming.openaiApiKey to streaming.providers.openai.apiKey.",
|
||||
"[voice-call] plugins.entries.voice-call.config.realtime.agentContext.includeSystemPrompt: Remove realtime.agentContext.includeSystemPrompt; realtime context now uses the generated agent prompt.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns doctor migration change lines", () => {
|
||||
const migration = migrateVoiceCallLegacyConfigInput({
|
||||
value: {
|
||||
provider: "log",
|
||||
streaming: {
|
||||
sttProvider: "openai",
|
||||
},
|
||||
realtime: {
|
||||
agentContext: {
|
||||
includeSystemPrompt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
configPathPrefix: "plugins.entries.voice-call.config",
|
||||
});
|
||||
|
||||
expect(migration.changes).toEqual([
|
||||
'Moved plugins.entries.voice-call.config.provider "log" → "mock".',
|
||||
"Moved plugins.entries.voice-call.config.streaming.sttProvider → plugins.entries.voice-call.config.streaming.provider.",
|
||||
"Removed plugins.entries.voice-call.config.realtime.agentContext.includeSystemPrompt.",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
// Voice Call tests cover setup-time config migration behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { migrateVoiceCallLegacyConfigInput } from "./config-migration.js";
|
||||
|
||||
describe("voice-call config migration", () => {
|
||||
it("maps deprecated provider and twilio.from fields into canonical config", () => {
|
||||
const migration = migrateVoiceCallLegacyConfigInput({
|
||||
value: {
|
||||
enabled: true,
|
||||
provider: "log",
|
||||
twilio: {
|
||||
from: "+15550001234",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(migration.config.provider).toBe("mock");
|
||||
expect(migration.config.fromNumber).toBe("+15550001234");
|
||||
});
|
||||
|
||||
it("moves legacy streaming OpenAI fields into streaming.providers.openai", () => {
|
||||
const migration = migrateVoiceCallLegacyConfigInput({
|
||||
value: {
|
||||
streaming: {
|
||||
enabled: true,
|
||||
sttProvider: "openai",
|
||||
openaiApiKey: "test",
|
||||
sttModel: "gpt-4o-transcribe",
|
||||
silenceDurationMs: 700,
|
||||
vadThreshold: 0.4,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const streaming = migration.config.streaming as
|
||||
| {
|
||||
enabled?: boolean;
|
||||
provider?: string;
|
||||
providers?: {
|
||||
openai?: {
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
silenceDurationMs?: number;
|
||||
vadThreshold?: number;
|
||||
};
|
||||
};
|
||||
openaiApiKey?: unknown;
|
||||
sttModel?: unknown;
|
||||
}
|
||||
| undefined;
|
||||
expect(streaming?.enabled).toBe(true);
|
||||
expect(streaming?.provider).toBe("openai");
|
||||
expect(streaming?.providers?.openai).toEqual({
|
||||
apiKey: "test",
|
||||
model: "gpt-4o-transcribe",
|
||||
silenceDurationMs: 700,
|
||||
vadThreshold: 0.4,
|
||||
});
|
||||
expect(streaming?.openaiApiKey).toBeUndefined();
|
||||
expect(streaming?.sttModel).toBeUndefined();
|
||||
});
|
||||
|
||||
it("removes legacy realtime agentContext system prompt toggle", () => {
|
||||
const migration = migrateVoiceCallLegacyConfigInput({
|
||||
value: {
|
||||
realtime: {
|
||||
agentContext: {
|
||||
enabled: true,
|
||||
includeSystemPrompt: false,
|
||||
includeWorkspaceFiles: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const agentContext = (
|
||||
migration.config.realtime as
|
||||
| {
|
||||
agentContext?: {
|
||||
enabled?: boolean;
|
||||
includeSystemPrompt?: unknown;
|
||||
includeWorkspaceFiles?: boolean;
|
||||
};
|
||||
}
|
||||
| undefined
|
||||
)?.agentContext;
|
||||
|
||||
expect(agentContext).toEqual({
|
||||
enabled: true,
|
||||
includeWorkspaceFiles: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not migrate non-finite legacy streaming numbers", () => {
|
||||
const migration = migrateVoiceCallLegacyConfigInput({
|
||||
value: {
|
||||
streaming: {
|
||||
silenceDurationMs: Number.NaN,
|
||||
vadThreshold: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
},
|
||||
configPathPrefix: "plugins.entries.voice-call.config",
|
||||
});
|
||||
const streaming = migration.config.streaming as
|
||||
| {
|
||||
providers?: {
|
||||
openai?: {
|
||||
silenceDurationMs?: number;
|
||||
vadThreshold?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
|
||||
expect(streaming?.providers?.openai).toBeUndefined();
|
||||
expect(migration.changes).toEqual([
|
||||
"Removed invalid plugins.entries.voice-call.config.streaming.silenceDurationMs.",
|
||||
"Removed invalid plugins.entries.voice-call.config.streaming.vadThreshold.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns doctor migration change lines", () => {
|
||||
const migration = migrateVoiceCallLegacyConfigInput({
|
||||
value: {
|
||||
provider: "log",
|
||||
streaming: {
|
||||
sttProvider: "openai",
|
||||
},
|
||||
realtime: {
|
||||
agentContext: {
|
||||
includeSystemPrompt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
configPathPrefix: "plugins.entries.voice-call.config",
|
||||
});
|
||||
|
||||
expect(migration.changes).toEqual([
|
||||
'Moved plugins.entries.voice-call.config.provider "log" → "mock".',
|
||||
"Moved plugins.entries.voice-call.config.streaming.sttProvider → plugins.entries.voice-call.config.streaming.provider.",
|
||||
"Removed plugins.entries.voice-call.config.realtime.agentContext.includeSystemPrompt.",
|
||||
]);
|
||||
});
|
||||
});
|
||||
+2
-117
@@ -1,19 +1,5 @@
|
||||
// Voice Call helper module supports config compat behavior.
|
||||
// Voice Call setup helper migrates legacy config to the canonical schema.
|
||||
import { asOptionalRecord, readStringField } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { VoiceCallConfig } from "./config.js";
|
||||
import { VoiceCallConfigSchema } from "./config.js";
|
||||
|
||||
// Legacy voice-call config warnings and doctor-fix migration helpers.
|
||||
|
||||
/** Version where legacy voice-call config shape support is removed. */
|
||||
export const VOICE_CALL_LEGACY_CONFIG_REMOVAL_VERSION = "2026.6.0";
|
||||
|
||||
/** One legacy config issue with the replacement path and message. */
|
||||
type VoiceCallLegacyConfigIssue = {
|
||||
path: string;
|
||||
replacement: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
const asObject = asOptionalRecord;
|
||||
const getString = readStringField;
|
||||
@@ -45,95 +31,6 @@ function mergeProviderConfig(
|
||||
};
|
||||
}
|
||||
|
||||
/** Collect legacy voice-call config keys that should be migrated. */
|
||||
export function collectVoiceCallLegacyConfigIssues(value: unknown): VoiceCallLegacyConfigIssue[] {
|
||||
const raw = asObject(value) ?? {};
|
||||
const realtime = asObject(raw.realtime);
|
||||
const realtimeAgentContext = asObject(realtime?.agentContext);
|
||||
const twilio = asObject(raw.twilio);
|
||||
const streaming = asObject(raw.streaming);
|
||||
|
||||
const issues: VoiceCallLegacyConfigIssue[] = [];
|
||||
if (raw.provider === "log") {
|
||||
issues.push({
|
||||
path: "provider",
|
||||
replacement: "provider",
|
||||
message: 'Replace provider "log" with "mock".',
|
||||
});
|
||||
}
|
||||
if (typeof twilio?.from === "string") {
|
||||
issues.push({
|
||||
path: "twilio.from",
|
||||
replacement: "fromNumber",
|
||||
message: "Move twilio.from to fromNumber.",
|
||||
});
|
||||
}
|
||||
if (typeof streaming?.sttProvider === "string") {
|
||||
issues.push({
|
||||
path: "streaming.sttProvider",
|
||||
replacement: "streaming.provider",
|
||||
message: "Move streaming.sttProvider to streaming.provider.",
|
||||
});
|
||||
}
|
||||
if (typeof streaming?.openaiApiKey === "string") {
|
||||
issues.push({
|
||||
path: "streaming.openaiApiKey",
|
||||
replacement: "streaming.providers.openai.apiKey",
|
||||
message: "Move streaming.openaiApiKey to streaming.providers.openai.apiKey.",
|
||||
});
|
||||
}
|
||||
if (typeof streaming?.sttModel === "string") {
|
||||
issues.push({
|
||||
path: "streaming.sttModel",
|
||||
replacement: "streaming.providers.openai.model",
|
||||
message: "Move streaming.sttModel to streaming.providers.openai.model.",
|
||||
});
|
||||
}
|
||||
if (typeof streaming?.silenceDurationMs === "number") {
|
||||
issues.push({
|
||||
path: "streaming.silenceDurationMs",
|
||||
replacement: "streaming.providers.openai.silenceDurationMs",
|
||||
message: "Move streaming.silenceDurationMs to streaming.providers.openai.silenceDurationMs.",
|
||||
});
|
||||
}
|
||||
if (typeof streaming?.vadThreshold === "number") {
|
||||
issues.push({
|
||||
path: "streaming.vadThreshold",
|
||||
replacement: "streaming.providers.openai.vadThreshold",
|
||||
message: "Move streaming.vadThreshold to streaming.providers.openai.vadThreshold.",
|
||||
});
|
||||
}
|
||||
if (realtimeAgentContext && Object.hasOwn(realtimeAgentContext, "includeSystemPrompt")) {
|
||||
issues.push({
|
||||
path: "realtime.agentContext.includeSystemPrompt",
|
||||
replacement: "realtime.agentContext",
|
||||
message:
|
||||
"Remove realtime.agentContext.includeSystemPrompt; realtime context now uses the generated agent prompt.",
|
||||
});
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
/** Format runtime warnings for legacy voice-call config keys. */
|
||||
export function formatVoiceCallLegacyConfigWarnings(params: {
|
||||
value: unknown;
|
||||
configPathPrefix: string;
|
||||
doctorFixCommand: string;
|
||||
}): string[] {
|
||||
const issues = collectVoiceCallLegacyConfigIssues(params.value);
|
||||
if (issues.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
`[voice-call] legacy config keys detected under ${params.configPathPrefix}; runtime loading will not rewrite them, and support for the legacy shape will be removed in ${VOICE_CALL_LEGACY_CONFIG_REMOVAL_VERSION}. Run "${params.doctorFixCommand}".`,
|
||||
...issues.map(
|
||||
(issue) => `[voice-call] ${params.configPathPrefix}.${issue.path}: ${issue.message}`,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/** Migrate legacy voice-call config input to the current canonical shape. */
|
||||
export function migrateVoiceCallLegacyConfigInput(params: {
|
||||
value: unknown;
|
||||
@@ -141,7 +38,6 @@ export function migrateVoiceCallLegacyConfigInput(params: {
|
||||
}): {
|
||||
config: Record<string, unknown>;
|
||||
changes: string[];
|
||||
issues: VoiceCallLegacyConfigIssue[];
|
||||
} {
|
||||
const raw = asObject(params.value) ?? {};
|
||||
const realtime = asObject(raw.realtime);
|
||||
@@ -149,7 +45,6 @@ export function migrateVoiceCallLegacyConfigInput(params: {
|
||||
const twilio = asObject(raw.twilio);
|
||||
const streaming = asObject(raw.streaming);
|
||||
const configPathPrefix = params.configPathPrefix ?? "plugins.entries.voice-call.config";
|
||||
const issues = collectVoiceCallLegacyConfigIssues(raw);
|
||||
|
||||
const legacyStreamingOpenAICompat: Record<string, unknown> = {};
|
||||
const streamingOpenAIApiKey = getString(streaming, "openaiApiKey");
|
||||
@@ -261,15 +156,5 @@ export function migrateVoiceCallLegacyConfigInput(params: {
|
||||
changes.push(`Removed ${configPathPrefix}.realtime.agentContext.includeSystemPrompt.`);
|
||||
}
|
||||
|
||||
return { config, changes, issues };
|
||||
}
|
||||
|
||||
/** Normalize legacy voice-call config input without returning migration metadata. */
|
||||
export function normalizeVoiceCallLegacyConfigInput(value: unknown): Record<string, unknown> {
|
||||
return migrateVoiceCallLegacyConfigInput({ value }).config;
|
||||
}
|
||||
|
||||
/** Parse voice-call plugin config after applying legacy normalization. */
|
||||
export function parseVoiceCallPluginConfig(value: unknown): VoiceCallConfig {
|
||||
return VoiceCallConfigSchema.parse(normalizeVoiceCallLegacyConfigInput(value));
|
||||
return { config, changes };
|
||||
}
|
||||
Reference in New Issue
Block a user