Files
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

1931 lines
70 KiB
TypeScript

/**
* Text generation model handlers
*
* Provides text generation using OpenAI's language models.
*/
import type {
GenerateTextParams,
IAgentRuntime,
JsonValue,
ModelTypeName,
RecordLlmCallDetails,
} from "@elizaos/core";
import {
assertActiveTrajectoryForLlmCall,
buildCanonicalSystemPrompt,
dropDuplicateLeadingSystemMessage,
logActiveTrajectoryLlmCall,
logger,
ModelType,
normalizeSchemaForCerebras,
recordLlmCall,
resolveEffectiveSystemPrompt,
sanitizeFunctionNameForCerebras,
} from "@elizaos/core";
import {
generateText,
type JSONSchema7,
jsonSchema,
type LanguageModelUsage,
type ModelMessage,
Output,
streamText,
type ToolChoice,
type ToolSet,
type UserContent,
} from "ai";
import { createOpenAIClient } from "../providers";
import type { TextStreamResult, TokenUsage } from "../types";
import {
getActionPlannerModel,
getExperimentalTelemetry,
getLargeModel,
getMediumModel,
getMegaModel,
getNanoModel,
getResponseHandlerModel,
getSmallModel,
isCerebrasMode,
} from "../utils/config";
import { emitModelUsageEvent } from "../utils/events";
// ============================================================================
// Types
// ============================================================================
/**
* Function to get model name from runtime
*/
type ModelNameGetter = (runtime: IAgentRuntime) => string;
type PromptCacheRetention = "in_memory" | "24h";
type ChatAttachment = {
data: string | Uint8Array | URL;
mediaType: string;
filename?: string;
};
interface OpenAIPromptCacheOptions {
promptCacheKey?: string;
promptCacheRetention?: PromptCacheRetention;
}
interface GenerateTextParamsWithOpenAIOptions
extends Omit<
GenerateTextParams,
"messages" | "tools" | "toolChoice" | "responseSchema" | "providerOptions"
> {
model?: string;
attachments?: ChatAttachment[];
messages?: unknown[];
tools?: unknown;
toolChoice?: unknown;
responseSchema?: unknown;
providerOptions?: Record<string, object | JsonValue> & {
agentName?: string;
openai?: OpenAIPromptCacheOptions;
};
}
type NativeOutput = NonNullable<Parameters<typeof generateText<ToolSet>>[0]["output"]>;
type NativeGenerateTextParams = Parameters<typeof generateText<ToolSet, NativeOutput>>[0];
type NativeStreamTextParams = Parameters<typeof streamText<ToolSet, NativeOutput>>[0];
type NativePrompt =
| { prompt: string; messages?: never }
| { messages: ModelMessage[]; prompt?: never };
type NativeTextParams = Omit<NativeGenerateTextParams, "messages" | "prompt"> &
Omit<NativeStreamTextParams, "messages" | "prompt"> &
NativePrompt & {
// Re-declared explicitly: TypeScript's `Parameters<typeof generateText>`
// inference produces an overload-union that drops this field, but the
// ai SDK's runtime signature accepts it (see ai@6 `CallSettings & Prompt`).
allowSystemInMessages?: boolean;
};
type NativeProviderOptions = NativeTextParams["providerOptions"];
type NativeTelemetrySettings = NativeTextParams["experimental_telemetry"];
type LanguageModelUsageWithCache = Omit<LanguageModelUsage, "inputTokenDetails"> & {
inputTokenDetails?: LanguageModelUsage["inputTokenDetails"] & {
cachedInputTokens?: number;
cacheCreationInputTokens?: number;
cacheCreationTokens?: number;
};
cachedInputTokens?: number;
cacheReadInputTokens?: number;
cacheCreationInputTokens?: number;
cacheWriteInputTokens?: number;
input_tokens_details?: {
cached_tokens?: number;
cache_read_input_tokens?: number;
cache_creation_input_tokens?: number;
};
prompt_tokens_details?: {
cached_tokens?: number;
};
};
interface NativeGenerateTextResult {
text: string;
toolCalls?: unknown[];
finishReason?: string;
usage?: TokenUsage;
providerMetadata?: unknown;
}
type NativeTextModelResult = string & NativeGenerateTextResult;
type RecordArgValueMode = "json-string" | "schema";
interface RecordArgTransform {
path: string;
entriesKey: string;
valueMode: RecordArgValueMode;
}
interface NormalizedNativeToolsResult {
tools?: ToolSet;
recordArgTransformsByTool: Record<string, RecordArgTransform[]>;
}
const TEXT_NANO_MODEL_TYPE = ModelType.TEXT_NANO as ModelTypeName;
const TEXT_MEDIUM_MODEL_TYPE = ModelType.TEXT_MEDIUM as ModelTypeName;
const TEXT_MEGA_MODEL_TYPE = ModelType.TEXT_MEGA as ModelTypeName;
const RESPONSE_HANDLER_MODEL_TYPE = ModelType.RESPONSE_HANDLER as ModelTypeName;
const ACTION_PLANNER_MODEL_TYPE = ModelType.ACTION_PLANNER as ModelTypeName;
function resolveRequestedModelName(
params: GenerateTextParamsWithOpenAIOptions,
runtime: IAgentRuntime,
getModelFn: ModelNameGetter
): string {
return typeof params.model === "string" && params.model.trim().length > 0
? params.model.trim()
: getModelFn(runtime);
}
function buildUserContent(params: GenerateTextParamsWithOpenAIOptions): UserContent {
const content: Array<
| { type: "text"; text: string }
| {
type: "file";
data: string | Uint8Array | URL;
mediaType: string;
filename?: string;
}
> = [{ type: "text", text: params.prompt ?? "" }];
for (const attachment of params.attachments ?? []) {
content.push({
type: "file",
data: attachment.data,
mediaType: attachment.mediaType,
...(attachment.filename ? { filename: attachment.filename } : {}),
});
}
return content;
}
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Converts AI SDK usage to our token usage format.
*
* Emits both the legacy `cachedPromptTokens` (kept for back-compat with
* existing OpenAI consumers) and the canonical v5 `cacheReadInputTokens`
* (consumed by the trajectory recorder + cost table). They always carry the
* same value when the AI SDK reports cached input.
*/
function convertUsage(usage: LanguageModelUsage | undefined): TokenUsage | undefined {
if (!usage) {
return undefined;
}
// The AI SDK uses inputTokens/outputTokens
const promptTokens = usage.inputTokens ?? 0;
const completionTokens = usage.outputTokens ?? 0;
const usageWithCache: LanguageModelUsageWithCache = usage;
const cachedInput =
firstNumber(
usageWithCache.cacheReadInputTokens,
usageWithCache.cachedInputTokens,
usageWithCache.inputTokenDetails?.cacheReadTokens,
usageWithCache.inputTokenDetails?.cachedInputTokens,
usageWithCache.input_tokens_details?.cache_read_input_tokens,
usageWithCache.input_tokens_details?.cached_tokens,
usageWithCache.prompt_tokens_details?.cached_tokens
) ?? undefined;
const cacheCreationInput = firstNumber(
usageWithCache.cacheCreationInputTokens,
usageWithCache.cacheWriteInputTokens,
usageWithCache.inputTokenDetails?.cacheCreationInputTokens,
usageWithCache.inputTokenDetails?.cacheCreationTokens,
usageWithCache.inputTokenDetails?.cacheWriteTokens,
usageWithCache.input_tokens_details?.cache_creation_input_tokens
);
return {
promptTokens,
completionTokens,
totalTokens: promptTokens + completionTokens,
cachedPromptTokens: cachedInput,
cacheReadInputTokens: cachedInput,
cacheCreationInputTokens: cacheCreationInput,
};
}
function firstNumber(...values: unknown[]): number | undefined {
for (const value of values) {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
}
return undefined;
}
function resolvePromptCacheOptions(params: GenerateTextParams): OpenAIPromptCacheOptions {
const withOpenAIOptions = params as GenerateTextParamsWithOpenAIOptions;
return {
promptCacheKey: withOpenAIOptions.providerOptions?.openai?.promptCacheKey,
promptCacheRetention: withOpenAIOptions.providerOptions?.openai?.promptCacheRetention,
};
}
/**
* Forward `OPENAI_REASONING_EFFORT` (runtime setting / process.env) as
* `reasoning_effort` on the outbound chat completions request. This is
* the OpenAI-spec knob for reasoning-capable models (`o1-*`, `o3-*`,
* `gpt-oss-*`, `deepseek-r1`, and similar families) — including
* Cerebras and OpenRouter, which honor the same field. `"low"` keeps
* reasoning short enough that visible content always fits inside
* `max_tokens`, which is the failure mode on Cerebras gpt-oss-120b when
* left unset.
*
* In Cerebras mode the field defaults to `"low"` when unset, but ONLY for
* reasoning-capable models (e.g. gpt-oss-* and deepseek-r1):
* gpt-oss-120b emits a separate reasoning channel and, left unbounded, spends
* the whole token budget reasoning — returning empty visible content, which
* makes the agent fall back to "I don't have a reply for that". `"low"` keeps
* reasoning short so a reply always materializes. Non-reasoning Cerebras models
* (Llama, etc.) reject `reasoning_effort`, so they must never receive the
* default. For all other models an unset/invalid value yields `undefined`, so
* they pay no overhead and the wire stays clean. An explicit valid
* `OPENAI_REASONING_EFFORT` always wins.
*
* Valid values follow the OpenAI spec exactly: `minimal`, `low`,
* `medium`, `high`. Anything else is logged and ignored.
*/
type ReasoningEffort = "minimal" | "low" | "medium" | "high";
const VALID_REASONING_EFFORTS: readonly ReasoningEffort[] = ["minimal", "low", "medium", "high"];
/**
* Reasoning-capable model families that emit a separate reasoning channel and
* honor `reasoning_effort`. Used to gate the Cerebras `"low"` default so
* non-reasoning models (Llama, etc.) are never sent the field.
*/
function isReasoningModel(modelName: string | undefined): boolean {
if (!modelName) return false;
const m = modelName.toLowerCase();
return (
m.includes("gpt-oss") ||
m.includes("o1") ||
m.includes("o3") ||
m.includes("o4") ||
m.includes("deepseek-r1") ||
m.includes("thinking") ||
m.includes("reasoning") ||
m.includes("qwq")
);
}
function resolveReasoningEffort(
runtime: IAgentRuntime,
modelName?: string
): ReasoningEffort | undefined {
const raw = runtime.getSetting("OPENAI_REASONING_EFFORT");
const normalized = typeof raw === "string" ? raw.trim().toLowerCase() : "";
if (normalized) {
if ((VALID_REASONING_EFFORTS as readonly string[]).includes(normalized)) {
return normalized as ReasoningEffort;
}
logger.warn(
`[OpenAI] OPENAI_REASONING_EFFORT=${raw} is not a valid reasoning effort; ignoring. Expected one of: ${VALID_REASONING_EFFORTS.join(", ")}.`
);
}
// gpt-oss-120b on Cerebras returns empty content when reasoning runs
// unbounded; default to "low" so a visible reply always fits — but only for
// reasoning-capable models. Non-reasoning Cerebras models (Llama, etc.)
// reject `reasoning_effort` and would break. An explicit valid value above
// wins over this default.
if (isCerebrasMode(runtime) && isReasoningModel(modelName)) {
return "low";
}
return undefined;
}
function resolveProviderOptions(
params: GenerateTextParams,
runtime: IAgentRuntime,
modelName?: string
): Record<string, unknown> | undefined {
const withOpenAIOptions = params as GenerateTextParamsWithOpenAIOptions;
const rawProviderOptions = withOpenAIOptions.providerOptions;
const promptCacheOptions = resolvePromptCacheOptions(params);
const reasoningEffort = resolveReasoningEffort(runtime, modelName);
if (
!rawProviderOptions &&
!promptCacheOptions.promptCacheKey &&
!promptCacheOptions.promptCacheRetention &&
!reasoningEffort
) {
return undefined;
}
// Cerebras supports prompt caching on gpt-oss-120b — 128-token blocks,
// default-on. The `prompt_cache_key` field IS accepted by Cerebras's
// OpenAI-compatible endpoint and surfaces hit counts via
// `usage.prompt_tokens_details.cached_tokens` (same shape as OpenAI), so
// we keep it in the request body. Only `prompt_cache_retention` is an
// OpenAI-direct-only field that Cerebras rejects with HTTP 400
// (`wrong_api_format`), so we strip just that one when in Cerebras mode.
const skipCacheRetention = isCerebrasMode(runtime);
const { agentName: _agentName, openai: rawOpenAIOptions, ...rest } = rawProviderOptions ?? {};
// When on Cerebras, scrub OpenAI-direct-only fields (e.g. `promptCacheRetention`)
// from `rawOpenAIOptions` before they're spread; otherwise they reach the wire
// and the Cerebras endpoint rejects with HTTP 400 `wrong_api_format`.
const sanitizedRawOpenAIOptions = (() => {
if (!rawOpenAIOptions || typeof rawOpenAIOptions !== "object") return rawOpenAIOptions;
if (!skipCacheRetention) return rawOpenAIOptions;
const { promptCacheRetention: _drop, ...rest2 } = rawOpenAIOptions as Record<string, unknown>;
return rest2;
})();
const openaiOptions = {
...(sanitizedRawOpenAIOptions ?? {}),
...(promptCacheOptions.promptCacheKey
? { promptCacheKey: promptCacheOptions.promptCacheKey }
: {}),
...(!skipCacheRetention && promptCacheOptions.promptCacheRetention
? { promptCacheRetention: promptCacheOptions.promptCacheRetention }
: {}),
// The caller's explicit `reasoningEffort` wins over the resolved default
// (env var, or Cerebras "low") — same precedence pattern as promptCacheKey.
...((sanitizedRawOpenAIOptions as { reasoningEffort?: unknown } | undefined)
?.reasoningEffort === undefined && reasoningEffort
? { reasoningEffort }
: {}),
};
const providerOptions = {
...rest,
...(Object.keys(openaiOptions).length > 0 ? { openai: openaiOptions } : {}),
};
return Object.keys(providerOptions).length > 0 ? providerOptions : undefined;
}
function buildStructuredOutput(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(sanitizeJsonSchema(schemaOptions.schema, true)),
...(schemaOptions.name ? { name: schemaOptions.name } : {}),
...(schemaOptions.description ? { description: schemaOptions.description } : {}),
}) as NativeOutput;
}
/**
* Native tool normalization plus the strict-safe record/map transform selected
* for #13111. Tool schemas still close every object with additionalProperties:
* false for strict-grammar providers (#11123/#11156), but a DECLARED open map
* gets a model-facing `__eliza_record_entries` key/value array. Returned tool
* calls are reverse-mapped before the runtime validates against the original
* schema, so tool authors still receive the object shape they declared.
*/
function normalizeNativeToolsForCall(
tools: unknown,
options: { cerebrasMode?: boolean } = {}
): NormalizedNativeToolsResult {
const recordArgTransformsByTool: Record<string, RecordArgTransform[]> = {};
if (!tools) {
return { recordArgTransformsByTool };
}
// Existing AI SDK callers already pass a ToolSet keyed by tool name. Keep it
// intact so custom tool instances, execute hooks, and dynamic tool metadata
// are preserved.
if (!Array.isArray(tools)) {
return { tools: tools as ToolSet, recordArgTransformsByTool };
}
const toolSet: Record<string, unknown> = {};
for (const rawTool of tools) {
const tool = asRecord(rawTool);
const functionTool = asRecord(tool.function);
const name = firstString(tool.name, functionTool.name);
if (!name) {
throw new Error("[OpenAI] Native tool definition is missing a name.");
}
const description = firstString(tool.description, functionTool.description);
// Default to a permissive object schema. The empty-properties shape
// (`{ type: "object", properties: {}, additionalProperties: false }`) is
// accepted by OpenAI but rejected by strict-grammar providers like
// Cerebras with `Object fields require at least one of: 'properties' or
// 'anyOf' with a list of possible properties`.
const rawSchema =
tool.parameters ?? functionTool.parameters ?? ({ type: "object" } satisfies JSONSchema7);
const recordArgTransforms: RecordArgTransform[] = [];
let inputSchema = sanitizeJsonSchema(rawSchema, true, "$", recordArgTransforms);
if (options.cerebrasMode) {
// User-supplied schemas may still contain empty-properties subobjects
// even after sanitizeJsonSchema. Apply Cerebras-specific normalization
// recursively so deep schemas are accepted by the grammar compiler.
// Pass isRoot: true so the top-level invariant is enforced (must be
// type:"object" with no root oneOf/anyOf/enum/not).
inputSchema = normalizeSchemaForCerebras(inputSchema, true) as JSONSchema7;
}
// Cerebras's grammar compiler rejects function names containing characters
// outside `[a-zA-Z0-9_-]` (e.g. `math.factorial`). The AI SDK looks up
// tools by the registered key, so we register under the sanitized name AND
// surface it to the model under that name. Tool calls come back with the
// sanitized name, which the runtime resolves through its action registry —
// any caller relying on dotted action names should pre-sanitize.
const registeredName = options.cerebrasMode ? sanitizeFunctionNameForCerebras(name) : name;
if (recordArgTransforms.length > 0) {
recordArgTransformsByTool[registeredName] = recordArgTransforms;
}
toolSet[registeredName] = {
...(description ? { description } : {}),
inputSchema: jsonSchema(inputSchema as JSONSchema7),
};
}
return {
tools: Object.keys(toolSet).length > 0 ? (toolSet as ToolSet) : undefined,
recordArgTransformsByTool,
};
}
function normalizeNativeTools(
tools: unknown,
options: { cerebrasMode?: boolean } = {}
): ToolSet | undefined {
return normalizeNativeToolsForCall(tools, options).tools;
}
function normalizeNativeMessages(messages: unknown): ModelMessage[] | undefined {
if (!Array.isArray(messages)) {
return undefined;
}
return messages.map((message) => normalizeNativeMessage(message));
}
function normalizeNativeMessage(message: unknown): ModelMessage {
const raw = asRecord(message);
const providerOptions = asOptionalRecord(raw.providerOptions);
if (raw.role === "system") {
return {
role: "system",
content: stringifyMessageContent(raw.content),
...(providerOptions ? { providerOptions } : {}),
} as ModelMessage;
}
if (raw.role === "assistant") {
return {
role: "assistant",
content: normalizeAssistantContent(raw),
...(providerOptions ? { providerOptions } : {}),
} as ModelMessage;
}
if (raw.role === "tool") {
return {
role: "tool",
content: normalizeToolContent(raw),
...(providerOptions ? { providerOptions } : {}),
} as ModelMessage;
}
return {
role: "user",
content: normalizeUserContent(raw.content),
...(providerOptions ? { providerOptions } : {}),
} as ModelMessage;
}
/**
* Strip reasoning-only parts from outbound assistant content.
*
* OpenAI-spec reasoning models (Cerebras gpt-oss-120b, OpenAI o1/o3,
* DeepSeek R1, and similar families) return reasoning in the assistant
* response — either as a separate `reasoning` / `reasoning_content`
* field, or as content parts with `type: "reasoning"`. Echoing those
* back to the next turn is wrong on both ends:
* - Cerebras returns HTTP 400 (`messages.X.assistant.reasoning_content:
* property is unsupported`).
* - OpenAI silently drops them, which wastes prompt tokens.
*
* The AI SDK upstream of this normalizer surfaces those reasoning blocks
* as `{ type: "reasoning", ... }` content parts. We drop them here so
* the wire stays spec-clean for the next turn. The reasoning itself
* remains usable as a single-turn signal (still on the response object);
* we only refuse to round-trip it.
*/
function stripReasoningParts(content: unknown[]): unknown[] {
return content.filter((part) => {
if (!part || typeof part !== "object") return true;
const type = (part as { type?: unknown }).type;
return type !== "reasoning" && type !== "thinking";
});
}
function normalizeAssistantContent(message: Record<string, unknown>): unknown {
const toolCalls = Array.isArray(message.toolCalls) ? message.toolCalls : [];
if (toolCalls.length === 0) {
if (Array.isArray(message.content)) {
return stripReasoningParts(message.content);
}
if (typeof message.content === "string") {
return message.content;
}
return "";
}
const parts: unknown[] = [];
if (typeof message.content === "string" && message.content.length > 0) {
parts.push({ type: "text", text: message.content });
} else if (Array.isArray(message.content)) {
parts.push(...stripReasoningParts(message.content));
}
for (const toolCall of toolCalls) {
const rawCall = asRecord(toolCall);
const rawFunction = asRecord(rawCall.function);
const toolCallId = firstString(rawCall.toolCallId, rawCall.id);
const toolName = firstString(rawCall.toolName, rawCall.name, rawFunction.name);
if (!toolCallId || !toolName) {
continue;
}
parts.push({
type: "tool-call",
toolCallId,
toolName,
input: parseToolCallInput(rawCall, rawFunction),
});
}
return parts;
}
function normalizeToolContent(message: Record<string, unknown>): unknown[] {
if (Array.isArray(message.content)) {
return message.content;
}
const toolCallId = firstString(message.toolCallId, message.id) ?? "tool-call";
const toolName = firstString(message.toolName, message.name) ?? "tool";
const parsed = parseJsonIfPossible(message.content);
return [
{
type: "tool-result",
toolCallId,
toolName,
output:
typeof parsed === "string"
? { type: "text", value: parsed }
: { type: "json", value: parsed },
},
];
}
function normalizeUserContent(content: unknown): UserContent {
if (Array.isArray(content)) {
return content as UserContent;
}
return stringifyMessageContent(content);
}
function stringifyMessageContent(content: unknown): string {
if (typeof content === "string") {
return content;
}
if (content == null) {
return "";
}
return typeof content === "object" ? JSON.stringify(content) : String(content);
}
function parseToolCallInput(
rawCall: Record<string, unknown>,
rawFunction: Record<string, unknown>
): unknown {
if ("input" in rawCall) {
return rawCall.input;
}
return parseJsonIfPossible(rawCall.arguments ?? rawFunction.arguments ?? {});
}
function parseJsonIfPossible(value: unknown): unknown {
if (typeof value !== "string") {
return value ?? "";
}
try {
return JSON.parse(value);
} catch {
// error-policy:J3 untrusted-input sanitizing — tool-call `arguments` may be a
// plain (non-JSON) string; returning the raw value is the correct parse of a
// non-JSON argument, not a swallowed failure.
return value;
}
}
function parseRecordArgPath(path: string): string[] {
if (path === "$") return [];
if (!path.startsWith("$.")) return [];
return path.slice(2).split(".");
}
function restoreStrictSafeRecordValue(value: unknown, transform: RecordArgTransform): unknown {
const record = asOptionalRecord(value);
if (!record) return value;
const entries = record[transform.entriesKey];
if (!Array.isArray(entries)) return value;
const restored: Record<string, unknown> = {};
for (const [key, nested] of Object.entries(record)) {
if (key !== transform.entriesKey) {
restored[key] = nested;
}
}
for (const entry of entries) {
const row = asOptionalRecord(entry);
if (!row) continue;
const key = typeof row.key === "string" ? row.key : undefined;
if (!key) continue;
const rawValue = row.value;
restored[key] =
transform.valueMode === "json-string" && typeof rawValue === "string"
? parseJsonIfPossible(rawValue)
: rawValue;
}
return restored;
}
function restoreRecordArgAtPath(
value: unknown,
tokens: string[],
transform: RecordArgTransform
): unknown {
if (tokens.length === 0) {
return restoreStrictSafeRecordValue(value, transform);
}
const [token, ...rest] = tokens;
if (token === "items" && Array.isArray(value)) {
return value.map((item) => restoreRecordArgAtPath(item, rest, transform));
}
if (/^items\[\d+\]$/.test(token)) {
return Array.isArray(value)
? value.map((item) => restoreRecordArgAtPath(item, rest, transform))
: value;
}
const record = asOptionalRecord(value);
if (!record || !(token in record)) {
return value;
}
return {
...record,
[token]: restoreRecordArgAtPath(record[token], rest, transform),
};
}
function restoreRecordArgInput(input: unknown, transforms: RecordArgTransform[]): unknown {
return [...transforms]
.sort((a, b) => parseRecordArgPath(a.path).length - parseRecordArgPath(b.path).length)
.reduce(
(current, transform) =>
restoreRecordArgAtPath(current, parseRecordArgPath(transform.path), transform),
input
);
}
function restoreRecordArgToolCalls(
toolCalls: unknown,
transformsByTool: Record<string, RecordArgTransform[]>
): unknown[] | undefined {
if (!Array.isArray(toolCalls)) {
return undefined;
}
return toolCalls.map((toolCall) => {
const call = asOptionalRecord(toolCall);
if (!call) return toolCall;
const rawFunction = asRecord(call.function);
const toolName = firstString(call.toolName, call.name, rawFunction.name);
const transforms = toolName ? transformsByTool[toolName] : undefined;
if (!transforms?.length) return toolCall;
if ("input" in call) {
return {
...call,
input: restoreRecordArgInput(call.input, transforms),
};
}
if (typeof call.arguments === "string") {
const parsed = parseJsonIfPossible(call.arguments);
return {
...call,
arguments: JSON.stringify(restoreRecordArgInput(parsed, transforms)),
};
}
if (typeof rawFunction.arguments === "string") {
const parsed = parseJsonIfPossible(rawFunction.arguments);
return {
...call,
function: {
...rawFunction,
arguments: JSON.stringify(restoreRecordArgInput(parsed, transforms)),
},
};
}
return toolCall;
});
}
function normalizeToolChoice(toolChoice: unknown): ToolChoice<ToolSet> | undefined {
if (!toolChoice) {
return undefined;
}
if (
typeof toolChoice === "string" &&
(toolChoice === "auto" || toolChoice === "none" || toolChoice === "required")
) {
return toolChoice;
}
const choice = asRecord(toolChoice);
if (choice.type === "tool") {
if (typeof choice.toolName === "string" && choice.toolName.length > 0) {
return toolChoice as ToolChoice<ToolSet>;
}
const toolName = firstString(choice.toolName, choice.name);
if (toolName) {
return { type: "tool", toolName };
}
}
if (choice.type === "function") {
const fn = asRecord(choice.function);
const toolName = firstString(fn.name);
if (toolName) {
return { type: "tool", toolName };
}
}
const namedTool = firstString(choice.name);
if (namedTool) {
return { type: "tool", toolName: namedTool };
}
return toolChoice as ToolChoice<ToolSet>;
}
function hasIllegalStrictRoot(node: Record<string, unknown>): boolean {
// Strict-mode JSON schema validators on OpenAI-compatible providers (Groq,
// Cerebras, OpenAI strict tools) reject tool-parameters whose top level is
// not `type: "object"` or carries `oneOf`/`anyOf`/`enum`/`not` at the root.
// The error wording varies by provider but the constraint is uniform.
if (node.type !== "object") return true;
if (Array.isArray(node.oneOf) && node.oneOf.length > 0) return true;
if (Array.isArray(node.anyOf) && node.anyOf.length > 0) return true;
if (Array.isArray(node.enum)) return true;
if (node.not !== undefined) return true;
return false;
}
// Constraint keywords that strict-grammar providers reject with a hard 400
// that fails the ENTIRE request. The exact set was bisected live against
// api.elizacloud.ai / gpt-oss-120b (Cerebras): maxItems/minItems/maxLength/
// minLength/pattern/format/min-maxProperties are rejected; numeric bounds
// (minimum/maximum/multipleOf) and uniqueItems are accepted, so they are NOT
// stripped. Each maps to a human phrase folded into `description` so the model
// still sees the intent after the machine-readable keyword is removed.
const STRICT_UNSUPPORTED_CONSTRAINTS: Record<string, (value: unknown) => string> = {
maxItems: (v) => `at most ${v} items`,
minItems: (v) => `at least ${v} items`,
maxLength: (v) => `at most ${v} characters`,
minLength: (v) => `at least ${v} characters`,
pattern: (v) => `matching the pattern ${v}`,
format: (v) => `in ${v} format`,
minProperties: (v) => `at least ${v} properties`,
maxProperties: (v) => `at most ${v} properties`,
};
/**
* Removes constraint keywords that strict-grammar providers reject, folding
* each into the node's `description` so the model keeps the guidance. Mutates
* the passed (already-shallow-copied) node in place.
*
* Removing them from the wire is lossless for correctness: `parseAndValidate`
* (runtime/validated-model-call.ts) re-checks the caller's ORIGINAL schema
* app-side, so any real bound is still enforced on the returned value.
*/
function stripStrictUnsupportedConstraints(node: Record<string, unknown>): void {
const hints: string[] = [];
for (const [keyword, phrase] of Object.entries(STRICT_UNSUPPORTED_CONSTRAINTS)) {
if (keyword in node) {
hints.push(phrase(node[keyword]));
delete node[keyword];
}
}
if (hints.length === 0) return;
const existing = typeof node.description === "string" ? node.description.trim() : "";
const suffix = `(${hints.join(", ")})`;
node.description = existing ? `${existing} ${suffix}` : suffix;
}
/**
* Human phrase describing a DECLARED free-form/open map so the intent survives
* when we close the object on the wire. Returns `null` for an undeclared
* (`undefined`) additionalProperties — that is a plain object, not a data-loss
* case. `true` → open map of any value; a schema value → open map of that type.
*/
function additionalPropertiesHint(additionalProperties: unknown): string | null {
if (additionalProperties === true) {
return "also accepts arbitrary additional properties as key/value pairs";
}
if (
additionalProperties &&
typeof additionalProperties === "object" &&
!Array.isArray(additionalProperties)
) {
const valueType = (additionalProperties as Record<string, unknown>).type;
const typeStr = typeof valueType === "string" ? `${valueType} ` : "";
return `also accepts arbitrary additional ${typeStr}values as key/value pairs`;
}
return null;
}
const STRICT_SAFE_RECORD_ENTRIES_KEY = "__eliza_record_entries";
function chooseRecordEntriesKey(properties: Record<string, unknown>): string {
if (!(STRICT_SAFE_RECORD_ENTRIES_KEY in properties)) {
return STRICT_SAFE_RECORD_ENTRIES_KEY;
}
let index = 2;
while (`${STRICT_SAFE_RECORD_ENTRIES_KEY}_${index}` in properties) {
index++;
}
return `${STRICT_SAFE_RECORD_ENTRIES_KEY}_${index}`;
}
function strictSafeRecordValueSchema(additionalProperties: unknown): {
schema: JSONSchema7;
mode: RecordArgValueMode;
} {
if (additionalProperties === true) {
return {
mode: "json-string",
schema: {
type: "string",
description:
"JSON-encoded value for this arbitrary key. Use plain text for string values and JSON text for objects, arrays, numbers, booleans, or null.",
},
};
}
return {
mode: "schema",
schema: sanitizeJsonSchema(additionalProperties),
};
}
function strictSafeRecordEntriesSchema(valueSchema: JSONSchema7): JSONSchema7 {
return {
type: "array",
description:
"Additional arbitrary key/value entries for this record/map. Each entry becomes a property on the original tool argument object before validation.",
items: {
type: "object",
properties: {
key: {
type: "string",
description: "Property key to add to the original record/map argument.",
},
value: valueSchema,
},
required: ["key", "value"],
additionalProperties: false,
},
};
}
/**
* @param path - dotted location threaded through recursion for reverse-mapping
* returned tool-call args.
*/
function sanitizeJsonSchema(
schema: unknown,
isRoot = false,
path = "$",
transforms?: RecordArgTransform[]
): JSONSchema7 {
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
// Permissive fallback: no `properties: {}`/`additionalProperties: false`
// pair, which strict-grammar providers reject. See `normalizeSchemaForCerebras`
// in @elizaos/core for the rationale.
return { type: "object" };
}
const record = schema as Record<string, unknown>;
let sanitized: Record<string, unknown> = { ...record };
// This is the single wire choke point — every response_format schema
// (buildStructuredOutput) and every tool schema (normalizeNativeTools)
// funnels through here, so strip the strict-unsupported constraint keywords
// centrally instead of relying on each schema author to remember the rule.
// UNCONDITIONAL, not Cerebras-gated: isCerebrasMode is proxy-blind — an agent
// pointed at api.elizacloud.ai with OPENAI_API_KEY looks like plain OpenAI,
// which is exactly the deployment where the 400 fired (#11123/#11141). The
// recursion below reaches nested nodes via properties/items/unions.
stripStrictUnsupportedConstraints(sanitized);
if (typeof sanitized.type !== "string") {
const inferredType = inferJsonSchemaType(sanitized, isRoot);
if (inferredType) {
sanitized.type = inferredType;
}
}
if (isRoot && hasIllegalStrictRoot(sanitized)) {
// Wrap the original schema under properties.value. Strict-tool callers
// that unwrap arguments will see `{ value: <original> }`. The recursion
// below normalises the wrapped child like any other property.
sanitized = {
type: "object",
properties: { value: { ...record } },
required: ["value"],
additionalProperties: false,
};
}
if (
sanitized.properties &&
typeof sanitized.properties === "object" &&
!Array.isArray(sanitized.properties)
) {
const properties: Record<string, unknown> = {};
for (const [key, value] of Object.entries(sanitized.properties as Record<string, unknown>)) {
properties[key] = sanitizeJsonSchema(value, false, `${path}.${key}`, transforms);
}
sanitized.properties = properties;
const propertyKeys = Object.keys(properties);
const existingRequired = Array.isArray(sanitized.required)
? sanitized.required.filter((key): key is string => typeof key === "string")
: [];
sanitized.required = [...new Set([...existingRequired, ...propertyKeys])];
}
if (sanitized.type === "object" && sanitized.additionalProperties !== false) {
// Strict-grammar providers reject open maps (schema-valued or `true`
// additionalProperties) with a hard 400, and provider strictness is
// proxy-blind (an agent on api.elizacloud.ai with OPENAI_API_KEY may still
// route to strict Cerebras — #11123/#11156), so we must always close the
// object on the wire. But a DECLARED free-form map (e.g. contact
// customFields = `additionalProperties: { type: "string" }`) was collapsed
// SILENTLY: the model saw a closed object, could emit no keys, and the arg
// always arrived empty (#11249). Fold the intent into `description`
// (mirroring stripStrictUnsupportedConstraints) so it is preserved —
// non-strict providers can still emit the pairs (app-side parseAndValidate
// re-checks the caller's ORIGINAL schema and accepts them), and strict
// providers surface the intent instead of losing it without a trace.
const hint = additionalPropertiesHint(sanitized.additionalProperties);
if (hint && transforms) {
const properties =
sanitized.properties &&
typeof sanitized.properties === "object" &&
!Array.isArray(sanitized.properties)
? ({ ...(sanitized.properties as Record<string, unknown>) } as Record<string, unknown>)
: {};
const entriesKey = chooseRecordEntriesKey(properties);
const { schema: valueSchema, mode } = strictSafeRecordValueSchema(
sanitized.additionalProperties
);
properties[entriesKey] = strictSafeRecordEntriesSchema(valueSchema);
sanitized.properties = properties;
sanitized.required = [
...new Set([
...(Array.isArray(sanitized.required)
? sanitized.required.filter((key): key is string => typeof key === "string")
: []),
...Object.keys(properties),
]),
];
transforms.push({ path, entriesKey, valueMode: mode });
const existing =
typeof sanitized.description === "string" ? sanitized.description.trim() : "";
const suffix = `${hint}; provide arbitrary entries in ${entriesKey} as key/value pairs`;
sanitized.description = existing ? `${existing} (${suffix})` : `(${suffix})`;
} else if (hint) {
// response_format schemas have no returned tool args to reverse-map, so
// they keep the old strict-safe close-and-describe behavior.
}
sanitized.additionalProperties = false;
if (hint && !transforms) {
const existing =
typeof sanitized.description === "string" ? sanitized.description.trim() : "";
sanitized.description = existing ? `${existing} (${hint})` : `(${hint})`;
}
}
if (sanitized.items) {
sanitized.items = Array.isArray(sanitized.items)
? sanitized.items.map((item, i) =>
sanitizeJsonSchema(item, false, `${path}.items[${i}]`, transforms)
)
: sanitizeJsonSchema(sanitized.items, false, `${path}.items`, transforms);
}
for (const unionKey of ["anyOf", "oneOf", "allOf"] as const) {
const value = sanitized[unionKey];
if (Array.isArray(value)) {
sanitized[unionKey] = value.map((item, i) =>
sanitizeJsonSchema(item, false, `${path}.${unionKey}[${i}]`, transforms)
);
}
}
// Every other schema-bearing keyword must be walked too, or a stripped
// keyword nested inside one survives to the wire. `$defs`/`definitions`
// matter most in practice: zod's `toJSONSchema` hoists reused/nullable
// sub-schemas into `$defs`, so a `.max()`/`.regex()` on a shared field would
// otherwise slip through the strip. `contains`/`propertyNames`/`not`/`if`/
// `then`/`else` take a single sub-schema; `patternProperties`/`$defs`/
// `definitions` are maps of them.
for (const singleKey of ["contains", "propertyNames", "not", "if", "then", "else"] as const) {
const value = sanitized[singleKey];
if (value && typeof value === "object" && !Array.isArray(value)) {
sanitized[singleKey] = sanitizeJsonSchema(value, false, `${path}.${singleKey}`, transforms);
}
}
for (const mapKey of ["patternProperties", "$defs", "definitions"] as const) {
const value = sanitized[mapKey];
if (value && typeof value === "object" && !Array.isArray(value)) {
const walked: Record<string, unknown> = {};
for (const [key, sub] of Object.entries(value as Record<string, unknown>)) {
walked[key] = sanitizeJsonSchema(sub, false, `${path}.${mapKey}.${key}`, transforms);
}
sanitized[mapKey] = walked;
}
}
return sanitized as JSONSchema7;
}
function inferJsonSchemaType(schema: Record<string, unknown>, isRoot: boolean): string | undefined {
if (
"properties" in schema ||
"required" in schema ||
"additionalProperties" in schema ||
isRoot
) {
return "object";
}
if ("items" in schema) {
return "array";
}
if (Array.isArray(schema.enum) && schema.enum.length > 0) {
const types = new Set(schema.enum.map((value) => typeof value));
if (types.size === 1) {
const [type] = [...types];
if (type === "string" || type === "number" || type === "boolean") {
return type;
}
}
}
return undefined;
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function asOptionalRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function firstString(...values: unknown[]): string | undefined {
for (const value of values) {
if (typeof value === "string" && value.length > 0) {
return value;
}
}
return undefined;
}
function usesNativeTextResult(params: GenerateTextParamsWithOpenAIOptions): boolean {
return Boolean(params.messages || params.tools || params.toolChoice || params.responseSchema);
}
function buildNativeTextResult(
result: {
text: string;
toolCalls?: unknown[];
finishReason?: string;
usage?: LanguageModelUsage;
providerMetadata?: unknown;
},
modelName?: string
): NativeGenerateTextResult {
return {
text: result.text,
toolCalls: result.toolCalls ?? [],
finishReason: result.finishReason,
usage: convertUsage(result.usage),
providerMetadata: mergeProviderModelName(result.providerMetadata, modelName),
};
}
function handledPromise<T>(value: T | PromiseLike<T>): Promise<T> {
const promise = Promise.resolve(value);
promise.catch(() => {
// error-policy:J5 unhandled-rejection suppression — the streaming path
// primarily consumes `textStream`. AI SDK companion promises such as `text`
// can reject later on empty streams even when no caller requested them; the
// real error is still observed by whoever awaits `textStream`.
});
return promise;
}
function handledMappedPromise<T, U>(
value: T | PromiseLike<T>,
mapper: (resolved: T) => U | PromiseLike<U>
): Promise<U> {
return handledPromise(handledPromise(value).then(mapper));
}
function mergeProviderModelName(providerMetadata: unknown, modelName?: string): unknown {
if (!modelName) {
return providerMetadata;
}
if (
providerMetadata &&
typeof providerMetadata === "object" &&
!Array.isArray(providerMetadata)
) {
return {
...(providerMetadata as Record<string, unknown>),
modelName,
};
}
return { modelName };
}
function createLlmCallDetails(
modelName: string,
params: GenerateTextParams,
systemPrompt: string | undefined,
actionType: string,
modelType?: ModelTypeName,
providerOptions?: Record<string, unknown>,
generateParams?: NativeTextParams
): RecordLlmCallDetails {
const originalParams = params as GenerateTextParamsWithOpenAIOptions;
const nativeParams = generateParams as
| (NativeTextParams & {
output?: unknown;
maxOutputTokens?: unknown;
})
| undefined;
const nativePrompt = nativeParams && "prompt" in nativeParams ? nativeParams.prompt : undefined;
const nativeMessages =
nativeParams && "messages" in nativeParams && Array.isArray(nativeParams.messages)
? nativeParams.messages
: undefined;
const nativeSystem =
typeof nativeParams?.system === "string" ? nativeParams.system : systemPrompt;
return {
model: modelName,
modelType,
provider: "vercel-ai-sdk",
systemPrompt: nativeSystem ?? "",
userPrompt:
typeof nativePrompt === "string"
? nativePrompt
: typeof params.prompt === "string"
? params.prompt
: "",
prompt: typeof nativePrompt === "string" ? nativePrompt : undefined,
messages: nativeMessages,
tools: nativeParams?.tools ?? originalParams.tools,
toolChoice: nativeParams?.toolChoice ?? originalParams.toolChoice,
output:
nativeParams?.output !== undefined
? buildTrajectoryOutputDescriptor(originalParams.responseSchema, nativeParams.output)
: undefined,
responseSchema: originalParams.responseSchema,
providerOptions:
providerOptions ?? nativeParams?.providerOptions ?? originalParams.providerOptions,
temperature: params.temperature ?? 0,
maxTokens:
typeof nativeParams?.maxOutputTokens === "number"
? nativeParams.maxOutputTokens
: params.omitMaxTokens
? 0
: (params.maxTokens ?? 8192),
maxTokensOmitted:
params.omitMaxTokens && typeof nativeParams?.maxOutputTokens !== "number" ? true : undefined,
purpose: "external_llm",
actionType,
};
}
function buildTrajectoryOutputDescriptor(responseSchema: unknown, output: unknown): unknown {
if (responseSchema !== undefined) {
return {
type: "object",
schema: responseSchema,
};
}
return toTrajectoryJsonSafe(output);
}
function toTrajectoryJsonSafe(value: unknown): unknown {
try {
return JSON.parse(
JSON.stringify(value, (_key, nested) => {
if (typeof nested === "function") return undefined;
if (typeof nested === "bigint") return nested.toString();
return nested;
})
) as unknown;
} catch {
// error-policy:J7 diagnostics-must-not-kill-the-loop — trajectory JSON
// serialization is a telemetry artifact; on a non-serializable value fall
// back to a string repr rather than failing the model call being logged.
return String(value);
}
}
function applyUsageToDetails(
details: RecordLlmCallDetails,
usage: LanguageModelUsage | undefined
): void {
if (!usage) {
return;
}
details.promptTokens = usage.inputTokens ?? 0;
details.completionTokens = usage.outputTokens ?? 0;
}
// ============================================================================
// Core Generation Function
// ============================================================================
/**
* Whether a thrown model-call error is a transient provider hiccup that is
* worth retrying. The AI SDK already retries clear-cut retryables (408/409/429/
* 5xx) via its own `maxRetries`, but Cerebras under load returns its transient
* "Encountered a server error, please try again" as an HTTP **400**, which the
* SDK classifies as non-retryable and surfaces immediately — failing a coding
* build that the very same request would complete on a second attempt (observed
* live: large multi-tool requests 400 intermittently under fleet load, succeed
* on retry). We treat such a 400 as transient ONLY when its body/message looks
* like an overload, never when it looks like a genuine validation error, so we
* don't mask real malformed-request bugs.
*/
function isTransientProviderError(error: unknown): boolean {
const e = error as
| { statusCode?: number; status?: number; message?: string; data?: unknown }
| undefined;
if (!e) return false;
const status = e.statusCode ?? e.status;
if (status === 408 || status === 409 || status === 429) return true;
if (typeof status === "number" && status >= 500 && status < 600) return true;
const msg = `${e.message ?? ""} ${JSON.stringify(e.data ?? "")} ${
(e as { type?: string }).type ?? ""
}`.toLowerCase();
// No HTTP status: either a network-level failure OR a provider that returns
// its transient error as a bare object (Cerebras passes
// `{message:"Encountered a server error, please try again", type:"server_error"}`
// straight to the AI SDK's onError with no statusCode). Retry both — but never
// a genuine validation error that merely lacks a status.
if (status === undefined) {
if (/invalid|unsupported|must be|required field|malformed|not allowed|json schema/.test(msg)) {
return false;
}
return /timeout|timed out|econnreset|econnrefused|socket|network|fetch failed|terminated|server error|server_error|try again|overload|capacity|temporarily|unavailable|busy|rate ?limit|please retry/.test(
msg
);
}
// Transient 400: overload/server-error wording. Do NOT retry genuine
// validation failures (invalid/unsupported/schema/required/malformed).
if (status === 400) {
if (/invalid|unsupported|must be|required|malformed|not allowed|schema/.test(msg)) {
return false;
}
return /server error|try again|overload|capacity|temporarily|busy|rate/.test(msg);
}
return false;
}
/**
* Call `generateText` with bounded retry + exponential backoff on transient
* provider errors (see {@link isTransientProviderError}). Mirrors opencode's
* resilience posture (it sets `retries: 2` on its coding LLM call) but also
* covers Cerebras's non-standard transient-400 that the AI SDK won't retry.
* Non-transient errors propagate immediately on the first attempt.
*/
async function generateTextWithTransientRetry(
generateParams: NativeGenerateTextParams,
maxRetries = 3
): Promise<Awaited<ReturnType<typeof generateText<ToolSet>>>> {
let attempt = 0;
for (;;) {
try {
return (await generateText(
generateParams as Parameters<typeof generateText>[0]
// biome-ignore lint/suspicious/noExplicitAny: see above.
)) as any;
} catch (error) {
// error-policy:J2 context-adding rethrow — terminal or retry-exhausted
// errors rethrow unchanged; only bounded transient provider errors retry.
if (attempt >= maxRetries || !isTransientProviderError(error)) throw error;
attempt++;
const backoffMs = Math.min(3000, 300 * 2 ** (attempt - 1)) + Math.floor(Math.random() * 200);
logger.warn(
`[OpenAI] transient model error (attempt ${attempt}/${maxRetries}), retrying in ${backoffMs}ms: ${
(error as { message?: string })?.message ?? String(error)
}`
);
await new Promise((resolve) => setTimeout(resolve, backoffMs));
}
}
}
interface BufferedStreamResult {
text: string;
toolCalls: Awaited<ReturnType<typeof streamText<ToolSet>>["toolCalls"]> | undefined;
usage: LanguageModelUsage | undefined;
finishReason: string | undefined;
}
/**
* Consume a `streamText` call to completion with bounded transient-error retry.
*
* Coding/structured planner calls stream, but Cerebras under fleet load returns
* intermittent transient 400s on large multi-tool requests — and for a stream
* that error surfaces only while the stream is *consumed*, so the AI SDK's
* `maxRetries` (which also won't retry a 400) never helps and the build fails on
* an error the very same request would survive on a second attempt. We buffer
* the stream and re-issue the whole call on a transient failure. Token streaming
* is not user-visible for coding (the sub-agent relays a final summary), so
* buffering loses nothing there. Used only in coding mode; chat keeps live
* streaming.
*/
async function consumeStreamWithTransientRetry(
generateParams: NativeGenerateTextParams,
onChunk: ((chunk: string) => void) | undefined,
maxRetries = 5
): Promise<BufferedStreamResult> {
let attempt = 0;
for (;;) {
try {
// The AI SDK does NOT throw on a request failure during streaming — it
// routes the error to `onError` and ends the stream empty (an empty
// result then reads as "model called no tool" upstream). Capture it here
// and rethrow after consumption so the retry below can act on it. (This
// is the same reason opencode attaches an onError to its streamText.)
let capturedError: unknown;
const result = streamText({
...(generateParams as Parameters<typeof streamText>[0]),
onError: ({ error }: { error: unknown }) => {
capturedError = error;
},
});
let text = "";
for await (const chunk of result.textStream) {
onChunk?.(chunk);
text += chunk;
}
const toolCalls = await result.toolCalls;
const usage = await result.usage;
const finishReason = (await result.finishReason) as string | undefined;
if (capturedError) throw capturedError;
return { text, toolCalls, usage, finishReason };
} catch (error) {
// error-policy:J2 context-adding rethrow — terminal or retry-exhausted
// errors rethrow unchanged; only bounded transient provider errors retry.
if (attempt >= maxRetries || !isTransientProviderError(error)) throw error;
attempt++;
const backoffMs = Math.min(3000, 300 * 2 ** (attempt - 1)) + Math.floor(Math.random() * 200);
logger.warn(
`[OpenAI] transient stream error (attempt ${attempt}/${maxRetries}), retrying in ${backoffMs}ms: ${
(error as { message?: string })?.message ?? String(error)
}`
);
await new Promise((resolve) => setTimeout(resolve, backoffMs));
}
}
}
/**
* Generates text using the specified model type.
*
* @param runtime - The agent runtime
* @param params - Generation parameters
* @param modelType - The type of model (TEXT_SMALL or TEXT_LARGE)
* @param getModelFn - Function to get the model name
* @returns Generated text or stream result
*/
async function generateTextByModelType(
runtime: IAgentRuntime,
params: GenerateTextParams,
modelType: ModelTypeName,
getModelFn: ModelNameGetter
): Promise<string | TextStreamResult> {
const paramsWithAttachments = params as GenerateTextParamsWithOpenAIOptions;
const openai = createOpenAIClient(runtime);
const modelName = resolveRequestedModelName(paramsWithAttachments, runtime, getModelFn);
logger.debug(`[OpenAI] Using ${modelType} model: ${modelName}`);
const providerOptions = resolveProviderOptions(params, runtime, modelName);
const hasAttachments = (paramsWithAttachments.attachments?.length ?? 0) > 0;
const userContent = hasAttachments ? buildUserContent(paramsWithAttachments) : undefined;
const shouldReturnNativeResult = usesNativeTextResult(paramsWithAttachments);
const systemPrompt = resolveEffectiveSystemPrompt({
params: paramsWithAttachments,
fallback: buildCanonicalSystemPrompt({ character: runtime.character }),
});
const agentName = paramsWithAttachments.providerOptions?.agentName;
const telemetryConfig: NativeTelemetrySettings = {
isEnabled: getExperimentalTelemetry(runtime),
functionId: agentName ? `agent:${agentName}` : undefined,
metadata: agentName ? { agentName } : undefined,
};
// Chat Completions is the default: broadest compatibility, and it works
// against every OpenAI-compatible endpoint (Cerebras, local servers, proxies).
// gpt-5 / gpt-5-mini reasoning models ignore temperature/penalty/stop params.
//
const model = openai.chat(modelName);
const cerebrasMode = isCerebrasMode(runtime);
const normalizedToolResult = normalizeNativeToolsForCall(paramsWithAttachments.tools, {
cerebrasMode,
});
const normalizedTools = normalizedToolResult.tools;
const normalizedToolChoice = normalizeToolChoice(paramsWithAttachments.toolChoice);
const normalizedMessages = normalizeNativeMessages(paramsWithAttachments.messages);
const wireMessages = dropDuplicateLeadingSystemMessage(normalizedMessages, systemPrompt);
const effectiveMessages =
wireMessages && wireMessages.length > 0 ? wireMessages : normalizedMessages;
const promptText =
typeof params.prompt === "string" && params.prompt.length > 0 ? params.prompt : "";
const promptOrMessages: NativePrompt =
effectiveMessages && effectiveMessages.length > 0
? { messages: effectiveMessages }
: userContent
? { messages: [{ role: "user" as const, content: userContent }] }
: { prompt: promptText };
// elizaOS callers pass `responseFormat: { type: "json_object" | "text" }`
// (see `GenerateTextParams` in @elizaos/core). The AI SDK's equivalent
// is `responseFormat: { type: "json" }` (which translates to
// `response_format: { type: "json_object" }` at the OpenAI wire layer).
// Translate the shape so the param actually reaches the API call —
// before this, callers asking for json_object were silently ignored
// and Cerebras returned plain text, dropping us into the simple-reply
// fallback every turn.
const callerResponseFormat = (paramsWithAttachments as { responseFormat?: unknown })
.responseFormat;
const responseFormatType =
typeof callerResponseFormat === "string"
? callerResponseFormat
: callerResponseFormat &&
typeof callerResponseFormat === "object" &&
"type" in callerResponseFormat
? (callerResponseFormat as { type: string }).type
: undefined;
const wireResponseFormat: { type: "json" } | { type: "text" } | undefined =
responseFormatType === "json_object"
? { type: "json" }
: responseFormatType === "text"
? { type: "text" }
: undefined;
const generateParams: NativeTextParams = {
model,
...promptOrMessages,
system: systemPrompt,
allowSystemInMessages: true,
// Omit the cap when the caller opted out (direct-channel Stage-1) so the
// model's own max applies — a hardcoded value 400s when it exceeds the
// model's limit. Other callers keep the 8192 default.
...(params.omitMaxTokens ? {} : { maxOutputTokens: params.maxTokens ?? 8192 }),
experimental_telemetry: telemetryConfig,
...(normalizedTools ? { tools: normalizedTools } : {}),
...(normalizedToolChoice ? { toolChoice: normalizedToolChoice } : {}),
// Cerebras's OpenAI-compatible endpoint does not accept the
// `response_format: { type: "json_schema", ... }` payload that the AI SDK
// emits when `output: Output.object(...)` is set. Fall back to relying on
// `responseFormat: { type: "json_object" }` (already passed by callers)
// plus the schema embedded in the prompt body.
...(paramsWithAttachments.responseSchema && !isCerebrasMode(runtime)
? { output: buildStructuredOutput(paramsWithAttachments.responseSchema) }
: {}),
...(wireResponseFormat ? { responseFormat: wireResponseFormat } : {}),
...(providerOptions ? { providerOptions: providerOptions as NativeProviderOptions } : {}),
};
// Handle streaming mode
if (params.stream) {
// Coding/structured planner calls prioritise reliability over live token
// streaming: buffer the stream to completion with transient-error retry so a
// Cerebras-under-load 400 doesn't fail an otherwise-good build (see
// consumeStreamWithTransientRetry). Token streaming isn't user-visible for
// coding. Regular chat falls through to the live-streaming path below.
const fullActionSurface = process.env.ELIZA_PLANNER_FULL_ACTION_SURFACE?.trim().toLowerCase();
if (
fullActionSurface === "1" ||
fullActionSurface === "true" ||
fullActionSurface === "yes" ||
fullActionSurface === "on"
) {
const details = createLlmCallDetails(
modelName,
params,
systemPrompt,
"ai.streamText",
modelType,
providerOptions,
generateParams
);
details.response = "";
const buffered = await recordLlmCall(runtime, details, () =>
consumeStreamWithTransientRetry(generateParams, params.onStreamChunk)
);
const restoredToolCalls = restoreRecordArgToolCalls(
buffered.toolCalls,
normalizedToolResult.recordArgTransformsByTool
);
details.response = buffered.text;
details.toolCalls = restoredToolCalls;
details.finishReason = buffered.finishReason;
if (buffered.usage) {
applyUsageToDetails(details, buffered.usage);
emitModelUsageEvent(runtime, modelType, params.prompt ?? "", buffered.usage);
}
return {
textStream: (async function* replayBufferedStream() {
if (buffered.text) yield buffered.text;
})(),
text: Promise.resolve(buffered.text),
...(shouldReturnNativeResult ? { toolCalls: Promise.resolve(restoredToolCalls) } : {}),
usage: Promise.resolve(convertUsage(buffered.usage)),
finishReason: Promise.resolve(buffered.finishReason),
};
}
const details = createLlmCallDetails(
modelName,
params,
systemPrompt,
"ai.streamText",
modelType,
providerOptions,
generateParams
);
details.response = "";
assertActiveTrajectoryForLlmCall({
actionType: details.actionType,
model: details.model,
modelType: details.modelType,
purpose: details.purpose,
});
const startedAt =
typeof performance !== "undefined" && typeof performance.now === "function"
? performance.now()
: Date.now();
const responseChunks: string[] = [];
let capturedStreamError: unknown;
let companionStreamError: unknown;
let telemetryFinalized = false;
const result = await streamText({
...generateParams,
onError: ({ error }: { error: unknown }) => {
capturedStreamError = error;
},
});
let structuredTextSettled = false;
let resolveStructuredText: (text: string) => void = () => {};
let rejectStructuredText: (error: unknown) => void = () => {};
const structuredTextPromise = new Promise<string>((resolve, reject) => {
resolveStructuredText = resolve;
rejectStructuredText = reject;
});
const settleStructuredText = (error?: unknown): void => {
if (params.streamStructured !== true || structuredTextSettled) return;
structuredTextSettled = true;
if (error) {
rejectStructuredText(error);
return;
}
resolveStructuredText(responseChunks.join(""));
};
const sdkTextPromise = handledPromise(result.text);
const textPromise = params.streamStructured === true ? structuredTextPromise : sdkTextPromise;
const rawUsagePromise = handledPromise(result.usage);
const rawFinishReasonPromise = handledPromise(result.finishReason);
const rawToolCallsPromise = handledPromise(result.toolCalls);
const restoredToolCallsPromise = handledMappedPromise(rawToolCallsPromise, (toolCalls) =>
restoreRecordArgToolCalls(toolCalls, normalizedToolResult.recordArgTransformsByTool)
);
const usagePromise = handledMappedPromise(rawUsagePromise, convertUsage);
const finishReasonPromise = handledMappedPromise(
rawFinishReasonPromise,
(r) => r as string | undefined
);
const finalizeStreamingTelemetry = async () => {
if (telemetryFinalized) {
return;
}
telemetryFinalized = true;
const [usageResult, finishReasonResult, toolCallsResult] = await Promise.allSettled([
rawUsagePromise,
rawFinishReasonPromise,
restoredToolCallsPromise,
]);
details.response = responseChunks.join("");
if (usageResult.status === "fulfilled" && usageResult.value) {
applyUsageToDetails(details, usageResult.value);
emitModelUsageEvent(runtime, modelType, params.prompt ?? "", usageResult.value);
} else if (usageResult.status === "rejected") {
companionStreamError ??= usageResult.reason;
}
if (finishReasonResult.status === "fulfilled") {
details.finishReason = finishReasonResult.value as string | undefined;
} else {
companionStreamError ??= finishReasonResult.reason;
}
if (toolCallsResult.status === "fulfilled") {
details.toolCalls = toolCallsResult.value;
} else {
companionStreamError ??= toolCallsResult.reason;
}
const elapsed =
(typeof performance !== "undefined" && typeof performance.now === "function"
? performance.now()
: Date.now()) - startedAt;
logActiveTrajectoryLlmCall(runtime, {
...details,
response: details.response,
latencyMs: Math.max(0, Math.round(elapsed)),
});
};
return {
textStream: (async function* textStreamWithCallback() {
let streamIterationError: unknown;
try {
if (params.streamStructured === true) {
// Structured Stage-1 calls force the envelope out as a native tool
// call, and the AI SDK's textStream carries only text-delta parts —
// tool-input (argument) deltas are silently dropped, so nothing
// streams while the model writes the envelope. Consume fullStream
// instead and forward only tool-input deltas. Some compatible
// providers narrate before the required tool call; if that prose is
// mixed into the structured stream, the runtime extractor correctly
// switches to plaintext passthrough and the raw envelope becomes
// visible. The authoritative parse still comes from toolCalls.
// Gated on streamStructured so planner/coding tool-call JSON never
// leaks into a visible stream.
for await (const part of result.fullStream) {
// The AI SDK renamed these delta fields across v6 minors
// (`tool-input-delta`: delta→inputTextDelta), and the workspace's
// declared (^6.0.30) and hoisted (6.0.174) copies disagree — read
// both spellings so the forwarding survives either resolution. A
// part carrying neither is a non-delta frame and is skipped.
const record = part as {
type: string;
delta?: string;
inputTextDelta?: string;
};
const chunk =
record.type === "tool-input-delta"
? (record.inputTextDelta ?? record.delta ?? null)
: null;
if (!chunk) continue;
responseChunks.push(chunk);
params.onStreamChunk?.(chunk);
yield chunk;
}
} else {
for await (const chunk of result.textStream) {
responseChunks.push(chunk);
params.onStreamChunk?.(chunk);
yield chunk;
}
}
} catch (error) {
// error-policy:J2 context-adding rethrow — capture the stream-iteration
// error so `finally` can finalize telemetry, then rethrow it below.
streamIterationError = error;
} finally {
await finalizeStreamingTelemetry();
}
const streamError = streamIterationError ?? capturedStreamError ?? companionStreamError;
settleStructuredText(streamError);
if (streamIterationError) throw streamIterationError;
if (capturedStreamError) throw capturedStreamError;
if (companionStreamError) throw companionStreamError;
})(),
text: textPromise,
...(shouldReturnNativeResult ? { toolCalls: restoredToolCallsPromise } : {}),
usage: usagePromise,
finishReason: finishReasonPromise,
};
}
// Non-streaming mode
const details = createLlmCallDetails(
modelName,
params,
systemPrompt,
"ai.generateText",
modelType,
providerOptions,
generateParams
);
const result = await recordLlmCall(runtime, details, async () => {
const result = await generateTextWithTransientRetry(generateParams);
const restoredToolCalls = restoreRecordArgToolCalls(
result.toolCalls,
normalizedToolResult.recordArgTransformsByTool
);
details.response = result.text;
details.toolCalls = restoredToolCalls;
details.finishReason = result.finishReason as string | undefined;
details.providerMetadata = result.providerMetadata;
applyUsageToDetails(details, result.usage);
return {
text: result.text,
toolCalls: restoredToolCalls as typeof result.toolCalls,
finishReason: result.finishReason,
usage: result.usage,
providerMetadata: result.providerMetadata,
};
});
if (result.usage) {
emitModelUsageEvent(runtime, modelType, params.prompt ?? "", result.usage);
}
if (shouldReturnNativeResult) {
return buildNativeTextResult(result, modelName) as NativeTextModelResult;
}
return result.text;
}
// ============================================================================
// Public Handlers
// ============================================================================
/**
* Handles TEXT_SMALL model requests.
*
* Uses the configured small model (default: gpt-5-mini).
*
* @param runtime - The agent runtime
* @param params - Generation parameters
* @returns Generated text or stream result
*/
export async function handleTextSmall(
runtime: IAgentRuntime,
params: GenerateTextParams
): Promise<string | TextStreamResult> {
return generateTextByModelType(runtime, params, ModelType.TEXT_SMALL, getSmallModel);
}
export async function handleTextNano(
runtime: IAgentRuntime,
params: GenerateTextParams
): Promise<string | TextStreamResult> {
return generateTextByModelType(runtime, params, TEXT_NANO_MODEL_TYPE, getNanoModel);
}
export async function handleTextMedium(
runtime: IAgentRuntime,
params: GenerateTextParams
): Promise<string | TextStreamResult> {
return generateTextByModelType(runtime, params, TEXT_MEDIUM_MODEL_TYPE, getMediumModel);
}
/**
* Handles TEXT_LARGE model requests.
*
* Uses the configured large model (default: gpt-5).
*
* @param runtime - The agent runtime
* @param params - Generation parameters
* @returns Generated text or stream result
*/
export async function handleTextLarge(
runtime: IAgentRuntime,
params: GenerateTextParams
): Promise<string | TextStreamResult> {
return generateTextByModelType(runtime, params, ModelType.TEXT_LARGE, getLargeModel);
}
export async function handleTextMega(
runtime: IAgentRuntime,
params: GenerateTextParams
): Promise<string | TextStreamResult> {
return generateTextByModelType(runtime, params, TEXT_MEGA_MODEL_TYPE, getMegaModel);
}
export async function handleResponseHandler(
runtime: IAgentRuntime,
params: GenerateTextParams
): Promise<string | TextStreamResult> {
return generateTextByModelType(
runtime,
params,
RESPONSE_HANDLER_MODEL_TYPE,
getResponseHandlerModel
);
}
export async function handleActionPlanner(
runtime: IAgentRuntime,
params: GenerateTextParams
): Promise<string | TextStreamResult> {
return generateTextByModelType(runtime, params, ACTION_PLANNER_MODEL_TYPE, getActionPlannerModel);
}
// ─── Test-only exports ──────────────────────────────────────────────────────
// These are exported for the shape tests in `__tests__/reasoning-effort.shape.test.ts`.
// Not part of the public API; do not import outside tests.
/** @internal — exported for unit tests only. */
export const __INTERNAL_resolveProviderOptions = resolveProviderOptions;
/** @internal — exported for unit tests only. */
export const __INTERNAL_normalizeNativeMessages = normalizeNativeMessages;
/** @internal — exported for unit tests only. */
export const __INTERNAL_stripReasoningParts = stripReasoningParts;
/** @internal — exported for unit tests only. */
export const __INTERNAL_sanitizeJsonSchema = sanitizeJsonSchema;
/** @internal — exported for unit tests only. */
export const __INTERNAL_normalizeNativeTools = normalizeNativeTools;
/** @internal — exported for unit tests only. */
export const __INTERNAL_normalizeNativeToolsForCall = normalizeNativeToolsForCall;
/** @internal — exported for unit tests only. */
export const __INTERNAL_restoreRecordArgToolCalls = restoreRecordArgToolCalls;