项目文件夹

文件
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

1072 行
37 KiB
TypeScript

/**
* 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 30–60s) 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;