mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-25 20:01:13 +00:00
refactor(deadcode): tighten more provider extension roots (#108515)
This commit is contained in:
committed by
GitHub
parent
16801bf4f0
commit
9bee0d4cb8
@@ -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(),
|
||||
|
||||
@@ -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" bad="1">` +
|
||||
`<voice name="en-US-JennyNeural" xml:lang="evil">` +
|
||||
`Tom & "Jerry" <tag></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" bad="1">` +
|
||||
`<voice name="en-US-JennyNeural" xml:lang="evil">` +
|
||||
`Tom & "Jerry" <tag></voice></speak>`,
|
||||
);
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
|
||||
@@ -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" ` +
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -268,10 +268,3 @@ export function buildDeepgramRealtimeTranscriptionProvider(): RealtimeTranscript
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const testing = {
|
||||
normalizeProviderConfig,
|
||||
normalizeDeepgramRealtimeBaseUrl,
|
||||
toDeepgramRealtimeWsUrl,
|
||||
};
|
||||
export { testing as __testing };
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -291,8 +291,3 @@ export function buildElevenLabsRealtimeTranscriptionProvider(): RealtimeTranscri
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const testing = {
|
||||
normalizeProviderConfig,
|
||||
toElevenLabsRealtimeWsUrl,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -271,8 +271,3 @@ export function buildMistralRealtimeTranscriptionProvider(): RealtimeTranscripti
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const testing = {
|
||||
normalizeProviderConfig,
|
||||
toMistralRealtimeWsUrl,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user