Files
wehub-resource-sync 426e9eeabd
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Waiting to run
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Waiting to run
Build Agent Image / build-and-push (push) Waiting to run
Chat shell gestures / Chat shell gesture + parity e2e (push) Waiting to run
Cloud Gateway Discord / Test (push) Waiting to run
Cloud Gateway Webhook / Test (push) Waiting to run
Cloud Tests / lint-and-types (push) Waiting to run
Cloud Tests / unit-tests (push) Waiting to run
Cloud Tests / integration-tests (push) Waiting to run
Cloud Tests / e2e-tests (push) Blocked by required conditions
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Deploy Apps Worker (Product 2) / Determine environment (push) Waiting to run
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Blocked by required conditions
Deploy Eliza Provisioning Worker / Determine environment (push) Waiting to run
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Blocked by required conditions
Dev Smoke / Classify changed paths (push) Waiting to run
Dev Smoke / bun run dev onboarding chat (push) Blocked by required conditions
Dev Smoke / Vite HMR dependency-level smoke (push) Blocked by required conditions
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Waiting to run
Publish @elizaos/example-code / check_npm (push) Waiting to run
Publish @elizaos/example-code / publish_npm (push) Blocked by required conditions
Publish @elizaos/plugin-elizacloud / verify_version (push) Waiting to run
Publish @elizaos/plugin-elizacloud / publish_npm (push) Blocked by required conditions
Sandbox Live Smoke / Sandbox live smoke (push) Waiting to run
Snap Build & Test / Build Snap (amd64) (push) Waiting to run
Snap Build & Test / Build Snap (arm64) (push) Waiting to run
supply-chain / sbom (push) Waiting to run
supply-chain / vulnerability-scan (push) Waiting to run
Build, Push & Deploy to Phala Cloud / build-and-push (push) Waiting to run
Test Packaging / Validate Packaging Configs (push) Waiting to run
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Waiting to run
Test Packaging / Pack & Test JS Tarballs (push) Waiting to run
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Waiting to run
UI Fixture E2E / ui-fixture-e2e (push) Waiting to run
UI Fixture E2E / fixture-e2e (push) Waiting to run
UI Story Gate / story-gate (push) Waiting to run
vault-ci / test (macos-latest) (push) Waiting to run
vault-ci / test (ubuntu-latest) (push) Waiting to run
vault-ci / test (windows-latest) (push) Waiting to run
vault-ci / app-core wiring tests (push) Waiting to run
verify-patches / verify patches/CHECKSUMS.sha256 (push) Waiting to run
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Waiting to run
Voice Benchmark Smoke / voice bench smoke summary (push) Blocked by required conditions
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Waiting to run
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Waiting to run
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Waiting to run
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Waiting to run
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Waiting to run
Test Packaging / Build & Test PyPI Package (push) Waiting to run
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
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
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

1068 lines
36 KiB
TypeScript

/**
* Every text `ModelType` handler (nano, small, medium, large, mega, response
* handler, action planner) routed through a single `generateTextWithModel`
* against the `@openrouter/ai-sdk-provider` chat model. Resolves the configured
* model name per type, builds AI SDK `generateText`/`streamText` params, and
* emits a `MODEL_USED` usage event after each call.
*
* Load-bearing normalization lives here: `readToolSet` rekeys runtime
* ToolDefinition arrays into a provider-safe `ToolSet` (bare arrays give Google
* function names like "0"); `supportsSamplingParameters` suppresses
* temperature/frequency/presence for `openai/*`, `anthropic/*`, and reasoning
* models that reject them; `buildStructuredOutput` wraps a `responseSchema` into
* the SDK `output` field; and prompt/messages/attachments are reconciled into a
* single wire shape. Callers that pass messages/tools/toolChoice/responseSchema
* get the richer native result object; plain prompts get a string.
*
* When routing Anthropic models through OpenRouter, message-level cache_control
* is injected on the system message to enable proper Anthropic prompt caching.
* The @openrouter/ai-sdk-provider only emits wire-level cache_control directives
* when providerOptions.anthropic.cacheControl is attached at the message level.
*
* For prompt-only calls that also supply promptSegments (e.g. the PromptBatcher /
* dynamicPromptExecFromState path), per-segment cache_control is additionally
* injected on the user content blocks corresponding to the cacheBreakpoints from
* the provider cache plan. This extends the single system-message breakpoint to
* up to three more user-content breakpoints under Anthropic's four-block cap.
*/
import type {
GenerateTextParams,
IAgentRuntime,
ModelTypeName,
TextStreamResult,
} from "@elizaos/core";
import {
buildCanonicalSystemPrompt,
dropDuplicateLeadingSystemMessage,
ElizaError,
ModelType,
resolveEffectiveSystemPrompt,
} from "@elizaos/core";
import {
generateText,
type JSONSchema7,
jsonSchema,
type LanguageModel,
type ModelMessage,
type SystemModelMessage,
streamText,
type TextPart,
type ToolChoice,
type ToolSet,
type UserContent,
} from "ai";
import { createOpenRouterProvider } from "../providers";
import {
getActionPlannerModel,
getLargeModel,
getMediumModel,
getMegaModel,
getNanoModel,
getResponseHandlerModel,
getSmallModel,
} from "../utils/config";
import { emitModelUsageEvent, extractCacheTokens } from "../utils/events";
const RESPONSES_ROUTED_PREFIXES = ["openai/", "anthropic/"] as const;
const NO_SAMPLING_MODEL_PATTERNS = ["o1", "o3", "o4", "gpt-5", "gpt-5-mini"] as const;
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 ChatAttachment = {
data: string | Uint8Array | URL;
mediaType: string;
filename?: string;
};
interface OpenRouterPromptCacheOptions {
promptCacheKey?: string;
}
interface AnthropicCacheControl {
type: "ephemeral";
ttl?: "5m" | "1h";
}
type GenerateTextParamsWithAttachments = GenerateTextParams & {
attachments?: ChatAttachment[];
messages?: ModelMessage[];
tools?: ToolSet;
toolChoice?: ToolChoice<ToolSet>;
responseSchema?: unknown;
providerOptions?: Record<string, object | unknown> & {
openrouter?: OpenRouterPromptCacheOptions;
anthropic?: {
cacheControl?: AnthropicCacheControl;
cacheSystem?: boolean;
cacheBreakpoints?: unknown[];
maxBreakpoints?: number;
cacheTools?: boolean;
cacheTrajectory?: boolean;
};
};
};
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 NormalizedUsage = {
promptTokens: number;
completionTokens: number;
totalTokens: number;
cacheReadInputTokens?: number;
cacheCreationInputTokens?: number;
};
type NativeGenerateTextResult = {
text: string;
toolCalls?: unknown[];
finishReason?: string;
usage?: NormalizedUsage;
};
type NativeTextModelResult = string & NativeGenerateTextResult;
function buildUserContent(
params: GenerateTextParamsWithAttachments,
options: { includePrompt?: boolean } = { includePrompt: true }
): UserContent {
const content: Array<
| { type: "text"; text: string }
| {
type: "file";
data: string | Uint8Array | URL;
mediaType: string;
filename?: string;
}
> = [];
if (
options.includePrompt !== false &&
typeof params.prompt === "string" &&
params.prompt.length > 0
) {
content.push({ type: "text", text: params.prompt });
}
for (const attachment of params.attachments ?? []) {
content.push({
type: "file",
data: attachment.data,
mediaType: attachment.mediaType,
...(attachment.filename ? { filename: attachment.filename } : {}),
});
}
return content;
}
function appendUserContentToMessages(
messages: ModelMessage[],
extraContent: UserContent
): ModelMessage[] {
if (extraContent.length === 0) {
return messages;
}
const lastUserIndex = messages.findLastIndex((message) => message.role === "user");
if (lastUserIndex === -1) {
return [...messages, { role: "user" as const, content: extraContent }];
}
const nextMessages = [...messages];
const userMessage = nextMessages[lastUserIndex];
if (!userMessage) {
return messages;
}
const existingContent = userMessage.content;
const content = [
...(typeof existingContent === "string"
? [{ type: "text" as const, text: existingContent }]
: Array.isArray(existingContent)
? existingContent
: []),
...extraContent,
];
nextMessages[lastUserIndex] = {
...userMessage,
content,
} as ModelMessage;
return nextMessages;
}
function textFromMessages(messages: ModelMessage[] | undefined): string {
if (!messages || messages.length === 0) return "";
return messages
.map((message) => {
const content = message.content;
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.map((part) =>
part && typeof part === "object" && "text" in part && typeof part.text === "string"
? part.text
: ""
)
.filter(Boolean)
.join("\n");
})
.filter(Boolean)
.join("\n");
}
function supportsSamplingParameters(modelName: string): boolean {
const lowerModelName = modelName.toLowerCase();
if (RESPONSES_ROUTED_PREFIXES.some((prefix) => lowerModelName.startsWith(prefix))) {
return false;
}
return !NO_SAMPLING_MODEL_PATTERNS.some((pattern) => lowerModelName.includes(pattern));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function isAnthropicModel(modelName: string): boolean {
return modelName.toLowerCase().startsWith("anthropic/");
}
// AI SDK `providerOptions` is typed `Record<string, JSONObject>`, so the
// optional `ttl` on AnthropicCacheControl (which widens to `... | undefined`,
// not a JSON value) is materialized here as a concrete key-or-omit shape. This
// lets the segment/system builders return real `SystemModelMessage` / `TextPart`
// values that the SDK accepts without an `as unknown` escape hatch.
type WireProviderOptions = NonNullable<SystemModelMessage["providerOptions"]>;
function anthropicCacheProviderOptions(cacheControl: AnthropicCacheControl): WireProviderOptions {
return {
anthropic: {
cacheControl:
cacheControl.ttl === "5m" || cacheControl.ttl === "1h"
? { type: "ephemeral", ttl: cacheControl.ttl }
: { type: "ephemeral" },
},
};
}
function buildCacheableSystemMessage(
systemPrompt: string | undefined,
cacheControl: AnthropicCacheControl | undefined
): SystemModelMessage | undefined {
if (!systemPrompt) {
return undefined;
}
if (!cacheControl) {
return undefined;
}
return {
role: "system",
content: systemPrompt,
providerOptions: anthropicCacheProviderOptions(cacheControl),
};
}
function readAnthropicCacheControl(
anthropicOptions: Record<string, unknown> | undefined
): AnthropicCacheControl | undefined {
if (!anthropicOptions) {
return undefined;
}
const cacheControl = anthropicOptions.cacheControl;
// A caller that supplies `cacheControl` is explicitly opting into prompt
// caching. A malformed shape must fail loudly: silently dropping it would emit
// an uncached request while the caller believes caching is active, and the
// wire would still report the option as accepted. Fail fast at the boundary.
if (cacheControl !== undefined) {
if (!isRecord(cacheControl) || cacheControl.type !== "ephemeral") {
throw new ElizaError("Invalid anthropic.cacheControl: expected { type: 'ephemeral' }", {
code: "OPENROUTER_INVALID_CACHE_CONTROL",
context: { cacheControl },
severity: "fatal",
});
}
const ttl = cacheControl.ttl;
if (ttl !== undefined && ttl !== "5m" && ttl !== "1h") {
throw new ElizaError("Invalid anthropic.cacheControl.ttl: expected '5m' or '1h'", {
code: "OPENROUTER_INVALID_CACHE_CONTROL",
context: { ttl },
severity: "fatal",
});
}
return {
type: "ephemeral",
...(ttl === "5m" || ttl === "1h" ? { ttl } : {}),
};
}
return anthropicOptions.cacheSystem === true ? { type: "ephemeral" } : undefined;
}
function stripLocalAnthropicCacheOptions(
anthropicOptions: Record<string, unknown> | undefined
): Record<string, unknown> | undefined {
if (!anthropicOptions) {
return undefined;
}
// Strip elizaOS-internal fields that are not Anthropic wire parameters.
// cacheBreakpoints and maxBreakpoints are consumed here to build segmented
// user content; sending them through would produce unknown-field errors.
const {
cacheControl: _cacheControl,
cacheSystem: _cacheSystem,
cacheBreakpoints: _cacheBreakpoints,
maxBreakpoints: _maxBreakpoints,
cacheTools: _cacheTools,
cacheTrajectory: _cacheTrajectory,
...wireOptions
} = anthropicOptions;
return Object.keys(wireOptions).length > 0 ? wireOptions : undefined;
}
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
: {};
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"]);
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
: {};
if (existingAnthropic.cacheControl) return messages;
const nextMessages = [...messages];
nextMessages[lastIndex] = {
...last,
content: [
...parts.slice(0, -1),
{
...lastPart,
providerOptions: {
...existingProviderOptions,
anthropic: { ...existingAnthropic, cacheControl },
},
},
],
} as ModelMessage;
return nextMessages;
}
function getRuntimeCacheControl(runtime: IAgentRuntime): AnthropicCacheControl {
const ttl = runtime.getSetting("ANTHROPIC_PROMPT_CACHE_TTL");
return ttl === "1h" ? { type: "ephemeral", ttl: "1h" } : { type: "ephemeral" };
}
type AnthropicCacheBreakpoint = {
segmentIndex: number;
cacheControl: AnthropicCacheControl;
};
function isAnthropicCacheBreakpoint(value: unknown): value is AnthropicCacheBreakpoint {
return (
isRecord(value) &&
typeof value.segmentIndex === "number" &&
Number.isInteger(value.segmentIndex) &&
value.segmentIndex >= 0 &&
isRecord(value.cacheControl) &&
value.cacheControl.type === "ephemeral"
);
}
function readAnthropicCacheBreakpoints(
anthropicOptions: Record<string, unknown> | undefined,
maxBreakpoints: number,
keepLatest = false
): AnthropicCacheBreakpoint[] {
const raw = anthropicOptions?.cacheBreakpoints;
if (!Array.isArray(raw) || maxBreakpoints <= 0) return [];
const valid = raw.filter(isAnthropicCacheBreakpoint);
return keepLatest ? valid.slice(-maxBreakpoints) : valid.slice(0, maxBreakpoints);
}
/**
* Builds user content as multiple text blocks (one per prompt segment) so that
* Anthropic cache_control breakpoints can be applied at the segment level, up to
* the three user-content slots under Anthropic's four-block cache cap. Only used
* on the prompt-only path (no `messages` supplied) when promptSegments and
* cacheBreakpoints are both available.
*/
function buildSegmentedPromptUserContent(
promptSegments: Array<{ content: string; stable?: boolean }>,
cacheBreakpoints: AnthropicCacheBreakpoint[]
): TextPart[] {
if (cacheBreakpoints.length === 0) {
return promptSegments.map((seg) => ({
type: "text" as const,
text: seg.content,
}));
}
const breakpointMap = new Map<number, AnthropicCacheControl>(
cacheBreakpoints.map((bp) => [bp.segmentIndex, bp.cacheControl])
);
return promptSegments.map((seg, index) => {
const cc = breakpointMap.get(index);
if (cc) {
return {
type: "text" as const,
text: seg.content,
providerOptions: anthropicCacheProviderOptions(cc),
};
}
return { type: "text" as const, text: seg.content };
});
}
function readToolSet(value: GenerateTextParams["tools"]): ToolSet | undefined {
if (!value) {
return undefined;
}
// The runtime exposes tools as ordered ToolDefinition arrays. The AI SDK
// expects a ToolSet keyed by provider-visible tool names; passing the array
// through gives providers function names like "0", which Google rejects.
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;
}
const functionTool = isRecord(rawTool.function) ? rawTool.function : undefined;
const name =
typeof rawTool.name === "string" && rawTool.name
? rawTool.name
: typeof functionTool?.name === "string" && functionTool.name
? functionTool.name
: undefined;
if (name) {
sawNamedTool = true;
const schema = isRecord(rawTool.parameters)
? (rawTool.parameters as JSONSchema7)
: isRecord(functionTool?.parameters)
? (functionTool.parameters as JSONSchema7)
: isRecord(rawTool.input_schema)
? (rawTool.input_schema as JSONSchema7)
: ({ type: "object" } satisfies JSONSchema7);
const description =
typeof rawTool.description === "string"
? rawTool.description
: typeof functionTool?.description === "string"
? functionTool.description
: undefined;
tools[name] = {
...(description ? { description } : {}),
inputSchema: jsonSchema(schema),
};
} else if (!isArr && !namedKeys.has(origKey)) {
tools[origKey] = rawTool;
}
}
if (sawNamedTool) {
return Object.keys(tools).length > 0 ? (tools as ToolSet) : undefined;
}
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 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: GenerateTextParamsWithAttachments): boolean {
return Boolean(params.messages || params.tools || params.toolChoice || params.responseSchema);
}
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;
function getModelNameForType(runtime: IAgentRuntime, modelType: TextModelType): string {
switch (modelType) {
case TEXT_NANO_MODEL_TYPE:
return getNanoModel(runtime);
case TEXT_MEDIUM_MODEL_TYPE:
return getMediumModel(runtime);
case ModelType.TEXT_SMALL:
return getSmallModel(runtime);
case ModelType.TEXT_LARGE:
return getLargeModel(runtime);
case TEXT_MEGA_MODEL_TYPE:
return getMegaModel(runtime);
case RESPONSE_HANDLER_MODEL_TYPE:
return getResponseHandlerModel(runtime);
case ACTION_PLANNER_MODEL_TYPE:
return getActionPlannerModel(runtime);
default:
return getLargeModel(runtime);
}
}
function getModelLabelForType(modelType: TextModelType): string {
switch (modelType) {
case TEXT_NANO_MODEL_TYPE:
return "TEXT_NANO";
case TEXT_MEDIUM_MODEL_TYPE:
return "TEXT_MEDIUM";
case ModelType.TEXT_SMALL:
return "TEXT_SMALL";
case ModelType.TEXT_LARGE:
return "TEXT_LARGE";
case TEXT_MEGA_MODEL_TYPE:
return "TEXT_MEGA";
case RESPONSE_HANDLER_MODEL_TYPE:
return "RESPONSE_HANDLER";
case ACTION_PLANNER_MODEL_TYPE:
return "ACTION_PLANNER";
default:
return String(modelType);
}
}
function buildGenerateParams(
runtime: IAgentRuntime,
modelType: TextModelType,
params: GenerateTextParams
) {
const paramsWithAttachments = params as GenerateTextParamsWithAttachments;
const prompt = typeof params.prompt === "string" ? params.prompt : undefined;
const usagePrompt = prompt ?? textFromMessages(paramsWithAttachments.messages);
const paramsWithMax = params as GenerateTextParams & {
maxOutputTokens?: number;
maxTokens?: number;
};
// Opt-out (direct-channel Stage-1): send no cap so the model's own max applies
// — a hardcoded value 400s when it exceeds the model's limit. Otherwise resolve
// the explicit value or the 8192 default.
const resolvedMaxOutput = params.omitMaxTokens
? undefined
: (paramsWithMax.maxOutputTokens ?? paramsWithMax.maxTokens ?? 8192);
const openrouter = createOpenRouterProvider(runtime);
const modelName = getModelNameForType(runtime, modelType);
const modelLabel = getModelLabelForType(modelType);
const supportsSampling = supportsSamplingParameters(modelName);
const stopSequences =
Array.isArray(params.stopSequences) && params.stopSequences.length > 0
? params.stopSequences
: undefined;
const userContent =
(paramsWithAttachments.attachments?.length ?? 0) > 0
? buildUserContent(paramsWithAttachments)
: undefined;
const attachmentContent =
paramsWithAttachments.messages && (paramsWithAttachments.attachments?.length ?? 0) > 0
? buildUserContent(paramsWithAttachments, { includePrompt: false })
: undefined;
const temperature = params.temperature ?? 0.7;
const frequencyPenalty = params.frequencyPenalty ?? 0.7;
const presencePenalty = params.presencePenalty ?? 0.7;
const systemPrompt = resolveEffectiveSystemPrompt({
params: paramsWithAttachments,
fallback: buildCanonicalSystemPrompt({ character: runtime.character }),
});
const wireMessages = dropDuplicateLeadingSystemMessage(
paramsWithAttachments.messages,
systemPrompt
);
// Detect if we need to inject Anthropic message-level cache control.
// When the caller passes an explicit providerOptions.anthropic.cacheControl we
// use that; otherwise we fall back to reading ANTHROPIC_PROMPT_CACHE_TTL from
// runtime settings — matching the always-on behaviour of plugin-anthropic so
// that every Anthropic model routed through OpenRouter gets at least the
// system-message breakpoint regardless of whether the calling path assembled a
// full cache plan (e.g. the PromptBatcher / dynamicPromptExecFromState path).
const isAnthropic = isAnthropicModel(modelName);
const rawProviderOptions = paramsWithAttachments.providerOptions;
const anthropicOptions = isRecord(rawProviderOptions?.anthropic)
? rawProviderOptions.anthropic
: undefined;
const anthropicCacheControl =
readAnthropicCacheControl(anthropicOptions) ??
(isAnthropic ? getRuntimeCacheControl(runtime) : undefined);
const anthropicCacheSystem = anthropicOptions?.cacheSystem !== false;
const cacheSystemMessage =
isAnthropic && anthropicCacheSystem
? buildCacheableSystemMessage(systemPrompt, anthropicCacheControl)
: undefined;
const shouldInjectMessageLevelCache = Boolean(cacheSystemMessage);
// Collect cacheBreakpoints for per-segment user-content injection.
// Only used on the prompt-only path (no messages) together with promptSegments.
// maxBreakpoints caps the number of slots used (Anthropic allows up to 3 user-content).
const requestedMaxBreakpoints =
typeof anthropicOptions?.maxBreakpoints === "number" &&
Number.isInteger(anthropicOptions.maxBreakpoints) &&
anthropicOptions.maxBreakpoints >= 0
? anthropicOptions.maxBreakpoints
: 3;
const reservesToolsBreakpoint =
isAnthropic &&
anthropicCacheControl &&
anthropicOptions?.cacheTools !== false &&
Boolean(paramsWithAttachments.tools && Object.keys(paramsWithAttachments.tools).length > 0);
const maxBreakpoints = Math.min(requestedMaxBreakpoints, reservesToolsBreakpoint ? 2 : 3);
const cacheBreakpoints = readAnthropicCacheBreakpoints(
anthropicOptions,
maxBreakpoints,
Boolean(reservesToolsBreakpoint)
);
const promptSegments = Array.isArray(
(paramsWithAttachments as { promptSegments?: unknown }).promptSegments
)
? ((
paramsWithAttachments as {
promptSegments?: Array<{ content: string; stable?: boolean }>;
}
).promptSegments ?? [])
: [];
let finalWireMessages = wireMessages;
if (cacheSystemMessage && paramsWithAttachments.messages) {
finalWireMessages = [cacheSystemMessage, ...(wireMessages || [])];
}
const promptOrMessages: NativePrompt = paramsWithAttachments.messages
? finalWireMessages && finalWireMessages.length > 0
? {
messages: attachmentContent
? appendUserContentToMessages(finalWireMessages, attachmentContent)
: finalWireMessages,
}
: userContent
? { messages: [{ role: "user" as const, content: userContent }] }
: prompt !== undefined
? { prompt }
: (() => {
throw new Error(
"OpenRouter text generation requires prompt, messages, or attachments"
);
})()
: shouldInjectMessageLevelCache && cacheSystemMessage
? userContent || prompt !== undefined
? {
messages: [
cacheSystemMessage,
{
role: "user" as const,
// When promptSegments and cacheBreakpoints are both available,
// build multi-block user content so per-segment cache_control can
// be stamped on the last block of each stable run (up to three
// additional breakpoints under Anthropic's four-block cap).
content:
promptSegments.length > 0 && cacheBreakpoints.length > 0 && !userContent
? buildSegmentedPromptUserContent(promptSegments, cacheBreakpoints)
: (userContent ?? buildUserContent(paramsWithAttachments)),
},
],
}
: (() => {
throw new Error("OpenRouter text generation requires prompt, messages, or attachments");
})()
: userContent
? { messages: [{ role: "user" as const, content: userContent }] }
: prompt !== undefined
? { prompt }
: (() => {
throw new Error(
"OpenRouter text generation requires prompt, messages, or attachments"
);
})();
// Resolve providerOptions: forward any caller-supplied options and merge in
// the openrouter.promptCacheKey when present. OpenRouter passes prompt_cache_key
// through to the underlying model provider for prefix caching.
const {
openrouter: rawOpenrouterOptions,
anthropic: _,
...restProviderOptions
} = rawProviderOptions ?? {};
const openrouterOptions: Record<string, unknown> = {
...(rawOpenrouterOptions ?? {}),
};
// Strip local Anthropic cache options if we injected message-level cache
const wireAnthropicOptions = isAnthropic
? stripLocalAnthropicCacheOptions(anthropicOptions)
: anthropicOptions;
const mergedProviderOptions: Record<string, unknown> = {
...restProviderOptions,
...(Object.keys(openrouterOptions).length > 0 ? { openrouter: openrouterOptions } : {}),
...(wireAnthropicOptions ? { anthropic: wireAnthropicOptions } : {}),
};
const resolvedProviderOptions =
Object.keys(mergedProviderOptions).length > 0 ? mergedProviderOptions : undefined;
const normalizedTools = readToolSet(paramsWithAttachments.tools);
const normalizedToolChoice = readToolChoice(paramsWithAttachments.toolChoice);
const cacheToolsEnabled = anthropicOptions?.cacheTools !== false;
const wireTools =
isAnthropic && normalizedTools && cacheToolsEnabled && anthropicCacheControl
? applyToolsCacheBreakpoint(normalizedTools, anthropicCacheControl)
: normalizedTools;
const cacheTrajectoryEnabled = anthropicOptions?.cacheTrajectory !== false;
const finalPromptOrMessages: NativePrompt =
isAnthropic && anthropicCacheControl && cacheTrajectoryEnabled && promptOrMessages.messages
? {
messages: applyTrajectoryTailCacheBreakpoint(
promptOrMessages.messages,
anthropicCacheControl
),
}
: promptOrMessages;
type NativeProviderOptions = NativeTextParams["providerOptions"];
const generateParams: NativeTextParams = {
model: openrouter.chat(modelName) as LanguageModel,
...finalPromptOrMessages,
// Omit system parameter when we injected message-level cache to prevent duplication
...(shouldInjectMessageLevelCache ? {} : { system: systemPrompt }),
...(supportsSampling
? {
temperature: temperature,
frequencyPenalty: frequencyPenalty,
presencePenalty: presencePenalty,
...(stopSequences ? { stopSequences } : {}),
}
: {}),
...(resolvedMaxOutput !== undefined ? { maxOutputTokens: resolvedMaxOutput } : {}),
...(wireTools ? { tools: wireTools } : {}),
...(normalizedToolChoice ? { toolChoice: normalizedToolChoice } : {}),
...(paramsWithAttachments.responseSchema
? { output: buildStructuredOutput(paramsWithAttachments.responseSchema) }
: {}),
...(resolvedProviderOptions
? { providerOptions: resolvedProviderOptions as NativeProviderOptions }
: {}),
};
return {
generateParams,
modelName,
modelLabel,
prompt: usagePrompt,
shouldReturnNativeResult: usesNativeTextResult(paramsWithAttachments),
};
}
type GenerateParams = ReturnType<typeof buildGenerateParams>["generateParams"];
function handleStreamingGeneration(
runtime: IAgentRuntime,
modelType: TextModelType,
generateParams: GenerateParams,
prompt: string,
modelName: string,
modelLabel: string,
shouldReturnNativeResult: boolean
): TextStreamResult {
let capturedStreamError: unknown;
const streamResult = streamText({
...(generateParams as Parameters<typeof streamText>[0]),
onError: ({ error }: { error: unknown }) => {
capturedStreamError = error;
},
});
const usagePromise = Promise.resolve(streamResult.usage).then((usage) => {
if (!usage) {
return undefined;
}
return emitModelUsageEvent(runtime, modelType, prompt, usage, modelName, modelLabel);
});
// error-policy:J5 unhandled-rejection suppression — usage emission is
// telemetry; the underlying stream failure is observed in
// `textStreamWithUsage` below (capturedStreamError rethrow), never here.
const ignoreUsageError = (): undefined => undefined;
async function* textStreamWithUsage(): AsyncIterable<string> {
let completed = false;
try {
for await (const chunk of streamResult.textStream) {
yield chunk;
}
completed = true;
// error-policy:J2 context-adding rethrow — a rejected finishReason is
// captured and rethrown as the typed stream error just below; it is never
// swallowed into a healthy-empty stream.
await Promise.resolve(streamResult.finishReason).catch((error) => {
capturedStreamError ??= error;
});
if (capturedStreamError) {
throw capturedStreamError instanceof Error
? capturedStreamError
: new Error(`[OpenRouter] streaming provider error: ${String(capturedStreamError)}`);
}
} finally {
if (completed) {
await usagePromise.catch(ignoreUsageError);
}
}
}
return {
textStream: textStreamWithUsage(),
text: Promise.resolve(streamResult.text).then(async (text) => {
await usagePromise.catch(ignoreUsageError);
return text;
}),
...(shouldReturnNativeResult ? { toolCalls: Promise.resolve(streamResult.toolCalls) } : {}),
usage: usagePromise,
finishReason: Promise.resolve(streamResult.finishReason) as Promise<string | undefined>,
};
}
function buildNativeTextResult(result: {
text: string;
toolCalls?: unknown[];
finishReason?: string;
usage?: {
inputTokens?: number;
outputTokens?: number;
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
cachedInputTokens?: number;
cacheReadInputTokens?: number;
cacheCreationInputTokens?: number;
// Real location of cache-write counts on `ai@^6` results — see
// `extractCacheTokens` in utils/events.ts for why the fields above alone
// under-report.
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number };
};
}): NativeGenerateTextResult {
const inputTokens = result.usage?.inputTokens ?? result.usage?.promptTokens ?? 0;
const outputTokens = result.usage?.outputTokens ?? result.usage?.completionTokens ?? 0;
if (!result.usage) {
return {
text: result.text,
toolCalls: result.toolCalls ?? [],
finishReason: result.finishReason,
};
}
const { cacheRead, cacheCreation } = extractCacheTokens(result.usage);
const usage: NormalizedUsage = {
promptTokens: inputTokens,
completionTokens: outputTokens,
totalTokens: result.usage.totalTokens ?? inputTokens + outputTokens,
...(typeof cacheRead === "number" ? { cacheReadInputTokens: cacheRead } : {}),
...(typeof cacheCreation === "number" ? { cacheCreationInputTokens: cacheCreation } : {}),
};
return {
text: result.text,
toolCalls: result.toolCalls ?? [],
finishReason: result.finishReason,
usage,
};
}
async function generateTextWithModel(
runtime: IAgentRuntime,
modelType: TextModelType,
params: GenerateTextParams
): Promise<string | TextStreamResult> {
const { generateParams, modelName, modelLabel, prompt, shouldReturnNativeResult } =
buildGenerateParams(runtime, modelType, params);
if (params.stream) {
return handleStreamingGeneration(
runtime,
modelType,
generateParams,
prompt,
modelName,
modelLabel,
shouldReturnNativeResult
);
}
const response = await generateText(generateParams);
if (response.usage) {
emitModelUsageEvent(runtime, modelType, prompt, response.usage, modelName, modelLabel);
}
if (shouldReturnNativeResult) {
return buildNativeTextResult(response) as NativeTextModelResult;
}
return response.text;
}
export async function handleTextSmall(
runtime: IAgentRuntime,
params: GenerateTextParams
): Promise<string | TextStreamResult> {
return generateTextWithModel(runtime, ModelType.TEXT_SMALL, params);
}
export async function handleTextNano(
runtime: IAgentRuntime,
params: GenerateTextParams
): Promise<string | TextStreamResult> {
return generateTextWithModel(runtime, TEXT_NANO_MODEL_TYPE, params);
}
export async function handleTextMedium(
runtime: IAgentRuntime,
params: GenerateTextParams
): Promise<string | TextStreamResult> {
return generateTextWithModel(runtime, TEXT_MEDIUM_MODEL_TYPE, params);
}
export async function handleTextLarge(
runtime: IAgentRuntime,
params: GenerateTextParams
): Promise<string | TextStreamResult> {
return generateTextWithModel(runtime, ModelType.TEXT_LARGE, params);
}
export async function handleTextMega(
runtime: IAgentRuntime,
params: GenerateTextParams
): Promise<string | TextStreamResult> {
return generateTextWithModel(runtime, TEXT_MEGA_MODEL_TYPE, params);
}
export async function handleResponseHandler(
runtime: IAgentRuntime,
params: GenerateTextParams
): Promise<string | TextStreamResult> {
return generateTextWithModel(runtime, RESPONSE_HANDLER_MODEL_TYPE, params);
}
export async function handleActionPlanner(
runtime: IAgentRuntime,
params: GenerateTextParams
): Promise<string | TextStreamResult> {
return generateTextWithModel(runtime, ACTION_PLANNER_MODEL_TYPE, params);
}