426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
252 lines
8.7 KiB
TypeScript
252 lines
8.7 KiB
TypeScript
/**
|
|
* Behavior tests for the Groq plugin: init key requirement, model-handler
|
|
* registration, and retry classification, against a stubbed runtime and mocked
|
|
* globals (no live Groq API).
|
|
*/
|
|
import { type IAgentRuntime, ModelType } from "@elizaos/core";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import groqPlugin, { classifyRetryError } from "../index";
|
|
|
|
function runtime(settings: Record<string, string | undefined> = {}): IAgentRuntime {
|
|
return {
|
|
character: { system: "system prompt" },
|
|
emitEvent: vi.fn(),
|
|
getService: vi.fn(),
|
|
getSetting: (key: string) => settings[key],
|
|
} as unknown as IAgentRuntime;
|
|
}
|
|
|
|
describe("@elizaos/plugin-groq behavior", () => {
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("requires GROQ_API_KEY during node init", async () => {
|
|
await expect(groqPlugin.init?.({}, runtime())).rejects.toThrow("GROQ_API_KEY is required");
|
|
await expect(groqPlugin.init?.({}, runtime({ GROQ_API_KEY: " \t\n " }))).rejects.toThrow(
|
|
"GROQ_API_KEY is required"
|
|
);
|
|
await expect(
|
|
groqPlugin.init?.({}, runtime({ GROQ_API_KEY: "gsk-test" }))
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("classifies retryable rate-limit and transient errors separately from fatal errors", () => {
|
|
expect(classifyRetryError(new Error("Rate limit exceeded, try again in 2.5s"))).toBe(
|
|
"rate-limit"
|
|
);
|
|
expect(classifyRetryError(new Error("fetch failed: ECONNRESET"))).toBe("transient");
|
|
expect(classifyRetryError(new Error("invalid API key"))).toBe("fatal");
|
|
expect(classifyRetryError("plain string")).toBe("fatal");
|
|
});
|
|
|
|
it("sends transcription requests with auth, model, and audio form data", async () => {
|
|
const fetchMock = vi.fn(
|
|
async () =>
|
|
new Response(JSON.stringify({ text: "transcribed text" }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
})
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
await expect(
|
|
groqPlugin.models?.[ModelType.TRANSCRIPTION]?.(
|
|
runtime({ GROQ_API_KEY: "gsk-test", GROQ_BASE_URL: "https://groq.test/v1" }),
|
|
{ audioData: new Uint8Array([1, 2, 3]) }
|
|
)
|
|
).resolves.toBe("transcribed text");
|
|
|
|
const [, init] = fetchMock.mock.calls[0];
|
|
expect(fetchMock.mock.calls[0][0]).toBe("https://groq.test/v1/audio/transcriptions");
|
|
expect(init).toMatchObject({
|
|
method: "POST",
|
|
headers: { Authorization: "Bearer gsk-test" },
|
|
});
|
|
expect(init.body).toBeInstanceOf(FormData);
|
|
expect((init.body as FormData).get("model")).toBe("whisper-large-v3-turbo");
|
|
expect((init.body as FormData).get("file")).toBeInstanceOf(File);
|
|
});
|
|
|
|
it("lets the transcription model be overridden via GROQ_TRANSCRIPTION_MODEL", async () => {
|
|
const fetchMock = vi.fn(
|
|
async () =>
|
|
new Response(JSON.stringify({ text: "ok" }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
})
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
await groqPlugin.models?.[ModelType.TRANSCRIPTION]?.(
|
|
runtime({ GROQ_API_KEY: "gsk-test", GROQ_TRANSCRIPTION_MODEL: "whisper-large-v3" }),
|
|
{ audioData: new Uint8Array([1, 2, 3]) }
|
|
);
|
|
|
|
const [, init] = fetchMock.mock.calls[0];
|
|
expect((init.body as FormData).get("model")).toBe("whisper-large-v3");
|
|
});
|
|
|
|
it("falls back to the generic TRANSCRIPTION_MODEL setting when no Groq-specific one is set", async () => {
|
|
const fetchMock = vi.fn(
|
|
async () =>
|
|
new Response(JSON.stringify({ text: "ok" }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
})
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
await groqPlugin.models?.[ModelType.TRANSCRIPTION]?.(
|
|
runtime({ GROQ_API_KEY: "gsk-test", TRANSCRIPTION_MODEL: "distil-whisper-large-v3-en" }),
|
|
{ audioData: new Uint8Array([1, 2, 3]) }
|
|
);
|
|
|
|
const [, init] = fetchMock.mock.calls[0];
|
|
expect((init.body as FormData).get("model")).toBe("distil-whisper-large-v3-en");
|
|
});
|
|
|
|
it("rejects empty or malformed transcription input before calling the API", async () => {
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
await expect(
|
|
groqPlugin.models?.[ModelType.TRANSCRIPTION]?.(runtime({ GROQ_API_KEY: "gsk-test" }), {})
|
|
).rejects.toThrow("Groq TRANSCRIPTION requires non-empty audio data.");
|
|
await expect(
|
|
groqPlugin.models?.[ModelType.TRANSCRIPTION]?.(runtime({ GROQ_API_KEY: "gsk-test" }), "")
|
|
).rejects.toThrow("Groq TRANSCRIPTION requires non-empty audio data.");
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("throws transcription response details on non-ok responses", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async () => new Response("bad audio", { status: 400, statusText: "Bad Request" }))
|
|
);
|
|
|
|
await expect(
|
|
groqPlugin.models?.[ModelType.TRANSCRIPTION]?.(
|
|
runtime({ GROQ_API_KEY: "gsk-test" }),
|
|
Buffer.from("audio")
|
|
)
|
|
).rejects.toThrow("Transcription failed: 400 bad audio");
|
|
});
|
|
|
|
it("sends text-to-speech requests with runtime and payload overrides", async () => {
|
|
const bytes = new Uint8Array([10, 20, 30]).buffer;
|
|
const fetchMock = vi.fn(async () => new Response(bytes, { status: 200 }));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
await expect(
|
|
groqPlugin.models?.[ModelType.TEXT_TO_SPEECH]?.(
|
|
runtime({
|
|
GROQ_API_KEY: "gsk-test",
|
|
GROQ_BASE_URL: "https://groq.test/v1",
|
|
GROQ_TTS_MODEL: "runtime-model",
|
|
GROQ_TTS_VOICE: "runtime-voice",
|
|
GROQ_TTS_RESPONSE_FORMAT: "mp3",
|
|
}),
|
|
{
|
|
text: "hello",
|
|
model: "payload-model",
|
|
voice: "payload-voice",
|
|
response_format: "wav",
|
|
}
|
|
)
|
|
).resolves.toEqual(new Uint8Array([10, 20, 30]));
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith("https://groq.test/v1/audio/speech", {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: "Bearer gsk-test",
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
model: "payload-model",
|
|
voice: "payload-voice",
|
|
input: "hello",
|
|
response_format: "wav",
|
|
}),
|
|
});
|
|
});
|
|
|
|
it("rejects malformed text-to-speech input before calling the API", async () => {
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
for (const params of [null, undefined, {}, { text: "" }, { text: " \n\t " }]) {
|
|
await expect(
|
|
groqPlugin.models?.[ModelType.TEXT_TO_SPEECH]?.(
|
|
runtime({ GROQ_API_KEY: "gsk-test" }),
|
|
params
|
|
)
|
|
).rejects.toThrow("Groq TEXT_TO_SPEECH requires non-empty text.");
|
|
}
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects invalid GROQ_BASE_URL values before calling the API", async () => {
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
for (const baseURL of ["not a url", "ftp://groq.test/v1", "javascript:alert(1)"]) {
|
|
await expect(
|
|
groqPlugin.models?.[ModelType.TEXT_TO_SPEECH]?.(
|
|
runtime({ GROQ_API_KEY: "gsk-test", GROQ_BASE_URL: baseURL }),
|
|
{ text: "hello" }
|
|
)
|
|
).rejects.toThrow("GROQ_BASE_URL must be a valid http(s) URL");
|
|
}
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("trims trailing slash from GROQ_BASE_URL when building audio endpoints", async () => {
|
|
const fetchMock = vi.fn(async () => new Response(new Uint8Array([1]).buffer, { status: 200 }));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
await groqPlugin.models?.[ModelType.TEXT_TO_SPEECH]?.(
|
|
runtime({ GROQ_API_KEY: "gsk-test", GROQ_BASE_URL: "https://groq.test/v1/" }),
|
|
{ text: "hello" }
|
|
);
|
|
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe("https://groq.test/v1/audio/speech");
|
|
});
|
|
|
|
it("throws text-to-speech response details on non-ok responses", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async () => new Response("voice unavailable", { status: 422 }))
|
|
);
|
|
|
|
await expect(
|
|
groqPlugin.models?.[ModelType.TEXT_TO_SPEECH]?.(runtime({ GROQ_API_KEY: "gsk-test" }), {
|
|
text: "hello",
|
|
})
|
|
).rejects.toThrow("TTS failed: 422 voice unavailable");
|
|
});
|
|
|
|
it("keeps text-to-speech defaults aligned with plugin metadata", async () => {
|
|
const bytes = new Uint8Array([10, 20, 30]).buffer;
|
|
const fetchMock = vi.fn(async () => new Response(bytes, { status: 200 }));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
await expect(
|
|
groqPlugin.models?.[ModelType.TEXT_TO_SPEECH]?.(runtime({ GROQ_API_KEY: "gsk-test" }), {
|
|
text: "hello",
|
|
})
|
|
).resolves.toEqual(new Uint8Array([10, 20, 30]));
|
|
|
|
const [, init] = fetchMock.mock.calls[0];
|
|
expect(JSON.parse(init.body as string)).toMatchObject({
|
|
model: "canopylabs/orpheus-v1-english",
|
|
voice: "troy",
|
|
input: "hello",
|
|
response_format: "wav",
|
|
});
|
|
});
|
|
});
|