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
1506 lines
54 KiB
TypeScript
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
|
|
);
|
|
}
|