|
| 1 | +import { describe, expect, it, vi } from "vitest"; |
| 2 | +import { validateActionInput } from "../../core/validation.ts"; |
| 3 | +import { groqcloudActions } from "./actions.ts"; |
| 4 | +import { groqcloudActionHandlers } from "./executors.ts"; |
| 5 | + |
| 6 | +const apiBaseUrl = "https://api.groq.com/openai/v1"; |
| 7 | +const audioBase64 = Buffer.from("fake-audio-bytes").toString("base64"); |
| 8 | + |
| 9 | +function createContext(fetcher: typeof fetch) { |
| 10 | + return { |
| 11 | + apiKey: "test-key", |
| 12 | + fetcher, |
| 13 | + }; |
| 14 | +} |
| 15 | + |
| 16 | +function jsonFetcher(payload: Record<string, unknown> = {}): typeof fetch { |
| 17 | + return vi.fn(async () => Response.json(payload)) as typeof fetch; |
| 18 | +} |
| 19 | + |
| 20 | +function requestInit(fetcher: typeof fetch): RequestInit { |
| 21 | + return vi.mocked(fetcher).mock.calls[0]![1] as RequestInit; |
| 22 | +} |
| 23 | + |
| 24 | +function requestForm(fetcher: typeof fetch): FormData { |
| 25 | + return requestInit(fetcher).body as FormData; |
| 26 | +} |
| 27 | + |
| 28 | +describe("GroqCloud audio transcription", () => { |
| 29 | + it("uploads inline base64 audio as multipart form data", async () => { |
| 30 | + const fetcher = jsonFetcher({ text: "hello" }); |
| 31 | + |
| 32 | + await groqcloudActionHandlers.create_audio_transcription( |
| 33 | + { |
| 34 | + model: "whisper-large-v3-turbo", |
| 35 | + file: { name: "meeting.mp3", mimetype: "audio/mpeg", content_base64: audioBase64 }, |
| 36 | + language: "en", |
| 37 | + temperature: 0, |
| 38 | + }, |
| 39 | + createContext(fetcher), |
| 40 | + ); |
| 41 | + |
| 42 | + expect(fetcher).toHaveBeenCalledWith( |
| 43 | + `${apiBaseUrl}/audio/transcriptions`, |
| 44 | + expect.objectContaining({ method: "POST" }), |
| 45 | + ); |
| 46 | + |
| 47 | + const form = requestForm(fetcher); |
| 48 | + const uploaded = form.get("file") as File; |
| 49 | + expect(uploaded.name).toBe("meeting.mp3"); |
| 50 | + expect(uploaded.type).toBe("audio/mpeg"); |
| 51 | + expect(await uploaded.text()).toBe("fake-audio-bytes"); |
| 52 | + expect(form.get("model")).toBe("whisper-large-v3-turbo"); |
| 53 | + expect(form.get("language")).toBe("en"); |
| 54 | + expect(form.get("temperature")).toBe("0"); |
| 55 | + }); |
| 56 | + |
| 57 | + it("omits the JSON content type so the multipart boundary is preserved", async () => { |
| 58 | + const fetcher = jsonFetcher({ text: "hello" }); |
| 59 | + |
| 60 | + await groqcloudActionHandlers.create_audio_transcription( |
| 61 | + { model: "whisper-large-v3", file: { name: "a.mp3", content_base64: audioBase64 } }, |
| 62 | + createContext(fetcher), |
| 63 | + ); |
| 64 | + |
| 65 | + const headers = requestInit(fetcher).headers as Record<string, string>; |
| 66 | + expect(headers["content-type"]).toBeUndefined(); |
| 67 | + expect(headers.authorization).toBe("Bearer test-key"); |
| 68 | + }); |
| 69 | + |
| 70 | + it("forwards a public url instead of downloading the audio", async () => { |
| 71 | + const fetcher = jsonFetcher({ text: "hello" }); |
| 72 | + |
| 73 | + await groqcloudActionHandlers.create_audio_translation( |
| 74 | + { model: "whisper-large-v3", file: { url: "https://example.com/clip.mp3" } }, |
| 75 | + createContext(fetcher), |
| 76 | + ); |
| 77 | + |
| 78 | + expect(vi.mocked(fetcher).mock.calls).toHaveLength(1); |
| 79 | + expect(String(vi.mocked(fetcher).mock.calls[0]![0])).toBe(`${apiBaseUrl}/audio/translations`); |
| 80 | + const form = requestForm(fetcher); |
| 81 | + expect(form.get("url")).toBe("https://example.com/clip.mp3"); |
| 82 | + expect(form.get("file")).toBeNull(); |
| 83 | + }); |
| 84 | + |
| 85 | + it("rejects private and loopback audio urls before forwarding them", () => { |
| 86 | + const fetcher = jsonFetcher(); |
| 87 | + |
| 88 | + expect(() => |
| 89 | + groqcloudActionHandlers.create_audio_transcription( |
| 90 | + { model: "whisper-large-v3", file: { url: "http://127.0.0.1/internal.mp3" } }, |
| 91 | + createContext(fetcher), |
| 92 | + ), |
| 93 | + ).toThrow(); |
| 94 | + |
| 95 | + expect(fetcher).not.toHaveBeenCalled(); |
| 96 | + }); |
| 97 | + |
| 98 | + it("requires exactly one audio source", () => { |
| 99 | + const fetcher = jsonFetcher(); |
| 100 | + |
| 101 | + expect(() => |
| 102 | + groqcloudActionHandlers.create_audio_transcription({ model: "whisper-large-v3" }, createContext(fetcher)), |
| 103 | + ).toThrow("file is required"); |
| 104 | + |
| 105 | + expect(() => |
| 106 | + groqcloudActionHandlers.create_audio_transcription( |
| 107 | + { model: "whisper-large-v3", file: { name: "a.mp3" } }, |
| 108 | + createContext(fetcher), |
| 109 | + ), |
| 110 | + ).toThrow("file must include url or content_base64"); |
| 111 | + |
| 112 | + expect(() => |
| 113 | + groqcloudActionHandlers.create_audio_transcription( |
| 114 | + { |
| 115 | + model: "whisper-large-v3", |
| 116 | + file: { name: "a.mp3", content_base64: audioBase64, url: "https://example.com/clip.mp3" }, |
| 117 | + }, |
| 118 | + createContext(fetcher), |
| 119 | + ), |
| 120 | + ).toThrow("provide only one of file.url or file.content_base64"); |
| 121 | + |
| 122 | + expect(fetcher).not.toHaveBeenCalled(); |
| 123 | + }); |
| 124 | + |
| 125 | + it("rejects malformed base64 audio content", () => { |
| 126 | + const fetcher = jsonFetcher(); |
| 127 | + |
| 128 | + expect(() => |
| 129 | + groqcloudActionHandlers.create_audio_transcription( |
| 130 | + { model: "whisper-large-v3", file: { name: "a.mp3", content_base64: "not*base64" } }, |
| 131 | + createContext(fetcher), |
| 132 | + ), |
| 133 | + ).toThrow("file.content_base64 must be valid base64"); |
| 134 | + }); |
| 135 | + |
| 136 | + it("rejects inline audio above GroqCloud's attachment limit", () => { |
| 137 | + const fetcher = jsonFetcher(); |
| 138 | + const attachmentMaxBytes = 25 * 1024 * 1024; |
| 139 | + const oversizedAudioBase64 = Buffer.alloc(attachmentMaxBytes + 1).toString("base64"); |
| 140 | + |
| 141 | + expect(() => |
| 142 | + groqcloudActionHandlers.create_audio_transcription( |
| 143 | + { model: "whisper-large-v3", file: { name: "a.mp3", content_base64: oversizedAudioBase64 } }, |
| 144 | + createContext(fetcher), |
| 145 | + ), |
| 146 | + ).toThrow(`file.content_base64 exceeds ${attachmentMaxBytes} bytes`); |
| 147 | + |
| 148 | + expect(fetcher).not.toHaveBeenCalled(); |
| 149 | + }); |
| 150 | + |
| 151 | + it("repeats timestamp granularities as an array field and requires verbose_json", async () => { |
| 152 | + const fetcher = jsonFetcher({ text: "hello" }); |
| 153 | + |
| 154 | + await groqcloudActionHandlers.create_audio_transcription( |
| 155 | + { |
| 156 | + model: "whisper-large-v3", |
| 157 | + file: { name: "a.mp3", content_base64: audioBase64 }, |
| 158 | + response_format: "verbose_json", |
| 159 | + timestamp_granularities: ["segment", "word"], |
| 160 | + }, |
| 161 | + createContext(fetcher), |
| 162 | + ); |
| 163 | + |
| 164 | + expect(requestForm(fetcher).getAll("timestamp_granularities[]")).toEqual(["segment", "word"]); |
| 165 | + expect(requestForm(fetcher).get("timestamp_granularities")).toBeNull(); |
| 166 | + |
| 167 | + expect(() => |
| 168 | + groqcloudActionHandlers.create_audio_transcription( |
| 169 | + { |
| 170 | + model: "whisper-large-v3", |
| 171 | + file: { name: "a.mp3", content_base64: audioBase64 }, |
| 172 | + timestamp_granularities: ["word"], |
| 173 | + }, |
| 174 | + createContext(jsonFetcher()), |
| 175 | + ), |
| 176 | + ).toThrow("timestamp_granularities requires response_format=verbose_json"); |
| 177 | + }); |
| 178 | + |
| 179 | + it("offers turbo for transcription but not for translation", () => { |
| 180 | + const transcription = groqcloudActions.find((action) => action.id === "groqcloud.create_audio_transcription")!; |
| 181 | + const translation = groqcloudActions.find((action) => action.id === "groqcloud.create_audio_translation")!; |
| 182 | + const file = { url: "https://example.com/a.mp3" }; |
| 183 | + |
| 184 | + expect(validateActionInput(transcription, { model: "whisper-large-v3-turbo", file }).valid).toBe(true); |
| 185 | + expect(validateActionInput(translation, { model: "whisper-large-v3", file }).valid).toBe(true); |
| 186 | + expect(validateActionInput(translation, { model: "whisper-large-v3-turbo", file }).valid).toBe(false); |
| 187 | + }); |
| 188 | + |
| 189 | + it("requires a name alongside inline audio content", () => { |
| 190 | + const action = groqcloudActions.find((action) => action.id === "groqcloud.create_audio_transcription")!; |
| 191 | + |
| 192 | + expect( |
| 193 | + validateActionInput(action, { model: "whisper-large-v3", file: { name: "a.mp3", content_base64: audioBase64 } }) |
| 194 | + .valid, |
| 195 | + ).toBe(true); |
| 196 | + expect( |
| 197 | + validateActionInput(action, { model: "whisper-large-v3", file: { content_base64: audioBase64 } }).valid, |
| 198 | + ).toBe(false); |
| 199 | + expect( |
| 200 | + validateActionInput(action, { |
| 201 | + model: "whisper-large-v3", |
| 202 | + file: { name: "a.mp3", content_base64: audioBase64, url: "https://example.com/a.mp3" }, |
| 203 | + }).valid, |
| 204 | + ).toBe(false); |
| 205 | + }); |
| 206 | + |
| 207 | + it("rejects empty audio urls in the action schema", () => { |
| 208 | + const action = groqcloudActions.find((action) => action.id === "groqcloud.create_audio_transcription")!; |
| 209 | + |
| 210 | + expect(validateActionInput(action, { model: "whisper-large-v3", file: { url: "" } }).valid).toBe(false); |
| 211 | + }); |
| 212 | +}); |
0 commit comments