fix(cron): isolate fresh cron session state

* fix(cron): isolate fresh cron session state

* fix(cron): deep-copy isolated session state

* fix(cron): reset isolated session context

* test(providers): avoid shared mock races

* test(providers): type injected stream fakes

* ci: refresh package boundary on reply runtime changes

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Vincent Koc
2026-04-24 22:15:19 -07:00
committed by GitHub
parent 7a9584f0f9
commit f0ceb4b68f
13 changed files with 595 additions and 154 deletions

View File

@@ -1,31 +1,30 @@
import type { Model } from "@mariozechner/pi-ai";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createAssistantMessageEventStream, type Model } from "@mariozechner/pi-ai";
import { beforeAll, describe, expect, it, vi } from "vitest";
import type { AnthropicVertexStreamDeps } from "./stream-runtime.js";
const hoisted = vi.hoisted(() => {
const streamAnthropicMock = vi.fn(() => Symbol("anthropic-vertex-stream"));
function createStreamDeps(): {
deps: AnthropicVertexStreamDeps;
streamAnthropicMock: ReturnType<typeof vi.fn>;
anthropicVertexCtorMock: ReturnType<typeof vi.fn>;
} {
const streamAnthropicMock = vi.fn(
(..._args: Parameters<AnthropicVertexStreamDeps["streamAnthropic"]>) =>
createAssistantMessageEventStream(),
);
const anthropicVertexCtorMock = vi.fn();
const MockAnthropicVertex = function MockAnthropicVertex(options: unknown) {
anthropicVertexCtorMock(options);
} as unknown as AnthropicVertexStreamDeps["AnthropicVertex"];
return {
deps: {
AnthropicVertex: MockAnthropicVertex,
streamAnthropic: streamAnthropicMock,
},
streamAnthropicMock,
anthropicVertexCtorMock,
};
});
vi.mock("@mariozechner/pi-ai", async () => {
const original =
await vi.importActual<typeof import("@mariozechner/pi-ai")>("@mariozechner/pi-ai");
return {
...original,
streamAnthropic: hoisted.streamAnthropicMock,
};
});
vi.mock("@anthropic-ai/vertex-sdk", () => ({
AnthropicVertex: vi.fn(function MockAnthropicVertex(options: unknown) {
hoisted.anthropicVertexCtorMock(options);
return { options };
}),
}));
}
let createAnthropicVertexStreamFn: typeof import("./api.js").createAnthropicVertexStreamFn;
let createAnthropicVertexStreamFnForModel: typeof import("./api.js").createAnthropicVertexStreamFnForModel;
@@ -45,33 +44,34 @@ describe("Anthropic Vertex API stream factories", () => {
await import("./api.js"));
});
beforeEach(() => {
hoisted.streamAnthropicMock.mockClear();
hoisted.anthropicVertexCtorMock.mockClear();
});
it("reuses the runtime stream factory across direct stream calls", async () => {
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5");
const { deps, streamAnthropicMock, anthropicVertexCtorMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
const model = makeModel();
await streamFn(model, { messages: [] }, {});
await streamFn(model, { messages: [] }, {});
expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledTimes(1);
expect(hoisted.streamAnthropicMock).toHaveBeenCalledTimes(2);
expect(anthropicVertexCtorMock).toHaveBeenCalledTimes(1);
expect(streamAnthropicMock).toHaveBeenCalledTimes(2);
});
it("reuses the runtime stream factory across model-derived stream calls", async () => {
const streamFn = createAnthropicVertexStreamFnForModel(makeModel(), {
ANTHROPIC_VERTEX_PROJECT_ID: "vertex-project",
GOOGLE_CLOUD_LOCATION: "us-east5",
} as NodeJS.ProcessEnv);
const { deps, streamAnthropicMock, anthropicVertexCtorMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFnForModel(
makeModel(),
{
ANTHROPIC_VERTEX_PROJECT_ID: "vertex-project",
GOOGLE_CLOUD_LOCATION: "us-east5",
} as NodeJS.ProcessEnv,
deps,
);
const model = makeModel();
await streamFn(model, { messages: [] }, {});
await streamFn(model, { messages: [] }, {});
expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledTimes(1);
expect(hoisted.streamAnthropicMock).toHaveBeenCalledTimes(2);
expect(anthropicVertexCtorMock).toHaveBeenCalledTimes(1);
expect(streamAnthropicMock).toHaveBeenCalledTimes(2);
});
});

View File

@@ -1,4 +1,5 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import type { AnthropicVertexStreamDeps } from "./stream-runtime.js";
export {
ANTHROPIC_VERTEX_DEFAULT_MODEL_ID,
@@ -47,9 +48,10 @@ export function createAnthropicVertexStreamFn(
projectId: string | undefined,
region: string,
baseURL?: string,
deps?: AnthropicVertexStreamDeps,
): StreamFn {
const streamFnPromise = import("./stream-runtime.js").then((runtime) =>
runtime.createAnthropicVertexStreamFn(projectId, region, baseURL),
runtime.createAnthropicVertexStreamFn(projectId, region, baseURL, deps),
);
return async (model, context, options) => {
const streamFn = await streamFnPromise;
@@ -60,9 +62,10 @@ export function createAnthropicVertexStreamFn(
export function createAnthropicVertexStreamFnForModel(
model: { baseUrl?: string },
env: NodeJS.ProcessEnv = process.env,
deps?: AnthropicVertexStreamDeps,
): StreamFn {
const streamFnPromise = import("./stream-runtime.js").then((runtime) =>
runtime.createAnthropicVertexStreamFnForModel(model, env),
runtime.createAnthropicVertexStreamFnForModel(model, env, deps),
);
return async (...args) => {
const streamFn = await streamFnPromise;

View File

@@ -1,36 +1,32 @@
import type { Model } from "@mariozechner/pi-ai";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createAssistantMessageEventStream, type Model } from "@mariozechner/pi-ai";
import { beforeAll, describe, expect, it, vi } from "vitest";
import type { AnthropicVertexStreamDeps } from "./stream-runtime.js";
const SYSTEM_PROMPT_CACHE_BOUNDARY = "\n<!-- OPENCLAW_CACHE_BOUNDARY -->\n";
const hoisted = vi.hoisted(() => {
const streamAnthropicMock = vi.fn<(model: unknown, context: unknown, options: unknown) => symbol>(
() => Symbol("anthropic-vertex-stream"),
function createStreamDeps(): {
deps: AnthropicVertexStreamDeps;
streamAnthropicMock: ReturnType<typeof vi.fn>;
anthropicVertexCtorMock: ReturnType<typeof vi.fn>;
} {
const streamAnthropicMock = vi.fn(
(..._args: Parameters<AnthropicVertexStreamDeps["streamAnthropic"]>) =>
createAssistantMessageEventStream(),
);
const anthropicVertexCtorMock = vi.fn();
const MockAnthropicVertex = function MockAnthropicVertex(options: unknown) {
anthropicVertexCtorMock(options);
} as unknown as AnthropicVertexStreamDeps["AnthropicVertex"];
return {
deps: {
AnthropicVertex: MockAnthropicVertex,
streamAnthropic: streamAnthropicMock,
},
streamAnthropicMock,
anthropicVertexCtorMock,
};
});
vi.mock("@mariozechner/pi-ai", async () => {
const original =
await vi.importActual<typeof import("@mariozechner/pi-ai")>("@mariozechner/pi-ai");
return {
...original,
streamAnthropic: (model: unknown, context: unknown, options: unknown) =>
hoisted.streamAnthropicMock(model, context, options),
};
});
vi.mock("@anthropic-ai/vertex-sdk", () => ({
AnthropicVertex: vi.fn(function MockAnthropicVertex(options: unknown) {
hoisted.anthropicVertexCtorMock(options);
return { options };
}),
}));
}
let createAnthropicVertexStreamFn: typeof import("./stream-runtime.js").createAnthropicVertexStreamFn;
let createAnthropicVertexStreamFnForModel: typeof import("./stream-runtime.js").createAnthropicVertexStreamFnForModel;
@@ -48,8 +44,12 @@ const CACHE_BOUNDARY_PROMPT = `Stable prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynam
type PayloadHook = (payload: unknown, payloadModel: unknown) => Promise<unknown>;
function captureCacheBoundaryPayloadHook(onPayload: PayloadHook) {
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5");
function captureCacheBoundaryPayloadHook(
onPayload: PayloadHook,
deps: AnthropicVertexStreamDeps,
streamAnthropicMock: ReturnType<typeof vi.fn>,
) {
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
const model = makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 });
void streamFn(
@@ -64,7 +64,7 @@ function captureCacheBoundaryPayloadHook(onPayload: PayloadHook) {
} as never,
);
const transportOptions = hoisted.streamAnthropicMock.mock.calls[0]?.[2] as {
const transportOptions = streamAnthropicMock.mock.calls[0]?.[2] as {
onPayload?: PayloadHook;
};
@@ -105,31 +105,29 @@ describe("createAnthropicVertexStreamFn", () => {
await import("./stream-runtime.js"));
});
beforeEach(() => {
hoisted.streamAnthropicMock.mockClear();
hoisted.anthropicVertexCtorMock.mockClear();
});
it("omits projectId when ADC credentials are used without an explicit project", () => {
const streamFn = createAnthropicVertexStreamFn(undefined, "global");
const { deps, anthropicVertexCtorMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFn(undefined, "global", undefined, deps);
void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 128000 }), { messages: [] }, {});
expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledWith({
expect(anthropicVertexCtorMock).toHaveBeenCalledWith({
region: "global",
});
});
it("passes an explicit baseURL through to the Vertex client", () => {
const { deps, anthropicVertexCtorMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFn(
"vertex-project",
"us-east5",
"https://proxy.example.test/vertex/v1",
deps,
);
void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 128000 }), { messages: [] }, {});
expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledWith({
expect(anthropicVertexCtorMock).toHaveBeenCalledWith({
projectId: "vertex-project",
region: "us-east5",
baseURL: "https://proxy.example.test/vertex/v1",
@@ -137,12 +135,13 @@ describe("createAnthropicVertexStreamFn", () => {
});
it("defaults maxTokens to the model limit instead of the old 32000 cap", () => {
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5");
const { deps, streamAnthropicMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
const model = makeModel({ id: "claude-opus-4-6", maxTokens: 128000 });
void streamFn(model, { messages: [] }, {});
expect(hoisted.streamAnthropicMock).toHaveBeenCalledWith(
expect(streamAnthropicMock).toHaveBeenCalledWith(
model,
{ messages: [] },
expect.objectContaining({
@@ -152,12 +151,13 @@ describe("createAnthropicVertexStreamFn", () => {
});
it("clamps explicit maxTokens to the selected model limit", () => {
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5");
const { deps, streamAnthropicMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
const model = makeModel({ id: "claude-sonnet-4-6", maxTokens: 128000 });
void streamFn(model, { messages: [] }, { maxTokens: 999999 });
expect(hoisted.streamAnthropicMock).toHaveBeenCalledWith(
expect(streamAnthropicMock).toHaveBeenCalledWith(
model,
{ messages: [] },
expect.objectContaining({
@@ -167,12 +167,13 @@ describe("createAnthropicVertexStreamFn", () => {
});
it("maps xhigh reasoning to max effort for adaptive Opus models", () => {
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5");
const { deps, streamAnthropicMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
const model = makeModel({ id: "claude-opus-4-6", maxTokens: 64000 });
void streamFn(model, { messages: [] }, { reasoning: "xhigh" });
expect(hoisted.streamAnthropicMock).toHaveBeenCalledWith(
expect(streamAnthropicMock).toHaveBeenCalledWith(
model,
{ messages: [] },
expect.objectContaining({
@@ -183,12 +184,13 @@ describe("createAnthropicVertexStreamFn", () => {
});
it("maps xhigh reasoning to xhigh effort for Opus 4.7", () => {
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5");
const { deps, streamAnthropicMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
const model = makeModel({ id: "claude-opus-4-7", maxTokens: 64000 });
void streamFn(model, { messages: [] }, { reasoning: "xhigh" });
expect(hoisted.streamAnthropicMock).toHaveBeenCalledWith(
expect(streamAnthropicMock).toHaveBeenCalledWith(
model,
{ messages: [] },
expect.objectContaining({
@@ -199,8 +201,13 @@ describe("createAnthropicVertexStreamFn", () => {
});
it("applies Anthropic cache-boundary shaping before forwarding payload hooks", async () => {
const { deps, streamAnthropicMock } = createStreamDeps();
const onPayload = vi.fn(async (payload: unknown) => payload);
const { model, onPayload: transportPayloadHook } = captureCacheBoundaryPayloadHook(onPayload);
const { model, onPayload: transportPayloadHook } = captureCacheBoundaryPayloadHook(
onPayload,
deps,
streamAnthropicMock,
);
const payload = {
system: [
{
@@ -220,6 +227,7 @@ describe("createAnthropicVertexStreamFn", () => {
});
it("reapplies Anthropic cache-boundary shaping when payload hooks return a fresh payload", async () => {
const { deps, streamAnthropicMock } = createStreamDeps();
const onPayload = vi.fn(async () => ({
system: [
{
@@ -229,7 +237,11 @@ describe("createAnthropicVertexStreamFn", () => {
],
messages: [{ role: "user", content: "Hello again" }],
}));
const { model, onPayload: transportPayloadHook } = captureCacheBoundaryPayloadHook(onPayload);
const { model, onPayload: transportPayloadHook } = captureCacheBoundaryPayloadHook(
onPayload,
deps,
streamAnthropicMock,
);
const nextPayload = await transportPayloadHook?.(
{
@@ -248,12 +260,13 @@ describe("createAnthropicVertexStreamFn", () => {
});
it("omits maxTokens when neither the model nor request provide a finite limit", () => {
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5");
const { deps, streamAnthropicMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
const model = makeModel({ id: "claude-sonnet-4-6" });
void streamFn(model, { messages: [] }, { maxTokens: Number.NaN });
expect(hoisted.streamAnthropicMock).toHaveBeenCalledWith(
expect(streamAnthropicMock).toHaveBeenCalledWith(
model,
{ messages: [] },
expect.not.objectContaining({
@@ -264,19 +277,17 @@ describe("createAnthropicVertexStreamFn", () => {
});
describe("createAnthropicVertexStreamFnForModel", () => {
beforeEach(() => {
hoisted.anthropicVertexCtorMock.mockClear();
});
it("derives project and region from the model and env", () => {
const { deps, anthropicVertexCtorMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFnForModel(
{ baseUrl: "https://europe-west4-aiplatform.googleapis.com" },
{ GOOGLE_CLOUD_PROJECT_ID: "vertex-project" } as NodeJS.ProcessEnv,
deps,
);
void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 }), { messages: [] }, {});
expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledWith({
expect(anthropicVertexCtorMock).toHaveBeenCalledWith({
projectId: "vertex-project",
region: "europe-west4",
baseURL: "https://europe-west4-aiplatform.googleapis.com/v1",
@@ -284,14 +295,16 @@ describe("createAnthropicVertexStreamFnForModel", () => {
});
it("preserves explicit custom provider base URLs", () => {
const { deps, anthropicVertexCtorMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFnForModel(
{ baseUrl: "https://proxy.example.test/custom-root/v1" },
{ GOOGLE_CLOUD_PROJECT_ID: "vertex-project" } as NodeJS.ProcessEnv,
deps,
);
void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 }), { messages: [] }, {});
expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledWith({
expect(anthropicVertexCtorMock).toHaveBeenCalledWith({
projectId: "vertex-project",
region: "global",
baseURL: "https://proxy.example.test/custom-root/v1",
@@ -299,14 +312,16 @@ describe("createAnthropicVertexStreamFnForModel", () => {
});
it("adds /v1 for path-prefixed custom provider base URLs", () => {
const { deps, anthropicVertexCtorMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFnForModel(
{ baseUrl: "https://proxy.example.test/custom-root" },
{ GOOGLE_CLOUD_PROJECT_ID: "vertex-project" } as NodeJS.ProcessEnv,
deps,
);
void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 }), { messages: [] }, {});
expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledWith({
expect(anthropicVertexCtorMock).toHaveBeenCalledWith({
projectId: "vertex-project",
region: "global",
baseURL: "https://proxy.example.test/custom-root/v1",

View File

@@ -1,6 +1,10 @@
import { AnthropicVertex } from "@anthropic-ai/vertex-sdk";
import { AnthropicVertex as AnthropicVertexSdk } from "@anthropic-ai/vertex-sdk";
import type { StreamFn } from "@mariozechner/pi-agent-core";
import { streamAnthropic, type AnthropicOptions, type Model } from "@mariozechner/pi-ai";
import {
streamAnthropic as streamAnthropicDefault,
type AnthropicOptions,
type Model,
} from "@mariozechner/pi-ai";
import {
applyAnthropicPayloadPolicyToParams,
resolveAnthropicPayloadPolicy,
@@ -9,6 +13,17 @@ import { resolveAnthropicVertexClientRegion, resolveAnthropicVertexProjectId } f
type AnthropicVertexEffort = NonNullable<AnthropicOptions["effort"]>;
type AnthropicVertexAdaptiveEffort = AnthropicVertexEffort | "xhigh";
type AnthropicVertexClientOptions = ConstructorParameters<typeof AnthropicVertexSdk>[0];
export type AnthropicVertexStreamDeps = {
AnthropicVertex: new (options: AnthropicVertexClientOptions) => unknown;
streamAnthropic: typeof streamAnthropicDefault;
};
const defaultAnthropicVertexStreamDeps: AnthropicVertexStreamDeps = {
AnthropicVertex: AnthropicVertexSdk as AnthropicVertexStreamDeps["AnthropicVertex"],
streamAnthropic: streamAnthropicDefault,
};
function isClaudeOpus47Model(modelId: string): boolean {
return modelId.includes("opus-4-7") || modelId.includes("opus-4.7");
@@ -104,8 +119,9 @@ export function createAnthropicVertexStreamFn(
projectId: string | undefined,
region: string,
baseURL?: string,
deps: AnthropicVertexStreamDeps = defaultAnthropicVertexStreamDeps,
): StreamFn {
const client = new AnthropicVertex({
const client = new deps.AnthropicVertex({
region,
...(baseURL ? { baseURL } : {}),
...(projectId ? { projectId } : {}),
@@ -122,7 +138,7 @@ export function createAnthropicVertexStreamFn(
requestedMaxTokens: options?.maxTokens,
});
const opts: AnthropicOptions = {
client: client as unknown as AnthropicOptions["client"],
client: client as AnthropicOptions["client"],
temperature: options?.temperature,
...(maxTokens !== undefined ? { maxTokens } : {}),
signal: options?.signal,
@@ -157,7 +173,7 @@ export function createAnthropicVertexStreamFn(
opts.thinkingEnabled = false;
}
return streamAnthropic(transportModel, context, opts);
return deps.streamAnthropic(transportModel, context, opts);
};
}
@@ -187,6 +203,7 @@ function resolveAnthropicVertexSdkBaseUrl(baseUrl?: string): string | undefined
export function createAnthropicVertexStreamFnForModel(
model: { baseUrl?: string },
env: NodeJS.ProcessEnv = process.env,
deps?: AnthropicVertexStreamDeps,
): StreamFn {
return createAnthropicVertexStreamFn(
resolveAnthropicVertexProjectId(env),
@@ -195,5 +212,6 @@ export function createAnthropicVertexStreamFnForModel(
env,
}),
resolveAnthropicVertexSdkBaseUrl(model.baseUrl),
deps,
);
}

View File

@@ -1,19 +1,19 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
let edgeTTS: typeof import("./tts.js").edgeTTS;
let mockTtsPromise = vi.fn<(text: string, filePath: string) => Promise<void>>();
vi.mock("node-edge-tts", () => ({
EdgeTTS: class {
ttsPromise(text: string, filePath: string) {
return mockTtsPromise(text, filePath);
}
},
}));
function createEdgeTTSDeps(ttsPromise: (text: string, filePath: string) => Promise<void>) {
return {
EdgeTTS: class {
ttsPromise(text: string, filePath: string) {
return ttsPromise(text, filePath);
}
},
};
}
const baseEdgeConfig = {
voice: "en-US-MichelleNeural",
@@ -40,17 +40,20 @@ describe("edgeTTS empty audio validation", () => {
tempDir = mkdtempSync(path.join(tmpdir(), "tts-test-"));
const outputPath = path.join(tempDir, "voice.mp3");
mockTtsPromise = vi.fn(async (_text: string, filePath: string) => {
const deps = createEdgeTTSDeps(async (_text: string, filePath: string) => {
writeFileSync(filePath, "");
});
await expect(
edgeTTS({
text: "Hello",
outputPath,
config: baseEdgeConfig,
timeoutMs: 10000,
}),
edgeTTS(
{
text: "Hello",
outputPath,
config: baseEdgeConfig,
timeoutMs: 10000,
},
deps,
),
).rejects.toThrow("Edge TTS produced empty audio file");
});
@@ -58,17 +61,20 @@ describe("edgeTTS empty audio validation", () => {
tempDir = mkdtempSync(path.join(tmpdir(), "tts-test-"));
const outputPath = path.join(tempDir, "voice.mp3");
mockTtsPromise = vi.fn(async (_text: string, filePath: string) => {
const deps = createEdgeTTSDeps(async (_text: string, filePath: string) => {
writeFileSync(filePath, Buffer.from([0xff, 0xfb, 0x90, 0x00]));
});
await expect(
edgeTTS({
text: "Hello",
outputPath,
config: baseEdgeConfig,
timeoutMs: 10000,
}),
edgeTTS(
{
text: "Hello",
outputPath,
config: baseEdgeConfig,
timeoutMs: 10000,
},
deps,
),
).resolves.toBeUndefined();
});
});

View File

@@ -2,6 +2,16 @@ import { statSync } from "node:fs";
import { EdgeTTS } from "node-edge-tts";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
type EdgeTTSDeps = {
EdgeTTS: new (config: ConstructorParameters<typeof EdgeTTS>[0]) => {
ttsPromise: (text: string, outputPath: string) => Promise<unknown>;
};
};
const defaultEdgeTTSDeps: EdgeTTSDeps = {
EdgeTTS,
};
export function inferEdgeExtension(outputFormat: string): string {
const normalized = normalizeLowercaseStringOrEmpty(outputFormat);
if (normalized.includes("webm")) {
@@ -19,24 +29,27 @@ export function inferEdgeExtension(outputFormat: string): string {
return ".mp3";
}
export async function edgeTTS(params: {
text: string;
outputPath: string;
config: {
voice: string;
lang: string;
outputFormat: string;
saveSubtitles: boolean;
proxy?: string;
rate?: string;
pitch?: string;
volume?: string;
timeoutMs?: number;
};
timeoutMs: number;
}): Promise<void> {
export async function edgeTTS(
params: {
text: string;
outputPath: string;
config: {
voice: string;
lang: string;
outputFormat: string;
saveSubtitles: boolean;
proxy?: string;
rate?: string;
pitch?: string;
volume?: string;
timeoutMs?: number;
};
timeoutMs: number;
},
deps: EdgeTTSDeps = defaultEdgeTTSDeps,
): Promise<void> {
const { text, outputPath, config, timeoutMs } = params;
const tts = new EdgeTTS({
const tts = new deps.EdgeTTS({
voice: config.voice,
lang: config.lang,
outputFormat: config.outputFormat,