refactor(deadcode): tighten more provider extension roots (#108515)

This commit is contained in:
Peter Steinberger
2026-07-15 17:05:21 -07:00
committed by GitHub
parent 16801bf4f0
commit 9bee0d4cb8
28 changed files with 458 additions and 327 deletions

View File

@@ -373,15 +373,26 @@ const config = {
project: ["index.js!", "scripts/**/*.js!"],
},
[`${BUNDLED_PLUGIN_ROOT_DIR}/amazon-bedrock-mantle`]: strictBundledPluginWorkspace(),
[`${BUNDLED_PLUGIN_ROOT_DIR}/azure-speech`]: strictBundledPluginWorkspace(),
[`${BUNDLED_PLUGIN_ROOT_DIR}/cloudflare-ai-gateway`]: strictBundledPluginWorkspace(),
[`${BUNDLED_PLUGIN_ROOT_DIR}/cohere`]: strictBundledPluginWorkspace(),
[`${BUNDLED_PLUGIN_ROOT_DIR}/deepgram`]: strictBundledPluginWorkspace(),
[`${BUNDLED_PLUGIN_ROOT_DIR}/elevenlabs`]: strictBundledPluginWorkspace(),
[`${BUNDLED_PLUGIN_ROOT_DIR}/featherless`]: strictBundledPluginWorkspace(),
[`${BUNDLED_PLUGIN_ROOT_DIR}/fireworks`]: strictBundledPluginWorkspace(),
[`${BUNDLED_PLUGIN_ROOT_DIR}/huggingface`]: strictBundledPluginWorkspace(),
[`${BUNDLED_PLUGIN_ROOT_DIR}/kilocode`]: strictBundledPluginWorkspace(),
[`${BUNDLED_PLUGIN_ROOT_DIR}/kimi-coding`]: strictBundledPluginWorkspace(),
[`${BUNDLED_PLUGIN_ROOT_DIR}/microsoft`]: strictBundledPluginWorkspace(),
[`${BUNDLED_PLUGIN_ROOT_DIR}/minimax`]: strictBundledPluginWorkspace(),
[`${BUNDLED_PLUGIN_ROOT_DIR}/mistral`]: strictBundledPluginWorkspace(),
[`${BUNDLED_PLUGIN_ROOT_DIR}/moonshot`]: strictBundledPluginWorkspace(),
[`${BUNDLED_PLUGIN_ROOT_DIR}/nvidia`]: strictBundledPluginWorkspace(),
[`${BUNDLED_PLUGIN_ROOT_DIR}/pixverse`]: strictBundledPluginWorkspace(),
[`${BUNDLED_PLUGIN_ROOT_DIR}/qianfan`]: strictBundledPluginWorkspace(),
[`${BUNDLED_PLUGIN_ROOT_DIR}/qwen`]: strictBundledPluginWorkspace(),
[`${BUNDLED_PLUGIN_ROOT_DIR}/senseaudio`]: strictBundledPluginWorkspace(),
[`${BUNDLED_PLUGIN_ROOT_DIR}/tavily`]: strictBundledPluginWorkspace(),
[`${BUNDLED_PLUGIN_ROOT_DIR}/tencent`]: strictBundledPluginWorkspace(),
[`${BUNDLED_PLUGIN_ROOT_DIR}/vllm`]: strictBundledPluginWorkspace(),
[`${BUNDLED_PLUGIN_ROOT_DIR}/xiaomi`]: strictBundledPluginWorkspace(),

View File

@@ -3,7 +3,6 @@ import { installPinnedHostnameTestHooks } from "openclaw/plugin-sdk/test-env";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
azureSpeechTTS,
buildAzureSpeechSsml,
inferAzureSpeechFileExtension,
isAzureSpeechVoiceCompatible,
listAzureSpeechVoices,
@@ -43,21 +42,6 @@ describe("azure speech tts", () => {
vi.restoreAllMocks();
});
it("escapes SSML text and attributes", () => {
expect(
buildAzureSpeechSsml({
text: `Tom & "Jerry" <tag>`,
voice: `en-US-JennyNeural" xml:lang="evil`,
lang: `en-US" bad="1`,
}),
).toBe(
`<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" ` +
`xml:lang="en-US&quot; bad=&quot;1">` +
`<voice name="en-US-JennyNeural&quot; xml:lang=&quot;evil">` +
`Tom &amp; "Jerry" &lt;tag&gt;</voice></speak>`,
);
});
it("normalizes region and endpoint routing", () => {
expect(normalizeAzureSpeechBaseUrl({ region: "eastus" })).toBe(
"https://eastus.tts.speech.microsoft.com",
@@ -86,11 +70,11 @@ describe("azure speech tts", () => {
vi.stubGlobal("fetch", fetchMock);
const result = await azureSpeechTTS({
text: "hello",
apiKey: "speech-key",
text: `Tom & "Jerry" <tag>`,
apiKey: "fixture-value",
region: "eastus",
voice: "en-US-JennyNeural",
lang: "en-US",
voice: `en-US-JennyNeural" xml:lang="evil`,
lang: `en-US" bad="1`,
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
timeoutMs: 1234,
});
@@ -101,10 +85,15 @@ describe("azure speech tts", () => {
expect(url).toBe("https://eastus.tts.speech.microsoft.com/cognitiveservices/v1");
expect(init.method).toBe("POST");
const headers = new Headers(init.headers);
expect(headers.get("Ocp-Apim-Subscription-Key")).toBe("speech-key");
expect(headers.get("Ocp-Apim-Subscription-Key")).toBe("fixture-value");
expect(headers.get("Content-Type")).toBe("application/ssml+xml");
expect(headers.get("X-Microsoft-OutputFormat")).toBe("audio-24khz-48kbitrate-mono-mp3");
expect(init.body).toContain(`<voice name="en-US-JennyNeural">hello</voice>`);
expect(init.body).toBe(
`<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" ` +
`xml:lang="en-US&quot; bad=&quot;1">` +
`<voice name="en-US-JennyNeural&quot; xml:lang=&quot;evil">` +
`Tom &amp; "Jerry" &lt;tag&gt;</voice></speak>`,
);
expect(init.signal).toBeInstanceOf(AbortSignal);
});

View File

@@ -79,11 +79,7 @@ function escapeXmlAttr(value: string): string {
}
/** Build escaped SSML for one Azure Speech synthesis request. */
export function buildAzureSpeechSsml(params: {
text: string;
voice: string;
lang?: string;
}): string {
function buildAzureSpeechSsml(params: { text: string; voice: string; lang?: string }): string {
const lang = trimToUndefined(params.lang) ?? DEFAULT_AZURE_SPEECH_LANG;
return (
`<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" ` +

View File

@@ -1,11 +1,7 @@
// Cloudflare Ai Gateway tests cover stream wrappers plugin behavior.
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
testing,
createCloudflareAiGatewayAnthropicThinkingPrefillWrapper,
wrapCloudflareAiGatewayProviderStream,
} from "./stream-wrappers.js";
import { wrapCloudflareAiGatewayProviderStream } from "./stream-wrappers.js";
const { warnMock } = vi.hoisted(() => ({
warnMock: vi.fn(),
@@ -33,9 +29,13 @@ function createPayloadBaseStream(payload: Record<string, unknown>): StreamFn {
}
function runWrapper(payload: Record<string, unknown>): Record<string, unknown> {
const wrapper = createCloudflareAiGatewayAnthropicThinkingPrefillWrapper(
createPayloadBaseStream(payload),
);
const wrapper = wrapCloudflareAiGatewayProviderStream({
model: { api: "anthropic-messages" },
streamFn: createPayloadBaseStream(payload),
} as never);
if (!wrapper) {
throw new Error("expected Cloudflare AI Gateway stream wrapper");
}
void wrapper(
{ provider: "cloudflare-ai-gateway", api: "anthropic-messages" } as never,
{} as never,
@@ -44,7 +44,7 @@ function runWrapper(payload: Record<string, unknown>): Record<string, unknown> {
return payload;
}
describe("createCloudflareAiGatewayAnthropicThinkingPrefillWrapper", () => {
describe("wrapCloudflareAiGatewayProviderStream", () => {
beforeEach(() => {
warnMock.mockClear();
});
@@ -156,6 +156,12 @@ describe("wrapCloudflareAiGatewayProviderStream", () => {
});
it("treats missing model API as the plugin's default Anthropic Messages route", () => {
expect(testing.shouldPatchAnthropicMessagesPayload({} as never)).toBe(true);
const baseStreamFn = createPayloadBaseStream({ messages: [] });
const wrapped = wrapCloudflareAiGatewayProviderStream({
model: {},
streamFn: baseStreamFn,
} as never);
expect(wrapped).not.toBe(baseStreamFn);
});
});

View File

@@ -17,7 +17,7 @@ function shouldPatchAnthropicMessagesPayload(model: ProviderWrapStreamFnContext[
* Creates a wrapper that removes trailing assistant prefill messages before
* extended-thinking Anthropic requests are sent through Cloudflare.
*/
export function createCloudflareAiGatewayAnthropicThinkingPrefillWrapper(
function createCloudflareAiGatewayAnthropicThinkingPrefillWrapper(
baseStreamFn: StreamFn | undefined,
): StreamFn {
return createAnthropicThinkingPrefillPayloadWrapper(baseStreamFn, (stripped) => {
@@ -38,6 +38,3 @@ export function wrapCloudflareAiGatewayProviderStream(
}
return createCloudflareAiGatewayAnthropicThinkingPrefillWrapper(ctx.streamFn);
}
/** Test-only access to wrapper decisions and logger injection points. */
export const testing = { log, shouldPatchAnthropicMessagesPayload };

View File

@@ -5,10 +5,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { afterEach, describe, expect, it, vi } from "vitest";
import type WebSocket from "ws";
import { WebSocketServer } from "ws";
import {
testing,
buildDeepgramRealtimeTranscriptionProvider,
} from "./realtime-transcription-provider.js";
import { buildDeepgramRealtimeTranscriptionProvider } from "./realtime-transcription-provider.js";
let cleanup: (() => Promise<void>) | undefined;
@@ -45,21 +42,6 @@ async function createDeepgramRealtimeServer(params: {
return { baseUrl: `http://127.0.0.1:${port}/deepgram/v1` };
}
function buildTestRealtimeUrl(baseUrl: string): URL {
return new URL(
testing.toDeepgramRealtimeWsUrl({
apiKey: "dg-key",
baseUrl,
model: "nova-3",
providerConfig: {},
sampleRate: 8000,
encoding: "mulaw",
interimResults: true,
endpointingMs: 800,
}),
);
}
describe("buildDeepgramRealtimeTranscriptionProvider", () => {
afterEach(async () => {
await cleanup?.();
@@ -98,24 +80,6 @@ describe("buildDeepgramRealtimeTranscriptionProvider", () => {
});
});
it("builds a Deepgram listen websocket URL", () => {
const url = testing.toDeepgramRealtimeWsUrl({
apiKey: "dg-key",
baseUrl: "https://api.deepgram.com/v1",
model: "nova-3",
providerConfig: {},
sampleRate: 8000,
encoding: "mulaw",
interimResults: true,
endpointingMs: 800,
});
expect(url).toContain("wss://api.deepgram.com/v1/listen?");
expect(url).toContain("model=nova-3");
expect(url).toContain("encoding=mulaw");
expect(url).toContain("sample_rate=8000");
});
it("requires an API key when creating sessions", () => {
vi.stubEnv("DEEPGRAM_API_KEY", "");
const provider = buildDeepgramRealtimeTranscriptionProvider();
@@ -124,32 +88,17 @@ describe("buildDeepgramRealtimeTranscriptionProvider", () => {
);
});
it("returns the default when no value or env is set", () => {
vi.stubEnv("DEEPGRAM_BASE_URL", "");
expect(testing.normalizeDeepgramRealtimeBaseUrl(undefined)).toBe("https://api.deepgram.com/v1");
expect(testing.normalizeDeepgramRealtimeBaseUrl(" ")).toBe("https://api.deepgram.com/v1");
});
it.each([
["http://localhost:8080/deepgram/v1", "ws:"],
["https://custom.example.com/deepgram/v1", "wss:"],
["ws://localhost:8080/deepgram/v1", "ws:"],
["wss://custom.example.com:8443/deepgram/v1", "wss:"],
])("maps or preserves %s as %s", (baseUrl, expectedProtocol) => {
const url = buildTestRealtimeUrl(baseUrl);
expect(url.protocol).toBe(expectedProtocol);
expect(url.pathname).toBe("/deepgram/v1/listen");
});
it.each(["not a url", "ftp://files.example.com"])("rejects invalid endpoint %s", (baseUrl) => {
expect(() => testing.normalizeDeepgramRealtimeBaseUrl(baseUrl)).toThrow(
const provider = buildDeepgramRealtimeTranscriptionProvider();
expect(() => provider.createSession({ providerConfig: { apiKey: "dg-key", baseUrl } })).toThrow(
/^Invalid Deepgram baseUrl:/,
);
});
it("validates the environment override", () => {
vi.stubEnv("DEEPGRAM_BASE_URL", "not a url");
expect(() => testing.normalizeDeepgramRealtimeBaseUrl()).toThrow(
const provider = buildDeepgramRealtimeTranscriptionProvider();
expect(() => provider.createSession({ providerConfig: { apiKey: "dg-key" } })).toThrow(
"Invalid Deepgram baseUrl: value is not a valid URL",
);
});
@@ -157,8 +106,9 @@ describe("buildDeepgramRealtimeTranscriptionProvider", () => {
it("does not echo the configured URL in validation errors", () => {
const rawMarker = "configured-value-marker";
const nonHttp = `ftp://files.example.com/${rawMarker}`;
const provider = buildDeepgramRealtimeTranscriptionProvider();
try {
testing.normalizeDeepgramRealtimeBaseUrl(nonHttp);
provider.createSession({ providerConfig: { apiKey: "dg-key", baseUrl: nonHttp } });
throw new Error("expected rejection");
} catch (error) {
const message = (error as Error).message;

View File

@@ -268,10 +268,3 @@ export function buildDeepgramRealtimeTranscriptionProvider(): RealtimeTranscript
},
};
}
export const testing = {
normalizeProviderConfig,
normalizeDeepgramRealtimeBaseUrl,
toDeepgramRealtimeWsUrl,
};
export { testing as __testing };

View File

@@ -1,12 +1,43 @@
// Elevenlabs tests cover realtime transcription provider plugin behavior.
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it } from "vitest";
import {
testing,
buildElevenLabsRealtimeTranscriptionProvider,
} from "./realtime-transcription-provider.js";
import { afterEach, describe, expect, it } from "vitest";
import type WebSocket from "ws";
import { WebSocketServer } from "ws";
import { buildElevenLabsRealtimeTranscriptionProvider } from "./realtime-transcription-provider.js";
let cleanup: (() => Promise<void>) | undefined;
async function createRealtimeServer(onRequest: (url: URL) => void) {
const server = createServer();
const wss = new WebSocketServer({ noServer: true });
const clients = new Set<WebSocket>();
server.on("upgrade", (request, socket, head) => {
onRequest(new URL(request.url ?? "/", "http://127.0.0.1"));
wss.handleUpgrade(request, socket, head, (ws) => {
clients.add(ws);
ws.on("close", () => clients.delete(ws));
ws.send(JSON.stringify({ message_type: "session_started" }));
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
cleanup = async () => {
for (const ws of clients) {
ws.terminate();
}
await new Promise<void>((resolve) => wss.close(() => resolve()));
await new Promise<void>((resolve) => server.close(() => resolve()));
};
return `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
}
describe("buildElevenLabsRealtimeTranscriptionProvider", () => {
afterEach(async () => {
await cleanup?.();
cleanup = undefined;
});
it("normalizes nested provider config", () => {
const provider = buildElevenLabsRealtimeTranscriptionProvider();
const resolved = provider.resolveConfig?.({
@@ -92,22 +123,29 @@ describe("buildElevenLabsRealtimeTranscriptionProvider", () => {
});
});
it("builds an ElevenLabs realtime websocket URL", () => {
const url = testing.toElevenLabsRealtimeWsUrl({
apiKey: "eleven-key",
baseUrl: "https://api.elevenlabs.io",
providerConfig: {},
modelId: "scribe_v2_realtime",
audioFormat: "ulaw_8000",
sampleRate: 8000,
commitStrategy: "vad",
languageCode: "en",
it("connects through the public session boundary with the configured URL params", async () => {
const requests: URL[] = [];
const baseUrl = await createRealtimeServer((url) => requests.push(url));
const session = buildElevenLabsRealtimeTranscriptionProvider().createSession({
providerConfig: {
apiKey: "fixture-value",
baseUrl,
modelId: "scribe_v2_realtime",
audioFormat: "ulaw_8000",
sampleRate: 8000,
commitStrategy: "vad",
languageCode: "en",
},
});
expect(url).toContain("wss://api.elevenlabs.io/v1/speech-to-text/realtime?");
expect(url).toContain("model_id=scribe_v2_realtime");
expect(url).toContain("audio_format=ulaw_8000");
expect(url).toContain("commit_strategy=vad");
expect(url).toContain("language_code=en");
await session.connect();
session.close();
expect(requests).toHaveLength(1);
expect(requests[0]?.pathname).toBe("/v1/speech-to-text/realtime");
expect(requests[0]?.searchParams.get("model_id")).toBe("scribe_v2_realtime");
expect(requests[0]?.searchParams.get("audio_format")).toBe("ulaw_8000");
expect(requests[0]?.searchParams.get("commit_strategy")).toBe("vad");
expect(requests[0]?.searchParams.get("language_code")).toBe("en");
});
});

View File

@@ -291,8 +291,3 @@ export function buildElevenLabsRealtimeTranscriptionProvider(): RealtimeTranscri
},
};
}
export const testing = {
normalizeProviderConfig,
toElevenLabsRealtimeWsUrl,
};

View File

@@ -2,12 +2,7 @@
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import type { Context, Model } from "openclaw/plugin-sdk/llm";
import { describe, expect, it } from "vitest";
import {
createKimiThinkingWrapper,
createKimiToolCallMarkupWrapper,
resolveKimiThinkingType,
wrapKimiProviderStream,
} from "./stream.js";
import { wrapKimiProviderStream } from "./stream.js";
type FakeStream = {
result: () => Promise<unknown>;
@@ -83,20 +78,11 @@ function createPayloadCapturingStream(initialPayload: Record<string, unknown> =
return { streamFn, getCapturedPayload: () => capturedPayload };
}
describe("kimi tool-call markup wrapper", () => {
it("defaults Kimi thinking to disabled unless explicitly enabled", () => {
expect(resolveKimiThinkingType({ configuredThinking: undefined })).toBe("disabled");
expect(resolveKimiThinkingType({ configuredThinking: undefined, thinkingLevel: "high" })).toBe(
"enabled",
);
expect(resolveKimiThinkingType({ configuredThinking: "off", thinkingLevel: "high" })).toBe(
"disabled",
);
expect(resolveKimiThinkingType({ configuredThinking: "enabled", thinkingLevel: "off" })).toBe(
"enabled",
);
});
function wrapKimiStream(streamFn: StreamFn, thinking: "enabled" | "off" = "off"): StreamFn {
return wrapKimiProviderStream({ streamFn, extraParams: { thinking } } as never);
}
describe("kimi tool-call markup wrapper", () => {
it("converts tagged Kimi tool-call text into structured tool calls", async () => {
const partial = {
role: "assistant",
@@ -123,7 +109,7 @@ describe("kimi tool-call markup wrapper", () => {
resultMessage: finalMessage,
}) as ReturnType<StreamFn>;
const wrapped = createKimiToolCallMarkupWrapper(baseStreamFn);
const wrapped = wrapKimiStream(baseStreamFn);
const stream = wrapped(
{ api: "anthropic-messages", provider: "kimi", id: "k2p5" } as Model<"anthropic-messages">,
{ messages: [] } as Context,
@@ -186,7 +172,7 @@ describe("kimi tool-call markup wrapper", () => {
resultMessage: finalMessage,
}) as ReturnType<StreamFn>;
const wrapped = createKimiToolCallMarkupWrapper(baseStreamFn);
const wrapped = wrapKimiStream(baseStreamFn);
const stream = wrapped(
{ api: "anthropic-messages", provider: "kimi", id: "k2p5" } as Model<"anthropic-messages">,
{ messages: [] } as Context,
@@ -201,7 +187,7 @@ describe("kimi tool-call markup wrapper", () => {
const baseStreamFn: StreamFn = async (model, context, options) =>
createResultStreamFn(finalMessage)(model, context, options);
const wrapped = createKimiToolCallMarkupWrapper(baseStreamFn);
const wrapped = wrapKimiStream(baseStreamFn);
const stream = await callKimiStream(wrapped);
await expect(stream.result()).resolves.toEqual({
@@ -219,7 +205,7 @@ describe("kimi tool-call markup wrapper", () => {
const finalMessage = createAssistantTextMessage(KIMI_MULTI_TOOL_TEXT);
const baseStreamFn = createResultStreamFn(finalMessage);
const wrapped = createKimiToolCallMarkupWrapper(baseStreamFn);
const wrapped = wrapKimiStream(baseStreamFn);
const stream = await callKimiStream(wrapped);
await expect(stream.result()).resolves.toEqual({
@@ -266,7 +252,7 @@ describe("kimi tool-call markup wrapper", () => {
reasoningEffort: "high",
});
const wrapped = createKimiThinkingWrapper(baseStreamFn, "disabled");
const wrapped = wrapKimiStream(baseStreamFn);
void wrapped(
{
api: "anthropic-messages",
@@ -318,7 +304,7 @@ describe("kimi tool-call markup wrapper", () => {
],
});
const wrapped = createKimiThinkingWrapper(baseStreamFn, "enabled");
const wrapped = wrapKimiStream(baseStreamFn, "enabled");
void wrapped(
{
api: "anthropic-messages",
@@ -330,6 +316,7 @@ describe("kimi tool-call markup wrapper", () => {
);
expect(getCapturedPayload()).toEqual({
max_tokens: 16000,
system: [{ type: "text", text: "stable" }],
messages: [
{
@@ -354,7 +341,7 @@ describe("kimi tool-call markup wrapper", () => {
],
},
],
thinking: { type: "enabled" },
thinking: { type: "enabled", budget_tokens: 1024 },
});
});
@@ -384,6 +371,46 @@ describe("kimi tool-call markup wrapper", () => {
});
});
it.each([
{
name: "uses session thinking when model params are absent",
extraParams: undefined,
thinkingLevel: "high",
expected: {
max_tokens: 16000,
thinking: { type: "enabled", budget_tokens: 8192 },
},
},
{
name: "lets explicit model params disable session thinking",
extraParams: { thinking: "off" },
thinkingLevel: "high",
expected: { thinking: { type: "disabled" } },
},
{
name: "lets explicit model params enable thinking when the session disables it",
extraParams: { thinking: "enabled" },
thinkingLevel: "off",
expected: {
max_tokens: 16000,
thinking: { type: "enabled", budget_tokens: 1024 },
},
},
])("$name", ({ extraParams, thinkingLevel, expected }) => {
const { streamFn: baseStreamFn, getCapturedPayload } = createPayloadCapturingStream();
const wrapped = wrapKimiProviderStream({
provider: "kimi",
modelId: "kimi-code",
extraParams,
thinkingLevel,
streamFn: baseStreamFn,
} as never);
void wrapped(KIMI_MODEL, KIMI_CONTEXT, {});
expect(getCapturedPayload()).toEqual(expected);
});
it("backfills Kimi OpenAI-compatible tool-call reasoning_content when thinking is enabled", () => {
const { streamFn: baseStreamFn, getCapturedPayload } = createPayloadCapturingStream({
messages: [
@@ -414,7 +441,7 @@ describe("kimi tool-call markup wrapper", () => {
],
});
const wrapped = createKimiThinkingWrapper(baseStreamFn, "enabled");
const wrapped = wrapKimiStream(baseStreamFn, "enabled");
void wrapped(
{
api: "openai-completions",
@@ -475,7 +502,7 @@ describe("kimi tool-call markup wrapper", () => {
],
});
const wrapped = createKimiThinkingWrapper(baseStreamFn, "disabled");
const wrapped = wrapKimiStream(baseStreamFn);
void wrapped(
{
api: "openai-completions",

View File

@@ -187,13 +187,6 @@ function resolveKimiThinkingConfig(params: {
: { type: "enabled", budget_tokens: levelBudgetTokens };
}
export function resolveKimiThinkingType(params: {
configuredThinking: unknown;
thinkingLevel?: KimiThinkingLevel;
}): KimiThinkingType {
return resolveKimiThinkingConfig(params).type;
}
function stripTaggedToolCallCounter(value: string): string {
return value.trim().replace(/:\d+$/, "");
}
@@ -361,7 +354,7 @@ function wrapStreamMessageObjects(
return stream;
}
export function createKimiToolCallMarkupWrapper(baseStreamFn: StreamFn | undefined): StreamFn {
function createKimiToolCallMarkupWrapper(baseStreamFn: StreamFn | undefined): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const maybeStream = underlying(model, context, options);
@@ -374,7 +367,7 @@ export function createKimiToolCallMarkupWrapper(baseStreamFn: StreamFn | undefin
};
}
export function createKimiThinkingWrapper(
function createKimiThinkingWrapper(
baseStreamFn: StreamFn | undefined,
thinkingConfig: KimiThinkingConfig | KimiThinkingType,
): StreamFn {

View File

@@ -1,13 +1,17 @@
// Microsoft tests cover microsoft plugin behavior.
import { isLiveTestEnabled } from "openclaw/plugin-sdk/test-env";
import { describe, expect, it } from "vitest";
import { listMicrosoftVoices } from "./speech-provider.js";
import { buildMicrosoftSpeechProvider } from "./speech-provider.js";
const describeLive = isLiveTestEnabled() ? describe : describe.skip;
describeLive("microsoft plugin live", () => {
it("lists Edge speech voices", async () => {
const voices = await listMicrosoftVoices();
const listVoices = buildMicrosoftSpeechProvider().listVoices;
if (!listVoices) {
throw new Error("expected Microsoft voice listing support");
}
const voices = await listVoices({ providerConfig: {} });
expect(voices.length).toBeGreaterThan(100);
expect(voices.map((voice) => voice.id)).toContain("en-US-MichelleNeural");

View File

@@ -30,15 +30,19 @@ vi.mock("node-edge-tts", () => ({
},
}));
import {
buildMicrosoftSpeechProvider,
isCjkDominant,
listMicrosoftVoices,
} from "./speech-provider.js";
import { buildMicrosoftSpeechProvider } from "./speech-provider.js";
import * as ttsModule from "./tts.js";
const TEST_CFG = {} as OpenClawConfig;
async function listVoicesThroughProvider() {
const listVoices = buildMicrosoftSpeechProvider().listVoices;
if (!listVoices) {
throw new Error("expected Microsoft voice listing support");
}
return await listVoices({ providerConfig: {} });
}
function requireFirstEdgeTtsCall(edgeSpy: ReturnType<typeof vi.spyOn>): {
config?: unknown;
outputPath: string;
@@ -83,7 +87,7 @@ describe("listMicrosoftVoices", () => {
),
) as unknown as typeof globalThis.fetch;
const voices = await listMicrosoftVoices();
const voices = await listVoicesThroughProvider();
expect(voices).toEqual([
{
@@ -108,7 +112,7 @@ describe("listMicrosoftVoices", () => {
new Response("nope", { status: 503 }),
) as unknown as typeof globalThis.fetch;
await expect(listMicrosoftVoices()).rejects.toThrow("Microsoft voices API error (503)");
await expect(listVoicesThroughProvider()).rejects.toThrow("Microsoft voices API error (503)");
});
it("prefers the configured provider request timeout", async () => {
@@ -149,7 +153,7 @@ describe("listMicrosoftVoices", () => {
sourceProcess: "openclaw",
});
await listMicrosoftVoices();
await listVoicesThroughProvider();
await vi.waitFor(() => {
const events = store.getSessionEvents("ms-voices-session", 10);
@@ -188,7 +192,7 @@ describe("listMicrosoftVoices", () => {
initializeDebugProxyCapture("test");
try {
await listMicrosoftVoices();
await listVoicesThroughProvider();
let events: Array<Record<string, unknown>> = [];
await vi.waitFor(() => {
@@ -206,28 +210,6 @@ describe("listMicrosoftVoices", () => {
});
});
describe("isCjkDominant", () => {
it("returns true for Chinese text", () => {
expect(isCjkDominant("你好世界")).toBe(true);
});
it("returns true for mixed text with majority CJK", () => {
expect(isCjkDominant("你好,这是一个测试 hello")).toBe(true);
});
it("returns false for English text", () => {
expect(isCjkDominant("Hello, this is a test")).toBe(false);
});
it("returns false for empty string", () => {
expect(isCjkDominant("")).toBe(false);
});
it("returns false for mostly English with a few CJK chars", () => {
expect(isCjkDominant("This is a long English sentence with one 字")).toBe(false);
});
});
describe("buildMicrosoftSpeechProvider", () => {
afterEach(() => {
vi.restoreAllMocks();

View File

@@ -119,7 +119,7 @@ function formatMicrosoftVoiceDescription(entry: MicrosoftVoiceListEntry): string
return personalities.length > 0 ? personalities.join(", ") : undefined;
}
export function isCjkDominant(text: string): boolean {
function isCjkDominant(text: string): boolean {
const stripped = text.replace(/\s+/g, "");
if (stripped.length === 0) {
return false;
@@ -142,7 +142,7 @@ export function isCjkDominant(text: string): boolean {
const DEFAULT_CHINESE_EDGE_VOICE = "zh-CN-XiaoxiaoNeural";
const DEFAULT_CHINESE_EDGE_LANG = "zh-CN";
export async function listMicrosoftVoices(
async function listMicrosoftVoices(
timeoutMs = DEFAULT_MICROSOFT_VOICE_LIST_TIMEOUT_MS,
): Promise<SpeechVoiceOption[]> {
const url =

View File

@@ -3,18 +3,14 @@ import { describe, expect, it } from "vitest";
import {
buildMinimaxApiModelDefinition,
buildMinimaxModelDefinition,
DEFAULT_MINIMAX_CONTEXT_WINDOW,
DEFAULT_MINIMAX_MAX_TOKENS,
MINIMAX_API_COST,
MINIMAX_API_HIGHSPEED_COST,
MINIMAX_HOSTED_MODEL_ID,
MINIMAX_M27_API_COST,
MINIMAX_M25_API_COST,
MINIMAX_M25_API_HIGHSPEED_COST,
} from "./model-definitions.js";
import { MINIMAX_TEXT_MODEL_CATALOG } from "./provider-models.js";
const MINIMAX_M3_CATALOG_CONTEXT_WINDOW = MINIMAX_TEXT_MODEL_CATALOG["MiniMax-M3"].contextWindow;
const EXPECTED_DEFAULT_CONTEXT_WINDOW = 204800;
describe("minimax model definitions", () => {
it("uses M3 as default hosted model", () => {
@@ -23,7 +19,9 @@ describe("minimax model definitions", () => {
it("uses the current upstream MiniMax context, token, and pricing defaults", () => {
expect(MINIMAX_M3_CATALOG_CONTEXT_WINDOW).toBe(1_000_000);
expect(DEFAULT_MINIMAX_CONTEXT_WINDOW).toBe(204800);
expect(buildMinimaxApiModelDefinition("MiniMax-Future").contextWindow).toBe(
EXPECTED_DEFAULT_CONTEXT_WINDOW,
);
expect(DEFAULT_MINIMAX_MAX_TOKENS).toBe(131072);
expect(MINIMAX_API_COST).toEqual({
input: 0.6,
@@ -55,11 +53,11 @@ describe("minimax model definitions", () => {
const model = buildMinimaxModelDefinition({
id: "MiniMax-M2.5",
cost: MINIMAX_API_COST,
contextWindow: DEFAULT_MINIMAX_CONTEXT_WINDOW,
contextWindow: EXPECTED_DEFAULT_CONTEXT_WINDOW,
maxTokens: DEFAULT_MINIMAX_MAX_TOKENS,
});
expect(model).toEqual({
contextWindow: DEFAULT_MINIMAX_CONTEXT_WINDOW,
contextWindow: EXPECTED_DEFAULT_CONTEXT_WINDOW,
cost: MINIMAX_API_COST,
id: "MiniMax-M2.5",
input: ["text"],
@@ -86,8 +84,8 @@ describe("minimax model definitions", () => {
it("keeps M2.7 on its existing price and text-only metadata", () => {
const model = buildMinimaxApiModelDefinition("MiniMax-M2.7");
expect(model.input).toEqual(["text"]);
expect(model.cost).toEqual(MINIMAX_M27_API_COST);
expect(model.contextWindow).toBe(DEFAULT_MINIMAX_CONTEXT_WINDOW);
expect(model.cost).toEqual({ input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0.375 });
expect(model.contextWindow).toBe(EXPECTED_DEFAULT_CONTEXT_WINDOW);
});
it("keeps M2.7 text-only on the Anthropic-compatible chat path", () => {
@@ -98,17 +96,17 @@ describe("minimax model definitions", () => {
it("keeps M2.7-highspeed text-only on the Anthropic-compatible chat path", () => {
const model = buildMinimaxApiModelDefinition("MiniMax-M2.7-highspeed");
expect(model.input).toEqual(["text"]);
expect(model.cost).toEqual(MINIMAX_API_HIGHSPEED_COST);
expect(model.cost).toEqual({ input: 0.6, output: 2.4, cacheRead: 0.06, cacheWrite: 0.375 });
});
it("M2.5 model remains text-only", () => {
const model = buildMinimaxApiModelDefinition("MiniMax-M2.5");
expect(model.input).toEqual(["text"]);
expect(model.cost).toEqual(MINIMAX_M25_API_COST);
expect(model.cost).toEqual({ input: 0.3, output: 1.2, cacheRead: 0.03, cacheWrite: 0.375 });
});
it("M2.5-highspeed keeps the M2.5 cache-read pricing", () => {
const model = buildMinimaxApiModelDefinition("MiniMax-M2.5-highspeed");
expect(model.cost).toEqual(MINIMAX_M25_API_HIGHSPEED_COST);
expect(model.cost).toEqual({ input: 0.6, output: 2.4, cacheRead: 0.03, cacheWrite: 0.375 });
});
});

View File

@@ -7,7 +7,7 @@ export const MINIMAX_API_BASE_URL = "https://api.minimax.io/anthropic";
export const MINIMAX_CN_API_BASE_URL = "https://api.minimaxi.com/anthropic";
export const MINIMAX_HOSTED_MODEL_ID = MINIMAX_DEFAULT_MODEL_ID;
export const MINIMAX_HOSTED_MODEL_REF = `minimax/${MINIMAX_HOSTED_MODEL_ID}`;
export const DEFAULT_MINIMAX_CONTEXT_WINDOW = 204800;
const DEFAULT_MINIMAX_CONTEXT_WINDOW = 204800;
export const DEFAULT_MINIMAX_MAX_TOKENS = 131072;
export const MINIMAX_API_COST = {
@@ -16,25 +16,25 @@ export const MINIMAX_API_COST = {
cacheRead: 0.12,
cacheWrite: 0,
};
export const MINIMAX_M27_API_COST = {
const MINIMAX_M27_API_COST = {
input: 0.3,
output: 1.2,
cacheRead: 0.06,
cacheWrite: 0.375,
};
export const MINIMAX_API_HIGHSPEED_COST = {
const MINIMAX_API_HIGHSPEED_COST = {
input: 0.6,
output: 2.4,
cacheRead: 0.06,
cacheWrite: 0.375,
};
export const MINIMAX_M25_API_COST = {
const MINIMAX_M25_API_COST = {
input: 0.3,
output: 1.2,
cacheRead: 0.03,
cacheWrite: 0.375,
};
export const MINIMAX_M25_API_HIGHSPEED_COST = {
const MINIMAX_M25_API_HIGHSPEED_COST = {
input: 0.6,
output: 2.4,
cacheRead: 0.03,

View File

@@ -3,7 +3,7 @@ import { createServer } from "node:http";
import type { Socket } from "node:net";
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { loginMiniMaxPortalOAuth, normalizeOAuthExpires } from "./oauth.js";
import { loginMiniMaxPortalOAuth } from "./oauth.js";
const MINIMAX_OAUTH_FETCH_TIMEOUT_MS = 30_000;
@@ -219,27 +219,92 @@ afterEach(() => {
vi.unstubAllGlobals();
});
describe("normalizeOAuthExpires", () => {
it("converts relative expiry seconds into an absolute millisecond timestamp", () => {
expect(normalizeOAuthExpires(86_400, 1_700_000_000_000)).toBe(1_700_086_400_000);
});
it("converts Unix second timestamps into milliseconds", () => {
expect(normalizeOAuthExpires(1_700_000_000)).toBe(1_700_000_000_000);
});
it("preserves absolute millisecond timestamps", () => {
expect(normalizeOAuthExpires(1_700_000_000_000)).toBe(1_700_000_000_000);
});
it("rejects unsafe and malformed expiry values", () => {
expect(normalizeOAuthExpires(Number.POSITIVE_INFINITY)).toBeUndefined();
expect(normalizeOAuthExpires(Number.MAX_SAFE_INTEGER + 1)).toBeUndefined();
expect(normalizeOAuthExpires("3600s")).toBeUndefined();
});
});
describe("loginMiniMaxPortalOAuth", () => {
it.each([
[3600, 1_700_003_600_000],
[1_700_000_000, 1_700_000_000_000],
[1_700_000_000_000, 1_700_000_000_000],
])("normalizes token expiry %s through the OAuth flow", async (expiredIn, expectedExpires) => {
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
let callCount = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
callCount += 1;
const body =
init?.body instanceof URLSearchParams
? init.body
: new URLSearchParams(typeof init?.body === "string" ? init.body : "");
return new Response(
JSON.stringify(
callCount === 1
? {
user_code: "CODE",
verification_uri: "https://example.com/device",
expired_in: Date.now() + 10_000,
state: body.get("state"),
}
: {
status: "success",
access_token: "access",
refresh_token: "refresh",
expired_in: expiredIn,
},
),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}),
);
await expect(
loginMiniMaxPortalOAuth({
openUrl: vi.fn(async () => undefined),
note: vi.fn(async () => undefined),
progress: { update: vi.fn(), stop: vi.fn() },
}),
).resolves.toMatchObject({ expires: expectedExpires });
});
it("rejects malformed token expiry through the OAuth flow", async () => {
let callCount = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
callCount += 1;
const body =
init?.body instanceof URLSearchParams
? init.body
: new URLSearchParams(typeof init?.body === "string" ? init.body : "");
return new Response(
JSON.stringify(
callCount === 1
? {
user_code: "CODE",
verification_uri: "https://example.com/device",
expired_in: Date.now() + 10_000,
state: body.get("state"),
}
: {
status: "success",
access_token: "access",
refresh_token: "refresh",
expired_in: "3600s",
},
),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}),
);
await expect(
loginMiniMaxPortalOAuth({
openUrl: vi.fn(async () => undefined),
note: vi.fn(async () => undefined),
progress: { update: vi.fn(), stop: vi.fn() },
}),
).rejects.toThrow("invalid token expiry");
});
it("times out authorization code HTTP requests against a hanging loopback server", async () => {
const realFetch = fetch;
const server = await startHangingLoopbackServer();

View File

@@ -74,7 +74,7 @@ type TokenResult =
* Normalize MiniMax token endpoint `expired_in` values to the auth-profile
* contract: absolute Unix milliseconds.
*/
export function normalizeOAuthExpires(expiredIn: unknown, now = Date.now()): number | undefined {
function normalizeOAuthExpires(expiredIn: unknown, now = Date.now()): number | undefined {
return resolveExpiresAtMsFromDurationOrEpoch(expiredIn, {
nowMs: now,
relativeSecondsThreshold: MINIMAX_RELATIVE_EXPIRY_SECONDS_THRESHOLD,

View File

@@ -1,12 +1,13 @@
// Mistral tests cover model definitions plugin behavior.
import { describe, expect, it } from "vitest";
import {
buildMistralCatalogModels,
buildMistralModelDefinition,
MISTRAL_DEFAULT_MODEL_ID,
} from "./model-definitions.js";
import { buildMistralModelDefinition, MISTRAL_DEFAULT_MODEL_ID } from "./model-definitions.js";
import { buildMistralProvider } from "./provider-catalog.js";
function catalogModelById(models: ReturnType<typeof buildMistralCatalogModels>, id: string) {
function buildCatalogModels() {
return buildMistralProvider().models;
}
function catalogModelById(models: ReturnType<typeof buildCatalogModels>, id: string) {
const model = models.find((candidate) => candidate.id === id);
if (!model) {
throw new Error(`expected Mistral catalog model ${id}`);
@@ -29,7 +30,7 @@ describe("mistral model definitions", () => {
});
it("prices cached Mistral input tokens at ten percent of standard input tokens", () => {
const models = buildMistralCatalogModels();
const models = buildCatalogModels();
for (const model of models) {
expect(model.cost.cacheRead).toBeCloseTo(model.cost.input * 0.1, 10);
@@ -47,7 +48,7 @@ describe("mistral model definitions", () => {
});
it("publishes a curated set of current Mistral catalog models", () => {
const models = buildMistralCatalogModels();
const models = buildCatalogModels();
const codestral = catalogModelById(models, "codestral-latest");
expect(codestral.input).toEqual(["text"]);
expect(codestral.contextWindow).toBe(256000);

View File

@@ -16,7 +16,7 @@ export function buildMistralModelDefinition(): ModelDefinitionConfig {
return model;
}
export function buildMistralCatalogModels(): ModelDefinitionConfig[] {
function buildMistralCatalogModels(): ModelDefinitionConfig[] {
return buildManifestModelProviderConfig({
providerId: "mistral",
catalog: MISTRAL_MANIFEST_CATALOG,

View File

@@ -1,13 +1,41 @@
// Mistral tests cover realtime transcription provider plugin behavior.
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
testing,
buildMistralRealtimeTranscriptionProvider,
} from "./realtime-transcription-provider.js";
import type WebSocket from "ws";
import { WebSocketServer } from "ws";
import { buildMistralRealtimeTranscriptionProvider } from "./realtime-transcription-provider.js";
let cleanup: (() => Promise<void>) | undefined;
async function createRealtimeServer(onRequest: (url: URL) => void) {
const server = createServer();
const wss = new WebSocketServer({ noServer: true });
const clients = new Set<WebSocket>();
server.on("upgrade", (request, socket, head) => {
onRequest(new URL(request.url ?? "/", "http://127.0.0.1"));
wss.handleUpgrade(request, socket, head, (ws) => {
clients.add(ws);
ws.on("close", () => clients.delete(ws));
ws.send(JSON.stringify({ type: "session.created" }));
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
cleanup = async () => {
for (const ws of clients) {
ws.terminate();
}
await new Promise<void>((resolve) => wss.close(() => resolve()));
await new Promise<void>((resolve) => server.close(() => resolve()));
};
return `http://127.0.0.1:${(server.address() as AddressInfo).port}/v1`;
}
describe("buildMistralRealtimeTranscriptionProvider", () => {
afterEach(() => {
afterEach(async () => {
await cleanup?.();
cleanup = undefined;
vi.unstubAllEnvs();
});
@@ -54,25 +82,32 @@ describe("buildMistralRealtimeTranscriptionProvider", () => {
expect(resolved?.apiKey).toBe("sk-mistral");
});
it("builds a Mistral realtime websocket URL", () => {
const url = testing.toMistralRealtimeWsUrl({
apiKey: "mistral-key",
baseUrl: "https://api.mistral.ai/v1",
model: "voxtral-mini-transcribe-realtime-2602",
providerConfig: {},
sampleRate: 8000,
encoding: "pcm_mulaw",
targetStreamingDelayMs: 800,
});
expect(url).toContain("wss://api.mistral.ai/v1/audio/transcriptions/realtime?");
expect(url).toContain("model=voxtral-mini-transcribe-realtime-2602");
expect(url).toContain("target_streaming_delay_ms=800");
});
it("requires an API key when creating sessions", () => {
vi.stubEnv("MISTRAL_API_KEY", "");
const provider = buildMistralRealtimeTranscriptionProvider();
expect(() => provider.createSession({ providerConfig: {} })).toThrow("Mistral API key missing");
});
it("connects through the public session boundary with the configured URL params", async () => {
const requests: URL[] = [];
const baseUrl = await createRealtimeServer((url) => requests.push(url));
const session = buildMistralRealtimeTranscriptionProvider().createSession({
providerConfig: {
apiKey: "fixture-value",
baseUrl,
model: "voxtral-mini-transcribe-realtime-2602",
sampleRate: 8000,
encoding: "pcm_mulaw",
targetStreamingDelayMs: 800,
},
});
await session.connect();
session.close();
expect(requests).toHaveLength(1);
expect(requests[0]?.pathname).toBe("/v1/audio/transcriptions/realtime");
expect(requests[0]?.searchParams.get("model")).toBe("voxtral-mini-transcribe-realtime-2602");
expect(requests[0]?.searchParams.get("target_streaming_delay_ms")).toBe("800");
});
});

View File

@@ -271,8 +271,3 @@ export function buildMistralRealtimeTranscriptionProvider(): RealtimeTranscripti
},
};
}
export const testing = {
normalizeProviderConfig,
toMistralRealtimeWsUrl,
};

View File

@@ -4,10 +4,20 @@ import {
installPinnedHostnameTestHooks,
} from "openclaw/plugin-sdk/test-env";
import { describe, expect, it } from "vitest";
import { describeMoonshotVideo } from "./media-understanding-provider.js";
import { moonshotMediaUnderstandingProvider } from "./media-understanding-provider.js";
installPinnedHostnameTestHooks();
async function describeVideo(
params: Parameters<NonNullable<typeof moonshotMediaUnderstandingProvider.describeVideo>>[0],
) {
const handler = moonshotMediaUnderstandingProvider.describeVideo;
if (!handler) {
throw new Error("expected Moonshot video description support");
}
return await handler(params);
}
function oversizedJsonResponse(params: { chunkCount: number; chunkSize: number }): {
response: Response;
getReadCount: () => number;
@@ -47,7 +57,7 @@ describe("describeMoonshotVideo", () => {
choices: [{ message: { content: "video ok" } }],
});
const result = await describeMoonshotVideo({
const result = await describeVideo({
buffer: Buffer.from("video-bytes"),
fileName: "clip.mp4",
apiKey: "moonshot-test",
@@ -112,7 +122,7 @@ describe("describeMoonshotVideo", () => {
choices: [{ message: { content: "", reasoning_content: "reasoned answer" } }],
});
const result = await describeMoonshotVideo({
const result = await describeVideo({
buffer: Buffer.from("video"),
fileName: "clip.mp4",
apiKey: "moonshot-test",
@@ -128,7 +138,7 @@ describe("describeMoonshotVideo", () => {
const streamed = oversizedJsonResponse({ chunkCount: 64, chunkSize: 1024 * 1024 });
await expect(
describeMoonshotVideo({
describeVideo({
buffer: Buffer.from("video-bytes"),
fileName: "clip.mp4",
mime: "video/mp4",
@@ -150,7 +160,7 @@ describe("describeMoonshotVideo", () => {
});
await expect(
describeMoonshotVideo({
describeVideo({
buffer: Buffer.from("video-bytes"),
fileName: "clip.mp4",
mime: "video/mp4",

View File

@@ -22,7 +22,7 @@ const DEFAULT_MOONSHOT_VIDEO_BASE_URL = "https://api.moonshot.ai/v1";
const DEFAULT_MOONSHOT_VIDEO_MODEL = MOONSHOT_DEFAULT_MODEL_ID;
const DEFAULT_MOONSHOT_VIDEO_PROMPT = "Describe the video.";
export async function describeMoonshotVideo(
async function describeMoonshotVideo(
params: VideoDescriptionRequest,
): Promise<VideoDescriptionResult> {
const fetchFn = params.fetchFn ?? fetch;

View File

@@ -7,7 +7,6 @@ import {
PIXVERSE_PROVIDER_ID,
} from "./constants.js";
import plugin from "./index.js";
import { applyPixVerseConfig, applyPixVerseProviderConfig } from "./onboard.js";
function registerPixVerseProvider() {
const captured = capturePluginRegistration(plugin);
@@ -22,23 +21,26 @@ function registerPixVerseProvider() {
return provider;
}
function createRuntimeContext(region: "international" | "cn") {
function createRuntimeContext(
region: "international" | "cn",
config: Record<string, unknown> = {
models: {
providers: {
pixverse: {
baseUrl: "https://proxy.example/openapi/v2",
models: [],
params: { quality: "720p" },
},
},
},
},
) {
const select = vi.fn(async (params: { message: string }) => {
expect(params.message).toBe("Select PixVerse API region");
return region;
});
const ctx = {
config: {
models: {
providers: {
pixverse: {
baseUrl: "https://proxy.example/openapi/v2",
models: [],
params: { quality: "720p" },
},
},
},
},
config,
env: {},
prompter: {
intro: vi.fn(),
@@ -145,7 +147,28 @@ describe("pixverse plugin", () => {
expect(result.notes).toEqual([`PixVerse endpoint: CN (${PIXVERSE_BASE_URL_BY_REGION.cn})`]);
});
it("only resets custom baseUrl when a region is explicitly selected", () => {
it("preserves an existing video generation default through setup", async () => {
const provider = registerPixVerseProvider();
const auth = provider?.auth?.[0];
if (!auth) {
throw new Error("expected PixVerse auth method");
}
const { ctx } = createRuntimeContext("international", {
agents: { defaults: { videoGenerationModel: { primary: "openai/sora-2" } } },
});
const result = await auth.run(ctx);
expect(result.configPatch?.agents?.defaults?.videoGenerationModel).toEqual({
primary: "openai/sora-2",
});
});
it("preserves a custom base URL during non-interactive setup without an explicit region", async () => {
const auth = registerPixVerseProvider().auth?.[0];
if (!auth?.runNonInteractive) {
throw new Error("expected PixVerse non-interactive auth method");
}
const config = {
models: {
providers: {
@@ -158,40 +181,52 @@ describe("pixverse plugin", () => {
},
};
expect(
applyPixVerseProviderConfig(config, "international").models?.providers?.pixverse,
).toEqual({
const result = await auth.runNonInteractive({
config,
opts: {},
env: {},
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
resolveApiKey: vi.fn(async () => ({ key: "fixture-value", source: "profile" })),
toApiKeyCredential: vi.fn(() => null),
} as never);
expect(result?.models?.providers?.pixverse).toMatchObject({
baseUrl: "https://proxy.example/openapi/v2",
models: [],
params: { quality: "720p" },
region: "international",
});
expect(
applyPixVerseProviderConfig(config, "cn", { resetBaseUrl: true }).models?.providers?.pixverse,
).toEqual({
});
it("resets a custom base URL when non-interactive setup selects a region", async () => {
const auth = registerPixVerseProvider().auth?.[0];
if (!auth?.runNonInteractive) {
throw new Error("expected PixVerse non-interactive auth method");
}
const config = {
models: {
providers: {
pixverse: {
baseUrl: "https://proxy.example/openapi/v2",
models: [],
params: { quality: "720p" },
},
},
},
};
const result = await auth.runNonInteractive({
config,
opts: { pixverseRegion: "cn" },
env: {},
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
resolveApiKey: vi.fn(async () => ({ key: "fixture-value", source: "profile" })),
toApiKeyCredential: vi.fn(() => null),
} as never);
expect(result?.models?.providers?.pixverse).toMatchObject({
baseUrl: PIXVERSE_BASE_URL_BY_REGION.cn,
models: [],
params: { quality: "720p" },
region: "cn",
});
});
it("preserves an existing video generation default", () => {
const result = applyPixVerseConfig(
{
agents: {
defaults: {
videoGenerationModel: {
primary: "openai/sora-2",
},
},
},
},
"international",
);
expect(result.agents?.defaults?.videoGenerationModel).toEqual({
primary: "openai/sora-2",
});
});
});

View File

@@ -66,7 +66,7 @@ function pixVerseRegionNote(region: PixVerseApiRegion): string {
return `PixVerse endpoint: ${label} (${PIXVERSE_BASE_URL_BY_REGION[region]})`;
}
export function applyPixVerseProviderConfig(
function applyPixVerseProviderConfig(
cfg: OpenClawConfig,
region: PixVerseApiRegion,
options?: { resetBaseUrl?: boolean },
@@ -94,7 +94,7 @@ export function applyPixVerseProviderConfig(
};
}
export function applyPixVerseConfig(
function applyPixVerseConfig(
cfg: OpenClawConfig,
region: PixVerseApiRegion,
options?: { resetBaseUrl?: boolean },

View File

@@ -4,7 +4,7 @@ import {
type WebSearchProviderPlugin,
} from "openclaw/plugin-sdk/provider-web-search-contract";
export const TAVILY_CREDENTIAL_PATH = "plugins.entries.tavily.config.webSearch.apiKey";
const TAVILY_CREDENTIAL_PATH = "plugins.entries.tavily.config.webSearch.apiKey";
export const TAVILY_GENERIC_SEARCH_DESCRIPTION =
"Search the web using Tavily. Returns structured results with snippets. Use tavily_search for Tavily-specific options like search depth, topic filtering, or AI answers.";

View File

@@ -42,15 +42,26 @@ describe("check-deadcode-exports", () => {
it.each([
"amazon-bedrock-mantle",
"azure-speech",
"cloudflare-ai-gateway",
"cohere",
"deepgram",
"elevenlabs",
"featherless",
"fireworks",
"huggingface",
"kilocode",
"kimi-coding",
"microsoft",
"minimax",
"mistral",
"moonshot",
"nvidia",
"pixverse",
"qianfan",
"qwen",
"senseaudio",
"tavily",
"tencent",
"vllm",
"xiaomi",