Files
wehub-resource-sync 426e9eeabd
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Waiting to run
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Waiting to run
Build Agent Image / build-and-push (push) Waiting to run
Chat shell gestures / Chat shell gesture + parity e2e (push) Waiting to run
ci / test (push) Waiting to run
ci / lint-and-format (push) Waiting to run
ci / build (push) Waiting to run
ci / dev-startup (push) Waiting to run
Cloud Gateway Discord / Test (push) Waiting to run
Cloud Gateway Webhook / Test (push) Waiting to run
Cloud Tests / lint-and-types (push) Waiting to run
Cloud Tests / unit-tests (push) Waiting to run
Cloud Tests / integration-tests (push) Waiting to run
Cloud Tests / e2e-tests (push) Blocked by required conditions
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Deploy Apps Worker (Product 2) / Determine environment (push) Waiting to run
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Blocked by required conditions
Deploy Eliza Provisioning Worker / Determine environment (push) Waiting to run
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Blocked by required conditions
Dev Smoke / Classify changed paths (push) Waiting to run
Dev Smoke / bun run dev onboarding chat (push) Blocked by required conditions
Dev Smoke / Vite HMR dependency-level smoke (push) Blocked by required conditions
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Waiting to run
gitleaks / gitleaks (push) Waiting to run
Markdown Links / Relative Markdown Links (push) Waiting to run
Publish @elizaos/example-code / check_npm (push) Waiting to run
Publish @elizaos/example-code / publish_npm (push) Blocked by required conditions
Publish @elizaos/plugin-elizacloud / verify_version (push) Waiting to run
Publish @elizaos/plugin-elizacloud / publish_npm (push) Blocked by required conditions
Quality (Extended) / Homepage Build (PR smoke) (push) Waiting to run
Quality (Extended) / Comment-only diff guard (push) Waiting to run
Quality (Extended) / Format + Type Safety Ratchet (push) Waiting to run
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Waiting to run
Quality (Extended) / Develop Gate (lint) (push) Waiting to run
Sandbox Live Smoke / Sandbox live smoke (push) Waiting to run
Snap Build & Test / Build Snap (amd64) (push) Waiting to run
Snap Build & Test / Build Snap (arm64) (push) Waiting to run
supply-chain / sbom (push) Waiting to run
supply-chain / vulnerability-scan (push) Waiting to run
Build, Push & Deploy to Phala Cloud / build-and-push (push) Waiting to run
Test Packaging / Validate Packaging Configs (push) Waiting to run
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Waiting to run
Test Packaging / Pack & Test JS Tarballs (push) Waiting to run
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Waiting to run
UI Fixture E2E / ui-fixture-e2e (push) Waiting to run
UI Fixture E2E / fixture-e2e (push) Waiting to run
UI Story Gate / story-gate (push) Waiting to run
vault-ci / test (macos-latest) (push) Waiting to run
vault-ci / test (ubuntu-latest) (push) Waiting to run
vault-ci / test (windows-latest) (push) Waiting to run
vault-ci / app-core wiring tests (push) Waiting to run
verify-patches / verify patches/CHECKSUMS.sha256 (push) Waiting to run
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Waiting to run
Voice Benchmark Smoke / voice bench smoke summary (push) Blocked by required conditions
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
Test Packaging / Build & Test PyPI Package (push) Waiting to run
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

1072 lines
37 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Groq plugin: registers the text-generation ModelType handlers (nano through
* mega, plus RESPONSE_HANDLER and ACTION_PLANNER) as well as TRANSCRIPTION and
* TEXT_TO_SPEECH, all via the Vercel AI SDK's @ai-sdk/groq provider. Init
* requires GROQ_API_KEY. Calls go through a shared retry loop that classifies
* failures (classifyRetryError) into rate-limit / transient / fatal and backs
* off on the first two.
*/
import { createGroq } from "@ai-sdk/groq";
import type {
EventPayload,
IAgentRuntime,
ModelTypeName,
Plugin,
RecordLlmCallDetails,
} from "@elizaos/core";
import {
buildCanonicalSystemPrompt,
ElizaError,
EventType,
type GenerateTextParams,
logger,
ModelType,
recordLlmCall,
renderChatMessagesForPrompt,
resolveEffectiveSystemPrompt,
} from "@elizaos/core";
import {
APICallError,
generateText,
type JSONSchema7,
jsonSchema,
type ModelMessage,
Output,
type ToolChoice,
type ToolSet,
} from "ai";
type RuntimeProcess = {
env?: Record<string, string | undefined>;
};
type RuntimeBufferConstructor = {
from(input: string, encoding?: string): Uint8Array;
from(input: ArrayBufferLike | ArrayLike<number>): Uint8Array;
alloc(size: number): Uint8Array;
isBuffer(value: unknown): boolean;
};
const _globalThis = globalThis as {
AI_SDK_LOG_WARNINGS?: boolean;
process?: RuntimeProcess;
Buffer?: RuntimeBufferConstructor;
};
_globalThis.AI_SDK_LOG_WARNINGS ??= false;
const DEFAULT_SMALL_MODEL = "openai/gpt-oss-120b";
const DEFAULT_LARGE_MODEL = "openai/gpt-oss-120b";
const DEFAULT_TTS_MODEL = "canopylabs/orpheus-v1-english";
const DEFAULT_TTS_VOICE = "troy";
const DEFAULT_TTS_RESPONSE_FORMAT = "wav";
const DEFAULT_TRANSCRIPTION_MODEL = "whisper-large-v3-turbo";
const DEFAULT_BASE_URL = "https://api.groq.com/openai/v1";
function resolveGroqSystemPrompt(
runtime: IAgentRuntime,
params: GenerateTextParams
): string | undefined {
return resolveEffectiveSystemPrompt({
params,
fallback: buildCanonicalSystemPrompt({ character: runtime.character }),
});
}
function resolveGroqPrompt(params: GenerateTextParams, systemPrompt: string | undefined): string {
return (
renderChatMessagesForPrompt(params.messages, {
omitDuplicateSystem: systemPrompt,
}) ??
params.prompt ??
""
);
}
type ProviderUsage = {
inputTokens?: number;
outputTokens?: number;
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
};
type NormalizedUsage = {
promptTokens: number;
completionTokens: number;
totalTokens: number;
estimated?: boolean;
};
function toFiniteNumber(value: unknown): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value)) {
return undefined;
}
return Math.max(0, Math.round(value));
}
function normalizeTokenUsage(usage: unknown): NormalizedUsage | null {
if (!usage || typeof usage !== "object") {
return null;
}
const record = usage as ProviderUsage;
const promptTokens = toFiniteNumber(record.inputTokens ?? record.promptTokens);
const completionTokens = toFiniteNumber(record.outputTokens ?? record.completionTokens);
const totalTokens = toFiniteNumber(record.totalTokens);
if (promptTokens === undefined && completionTokens === undefined && totalTokens === undefined) {
return null;
}
const normalizedPromptTokens =
promptTokens ??
(completionTokens === undefined && totalTokens !== undefined
? totalTokens
: Math.max(0, (totalTokens ?? 0) - (completionTokens ?? 0)));
const normalizedCompletionTokens =
completionTokens ??
Math.max(0, (totalTokens ?? normalizedPromptTokens) - normalizedPromptTokens);
return {
promptTokens: normalizedPromptTokens,
completionTokens: normalizedCompletionTokens,
totalTokens: totalTokens ?? normalizedPromptTokens + normalizedCompletionTokens,
};
}
function applyUsageToDetails(details: RecordLlmCallDetails, usage: unknown): void {
const normalized = normalizeTokenUsage(usage);
if (!normalized) {
return;
}
details.promptTokens = normalized.promptTokens;
details.completionTokens = normalized.completionTokens;
}
function estimateTokenCount(text: string): number {
return text.length === 0 ? 0 : Math.ceil(text.length / 4);
}
function stringifyForUsage(value: unknown): string {
if (typeof value === "string") {
return value;
}
try {
return JSON.stringify(value);
} catch {
// error-policy:J3 untrusted-input sanitizing — a circular/unstringifiable
// response degrades to String() for token *estimation* only (the usage
// event is flagged `estimated`); no completion data is fabricated.
return String(value);
}
}
function estimateUsage(prompt: string, response: unknown): NormalizedUsage {
const promptTokens = estimateTokenCount(prompt);
const completionTokens = estimateTokenCount(stringifyForUsage(response));
return {
promptTokens,
completionTokens,
totalTokens: promptTokens + completionTokens,
estimated: true,
};
}
function boundedNumber(value: unknown, fallback: number, min: number, max: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
return Math.min(max, Math.max(min, value));
}
function positiveInteger(value: unknown, fallback: number): number {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
return fallback;
}
return Math.floor(value);
}
function stringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter((item): item is string => typeof item === "string");
}
function emitModelUsed(
runtime: IAgentRuntime,
type: ModelTypeName,
model: string,
usage: NormalizedUsage
): void {
void runtime.emitEvent(
EventType.MODEL_USED as string,
{
runtime,
source: "groq",
provider: "groq",
type,
model,
modelName: model,
tokens: {
prompt: usage.promptTokens,
completion: usage.completionTokens,
total: usage.totalTokens,
...(usage.estimated ? { estimated: true } : {}),
},
...(usage.estimated ? { usageEstimated: true } : {}),
} as EventPayload
);
}
function isBrowser(): boolean {
return (
typeof globalThis !== "undefined" &&
typeof (globalThis as { document?: Document }).document !== "undefined"
);
}
function env(name: string): string | null {
return _globalThis.process?.env?.[name] ?? null;
}
function nonEmptyString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function getRuntimeBuffer(): RuntimeBufferConstructor | null {
return _globalThis.Buffer ?? null;
}
function getBaseURL(runtime: IAgentRuntime): string {
const configured = nonEmptyString(runtime.getSetting("GROQ_BASE_URL"));
if (!configured) {
return DEFAULT_BASE_URL;
}
let parsed: URL;
try {
parsed = new URL(configured);
} catch (error) {
// error-policy:J2 context-adding rethrow — names the misconfigured setting;
// the original URL parse failure travels as `cause`.
throw new Error("GROQ_BASE_URL must be a valid http(s) URL", { cause: error });
}
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
throw new Error("GROQ_BASE_URL must be a valid http(s) URL");
}
return configured.replace(/\/+$/, "");
}
function getSmallModel(runtime: IAgentRuntime): string {
const setting = runtime.getSetting("GROQ_SMALL_MODEL") || runtime.getSetting("SMALL_MODEL");
return typeof setting === "string" ? setting : DEFAULT_SMALL_MODEL;
}
function getNanoModel(runtime: IAgentRuntime): string {
const setting = runtime.getSetting("GROQ_NANO_MODEL") || runtime.getSetting("NANO_MODEL");
return typeof setting === "string" ? setting : getSmallModel(runtime);
}
function getMediumModel(runtime: IAgentRuntime): string {
const setting = runtime.getSetting("GROQ_MEDIUM_MODEL") || runtime.getSetting("MEDIUM_MODEL");
return typeof setting === "string" ? setting : getSmallModel(runtime);
}
function getLargeModel(runtime: IAgentRuntime): string {
const setting = runtime.getSetting("GROQ_LARGE_MODEL") || runtime.getSetting("LARGE_MODEL");
return typeof setting === "string" ? setting : DEFAULT_LARGE_MODEL;
}
function getMegaModel(runtime: IAgentRuntime): string {
const setting = runtime.getSetting("GROQ_MEGA_MODEL") || runtime.getSetting("MEGA_MODEL");
return typeof setting === "string" ? setting : getLargeModel(runtime);
}
function getResponseHandlerModel(runtime: IAgentRuntime): string {
const setting =
runtime.getSetting("GROQ_RESPONSE_HANDLER_MODEL") ||
runtime.getSetting("GROQ_SHOULD_RESPOND_MODEL") ||
runtime.getSetting("RESPONSE_HANDLER_MODEL") ||
runtime.getSetting("SHOULD_RESPOND_MODEL");
return typeof setting === "string" ? setting : getNanoModel(runtime);
}
function getTranscriptionModel(runtime: IAgentRuntime): string {
const setting =
runtime.getSetting("GROQ_TRANSCRIPTION_MODEL") || runtime.getSetting("TRANSCRIPTION_MODEL");
return typeof setting === "string" ? setting : DEFAULT_TRANSCRIPTION_MODEL;
}
function getActionPlannerModel(runtime: IAgentRuntime): string {
const setting =
runtime.getSetting("GROQ_ACTION_PLANNER_MODEL") ||
runtime.getSetting("GROQ_PLANNER_MODEL") ||
runtime.getSetting("ACTION_PLANNER_MODEL") ||
runtime.getSetting("PLANNER_MODEL");
// Action planning is a reasoning-heavy task — route to the LARGE tier by
// default (gpt-oss-120b) rather than the SMALL/MEDIUM tier. Small models
// mis-classify semantically adjacent actions too often to be the default.
return typeof setting === "string" ? setting : getLargeModel(runtime);
}
function createGroqClient(runtime: IAgentRuntime) {
// In browsers, default to *not* sending secrets.
// Use a server-side proxy and configure GROQ_BASE_URL (or explicitly opt-in).
const allowBrowserKey =
!isBrowser() ||
String(runtime.getSetting("GROQ_ALLOW_BROWSER_API_KEY") ?? "").toLowerCase() === "true";
const apiKey = allowBrowserKey ? nonEmptyString(runtime.getSetting("GROQ_API_KEY")) : undefined;
return createGroq({
apiKey,
fetch: runtime.fetch ?? undefined,
baseURL: getBaseURL(runtime),
});
}
// Groq 429s phrase the cooldown as a Go-style duration: "7m30s", "2m59.56s",
// "859ms", or plain "30s". Sum every component; a seconds-only regex reads
// "7m30s" as no match (10s default) and re-collides with the same window.
export function extractRetryDelay(message: string): number {
const match = message.match(/try again in ((?:\d+(?:\.\d+)?(?:ms|[smhd]))+)/i);
if (!match?.[1]) {
return 10000;
}
const unitMs: Record<string, number> = {
ms: 1,
s: 1_000,
m: 60_000,
h: 3_600_000,
d: 86_400_000,
};
let totalMs = 0;
for (const part of match[1].matchAll(/(\d+(?:\.\d+)?)(ms|[smhd])/gi)) {
totalMs +=
Number.parseFloat(part[1] ?? "0") * (unitMs[(part[2] ?? "s").toLowerCase()] ?? 1_000);
}
return Math.ceil(totalMs) + 1000;
}
/**
* Classify an error thrown by `generateText`/`generateObject`. The AI SDK
* already retries transient 5xx and network failures up to `maxRetries`
* times with exponential backoff (~2s, 4s, 8s). This outer layer only kicks
* in when the AI SDK gives up — typically for 429 rate limits whose
* server-suggested cooldown (often 3060s) exceeds the AI SDK's budget.
*
* Returns `"rate-limit"` for 429s (where we honor `try again in Ns`),
* `"transient"` for 5xx / network failures worth one more shot, and
* `"fatal"` for auth / validation / unknown errors that should propagate
* immediately.
*/
export function classifyRetryError(error: unknown): "rate-limit" | "transient" | "fatal" {
if (APICallError.isInstance(error)) {
if (error.statusCode === 429) return "rate-limit";
if (typeof error.statusCode === "number" && error.statusCode >= 500 && error.statusCode < 600) {
return "transient";
}
if (error.isRetryable) return "transient";
return "fatal";
}
if (!(error instanceof Error)) return "fatal";
const message = error.message.toLowerCase();
if (
message.includes("rate limit") ||
message.includes("rate_limit") ||
message.includes("too many requests") ||
/try again in \d/i.test(error.message)
) {
return "rate-limit";
}
// Node fetch / undici transient network failures.
if (
message.includes("econnreset") ||
message.includes("etimedout") ||
message.includes("enotfound") ||
message.includes("econnrefused") ||
message.includes("socket hang up") ||
message.includes("network error") ||
message.includes("fetch failed")
) {
return "transient";
}
return "fatal";
}
type NativeOutput = NonNullable<Parameters<typeof generateText<ToolSet>>[0]["output"]>;
function buildGroqStructuredOutput(responseSchema: unknown): NativeOutput {
if (
responseSchema &&
typeof responseSchema === "object" &&
"responseFormat" in responseSchema &&
"parseCompleteOutput" in responseSchema
) {
return responseSchema as NativeOutput;
}
const schemaOptions =
responseSchema && typeof responseSchema === "object" && "schema" in responseSchema
? (responseSchema as { schema: unknown; name?: string; description?: string })
: { schema: responseSchema };
return Output.object({
schema: jsonSchema(schemaOptions.schema as JSONSchema7),
...(schemaOptions.name ? { name: schemaOptions.name } : {}),
...(schemaOptions.description ? { description: schemaOptions.description } : {}),
}) as NativeOutput;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
// The runtime exposes tools as ordered core `ToolDefinition[]` ({ name,
// description, parameters }). The AI SDK expects a `ToolSet` keyed by
// provider-visible tool names with `inputSchema`; passing the array through
// gives Groq function names like "0" with an empty schema. Pre-built ToolSet
// objects (no `name` field on values) pass through unchanged.
function readGroqToolSet(value: unknown): ToolSet | undefined {
if (!value) {
return undefined;
}
const isArr = Array.isArray(value);
if (!isArr && !isRecord(value)) {
return undefined;
}
const entries: Array<[string, unknown]> = isArr
? (value as unknown[]).map((v, i) => [String(i), v] as [string, unknown])
: Object.entries(value as Record<string, unknown>);
const tools: Record<string, unknown> = {};
let sawNamedTool = false;
for (const [origKey, rawTool] of entries) {
if (!isRecord(rawTool)) {
continue;
}
const functionTool = isRecord(rawTool.function) ? rawTool.function : undefined;
const name =
typeof rawTool.name === "string" && rawTool.name
? rawTool.name
: typeof functionTool?.name === "string" && functionTool.name
? functionTool.name
: undefined;
if (name) {
sawNamedTool = true;
const schema = isRecord(rawTool.parameters)
? (rawTool.parameters as JSONSchema7)
: isRecord(functionTool?.parameters)
? (functionTool.parameters as JSONSchema7)
: isRecord(rawTool.input_schema)
? (rawTool.input_schema as JSONSchema7)
: ({ type: "object" } satisfies JSONSchema7);
const description =
typeof rawTool.description === "string"
? rawTool.description
: typeof functionTool?.description === "string"
? functionTool.description
: undefined;
tools[name] = {
...(description ? { description } : {}),
inputSchema: jsonSchema(schema),
};
} else if (!isArr) {
tools[origKey] = rawTool;
}
}
if (sawNamedTool) {
return Object.keys(tools).length > 0 ? (tools as ToolSet) : undefined;
}
return !isArr && isRecord(value) ? (value as ToolSet) : undefined;
}
// Core `ToolChoice` uses `{ type: "tool", name }` / `{ type: "function",
// function: { name } }`; the AI SDK only understands `{ type: "tool",
// toolName }` and the string enums.
function readGroqToolChoice(value: unknown): ToolChoice<ToolSet> | undefined {
if (!value) {
return undefined;
}
if (typeof value === "string" && (value === "auto" || value === "none" || value === "required")) {
return value;
}
if (!isRecord(value)) {
return undefined;
}
if (value.type === "tool" && typeof value.toolName === "string") {
return value as ToolChoice<ToolSet>;
}
if (value.type === "tool" && typeof value.name === "string") {
return { type: "tool", toolName: value.name };
}
if (value.type === "function" && isRecord(value.function)) {
const name = value.function.name;
return typeof name === "string" ? { type: "tool", toolName: name } : undefined;
}
return typeof value.name === "string" ? { type: "tool", toolName: value.name } : undefined;
}
type GroqUsage = {
inputTokens?: number;
outputTokens?: number;
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
};
interface GroqNativeTextResult {
text: string;
toolCalls: unknown[];
finishReason?: string;
usage?: { promptTokens: number; completionTokens: number; totalTokens: number };
}
function buildGroqNativeTextResult(result: {
text: string;
toolCalls?: unknown[];
finishReason?: string;
usage?: GroqUsage;
}): GroqNativeTextResult {
const inputTokens = result.usage?.inputTokens ?? result.usage?.promptTokens ?? 0;
const outputTokens = result.usage?.outputTokens ?? result.usage?.completionTokens ?? 0;
const usage = result.usage
? {
promptTokens: inputTokens,
completionTokens: outputTokens,
totalTokens: result.usage.totalTokens ?? inputTokens + outputTokens,
}
: undefined;
return {
text: result.text,
toolCalls: result.toolCalls ?? [],
finishReason: result.finishReason,
...(usage ? { usage } : {}),
};
}
async function generateWithRetry(
runtime: IAgentRuntime,
groq: ReturnType<typeof createGroq>,
modelType: ModelTypeName,
model: string,
params: {
prompt: string;
system?: string;
temperature: number;
maxTokens?: number;
omitMaxTokens?: boolean;
frequencyPenalty: number;
presencePenalty: number;
stopSequences: string[];
messages?: ModelMessage[];
tools?: ToolSet;
toolChoice?: ToolChoice<ToolSet>;
responseSchema?: unknown;
returnNative?: boolean;
}
): Promise<string | GroqNativeTextResult> {
const generate = () => {
const details: RecordLlmCallDetails = {
model,
systemPrompt: params.system ?? "",
userPrompt: params.prompt,
temperature: params.temperature,
maxTokens: params.maxTokens ?? 0,
maxTokensOmitted: params.omitMaxTokens ? true : undefined,
purpose: "external_llm",
actionType: "ai.generateText",
};
return recordLlmCall(runtime, details, async () => {
// Native tool calling + structured output: when callers pass `tools`,
// `toolChoice`, `responseSchema`, or `messages`, route through the AI
// SDK's native shape (Groq's OpenAI-compatible chat.completions API
// accepts `tools`, `tool_choice`, and `response_format` for JSON mode).
// When only `prompt` is supplied, fall back to the simple generate-text
// shape — this keeps caching/cost flow untouched for the common path.
const sharedSettings = {
model: groq.languageModel(model),
system: params.system,
temperature: params.temperature,
// Omit the cap on opt-out (direct-channel Stage-1) so the model's own
// max applies; otherwise send the resolved value.
...(params.omitMaxTokens ? {} : { maxOutputTokens: params.maxTokens }),
maxRetries: 3,
frequencyPenalty: params.frequencyPenalty,
presencePenalty: params.presencePenalty,
stopSequences: params.stopSequences,
...(params.tools ? { tools: params.tools } : {}),
...(params.toolChoice ? { toolChoice: params.toolChoice } : {}),
...(params.responseSchema
? { output: buildGroqStructuredOutput(params.responseSchema) }
: {}),
};
const result =
params.messages && params.messages.length > 0
? await generateText({ ...sharedSettings, messages: params.messages })
: await generateText({ ...sharedSettings, prompt: params.prompt });
details.response = result.text;
applyUsageToDetails(details, result.usage);
return result;
});
};
const MAX_RATE_LIMIT_RETRIES = 5;
const MAX_TRANSIENT_RETRIES = 2;
let rateLimitAttempts = 0;
let transientAttempts = 0;
while (true) {
try {
const result = await generate();
// A completion with no text and no tool calls is a provider failure
// (moderation block, truncation, upstream bug) — never a legitimate
// result. Returning "" here would fabricate a healthy-empty completion
// the planner cannot distinguish from a real answer (#9324: throw,
// never fabricate) — and would emit success usage telemetry for it.
const resultToolCalls = Array.isArray(result.toolCalls) ? result.toolCalls : [];
if (result.text.length === 0 && resultToolCalls.length === 0) {
throw new ElizaError(
`[Groq] ${modelType} returned an empty completion${
result.finishReason ? ` (finishReason: ${result.finishReason})` : ""
}`,
{
code: "MODEL_EMPTY_COMPLETION",
context: { modelType, model, finishReason: result.finishReason },
}
);
}
const usage = normalizeTokenUsage(result.usage) ?? estimateUsage(params.prompt, result.text);
emitModelUsed(runtime, modelType, model, usage);
if (params.returnNative) {
return buildGroqNativeTextResult(result);
}
const { text } = result;
return text;
} catch (error) {
// error-policy:J2 context-adding rethrow — rate-limit/transient errors
// are retried with backoff; fatal errors and exhausted attempts rethrow
// the original provider error unchanged. No failure becomes a result.
const kind = classifyRetryError(error);
if (kind === "rate-limit" && rateLimitAttempts < MAX_RATE_LIMIT_RETRIES) {
const message = error instanceof Error ? error.message : String(error);
// Respect the server-suggested wait, then add exponential jitter on
// top so multiple parallel callers don't re-collide on the same
// window boundary.
const hinted = extractRetryDelay(message);
const backoff = Math.min(30_000, 500 * 2 ** rateLimitAttempts);
const delay = hinted + backoff;
rateLimitAttempts += 1;
logger.warn(
`Groq rate limit hit (attempt ${rateLimitAttempts}/${MAX_RATE_LIMIT_RETRIES}), retrying in ${delay}ms`
);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
if (kind === "transient" && transientAttempts < MAX_TRANSIENT_RETRIES) {
// AI SDK already retried with exponential backoff; use a small fixed
// backoff with jitter here to smooth over post-exhaustion flakiness.
const delay = 1_000 + Math.floor(Math.random() * 1_500);
transientAttempts += 1;
logger.warn(
`Groq transient failure (attempt ${transientAttempts}/${MAX_TRANSIENT_RETRIES}), retrying in ${delay}ms: ${error instanceof Error ? error.message : String(error)}`
);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
throw error;
}
}
}
function buildGroqGenerateParams(
params: GenerateTextParams,
systemPrompt: string | undefined,
promptText: string
): {
prompt: string;
system?: string;
temperature: number;
maxTokens?: number;
omitMaxTokens?: boolean;
frequencyPenalty: number;
presencePenalty: number;
stopSequences: string[];
messages?: ModelMessage[];
tools?: ToolSet;
toolChoice?: ToolChoice<ToolSet>;
responseSchema?: unknown;
returnNative?: boolean;
} {
const paramsWithNative = params as GenerateTextParams & {
messages?: ModelMessage[];
tools?: unknown;
toolChoice?: unknown;
responseSchema?: unknown;
};
const returnNative = Boolean(
paramsWithNative.messages ||
paramsWithNative.tools ||
paramsWithNative.toolChoice ||
paramsWithNative.responseSchema
);
const normalizedTools = readGroqToolSet(paramsWithNative.tools);
const normalizedToolChoice = readGroqToolChoice(paramsWithNative.toolChoice);
return {
prompt: promptText,
system: systemPrompt,
temperature: boundedNumber(params.temperature, 0.7, 0, 2),
// Stage-1 direct reply opts out of any cap; everyone else keeps the 8192
// default so they stay bounded.
maxTokens: params.omitMaxTokens ? undefined : positiveInteger(params.maxTokens, 8192),
omitMaxTokens: params.omitMaxTokens,
frequencyPenalty: boundedNumber(params.frequencyPenalty, 0.7, -2, 2),
presencePenalty: boundedNumber(params.presencePenalty, 0.7, -2, 2),
stopSequences: stringArray(params.stopSequences),
...(paramsWithNative.messages ? { messages: paramsWithNative.messages } : {}),
...(normalizedTools ? { tools: normalizedTools } : {}),
...(normalizedToolChoice ? { toolChoice: normalizedToolChoice } : {}),
...(paramsWithNative.responseSchema ? { responseSchema: paramsWithNative.responseSchema } : {}),
...(returnNative ? { returnNative } : {}),
};
}
async function handleTextModel(
runtime: IAgentRuntime,
params: GenerateTextParams,
modelType: ModelTypeName
): Promise<string> {
const groq = createGroqClient(runtime);
const model = getTextModelForType(runtime, modelType);
const system = resolveGroqSystemPrompt(runtime, params);
const result = await generateWithRetry(
runtime,
groq,
modelType,
model,
buildGroqGenerateParams(params, system, resolveGroqPrompt(params, system))
);
// Native result (with toolCalls / usage / finishReason) is cast through the
// string return type because elizaOS's plugin Model handler signature is
// `(runtime, params) => Promise<string | TextStreamResult>`. The runtime
// unwraps the native shape via `useModel` consumers that pass `tools` /
// `messages` / `responseSchema` / `toolChoice`.
return result as string;
}
function getTextModelForType(runtime: IAgentRuntime, modelType: string): string {
switch (modelType) {
case ModelType.TEXT_NANO:
return getNanoModel(runtime);
case ModelType.TEXT_MEDIUM:
return getMediumModel(runtime);
case ModelType.TEXT_SMALL:
return getSmallModel(runtime);
case ModelType.TEXT_LARGE:
return getLargeModel(runtime);
case ModelType.TEXT_MEGA:
return getMegaModel(runtime);
case ModelType.RESPONSE_HANDLER:
return getResponseHandlerModel(runtime);
case ModelType.ACTION_PLANNER:
return getActionPlannerModel(runtime);
default:
return getLargeModel(runtime);
}
}
export const groqPlugin: Plugin = {
name: "groq",
description: "Groq LLM provider - fast inference with GPT-OSS models",
autoEnable: {
envKeys: ["GROQ_API_KEY"],
},
config: {
GROQ_API_KEY: env("GROQ_API_KEY"),
GROQ_BASE_URL: env("GROQ_BASE_URL"),
GROQ_NANO_MODEL: env("GROQ_NANO_MODEL"),
GROQ_MEDIUM_MODEL: env("GROQ_MEDIUM_MODEL"),
GROQ_SMALL_MODEL: env("GROQ_SMALL_MODEL"),
GROQ_LARGE_MODEL: env("GROQ_LARGE_MODEL"),
GROQ_MEGA_MODEL: env("GROQ_MEGA_MODEL"),
GROQ_RESPONSE_HANDLER_MODEL: env("GROQ_RESPONSE_HANDLER_MODEL"),
GROQ_SHOULD_RESPOND_MODEL: env("GROQ_SHOULD_RESPOND_MODEL"),
GROQ_ACTION_PLANNER_MODEL: env("GROQ_ACTION_PLANNER_MODEL"),
GROQ_PLANNER_MODEL: env("GROQ_PLANNER_MODEL"),
GROQ_TRANSCRIPTION_MODEL: env("GROQ_TRANSCRIPTION_MODEL"),
TRANSCRIPTION_MODEL: env("TRANSCRIPTION_MODEL"),
NANO_MODEL: env("NANO_MODEL"),
MEDIUM_MODEL: env("MEDIUM_MODEL"),
SMALL_MODEL: env("SMALL_MODEL"),
LARGE_MODEL: env("LARGE_MODEL"),
MEGA_MODEL: env("MEGA_MODEL"),
RESPONSE_HANDLER_MODEL: env("RESPONSE_HANDLER_MODEL"),
SHOULD_RESPOND_MODEL: env("SHOULD_RESPOND_MODEL"),
ACTION_PLANNER_MODEL: env("ACTION_PLANNER_MODEL"),
PLANNER_MODEL: env("PLANNER_MODEL"),
},
async init(_config: Record<string, string>, runtime: IAgentRuntime): Promise<void> {
const apiKey = nonEmptyString(runtime.getSetting("GROQ_API_KEY"));
if (!apiKey && !isBrowser()) {
throw new Error("GROQ_API_KEY is required");
}
},
models: {
[ModelType.TEXT_NANO]: (runtime, params: GenerateTextParams) =>
handleTextModel(runtime, params, ModelType.TEXT_NANO),
[ModelType.TEXT_SMALL]: (runtime, params: GenerateTextParams) =>
handleTextModel(runtime, params, ModelType.TEXT_SMALL),
[ModelType.TEXT_MEDIUM]: (runtime, params: GenerateTextParams) =>
handleTextModel(runtime, params, ModelType.TEXT_MEDIUM),
[ModelType.TEXT_LARGE]: (runtime, params: GenerateTextParams) =>
handleTextModel(runtime, params, ModelType.TEXT_LARGE),
[ModelType.TEXT_MEGA]: (runtime, params: GenerateTextParams) =>
handleTextModel(runtime, params, ModelType.TEXT_MEGA),
[ModelType.RESPONSE_HANDLER]: (runtime, params: GenerateTextParams) =>
handleTextModel(runtime, params, ModelType.RESPONSE_HANDLER),
[ModelType.ACTION_PLANNER]: (runtime, params: GenerateTextParams) =>
handleTextModel(runtime, params, ModelType.ACTION_PLANNER),
[ModelType.TRANSCRIPTION]: async (runtime, params) => {
type AudioDataShape = { audioData: Uint8Array };
function hasAudioData(obj: object): obj is AudioDataShape {
return "audioData" in obj && (obj as AudioDataShape).audioData instanceof Uint8Array;
}
if (isBrowser()) {
throw new Error(
"Groq TRANSCRIPTION is not supported directly in browsers. Use a server proxy or submit a Blob/ArrayBuffer to a server."
);
}
const buffer = getRuntimeBuffer();
if (!buffer) {
throw new Error("Groq TRANSCRIPTION requires Buffer support outside browsers.");
}
const audioBuffer: Uint8Array =
typeof params === "string"
? buffer.from(params, "base64")
: buffer.isBuffer(params)
? (params as Uint8Array)
: typeof params === "object" && params !== null && hasAudioData(params)
? buffer.from((params as AudioDataShape).audioData)
: buffer.alloc(0);
if (audioBuffer.byteLength === 0) {
throw new Error("Groq TRANSCRIPTION requires non-empty audio data.");
}
const baseURL = getBaseURL(runtime);
const transcriptionModel = getTranscriptionModel(runtime);
const formData = new FormData();
formData.append(
"file",
new File([audioBuffer as BlobPart], "audio.mp3", { type: "audio/mp3" })
);
formData.append("model", transcriptionModel);
const apiKey = nonEmptyString(runtime.getSetting("GROQ_API_KEY"));
// A missing credential must surface as a typed failure before the
// request goes out — an empty bearer token would masquerade as a
// provider-side 401 and hide the real misconfiguration.
if (!apiKey) {
throw new ElizaError("[Groq] TRANSCRIPTION requires GROQ_API_KEY", {
code: "MODEL_MISSING_CREDENTIAL",
context: { modelType: ModelType.TRANSCRIPTION },
});
}
const details: RecordLlmCallDetails = {
model: transcriptionModel,
systemPrompt: "",
userPrompt: `audio transcription request: ${audioBuffer.byteLength} bytes`,
temperature: 0,
maxTokens: 0,
purpose: "external_llm",
actionType: "groq.audio.transcriptions.create",
};
const data = await recordLlmCall(runtime, details, async () => {
const response = await fetch(`${baseURL}/audio/transcriptions`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
},
body: formData,
});
if (!response.ok) {
throw new Error(`Transcription failed: ${response.status} ${await response.text()}`);
}
const result = (await response.json()) as { text: string };
details.response = result.text;
return result;
});
return data.text;
},
[ModelType.TEXT_TO_SPEECH]: async (runtime: IAgentRuntime, params) => {
if (isBrowser()) {
throw new Error(
"Groq TEXT_TO_SPEECH is not supported directly in browsers. Use a server proxy."
);
}
const payload =
typeof params === "string"
? { text: params }
: params && typeof params === "object"
? (params as {
text?: string;
voice?: string;
model?: string;
responseFormat?: string;
response_format?: string;
})
: {};
const text = nonEmptyString(payload.text);
if (!text) {
throw new Error("Groq TEXT_TO_SPEECH requires non-empty text.");
}
const baseURL = getBaseURL(runtime);
const modelSetting = runtime.getSetting("GROQ_TTS_MODEL");
const voiceSetting = runtime.getSetting("GROQ_TTS_VOICE");
const responseFormatSetting = runtime.getSetting("GROQ_TTS_RESPONSE_FORMAT");
const model =
typeof payload.model === "string" && payload.model
? payload.model
: typeof modelSetting === "string"
? modelSetting
: DEFAULT_TTS_MODEL;
const voice =
typeof payload.voice === "string" && payload.voice
? payload.voice
: typeof voiceSetting === "string"
? voiceSetting
: DEFAULT_TTS_VOICE;
const responseFormat =
typeof payload.responseFormat === "string" && payload.responseFormat
? payload.responseFormat
: typeof payload.response_format === "string" && payload.response_format
? payload.response_format
: typeof responseFormatSetting === "string"
? responseFormatSetting
: DEFAULT_TTS_RESPONSE_FORMAT;
const apiKey = nonEmptyString(runtime.getSetting("GROQ_API_KEY"));
// Same rule as TRANSCRIPTION: a missing credential is a typed local
// failure, never an empty bearer token sent upstream.
if (!apiKey) {
throw new ElizaError("[Groq] TEXT_TO_SPEECH requires GROQ_API_KEY", {
code: "MODEL_MISSING_CREDENTIAL",
context: { modelType: ModelType.TEXT_TO_SPEECH },
});
}
const details: RecordLlmCallDetails = {
model,
systemPrompt: "",
userPrompt: text,
temperature: 0,
maxTokens: 0,
purpose: "external_llm",
actionType: "groq.audio.speech.create",
};
const arrayBuffer = await recordLlmCall(runtime, details, async () => {
const response = await fetch(`${baseURL}/audio/speech`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model,
voice,
input: text,
response_format: responseFormat,
}),
});
if (!response.ok) {
throw new Error(`TTS failed: ${response.status} ${await response.text()}`);
}
const result = await response.arrayBuffer();
details.response = `[audio bytes=${result.byteLength} format=${responseFormat}]`;
return result;
});
return new Uint8Array(arrayBuffer);
},
},
tests: [
{
name: "groq_plugin_tests",
tests: [
{
name: "validate_api_key",
fn: async (runtime) => {
const baseURL = getBaseURL(runtime);
const response = await fetch(`${baseURL}/models`, {
headers: {
Authorization: `Bearer ${runtime.getSetting("GROQ_API_KEY")}`,
},
});
if (!response.ok) {
throw new Error(`API key validation failed: ${response.statusText}`);
}
const data = (await response.json()) as {
data: Array<{ id: string; owned_by: string }>;
};
logger.info(`Groq API validated, ${data.data.length} models available`);
},
},
{
name: "text_small",
fn: async (runtime) => {
const text = await runtime.useModel(ModelType.TEXT_SMALL, {
prompt: "Say hello in exactly 3 words.",
});
if (!text || text.length === 0) {
throw new Error("Empty response from TEXT_SMALL");
}
logger.info("TEXT_SMALL:", text);
},
},
{
name: "text_large",
fn: async (runtime) => {
const text = await runtime.useModel(ModelType.TEXT_LARGE, {
prompt: "What is 2+2? Answer with just the number.",
});
if (!text || text.length === 0) {
throw new Error("Empty response from TEXT_LARGE");
}
logger.info("TEXT_LARGE:", text);
},
},
],
},
],
};
export default groqPlugin;