Files
elizaos--eliza/plugins/plugin-anthropic/models/text.ts
T
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

1506 lines
54 KiB
TypeScript

/**
* Text-generation core for every Anthropic text/reasoning `ModelType`. Exposes
* the per-slot handlers (`handleTextSmall`, `handleTextLarge`,
* `handleReasoningLarge`, `handleActionPlanner`, …), each of which resolves its
* default model from `utils/config` and calls the shared `generateTextWithModel`.
*
* `resolveTextParams` normalizes the request before it reaches the AI SDK:
* builds the canonical system prompt, applies prompt-cache breakpoints, forces
* `temperature=1` for opus-4 / temperature-locked models, drops `topP` when
* both topP and temperature are set (the API rejects both), and caps
* `maxTokens`. Streaming vs non-streaming is chosen per request; tool-using and
* `ELIZA_ANTHROPIC_DISABLE_STREAM` requests take the non-streaming path to avoid
* `AI_NoOutputGeneratedError` on tool_use-only responses. `responseSchema`
* requests build a native AI SDK `output` object and return parsed JSON.
*
* When the auth mode is `cli`, generation is delegated to `claude -p` via
* `generateViaCli` / `streamViaCli` instead of the SDK client.
*/
import type {
GenerateTextParams,
IAgentRuntime,
ModelTypeName,
PromptSegment,
TextStreamResult,
} from "@elizaos/core";
import {
buildCanonicalSystemPrompt,
dropDuplicateLeadingSystemMessage,
logger,
ModelType,
resolveEffectiveSystemPrompt,
} from "@elizaos/core";
import {
generateText,
type JSONSchema7,
jsonSchema,
type ModelMessage,
streamText,
type ToolChoice,
type ToolSet,
type UserContent,
} from "ai";
import { createAnthropicClientWithTopPSupport } from "../providers/anthropic";
import { createModelName, type ModelName, type ModelSize } from "../types";
import { generateViaCli, streamViaCli } from "../utils/claude-cli";
import {
type AnthropicEffort,
getActionPlannerModel,
getAnthropicEffort,
getAuthMode,
getCoTBudget,
getExperimentalTelemetry,
getLargeModel,
getMaxOutputTokensOverride,
getMediumModel,
getMegaModel,
getNanoModel,
getReasoningLargeModel,
getReasoningSmallModel,
getResponseHandlerModel,
getSmallModel,
isTemperatureLockedModel,
} from "../utils/config";
import { emitModelUsageEvent } from "../utils/events";
import { executeWithRetry, formatModelError } from "../utils/retry";
type ProviderOptionValue =
| string
| number
| boolean
| null
| ProviderOptionValue[]
| { [key: string]: ProviderOptionValue | undefined };
interface ProviderOptions {
[key: string]: ProviderOptionValue | undefined;
readonly agentName?: string;
readonly anthropic?: AnthropicProviderOptions;
}
interface AnthropicProviderOptions {
[key: string]: ProviderOptionValue | undefined;
readonly thinking?:
| {
[key: string]: ProviderOptionValue | undefined;
readonly type: "enabled";
readonly budgetTokens: number;
}
| {
[key: string]: ProviderOptionValue | undefined;
readonly type: "adaptive";
};
/** output_config.effort on the wire; see getAnthropicEffort. */
readonly effort?: AnthropicEffort;
readonly cacheControl?: {
[key: string]: ProviderOptionValue | undefined;
readonly type: "ephemeral";
readonly ttl?: "5m" | "1h";
};
}
type ChatAttachment = {
data: string | Uint8Array | URL;
mediaType: string;
filename?: string;
};
interface ResolvedTextParams {
readonly prompt: string;
readonly stopSequences: readonly string[];
readonly maxTokens: number;
readonly temperature: number | undefined;
readonly topP: number | undefined;
readonly frequencyPenalty: number;
readonly presencePenalty: number;
readonly providerOptions: ProviderOptions;
}
interface GenerateTextParamsWithProviderOptions
extends Omit<
GenerateTextParams,
"messages" | "tools" | "toolChoice" | "responseSchema" | "providerOptions"
> {
attachments?: ChatAttachment[];
messages?: ModelMessage[];
tools?: ToolSet;
toolChoice?: ToolChoice<ToolSet>;
responseSchema?: unknown;
providerOptions?: ProviderOptions;
}
function resolveRequestedModelName(params: GenerateTextParams, fallback: ModelName): ModelName {
const requestedModel = (params as GenerateTextParams & { model?: unknown }).model;
return typeof requestedModel === "string" && requestedModel.trim().length > 0
? createModelName(requestedModel.trim())
: fallback;
}
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;
type NativeProviderOptions = NativeTextParams["providerOptions"];
type NativeTelemetrySettings = NativeTextParams["experimental_telemetry"];
type AnthropicCacheControl = NonNullable<NonNullable<ProviderOptions["anthropic"]>["cacheControl"]>;
type AnthropicCacheBreakpoint = {
segmentIndex?: number;
ttl?: "short" | "long" | "5m" | "1h";
cacheControl?: AnthropicCacheControl;
};
interface AnthropicUsageWithCache {
// Legacy (older AI SDK / direct Anthropic SDK) field names — kept for
// back-compat with stream usage emitted in pre-v6 callers.
promptTokens?: number;
completionTokens?: number;
cacheReadInputTokens?: number;
cacheCreationInputTokens?: number;
// AI SDK v6 LanguageModelUsage shape — what `generateText`/`streamText`
// actually return today. The Anthropic provider populates
// `inputTokenDetails.cacheReadTokens` for cache hits, and exposes
// `cacheCreationInputTokens` via `providerMetadata.anthropic` (read by the
// caller, not on the usage object directly).
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
cachedInputTokens?: number;
inputTokenDetails?: {
noCacheTokens?: number;
cacheReadTokens?: number;
cacheWriteTokens?: number;
};
}
interface AnthropicNormalizedUsage {
promptTokens: number;
completionTokens: number;
totalTokens: number;
cacheReadInputTokens?: number;
cacheCreationInputTokens?: number;
}
interface NativeGenerateTextResult {
text: string;
toolCalls?: unknown[];
finishReason?: string;
usage?: AnthropicNormalizedUsage;
providerMetadata?: Record<string, unknown>;
}
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;
type TextModelType =
| typeof TEXT_NANO_MODEL_TYPE
| typeof ModelType.TEXT_SMALL
| typeof TEXT_MEDIUM_MODEL_TYPE
| typeof ModelType.TEXT_LARGE
| typeof TEXT_MEGA_MODEL_TYPE
| typeof RESPONSE_HANDLER_MODEL_TYPE
| typeof ACTION_PLANNER_MODEL_TYPE
| typeof TEXT_REASONING_SMALL_MODEL_TYPE
| typeof TEXT_REASONING_LARGE_MODEL_TYPE;
type AnthropicTextPart = {
type: "text";
text: string;
providerOptions?: {
anthropic?: {
cacheControl?: AnthropicCacheControl;
};
};
};
type AnthropicFilePart = {
type: "file";
data: string | Uint8Array | URL;
mediaType: string;
filename?: string;
};
type AnthropicUserContentPart = AnthropicTextPart | AnthropicFilePart;
function isProviderOptionValue(value: unknown): value is ProviderOptionValue {
if (
value === null ||
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
return true;
}
if (Array.isArray(value)) {
return value.every(isProviderOptionValue);
}
if (typeof value === "object" && value !== null) {
return Object.values(value).every(
(entry) => entry === undefined || isProviderOptionValue(entry)
);
}
return false;
}
function readProviderOptions(value: unknown): ProviderOptions | undefined {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return undefined;
}
const entries = Object.entries(value);
if (!entries.every(([, entry]) => entry === undefined || isProviderOptionValue(entry))) {
return undefined;
}
return Object.fromEntries(entries) as ProviderOptions;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function isModelMessage(value: unknown): value is ModelMessage {
if (!isRecord(value) || typeof value.role !== "string") {
return false;
}
switch (value.role) {
case "system":
return typeof value.content === "string";
case "user":
case "tool":
// Eliza runtime synthesizes tool / user messages with string or array
// content (see buildStageChatMessages); the AI SDK accepts these and
// the underlying provider normalizes them.
return typeof value.content === "string" || Array.isArray(value.content);
case "assistant":
// Most callers emit string-or-array content. Defensively also accept
// assistant messages with `content: null` when a tool call is attached
// — the OpenAI v0.x / legacy shape that some callers still produce.
// Without this, `readModelMessages` returns `undefined` and the AI SDK
// silently drops the entire conversation, blinding any downstream model
// call to the tool history.
if (typeof value.content === "string" || Array.isArray(value.content)) {
return true;
}
if (value.content === null || value.content === undefined) {
return Array.isArray(value.toolCalls) && value.toolCalls.length > 0;
}
return false;
default:
return false;
}
}
function readModelMessages(value: GenerateTextParams["messages"]): ModelMessage[] | undefined {
if (!value) {
return undefined;
}
const messages: ModelMessage[] = [];
for (const message of value) {
if (!isModelMessage(message)) {
return undefined;
}
messages.push(message as ModelMessage);
}
return messages;
}
function readToolSet(value: GenerateTextParams["tools"]): ToolSet | undefined {
if (!value) {
return undefined;
}
// Source can be either an array of ToolDefinition (each with .name) or a
// Record<string, ...>. ELIZAOS upstream sometimes passes the array as a
// Record with numeric keys (`{0: tool, 1: tool}`), which makes the AI SDK
// wire the tool name as "0" / "1" — the runtime parser then can't match
// the response against canonical names like HANDLE_RESPONSE / PLAN_ACTIONS.
// Walk both forms and rebuild keyed by tool.name when present. Heterogeneous
// Records (raw ToolDefinitions mixed with already-built AI SDK Tool objects
// that lack `.name`) preserve the SDK Tool entries under their original key
// so we don't silently drop them. Two passes so named-tool keys always win
// deterministically over an SDK passthrough at the same key, regardless of
// iteration order.
const isArr = Array.isArray(value);
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 namedKeys = new Set<string>();
for (const [, rawTool] of entries) {
if (isRecord(rawTool) && typeof rawTool.name === "string" && rawTool.name) {
namedKeys.add(rawTool.name);
}
}
const tools: Record<string, unknown> = {};
let sawNamedTool = false;
for (const [origKey, rawTool] of entries) {
if (!isRecord(rawTool)) {
continue;
}
if (typeof rawTool.name === "string" && rawTool.name) {
sawNamedTool = true;
const schema = isRecord(rawTool.parameters)
? (rawTool.parameters as JSONSchema7)
: isRecord(rawTool.input_schema)
? (rawTool.input_schema as JSONSchema7)
: ({ type: "object" } satisfies JSONSchema7);
tools[rawTool.name] = {
...(typeof rawTool.description === "string" ? { description: rawTool.description } : {}),
inputSchema: jsonSchema(schema),
};
} else if (!isArr && !namedKeys.has(origKey)) {
// Pre-built AI SDK Tool entry inside a Record — pass through under its
// original string key, but only if no named tool will claim that key
// later in the same pass; otherwise the named tool would silently
// overwrite (or be overwritten by) this entry depending on order.
tools[origKey] = rawTool;
}
}
if (sawNamedTool) {
return Object.keys(tools).length > 0 ? (tools as ToolSet) : undefined;
}
// Fall back to the original Record (already keyed by canonical names).
return !isArr && isRecord(value) ? (value as ToolSet) : undefined;
}
function readToolChoice(value: GenerateTextParams["toolChoice"]): ToolChoice<ToolSet> | undefined {
if (!value) {
return undefined;
}
if (typeof value === "string" && (value === "auto" || value === "none" || value === "required")) {
return value;
}
if (!isRecord(value)) {
return undefined;
}
const choice = value as Record<string, unknown>;
if (choice.type === "tool" && typeof choice.name === "string") {
return { type: "tool", toolName: choice.name };
}
if (choice.type === "function" && isRecord(choice.function)) {
const name = choice.function.name;
return typeof name === "string" ? { type: "tool", toolName: name } : undefined;
}
return typeof choice.name === "string" ? { type: "tool", toolName: choice.name } : undefined;
}
function toAnthropicTextParams(params: GenerateTextParams): GenerateTextParamsWithProviderOptions {
const { messages, providerOptions, tools, toolChoice, ...rest } = params;
const normalized: GenerateTextParamsWithProviderOptions = {
...rest,
messages: readModelMessages(messages),
tools: readToolSet(tools),
toolChoice: readToolChoice(toolChoice),
providerOptions: readProviderOptions(providerOptions),
};
return normalized;
}
function isOpus4Model(modelName: ModelName): boolean {
return modelName.toLowerCase().includes("opus-4");
}
/**
* Whether a model accepts the effort parameter (output_config.effort) at all.
* Live-probed 2026-07-12: haiku-4-5 rejects both `effort` ("This model does
* not support the effort parameter") and adaptive thinking, so sending the
* knob 400s every request. Claude-3-era models predate the parameter. Mirrors
* the server-side model catalog (packages/agent/src/api/model-catalog.ts).
*/
function supportsEffortParameter(modelName: ModelName): boolean {
const name = modelName.toLowerCase();
return !name.includes("haiku") && !name.includes("claude-3");
}
/**
* Whether a model accepts the xhigh/max effort tiers. Mirrors the server-side
* model catalog (packages/agent/src/api/model-catalog.ts): fable-5 and
* opus >= 4.7 take the full range; everything else caps at high — sending
* higher 400s the request.
*/
function supportsExtendedEffort(modelName: ModelName): boolean {
const name = modelName.toLowerCase();
if (name.includes("fable-5")) return true;
const opus = name.match(/opus-4-(\d+)/);
return opus !== null && Number(opus[1]) >= 7;
}
/**
* Clamp a configured effort to what the resolved model accepts. Clamping (to
* "high") rather than dropping keeps the operator's intent — they asked for
* maximum reasoning; the model's ceiling is the closest legal request.
*/
function clampEffortForModel(effort: AnthropicEffort, modelName: ModelName): AnthropicEffort {
if ((effort === "xhigh" || effort === "max") && !supportsExtendedEffort(modelName)) {
logger.warn(
`[Anthropic] effort "${effort}" is not supported by ${modelName}; clamping to "high"`
);
return "high";
}
return effort;
}
function buildUserContent(params: GenerateTextParamsWithProviderOptions): UserContent {
const content: AnthropicUserContentPart[] = [{ type: "text", text: params.prompt ?? "" }];
appendAttachments(content, params.attachments);
return content;
}
function appendAttachments(
content: AnthropicUserContentPart[],
attachments: ChatAttachment[] | undefined
): void {
for (const attachment of attachments ?? []) {
content.push({
type: "file",
data: attachment.data,
mediaType: attachment.mediaType,
...(attachment.filename ? { filename: attachment.filename } : {}),
});
}
}
function buildSegmentedUserContent(
params: GenerateTextParamsWithProviderOptions,
anthropicOptions?: ProviderOptions["anthropic"],
fallbackCacheControl?: AnthropicCacheControl,
reservedNonSegmentBreakpoints = 0
): UserContent {
const segmentCacheControls = buildSegmentCacheControls(
params,
anthropicOptions,
fallbackCacheControl,
reservedNonSegmentBreakpoints
);
return buildSegmentedUserContentFromSegments(
params.promptSegments ?? [],
params.attachments,
segmentCacheControls
);
}
function buildSegmentedUserContentFromSegments(
segments: readonly PromptSegment[],
attachments: ChatAttachment[] | undefined,
segmentCacheControls: Map<number, AnthropicCacheControl> = new Map()
): UserContent {
const content: AnthropicUserContentPart[] = [];
for (const [index, segment] of segments.entries()) {
const textPart: AnthropicTextPart = {
type: "text",
text: segment.content,
};
const cacheControl = segmentCacheControls.get(index);
if (cacheControl) {
textPart.providerOptions = { anthropic: { cacheControl } };
}
content.push(textPart);
}
appendAttachments(content, attachments);
return content;
}
function buildSegmentedUserContentForMessages(
params: GenerateTextParamsWithProviderOptions
): UserContent | undefined {
const dynamicSegments = (params.promptSegments ?? []).filter(
(segment: PromptSegment) => !segment.stable
);
if (dynamicSegments.length === 0 && (params.attachments?.length ?? 0) === 0) {
return undefined;
}
return buildSegmentedUserContentFromSegments(dynamicSegments, params.attachments);
}
function buildPlannerWireMessages(
wireMessages: ModelMessage[],
userContent: UserContent | string
): ModelMessage[] {
if (wireMessages[0]?.role === "user") {
const [first, ...tail] = wireMessages;
return [{ ...first, content: userContent }, ...tail];
}
return [{ role: "user", content: userContent }, ...wireMessages];
}
function buildSegmentCacheControls(
params: GenerateTextParamsWithProviderOptions,
anthropicOptions?: ProviderOptions["anthropic"],
fallbackCacheControl?: AnthropicCacheControl,
reservedNonSegmentBreakpoints = 0
): Map<number, AnthropicCacheControl> {
const controls = new Map<number, AnthropicCacheControl>();
if (!fallbackCacheControl) {
return controls;
}
const maxBreakpointsRaw = anthropicOptions?.maxBreakpoints;
const maxBreakpoints =
typeof maxBreakpointsRaw === "number" && Number.isFinite(maxBreakpointsRaw)
? Math.max(0, Math.floor(maxBreakpointsRaw))
: 4;
// Anthropic allows at most 4 cache_control breakpoints per request. The
// budget is spent in priority order:
// 1. system prompt (cacheSystem !== false)
// 2. non-segment reservations passed by the caller — currently the tools
// array tail breakpoint (tools render before system, so caching them
// is the widest shared prefix for tool-heavy agents)
// 3. stable prompt segments (whatever budget remains)
// The trajectory-tail breakpoint only exists on the native-messages path,
// which never stamps segment breakpoints, so it never competes here.
const systemConsumesBreakpoint = anthropicOptions?.cacheSystem !== false;
const maxSegmentBreakpoints = Math.max(
0,
maxBreakpoints -
(systemConsumesBreakpoint ? 1 : 0) -
Math.max(0, Math.floor(reservedNonSegmentBreakpoints))
);
if (maxSegmentBreakpoints === 0) {
return controls;
}
const plannedBreakpoints = Array.isArray(anthropicOptions?.cacheBreakpoints)
? (anthropicOptions.cacheBreakpoints as AnthropicCacheBreakpoint[])
: undefined;
if (plannedBreakpoints) {
// When the plan carries more breakpoints than the remaining budget, keep
// the LAST N (highest segment indexes). A breakpoint caches everything
// before it, so later breakpoints produce the longest matching prefix;
// dropping the earliest ones only loses partial-prefix granularity.
for (const breakpoint of plannedBreakpoints.slice(-maxSegmentBreakpoints)) {
if (typeof breakpoint.segmentIndex !== "number") {
continue;
}
controls.set(
breakpoint.segmentIndex,
normalizeBreakpointCacheControl(breakpoint, fallbackCacheControl)
);
}
return controls;
}
// Pick the LAST N stable segments rather than the first N. A cache_control
// breakpoint says "everything up to here is cached"; placing breakpoints at
// late stable segments creates the longest matching cached prefix on
// subsequent calls. Earlier stable segments still ride along inside any
// longer matching prefix that a later breakpoint creates — we lose
// granularity on partial-prefix hits but not coverage.
const stableIndices: number[] = [];
(params.promptSegments ?? []).forEach((segment: PromptSegment, index: number) => {
if (segment.stable) stableIndices.push(index);
});
for (const index of stableIndices.slice(-maxSegmentBreakpoints)) {
controls.set(index, fallbackCacheControl);
}
return controls;
}
function normalizeBreakpointCacheControl(
breakpoint: AnthropicCacheBreakpoint,
fallbackCacheControl: AnthropicCacheControl
): AnthropicCacheControl {
if (isAnthropicCacheControl(breakpoint.cacheControl)) {
return breakpoint.cacheControl;
}
if (breakpoint.ttl === "long" || breakpoint.ttl === "1h") {
return { type: "ephemeral", ttl: "1h" };
}
if (breakpoint.ttl === "short" || breakpoint.ttl === "5m") {
return { ...fallbackCacheControl };
}
return fallbackCacheControl;
}
function isAnthropicCacheControl(value: unknown): value is AnthropicCacheControl {
return (
typeof value === "object" &&
value !== null &&
(value as { type?: unknown }).type === "ephemeral"
);
}
function getRuntimeCacheControl(runtime: IAgentRuntime): AnthropicCacheControl {
// cache_control is always emitted for stable segments — Anthropic requires it.
// TTL is configurable via ANTHROPIC_PROMPT_CACHE_TTL ("5m" | "1h"); default is "5m".
const ttlSetting = runtime.getSetting("ANTHROPIC_PROMPT_CACHE_TTL");
if (typeof ttlSetting === "string") {
const ttl = ttlSetting.trim().toLowerCase();
if (ttl === "1h") {
return { type: "ephemeral", ttl: "1h" };
}
}
return { type: "ephemeral" };
}
function buildCacheableSystemPrompt(
systemPrompt: string | undefined,
cacheControl: AnthropicCacheControl | undefined
): NativeTextParams["system"] {
if (!systemPrompt) {
return undefined;
}
if (!cacheControl) {
return systemPrompt;
}
return {
role: "system",
content: systemPrompt,
providerOptions: {
anthropic: { cacheControl },
},
};
}
function stripLocalAnthropicCacheOptions(
anthropicOptions: ProviderOptions["anthropic"] | undefined
): ProviderOptions["anthropic"] | undefined {
if (!anthropicOptions) {
return undefined;
}
const {
cacheControl: _cacheControl,
cacheBreakpoints: _cacheBreakpoints,
cacheSystem: _cacheSystem,
maxBreakpoints: _maxBreakpoints,
cacheTools: _cacheTools,
cacheTrajectory: _cacheTrajectory,
...wireOptions
} = anthropicOptions as Record<string, unknown>;
return Object.keys(wireOptions).length > 0
? (wireOptions as ProviderOptions["anthropic"])
: undefined;
}
/**
* Stamp a cache_control breakpoint on the LAST tool in the tool set. Tools
* render before `system` and `messages` in Anthropic's prompt, so a single
* breakpoint after the last tool caches the entire (stable) tool catalog —
* the widest shared prefix for tool-heavy agents. Consumes one of the four
* breakpoints; callers must reserve budget for it (see
* `buildSegmentCacheControls`). A tool that already carries an explicit
* cacheControl wins; the input tool set is never mutated.
*/
function applyToolsCacheBreakpoint(tools: ToolSet, cacheControl: AnthropicCacheControl): ToolSet {
const names = Object.keys(tools);
const lastName = names[names.length - 1];
if (!lastName) {
return tools;
}
const lastTool = tools[lastName];
if (!isRecord(lastTool)) {
return tools;
}
const existingProviderOptions = isRecord(lastTool.providerOptions)
? lastTool.providerOptions
: {};
const existingAnthropic = isRecord(existingProviderOptions.anthropic)
? (existingProviderOptions.anthropic as Record<string, unknown>)
: {};
if (existingAnthropic.cacheControl) {
return tools;
}
return {
...tools,
[lastName]: {
...lastTool,
providerOptions: {
...existingProviderOptions,
anthropic: { ...existingAnthropic, cacheControl },
},
},
} as ToolSet;
}
const TRAJECTORY_CACHEABLE_PART_TYPES = new Set(["text", "tool-call", "tool-result"]);
/**
* Stamp a cache_control breakpoint on the last content part of the final
* assistant/tool message — the tail of the kept trajectory history. The
* planner loop's assistant/tool suffix grows append-only across iterations,
* so a breakpoint at the tail lets every subsequent planner call read the
* whole prior trajectory (system + tools + context + earlier tool calls)
* from cache and re-process only the newly appended turn.
*
* Deliberately skipped when the final message is a `user` turn: on this wire
* shape the leading user message carries the per-turn dynamic context, and a
* `[user]`-only request (planner iteration 1, plain chat) would stamp
* volatile content — a cache write that is never read back. String-content
* tails (legacy shapes) are also skipped; the planner always emits part
* arrays. The input array is never mutated; a part that already carries an
* explicit cacheControl wins.
*/
function applyTrajectoryTailCacheBreakpoint(
messages: ModelMessage[],
cacheControl: AnthropicCacheControl
): ModelMessage[] {
const lastIndex = messages.length - 1;
const last = messages[lastIndex];
if (!last || (last.role !== "assistant" && last.role !== "tool")) {
return messages;
}
if (!Array.isArray(last.content) || last.content.length === 0) {
return messages;
}
const parts = last.content as unknown[];
const lastPart = parts[parts.length - 1];
if (
!isRecord(lastPart) ||
typeof lastPart.type !== "string" ||
!TRAJECTORY_CACHEABLE_PART_TYPES.has(lastPart.type)
) {
return messages;
}
const existingProviderOptions = isRecord(lastPart.providerOptions)
? lastPart.providerOptions
: {};
const existingAnthropic = isRecord(existingProviderOptions.anthropic)
? (existingProviderOptions.anthropic as Record<string, unknown>)
: {};
if (existingAnthropic.cacheControl) {
return messages;
}
const stampedPart = {
...lastPart,
providerOptions: {
...existingProviderOptions,
anthropic: { ...existingAnthropic, cacheControl },
},
};
const nextMessages = [...messages];
nextMessages[lastIndex] = {
...last,
content: [...parts.slice(0, -1), stampedPart],
} as ModelMessage;
return nextMessages;
}
function firstNumber(...values: unknown[]): number | undefined {
for (const value of values) {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
}
return undefined;
}
function readAnthropicCacheCreationFromProviderMetadata(
providerMetadata: unknown
): number | undefined {
if (
!providerMetadata ||
typeof providerMetadata !== "object" ||
Array.isArray(providerMetadata)
) {
return undefined;
}
const anthropic = (providerMetadata as Record<string, unknown>).anthropic;
if (!anthropic || typeof anthropic !== "object" || Array.isArray(anthropic)) {
return undefined;
}
const value = (anthropic as Record<string, unknown>).cacheCreationInputTokens;
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function normalizeAnthropicUsage(
usage: AnthropicUsageWithCache | undefined,
providerMetadata?: unknown
): AnthropicNormalizedUsage | undefined {
if (!usage) {
return undefined;
}
const promptTokens = firstNumber(usage.promptTokens, usage.inputTokens) ?? 0;
const completionTokens = firstNumber(usage.completionTokens, usage.outputTokens) ?? 0;
// The AI SDK v6 Anthropic provider reports cache reads via
// `inputTokenDetails.cacheReadTokens` (and the deprecated `cachedInputTokens`
// mirror). Older callers may still pass the legacy `cacheReadInputTokens`
// field directly. Read both.
const cacheRead = firstNumber(
usage.cacheReadInputTokens,
usage.inputTokenDetails?.cacheReadTokens,
usage.cachedInputTokens
);
// Cache writes ride on `inputTokenDetails.cacheWriteTokens` in the v6 SDK
// shape, with the canonical count exposed via
// `providerMetadata.anthropic.cacheCreationInputTokens`. Either source is
// authoritative; fall back to the legacy direct field for callers that still
// emit the pre-v6 shape (e.g. our streaming usage promise).
const cacheCreation = firstNumber(
usage.cacheCreationInputTokens,
usage.inputTokenDetails?.cacheWriteTokens,
readAnthropicCacheCreationFromProviderMetadata(providerMetadata)
);
return {
promptTokens,
completionTokens,
totalTokens: usage.totalTokens ?? promptTokens + completionTokens,
...(cacheRead !== undefined ? { cacheReadInputTokens: cacheRead } : {}),
...(cacheCreation !== undefined ? { cacheCreationInputTokens: cacheCreation } : {}),
};
}
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 {
name: "object",
responseFormat: Promise.resolve({
type: "json" as const,
schema: schemaOptions.schema as JSONSchema7,
...(schemaOptions.name ? { name: schemaOptions.name } : {}),
...(schemaOptions.description ? { description: schemaOptions.description } : {}),
}),
async parseCompleteOutput({ text }: { text: string }) {
return JSON.parse(text);
},
async parsePartialOutput(): Promise<undefined> {
return undefined;
},
createElementStreamTransform(): undefined {
return undefined;
},
} satisfies NativeOutput;
}
function usesNativeTextResult(params: GenerateTextParamsWithProviderOptions): boolean {
return Boolean(params.messages || params.tools || params.toolChoice || params.responseSchema);
}
function buildNativeTextResult(
result: {
text: string;
toolCalls?: unknown[];
finishReason?: string;
usage?: AnthropicUsageWithCache;
providerMetadata?: unknown;
},
modelName?: string
): NativeGenerateTextResult {
return {
text: result.text,
toolCalls: result.toolCalls ?? [],
finishReason: result.finishReason,
usage: normalizeAnthropicUsage(result.usage, result.providerMetadata),
providerMetadata: mergeProviderModelName(result.providerMetadata, modelName),
};
}
function mergeProviderModelName(
providerMetadata: unknown,
modelName?: string
): Record<string, unknown> | undefined {
if (!modelName) {
return providerMetadata &&
typeof providerMetadata === "object" &&
!Array.isArray(providerMetadata)
? (providerMetadata as Record<string, unknown>)
: undefined;
}
if (
providerMetadata &&
typeof providerMetadata === "object" &&
!Array.isArray(providerMetadata)
) {
return {
...(providerMetadata as Record<string, unknown>),
modelName,
};
}
return { modelName };
}
function resolveTextParams(
runtime: IAgentRuntime,
params: GenerateTextParamsWithProviderOptions,
modelName: ModelName,
cotBudget: number,
effort?: AnthropicEffort
): ResolvedTextParams {
const prompt = params.prompt ?? "";
const stopSequences = params.stopSequences ?? [];
const frequencyPenalty = params.frequencyPenalty ?? 0.7;
const presencePenalty = params.presencePenalty ?? 0.7;
const hasTopP = params.topP !== undefined;
const hasTemperature = params.temperature !== undefined;
let temperature: number | undefined;
let topP: number | undefined;
if (hasTopP && hasTemperature) {
// Anthropic only supports one at a time; prefer temperature, drop topP
logger.warn(
"[Anthropic] Both temperature and topP provided; using temperature only (Anthropic API limitation)."
);
temperature = params.temperature;
topP = undefined;
} else if (hasTopP) {
topP = params.topP;
temperature = undefined;
} else {
temperature = params.temperature ?? 0.7;
topP = undefined;
}
// Temperature-locked models only accept temperature=1; Anthropic returns 400
// "Invalid request data" otherwise. ANTHROPIC_TEMPERATURE_LOCKED_MODELS lets
// an operator declare the constraint for any model id (new releases the
// substring heuristic can't know about); the opus-4 name check remains the
// built-in default.
const temperatureLocked = isTemperatureLockedModel(runtime, modelName) || isOpus4Model(modelName);
if (temperatureLocked && temperature !== undefined && temperature !== 1) {
temperature = 1;
}
const defaultMaxTokens = modelName.includes("-3-") ? 4096 : 8192;
// Cap output tokens at the model's hard limit. Opus 4.x = 32k, Sonnet 4.x = 64k.
// Callers (eliza runtime) sometimes pass the prompt context window (128k+) as
// maxTokens, which the API rejects with "Invalid request data".
// ANTHROPIC_MAX_OUTPUT_TOKENS overrides the heuristic (bare number or
// per-model `id:tokens` pairs) so unknown ids get the right ceiling.
const modelHardCap =
getMaxOutputTokensOverride(runtime, modelName) ?? (isOpus4Model(modelName) ? 32_000 : 64_000);
// Anthropic's Messages API REQUIRES max_tokens — an opt-out caller (direct-
// channel Stage-1) can't drop it, so send the model's hard cap. The reply is
// then bounded only by the model's real max (never an arbitrary 8192), and the
// value never 400s because it equals the documented limit. Other callers keep
// the existing default, Math.min-capped.
const maxTokens = params.omitMaxTokens
? modelHardCap
: Math.min(params.maxTokens ?? defaultMaxTokens, modelHardCap);
const rawProviderOptions = params.providerOptions;
const rawAnthropicOptions = rawProviderOptions?.anthropic;
const baseProviderOptions: ProviderOptions = rawProviderOptions
? {
...rawProviderOptions,
anthropic:
rawAnthropicOptions && typeof rawAnthropicOptions === "object"
? { ...(rawAnthropicOptions as Record<string, ProviderOptionValue | undefined>) }
: undefined,
}
: {};
// Effort (the modern knob — maps to the API's output_config.effort, paired
// with adaptive thinking) wins over the legacy fixed CoT budget when both
// are configured; the budget shape stays for existing ANTHROPIC_COT_BUDGET
// operators. A model without the effort parameter falls back to the budget
// path (or nothing) — sending the knob anyway would 400 every request.
let clampedEffort = effort !== undefined ? clampEffortForModel(effort, modelName) : undefined;
if (clampedEffort !== undefined && !supportsEffortParameter(modelName)) {
logger.warn(
`[Anthropic] effort is configured but ${modelName} does not support the effort parameter; ignoring it for this model`
);
clampedEffort = undefined;
}
const providerOptions: ProviderOptions =
clampedEffort !== undefined
? {
...baseProviderOptions,
anthropic: {
...(baseProviderOptions.anthropic ?? {}),
thinking: { type: "adaptive" },
effort: clampedEffort,
},
}
: cotBudget > 0
? {
...baseProviderOptions,
anthropic: {
...(baseProviderOptions.anthropic ?? {}),
thinking: { type: "enabled", budgetTokens: cotBudget },
},
}
: baseProviderOptions;
// Thinking-enabled requests only accept temperature=1 and reject topP — the
// API 400s otherwise. The opus-4 lock above covers those models regardless
// of thinking; this covers thinking on everything else.
if (clampedEffort !== undefined || cotBudget > 0) {
if (temperature !== undefined && temperature !== 1) {
temperature = 1;
}
if (topP !== undefined) {
logger.warn("[Anthropic] dropping topP: not accepted alongside extended thinking");
topP = undefined;
}
}
return {
prompt,
stopSequences,
maxTokens,
temperature,
topP,
frequencyPenalty,
presencePenalty,
providerOptions,
};
}
async function generateTextWithModel(
runtime: IAgentRuntime,
params: GenerateTextParams,
modelName: ModelName,
modelSize: ModelSize,
modelType: TextModelType
): Promise<string | TextStreamResult> {
const paramsWithAttachments = toAnthropicTextParams(params);
const shouldReturnNativeResult = usesNativeTextResult(paramsWithAttachments);
const systemPrompt = resolveEffectiveSystemPrompt({
params: paramsWithAttachments,
fallback: buildCanonicalSystemPrompt({ character: runtime.character }),
});
const cotBudget = getCoTBudget(runtime, modelSize);
const effort = getAnthropicEffort(runtime, modelSize);
const resolved = resolveTextParams(runtime, paramsWithAttachments, modelName, cotBudget, effort);
if (getAuthMode(runtime) === "cli") {
if (shouldReturnNativeResult) {
throw new Error(
"[Anthropic] Native messages, tools, toolChoice, and responseSchema are not supported when ANTHROPIC_AUTH_MODE=cli."
);
}
if (params.stream) {
return streamViaCli(
runtime,
resolved.prompt,
modelName,
modelType,
params.maxTokens,
systemPrompt
);
}
const result = await generateViaCli(
runtime,
resolved.prompt,
modelName,
modelType,
params.maxTokens,
systemPrompt
);
return result.text;
}
const anthropic = createAnthropicClientWithTopPSupport(runtime);
const experimentalTelemetry = getExperimentalTelemetry(runtime);
logger.log(`[Anthropic] Using ${modelType} model: ${modelName}`);
// cache_control is always-on: getRuntimeCacheControl always returns a value.
// Callers can still override by supplying anthropic.cacheControl in providerOptions.
const runtimeCacheControl = getRuntimeCacheControl(runtime);
const providerOptions: ProviderOptions = {
...resolved.providerOptions,
anthropic: {
...(resolved.providerOptions.anthropic ?? {}),
...(!resolved.providerOptions.anthropic?.cacheControl
? { cacheControl: runtimeCacheControl }
: {}),
},
};
const segmentedPrompt =
Array.isArray(paramsWithAttachments.promptSegments) &&
paramsWithAttachments.promptSegments.length > 0;
const cacheControl = providerOptions.anthropic?.cacheControl;
const cacheSystem = providerOptions.anthropic?.cacheSystem !== false;
// Tools-array breakpoint (one of the four): tools render first in
// Anthropic's prompt, so caching the (stable) tool catalog benefits every
// call that carries tools. Opt out per call with
// providerOptions.anthropic.cacheTools = false.
const hasNamedTools = paramsWithAttachments.tools
? Object.keys(paramsWithAttachments.tools).length > 0
: false;
const cacheToolsEnabled = providerOptions.anthropic?.cacheTools !== false;
const toolsCacheControl =
hasNamedTools && cacheToolsEnabled && cacheControl ? cacheControl : undefined;
const system = buildCacheableSystemPrompt(systemPrompt, cacheSystem ? cacheControl : undefined);
const userContent =
segmentedPrompt || (paramsWithAttachments.attachments?.length ?? 0) > 0
? segmentedPrompt
? buildSegmentedUserContent(
paramsWithAttachments,
providerOptions.anthropic,
cacheControl,
toolsCacheControl ? 1 : 0
)
: buildUserContent(paramsWithAttachments)
: undefined;
const anthropicOptions =
providerOptions.anthropic && (segmentedPrompt || system)
? stripLocalAnthropicCacheOptions(providerOptions.anthropic)
: providerOptions.anthropic;
const anthropicProviderOptions = anthropicOptions ? { anthropic: anthropicOptions } : undefined;
const agentName = resolved.providerOptions.agentName;
const telemetryConfig: NativeTelemetrySettings = {
isEnabled: experimentalTelemetry,
functionId: agentName ? `agent:${agentName}` : undefined,
metadata: agentName ? { agentName } : undefined,
};
const wireMessages = dropDuplicateLeadingSystemMessage(
paramsWithAttachments.messages,
systemPrompt
);
// Planner / evaluator wire path: when the runtime passes BOTH `messages`
// (system + user + assistant/tool trajectory built by `buildStageChatMessages`)
// AND `promptSegments` (the same content as labeled stable/dynamic parts),
// the segmented `userContent` carries cache_control on stable parts. Without
// this branch the segmented content is built and discarded because the
// messages path sends `wireMessages` directly with flat string content. We
// inject `userContent` as the leading user message and keep the trajectory
// turns verbatim. The leading user message in `wireMessages` was synthesized
// from dynamic context that is fully covered by `promptSegments`, so we drop
// it to avoid duplicating tokens. Unlike PR #7469 we keep `system` because
// our `buildCacheableSystemPrompt` puts cache_control on the system param
// itself (Anthropic's separate `system` parameter accepts cache_control via
// providerOptions).
const segmentedMessageUserContent =
segmentedPrompt && paramsWithAttachments.messages
? buildSegmentedUserContentForMessages(paramsWithAttachments)
: undefined;
const basePromptOrMessages: NativePrompt = paramsWithAttachments.messages
? wireMessages && wireMessages.length > 0
? segmentedMessageUserContent
? { messages: buildPlannerWireMessages(wireMessages, segmentedMessageUserContent) }
: { messages: wireMessages }
: {
messages: [
{
role: "user" as const,
content: userContent ?? resolved.prompt,
},
],
}
: {
messages: [
{
role: "user" as const,
content: userContent ?? resolved.prompt,
},
],
};
// Kept-trajectory tail breakpoint (planner/evaluator wire path): stamp the
// final assistant/tool turn so the next planner iteration reads the whole
// prior trajectory from cache. The helper is a no-op for user-tail message
// arrays (dynamic content) and string-content tails, so plain chat calls are
// untouched. Opt out per call with providerOptions.anthropic.cacheTrajectory
// = false. Budget: this path stamps no segment breakpoints, so system(1) +
// tools(0..1) + trajectory(1) stays within Anthropic's four-breakpoint cap.
const cacheTrajectoryEnabled = providerOptions.anthropic?.cacheTrajectory !== false;
const promptOrMessages: NativePrompt =
cacheControl && cacheTrajectoryEnabled && basePromptOrMessages.messages
? {
messages: applyTrajectoryTailCacheBreakpoint(basePromptOrMessages.messages, cacheControl),
}
: basePromptOrMessages;
const generateParams: NativeTextParams = {
model: anthropic(modelName),
...promptOrMessages,
system,
temperature: resolved.temperature,
stopSequences: resolved.stopSequences as string[],
frequencyPenalty: resolved.frequencyPenalty,
presencePenalty: resolved.presencePenalty,
experimental_telemetry: telemetryConfig,
maxOutputTokens: resolved.maxTokens,
topP: resolved.topP,
...(paramsWithAttachments.tools
? {
tools: toolsCacheControl
? applyToolsCacheBreakpoint(paramsWithAttachments.tools, toolsCacheControl)
: paramsWithAttachments.tools,
}
: {}),
...(paramsWithAttachments.toolChoice ? { toolChoice: paramsWithAttachments.toolChoice } : {}),
...(paramsWithAttachments.responseSchema
? { output: buildStructuredOutput(paramsWithAttachments.responseSchema) }
: {}),
...(anthropicProviderOptions
? { providerOptions: anthropicProviderOptions as NativeProviderOptions }
: {}),
};
const operationName = `${modelType} request using ${modelName}`;
// Route tool-using requests (and any request when ELIZA_ANTHROPIC_DISABLE_STREAM=1)
// to the non-streaming generateText path. The AI SDK streaming companion
// promises raise AI_NoOutputGeneratedError when a response contains only
// tool_use blocks and no text; generateText preserves response.toolCalls and
// text reliably. `readToolSet` has already normalized tools to a ToolSet
// record (or undefined), so a non-empty tool set means there are tool keys.
const toolSet = paramsWithAttachments.tools;
const hasToolSurface =
(toolSet ? Object.keys(toolSet).length > 0 : false) ||
Boolean(paramsWithAttachments.toolChoice);
const streamDisabled = process.env.ELIZA_ANTHROPIC_DISABLE_STREAM === "1" || hasToolSurface;
// Structured-output calls must not stream: the parsed native object is only
// available on the non-stream `generateText` result (returned via
// `buildNativeTextResult` below). A streamed structured call would emit raw
// text chunks and discard the parsed object, so fall through to generateText.
if (params.stream && !streamDisabled && !paramsWithAttachments.responseSchema) {
try {
const streamResult = streamText(generateParams);
// error-policy:J5 unhandled-rejection suppression — provider metadata is
// usage-normalization enrichment only; the underlying stream failure is
// observed in `textStreamWithUsage` (finishReason await rethrows).
const providerMetadataPromise: Promise<unknown> = Promise.resolve(
(streamResult as { providerMetadata?: PromiseLike<unknown> }).providerMetadata
).catch((): undefined => undefined);
const usagePromise = Promise.resolve(streamResult.usage).then(async (usage) => {
if (!usage) {
return undefined;
}
// Normalize BEFORE emitting so the MODEL_USED event (and its
// structured cache-usage log) carries cacheReadInputTokens /
// cacheCreationInputTokens even in the AI SDK v6 usage shape, where
// cache counts ride on inputTokenDetails / providerMetadata instead
// of the legacy direct fields.
const providerMetadata = await providerMetadataPromise;
const normalizedUsage = normalizeAnthropicUsage(
usage as AnthropicUsageWithCache,
providerMetadata
);
emitModelUsageEvent(
runtime,
modelType,
resolved.prompt,
normalizedUsage ?? (usage as AnthropicUsageWithCache),
modelName
);
return normalizedUsage;
});
// error-policy:J5 unhandled-rejection suppression — usage emission is
// telemetry; the underlying stream failure is observed in
// `textStreamWithUsage` (finishReason await rethrows), never here.
const ignoreUsageError = (): undefined => undefined;
async function* textStreamWithUsage(): AsyncIterable<string> {
let completed = false;
try {
for await (const chunk of streamResult.textStream) {
yield chunk;
}
// The AI SDK's `textStream` terminates with zero chunks on a hard
// failure (auth/transport) instead of throwing — the real error
// (e.g. APICallError 401) only rejects the companion promises. Await
// `finishReason` here so an errored/empty stream re-throws the real
// cause (matching the non-stream generateText branch) rather than
// silently returning ''. The happy path resolves with a value.
await streamResult.finishReason;
completed = true;
} catch (error) {
// error-policy:J2 context-adding rethrow — formatModelError wraps the
// provider error with `cause`; an errored/empty stream surfaces to
// the consumer instead of silently yielding "".
throw formatModelError(operationName, error);
} finally {
if (completed) {
await usagePromise.catch(ignoreUsageError);
}
}
}
// error-policy:J5 unhandled-rejection suppression — the streaming path
// primarily consumes `textStream`. The AI SDK's companion promises
// (text/toolCalls/finishReason/usage) reject on an empty stream ("No
// output generated") even when no caller awaits them, which otherwise
// surfaces as an unhandled rejection. Attach a no-op catch so each bare
// promise is always considered handled; real consumers still observe the
// value or error. Mirrors plugin-openai's `handledPromise`.
const handledPromise = <T>(value: T | PromiseLike<T>): Promise<T> => {
const promise = Promise.resolve(value);
promise.catch(() => {});
return promise;
};
return {
textStream: textStreamWithUsage(),
text: handledPromise(
Promise.resolve(streamResult.text).then(async (text) => {
await usagePromise.catch(ignoreUsageError);
return text;
})
),
...(shouldReturnNativeResult
? { toolCalls: handledPromise(Promise.resolve(streamResult.toolCalls)) }
: {}),
usage: handledPromise(usagePromise),
finishReason: handledPromise(
Promise.resolve(streamResult.finishReason) as Promise<string | undefined>
),
};
} catch (error) {
// error-policy:J2 context-adding rethrow — formatModelError wraps the
// provider error with `cause` and a caller-facing reason.
throw formatModelError(operationName, error);
}
}
try {
const response = await executeWithRetry(operationName, () => generateText(generateParams));
if (response.usage) {
// Normalize BEFORE emitting so MODEL_USED (and the structured cache
// log) carries cache read/write counts in the AI SDK v6 usage shape.
emitModelUsageEvent(
runtime,
modelType,
resolved.prompt,
normalizeAnthropicUsage(
response.usage as AnthropicUsageWithCache,
response.providerMetadata
) ?? (response.usage as AnthropicUsageWithCache),
modelName
);
}
if (shouldReturnNativeResult) {
return buildNativeTextResult(response, modelName) as string & NativeGenerateTextResult;
}
return response.text;
} catch (error) {
// error-policy:J2 context-adding rethrow — formatModelError wraps the
// provider error with `cause` and a caller-facing reason.
throw formatModelError(operationName, error);
}
}
export async function handleTextSmall(
runtime: IAgentRuntime,
params: GenerateTextParams
): Promise<string | TextStreamResult> {
const modelName = resolveRequestedModelName(params, getSmallModel(runtime));
return generateTextWithModel(runtime, params, modelName, "small", ModelType.TEXT_SMALL);
}
export async function handleTextLarge(
runtime: IAgentRuntime,
params: GenerateTextParams
): Promise<string | TextStreamResult> {
const modelName = resolveRequestedModelName(params, getLargeModel(runtime));
return generateTextWithModel(runtime, params, modelName, "large", ModelType.TEXT_LARGE);
}
export async function handleTextNano(
runtime: IAgentRuntime,
params: GenerateTextParams
): Promise<string | TextStreamResult> {
return generateTextWithModel(
runtime,
params,
resolveRequestedModelName(params, getNanoModel(runtime)),
"small",
TEXT_NANO_MODEL_TYPE
);
}
export async function handleTextMedium(
runtime: IAgentRuntime,
params: GenerateTextParams
): Promise<string | TextStreamResult> {
return generateTextWithModel(
runtime,
params,
resolveRequestedModelName(params, getMediumModel(runtime)),
"large",
TEXT_MEDIUM_MODEL_TYPE
);
}
export async function handleTextMega(
runtime: IAgentRuntime,
params: GenerateTextParams
): Promise<string | TextStreamResult> {
return generateTextWithModel(
runtime,
params,
resolveRequestedModelName(params, getMegaModel(runtime)),
"large",
TEXT_MEGA_MODEL_TYPE
);
}
export async function handleResponseHandler(
runtime: IAgentRuntime,
params: GenerateTextParams
): Promise<string | TextStreamResult> {
return generateTextWithModel(
runtime,
params,
resolveRequestedModelName(params, getResponseHandlerModel(runtime)),
"small",
RESPONSE_HANDLER_MODEL_TYPE
);
}
export async function handleActionPlanner(
runtime: IAgentRuntime,
params: GenerateTextParams
): Promise<string | TextStreamResult> {
return generateTextWithModel(
runtime,
params,
resolveRequestedModelName(params, getActionPlannerModel(runtime)),
"large",
ACTION_PLANNER_MODEL_TYPE
);
}
const TEXT_REASONING_SMALL_MODEL_TYPE = ModelType.TEXT_REASONING_SMALL as ModelTypeName;
const TEXT_REASONING_LARGE_MODEL_TYPE = ModelType.TEXT_REASONING_LARGE as ModelTypeName;
export async function handleReasoningSmall(
runtime: IAgentRuntime,
params: GenerateTextParams
): Promise<string | TextStreamResult> {
return generateTextWithModel(
runtime,
params,
resolveRequestedModelName(params, getReasoningSmallModel(runtime)),
"small",
TEXT_REASONING_SMALL_MODEL_TYPE
);
}
export async function handleReasoningLarge(
runtime: IAgentRuntime,
params: GenerateTextParams
): Promise<string | TextStreamResult> {
return generateTextWithModel(
runtime,
params,
resolveRequestedModelName(params, getReasoningLargeModel(runtime)),
"large",
TEXT_REASONING_LARGE_MODEL_TYPE
);
}