


















@@ -0,0 +1,180 @@
1+import { afterEach, describe, expect, it, vi } from "vitest";
2+import {
3+openrouterMediaUnderstandingProvider,
4+transcribeOpenRouterAudio,
5+} from "./media-understanding-provider.js";
6+7+const { assertOkOrThrowHttpErrorMock, postJsonRequestMock, resolveProviderHttpRequestConfigMock } =
8+vi.hoisted(() => ({
9+assertOkOrThrowHttpErrorMock: vi.fn(async () => {}),
10+postJsonRequestMock: vi.fn(),
11+resolveProviderHttpRequestConfigMock: vi.fn((params: Record<string, unknown>) => ({
12+baseUrl: params.baseUrl ?? params.defaultBaseUrl ?? "https://openrouter.ai/api/v1",
13+allowPrivateNetwork: false,
14+headers: new Headers(params.defaultHeaders as HeadersInit | undefined),
15+dispatcherPolicy: undefined,
16+})),
17+}));
18+19+vi.mock("openclaw/plugin-sdk/provider-http", () => ({
20+assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
21+postJsonRequest: postJsonRequestMock,
22+requireTranscriptionText: (value: string | undefined, message: string) => {
23+const text = value?.trim();
24+if (!text) {
25+throw new Error(message);
26+}
27+return text;
28+},
29+resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
30+}));
31+32+describe("openrouter media understanding provider", () => {
33+afterEach(() => {
34+assertOkOrThrowHttpErrorMock.mockClear();
35+postJsonRequestMock.mockReset();
36+resolveProviderHttpRequestConfigMock.mockClear();
37+});
38+39+it("declares image and audio capabilities with defaults", () => {
40+expect(openrouterMediaUnderstandingProvider).toMatchObject({
41+id: "openrouter",
42+capabilities: ["image", "audio"],
43+defaultModels: {
44+image: "auto",
45+audio: "openai/whisper-large-v3-turbo",
46+},
47+autoPriority: { audio: 35 },
48+});
49+expect(openrouterMediaUnderstandingProvider.transcribeAudio).toBeTypeOf("function");
50+});
51+52+it("sends JSON STT payload to OpenRouter transcriptions endpoint", async () => {
53+const release = vi.fn(async () => {});
54+postJsonRequestMock.mockResolvedValue({
55+response: new Response(JSON.stringify({ text: "hello world" }), { status: 200 }),
56+ release,
57+});
58+59+const result = await transcribeOpenRouterAudio({
60+buffer: Buffer.from("audio-bytes"),
61+fileName: "voice.oga",
62+mime: "audio/ogg",
63+apiKey: "sk-openrouter",
64+timeoutMs: 12_000,
65+language: " en ",
66+fetchFn: fetch,
67+});
68+69+expect(result).toEqual({
70+text: "hello world",
71+model: "openai/whisper-large-v3-turbo",
72+});
73+expect(resolveProviderHttpRequestConfigMock).toHaveBeenCalledWith(
74+expect.objectContaining({
75+provider: "openrouter",
76+capability: "audio",
77+}),
78+);
79+expect(postJsonRequestMock).toHaveBeenCalledWith(
80+expect.objectContaining({
81+url: "https://openrouter.ai/api/v1/audio/transcriptions",
82+timeoutMs: 12_000,
83+body: {
84+model: "openai/whisper-large-v3-turbo",
85+input_audio: {
86+data: Buffer.from("audio-bytes").toString("base64"),
87+format: "ogg",
88+},
89+language: "en",
90+},
91+}),
92+);
93+const headers = postJsonRequestMock.mock.calls[0]?.[0]?.headers as Headers;
94+expect(headers.get("authorization")).toBe("Bearer sk-openrouter");
95+expect(headers.get("http-referer")).toBe("https://openclaw.ai");
96+expect(headers.get("x-openrouter-title")).toBe("OpenClaw");
97+expect(release).toHaveBeenCalledOnce();
98+});
99+100+it("accepts temperature via provider query options", async () => {
101+const release = vi.fn(async () => {});
102+postJsonRequestMock.mockResolvedValue({
103+response: new Response(JSON.stringify({ text: "ok" }), { status: 200 }),
104+ release,
105+});
106+107+await transcribeOpenRouterAudio({
108+buffer: Buffer.from("audio"),
109+fileName: "voice.webm",
110+apiKey: "sk-openrouter",
111+timeoutMs: 5_000,
112+query: { temperature: 0.2 },
113+fetchFn: fetch,
114+});
115+116+expect(postJsonRequestMock).toHaveBeenCalledWith(
117+expect.objectContaining({
118+body: expect.objectContaining({
119+temperature: 0.2,
120+}),
121+}),
122+);
123+});
124+125+it("falls back to filename extension when mime is missing", async () => {
126+const release = vi.fn(async () => {});
127+postJsonRequestMock.mockResolvedValue({
128+response: new Response(JSON.stringify({ text: "ok" }), { status: 200 }),
129+ release,
130+});
131+132+await transcribeOpenRouterAudio({
133+buffer: Buffer.from("audio"),
134+fileName: "voice.opus",
135+apiKey: "sk-openrouter",
136+timeoutMs: 5_000,
137+fetchFn: fetch,
138+});
139+140+expect(postJsonRequestMock).toHaveBeenCalledWith(
141+expect.objectContaining({
142+body: expect.objectContaining({
143+input_audio: expect.objectContaining({ format: "ogg" }),
144+}),
145+}),
146+);
147+});
148+149+it("throws when format cannot be resolved", async () => {
150+await expect(
151+transcribeOpenRouterAudio({
152+buffer: Buffer.from("audio"),
153+fileName: "voice.bin",
154+mime: "application/octet-stream",
155+apiKey: "sk-openrouter",
156+timeoutMs: 5_000,
157+fetchFn: fetch,
158+}),
159+).rejects.toThrow("OpenRouter STT could not resolve audio format");
160+expect(postJsonRequestMock).not.toHaveBeenCalled();
161+});
162+163+it("throws when provider response omits text", async () => {
164+const release = vi.fn(async () => {});
165+postJsonRequestMock.mockResolvedValue({
166+response: new Response(JSON.stringify({}), { status: 200 }),
167+ release,
168+});
169+170+await expect(
171+transcribeOpenRouterAudio({
172+buffer: Buffer.from("audio"),
173+fileName: "voice.mp3",
174+apiKey: "sk-openrouter",
175+timeoutMs: 5_000,
176+fetchFn: fetch,
177+}),
178+).rejects.toThrow("OpenRouter transcription response missing text");
179+});
180+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。