diff --git a/config/knip.config.ts b/config/knip.config.ts index 989ecf854e91..86532f42d02a 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -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(), diff --git a/extensions/azure-speech/tts.test.ts b/extensions/azure-speech/tts.test.ts index f576ff764f6b..b4f5f945e9ab 100644 --- a/extensions/azure-speech/tts.test.ts +++ b/extensions/azure-speech/tts.test.ts @@ -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" `, - voice: `en-US-JennyNeural" xml:lang="evil`, - lang: `en-US" bad="1`, - }), - ).toBe( - `` + - `` + - `Tom & "Jerry" <tag>`, - ); - }); - 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" `, + 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(`hello`); + expect(init.body).toBe( + `` + + `` + + `Tom & "Jerry" <tag>`, + ); expect(init.signal).toBeInstanceOf(AbortSignal); }); diff --git a/extensions/azure-speech/tts.ts b/extensions/azure-speech/tts.ts index b65f3c2e9359..acc5698315de 100644 --- a/extensions/azure-speech/tts.ts +++ b/extensions/azure-speech/tts.ts @@ -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 ( ` ({ warnMock: vi.fn(), @@ -33,9 +29,13 @@ function createPayloadBaseStream(payload: Record): StreamFn { } function runWrapper(payload: Record): Record { - 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): Record { 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); }); }); diff --git a/extensions/cloudflare-ai-gateway/stream-wrappers.ts b/extensions/cloudflare-ai-gateway/stream-wrappers.ts index 5da8b1eae29f..41691b733a14 100644 --- a/extensions/cloudflare-ai-gateway/stream-wrappers.ts +++ b/extensions/cloudflare-ai-gateway/stream-wrappers.ts @@ -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 }; diff --git a/extensions/deepgram/realtime-transcription-provider.test.ts b/extensions/deepgram/realtime-transcription-provider.test.ts index c50f7a552638..c194dc28d7ad 100644 --- a/extensions/deepgram/realtime-transcription-provider.test.ts +++ b/extensions/deepgram/realtime-transcription-provider.test.ts @@ -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) | 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; diff --git a/extensions/deepgram/realtime-transcription-provider.ts b/extensions/deepgram/realtime-transcription-provider.ts index 65d01dc72ab0..68239d2d62f0 100644 --- a/extensions/deepgram/realtime-transcription-provider.ts +++ b/extensions/deepgram/realtime-transcription-provider.ts @@ -268,10 +268,3 @@ export function buildDeepgramRealtimeTranscriptionProvider(): RealtimeTranscript }, }; } - -export const testing = { - normalizeProviderConfig, - normalizeDeepgramRealtimeBaseUrl, - toDeepgramRealtimeWsUrl, -}; -export { testing as __testing }; diff --git a/extensions/elevenlabs/realtime-transcription-provider.test.ts b/extensions/elevenlabs/realtime-transcription-provider.test.ts index fa00b798ef82..992611a29e71 100644 --- a/extensions/elevenlabs/realtime-transcription-provider.test.ts +++ b/extensions/elevenlabs/realtime-transcription-provider.test.ts @@ -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) | undefined; + +async function createRealtimeServer(onRequest: (url: URL) => void) { + const server = createServer(); + const wss = new WebSocketServer({ noServer: true }); + const clients = new Set(); + 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((resolve) => server.listen(0, "127.0.0.1", resolve)); + cleanup = async () => { + for (const ws of clients) { + ws.terminate(); + } + await new Promise((resolve) => wss.close(() => resolve())); + await new Promise((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"); }); }); diff --git a/extensions/elevenlabs/realtime-transcription-provider.ts b/extensions/elevenlabs/realtime-transcription-provider.ts index 0393caa74b5b..9f918396c2ab 100644 --- a/extensions/elevenlabs/realtime-transcription-provider.ts +++ b/extensions/elevenlabs/realtime-transcription-provider.ts @@ -291,8 +291,3 @@ export function buildElevenLabsRealtimeTranscriptionProvider(): RealtimeTranscri }, }; } - -export const testing = { - normalizeProviderConfig, - toElevenLabsRealtimeWsUrl, -}; diff --git a/extensions/kimi-coding/stream.test.ts b/extensions/kimi-coding/stream.test.ts index cd1e06ce3f91..4e3246d25739 100644 --- a/extensions/kimi-coding/stream.test.ts +++ b/extensions/kimi-coding/stream.test.ts @@ -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; @@ -83,20 +78,11 @@ function createPayloadCapturingStream(initialPayload: Record = 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; - 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; - 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", diff --git a/extensions/kimi-coding/stream.ts b/extensions/kimi-coding/stream.ts index c60b046ffe05..cb31c93a97cc 100644 --- a/extensions/kimi-coding/stream.ts +++ b/extensions/kimi-coding/stream.ts @@ -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 { diff --git a/extensions/microsoft/microsoft.live.test.ts b/extensions/microsoft/microsoft.live.test.ts index a41ca69cecf5..7b869a524d6c 100644 --- a/extensions/microsoft/microsoft.live.test.ts +++ b/extensions/microsoft/microsoft.live.test.ts @@ -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"); diff --git a/extensions/microsoft/speech-provider.test.ts b/extensions/microsoft/speech-provider.test.ts index 1d66159d4f95..729c4840eacf 100644 --- a/extensions/microsoft/speech-provider.test.ts +++ b/extensions/microsoft/speech-provider.test.ts @@ -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): { 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> = []; 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(); diff --git a/extensions/microsoft/speech-provider.ts b/extensions/microsoft/speech-provider.ts index 582ecbacfabc..7ba2c8126ea1 100644 --- a/extensions/microsoft/speech-provider.ts +++ b/extensions/microsoft/speech-provider.ts @@ -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 { const url = diff --git a/extensions/minimax/model-definitions.test.ts b/extensions/minimax/model-definitions.test.ts index b3496c6656e4..0a2994cfca88 100644 --- a/extensions/minimax/model-definitions.test.ts +++ b/extensions/minimax/model-definitions.test.ts @@ -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 }); }); }); diff --git a/extensions/minimax/model-definitions.ts b/extensions/minimax/model-definitions.ts index 1332672466a8..640ebeb16cae 100644 --- a/extensions/minimax/model-definitions.ts +++ b/extensions/minimax/model-definitions.ts @@ -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, diff --git a/extensions/minimax/oauth.test.ts b/extensions/minimax/oauth.test.ts index d8f5f0cc628e..796ac15fab6f 100644 --- a/extensions/minimax/oauth.test.ts +++ b/extensions/minimax/oauth.test.ts @@ -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(); diff --git a/extensions/minimax/oauth.ts b/extensions/minimax/oauth.ts index fd8d9cd9ab9c..4c326b2b58fd 100644 --- a/extensions/minimax/oauth.ts +++ b/extensions/minimax/oauth.ts @@ -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, diff --git a/extensions/mistral/model-definitions.test.ts b/extensions/mistral/model-definitions.test.ts index d79969d21d6f..8f37843ff0ef 100644 --- a/extensions/mistral/model-definitions.test.ts +++ b/extensions/mistral/model-definitions.test.ts @@ -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, id: string) { +function buildCatalogModels() { + return buildMistralProvider().models; +} + +function catalogModelById(models: ReturnType, 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); diff --git a/extensions/mistral/model-definitions.ts b/extensions/mistral/model-definitions.ts index a5f2171aa8c3..42f6d0eda21c 100644 --- a/extensions/mistral/model-definitions.ts +++ b/extensions/mistral/model-definitions.ts @@ -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, diff --git a/extensions/mistral/realtime-transcription-provider.test.ts b/extensions/mistral/realtime-transcription-provider.test.ts index 6bcbe53131db..4ae5eb8b6da7 100644 --- a/extensions/mistral/realtime-transcription-provider.test.ts +++ b/extensions/mistral/realtime-transcription-provider.test.ts @@ -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) | undefined; + +async function createRealtimeServer(onRequest: (url: URL) => void) { + const server = createServer(); + const wss = new WebSocketServer({ noServer: true }); + const clients = new Set(); + 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((resolve) => server.listen(0, "127.0.0.1", resolve)); + cleanup = async () => { + for (const ws of clients) { + ws.terminate(); + } + await new Promise((resolve) => wss.close(() => resolve())); + await new Promise((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"); + }); }); diff --git a/extensions/mistral/realtime-transcription-provider.ts b/extensions/mistral/realtime-transcription-provider.ts index 2a867556232d..649c3db99394 100644 --- a/extensions/mistral/realtime-transcription-provider.ts +++ b/extensions/mistral/realtime-transcription-provider.ts @@ -271,8 +271,3 @@ export function buildMistralRealtimeTranscriptionProvider(): RealtimeTranscripti }, }; } - -export const testing = { - normalizeProviderConfig, - toMistralRealtimeWsUrl, -}; diff --git a/extensions/moonshot/media-understanding-provider.test.ts b/extensions/moonshot/media-understanding-provider.test.ts index 27070ed04d44..92a4a2e117db 100644 --- a/extensions/moonshot/media-understanding-provider.test.ts +++ b/extensions/moonshot/media-understanding-provider.test.ts @@ -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>[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", diff --git a/extensions/moonshot/media-understanding-provider.ts b/extensions/moonshot/media-understanding-provider.ts index 7c0c287482cc..7fbe8c83323f 100644 --- a/extensions/moonshot/media-understanding-provider.ts +++ b/extensions/moonshot/media-understanding-provider.ts @@ -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 { const fetchFn = params.fetchFn ?? fetch; diff --git a/extensions/pixverse/index.test.ts b/extensions/pixverse/index.test.ts index ebed934c0a0e..8400887634b4 100644 --- a/extensions/pixverse/index.test.ts +++ b/extensions/pixverse/index.test.ts @@ -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 = { + 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", - }); - }); }); diff --git a/extensions/pixverse/onboard.ts b/extensions/pixverse/onboard.ts index 6d9d0206960c..c123fd9765fe 100644 --- a/extensions/pixverse/onboard.ts +++ b/extensions/pixverse/onboard.ts @@ -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 }, diff --git a/extensions/tavily/web-search-shared.ts b/extensions/tavily/web-search-shared.ts index 5cdac9689972..d4baa99182c0 100644 --- a/extensions/tavily/web-search-shared.ts +++ b/extensions/tavily/web-search-shared.ts @@ -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."; diff --git a/test/scripts/check-deadcode-exports.test.ts b/test/scripts/check-deadcode-exports.test.ts index ccc4c136d008..15a074daf39d 100644 --- a/test/scripts/check-deadcode-exports.test.ts +++ b/test/scripts/check-deadcode-exports.test.ts @@ -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",