Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const result = await agent.run("What is 144 divided by 12?");

console.log(result.outputText);
console.log(result.history);
console.log(result.inputTokens, result.outputTokens);
console.log(result.usage.inputTokens, result.usage.outputTokens);
```

`agent.run(...)` accepts either a plain string or a `ChatItem[]` history.
Expand All @@ -70,7 +70,7 @@ The returned object includes:
- `output`: the structured result if you configured one, otherwise `undefined` unless a tool returns `ModelOutput`
- `outputText`: a convenient string form of the final answer
- `history`: flat chat history items that are easy to store
- `inputTokens` and `outputTokens`
- `usage`: token counts for the run (see [Prompt caching](#prompt-caching))

## Structured output

Expand Down Expand Up @@ -170,6 +170,35 @@ const agent = new Agent({
- Tools support per-tool retries, timeouts and signal cancellation
- Agent-level aborts propagate into running tools

## Prompt caching

Prompt caching reuses the cost of the tokens a call shares with an earlier one. Since providers are different about
their caching systems, configuring how the cache works must be done on the model factory. For example, you can opt into
the expensive 1-hour cache in `anthropicModel`

```tsx
const agent = new Agent({
model: anthropicModel({
model: "claude-opus-4-8",
cache: { ttl: "1h" },
}),
instructions: "You are a helpful assistant",
});
```

By default, caches are opted into if any tools are provided, since this almost always is a cost save. You can disable
this behavior either by setting `cache: false` on the model factory, or the same on the `Agent`.

```tsx
const agent = new Agent({
model: "anthropic:claude-opus-4-8"
instructions:
`My prompt is generated and only run once: ${Math.random()}, `
+ `so caching doesn't help me!`,
cache: false,
});
```

## Fallback models and retry strategy

An agent can be configured with multiple models for automatic fallback. The retry behavior is dependant on the error
Expand Down
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"@std/encoding": "jsr:@std/encoding@^1.0.10",
"@std/testing": "jsr:@std/testing@^1.0.17",
"@std/uuid": "jsr:@std/uuid@^1.1.0",
"openai": "npm:openai@^6.33.0",
"openai": "npm:openai@^6.47.0",
"zod": "npm:zod@^4.3.6"
},
"publish": {
Expand Down
39 changes: 27 additions & 12 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions examples/cache-control.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Agent } from "../mod.ts";
import { anthropicModel } from "../src/adapters/anthropic/adapter.ts";

const agent = new Agent({

Check failure on line 4 in examples/cache-control.ts

View workflow job for this annotation

GitHub Actions / tests

`agent` is never used
model: anthropicModel({
model: "claude-opus-4-8",
cache: { ttl: "1h" },
}),
instructions: "You are a helpful assistant",
});

console.log(result.output);
10 changes: 10 additions & 0 deletions src/adapters/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ export interface AdapterStreamOptions<zO, zI> {
instructions: string;
/** Previous conversation history */
history: ChatItem[];
/**
* Whether to prompt cache this call, overriding the adapter's default. Unset
* leaves the adapter to decide.
*
* Only adapters whose provider takes cache instructions on the request can honor
* this; where the provider caches automatically and offers no opt out, it is
* ignored. An option the adapter was constructed with wins over this, so an
* explicitly configured cache is never silently turned off.
*/
cache?: boolean;
/** Cancellation signal */
signal: AbortSignal;
}
Expand Down
48 changes: 45 additions & 3 deletions src/adapters/anthropic/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { type ClassifiedError, createClassifiedError } from "../../errors.ts";
import type { AdapterStreamIterator, ChatItem } from "../../types.ts";
import type { Adapter, AdapterStreamOptions } from "../adapter.ts";
import type { SchemaCompatibility } from "../shared/schema_compatibility.ts";
import { getAnthropicHistory, rememberAnthropicReasoningSignature } from "./history.ts";
import { applyAnthropicCacheBreakpoint, getAnthropicHistory, rememberAnthropicReasoningSignature } from "./history.ts";
import {
type AnthropicModels,
anthropicModelStructuredOutputSupport,
Expand Down Expand Up @@ -50,12 +50,37 @@ type EffortOption<TModel extends AnthropicModels> = SupportedEffortLevel<TModel>
type InterleavedOption<TModel extends AnthropicModels> = SupportsInterleaved<TModel> extends true ? boolean
: undefined;

/**
* Prompt caching configuration. `true` uses the 5 minute cache; `{ ttl: "1h" }`
* survives longer gaps between calls at double the write cost.
*/
export type AnthropicCacheOptions = {
ttl?: "5m" | "1h";
};

export function anthropicModel<zO, zI, TModel extends AnthropicModels>(options: {
model: TModel;
effort?: EffortOption<TModel>;
thinkingLevel?: ThinkingLevelOption<TModel>;
interleaved?: InterleavedOption<TModel>;
thinkingDisplay?: ThinkingDisplay;
/**
* Cache the instructions, tools and conversation prefix across calls.
*
* Defaults to the 5 minute cache when the agent has tools, and to off when it
* has none: cached tokens are billed at ~0.1x but writes at 1.25x (2x for
* `ttl: "1h"`), so caching pays off from the second call sharing a prefix
* onward, and tools are the signal that a second call is coming. Pass `true`
* to cache a toolless agent anyway (worth it if you rerun the same
* instructions), or `false` to opt out entirely. Set here, this outranks the
* `cache` on the agent using the model.
*
* Anthropic silently declines to cache prefixes below the model's minimum,
* which is per-model and ranges from 1024 tokens (Sonnet 4.5 and older) to
* 4096 (Opus, Haiku 4.5), so read `usage.cacheReadTokens` rather than
* assuming a hit.
*/
cache?: boolean | AnthropicCacheOptions;
baseUrl?: string;
apiKey?: string;
client?: Anthropic;
Expand Down Expand Up @@ -118,21 +143,36 @@ ${JSON.stringify(structuredOutput.originalJsonSchema, null, 2)}
provider: "Anthropic",
model: options.model,
stream: async function* stream<zO, zI>(
{ history, instructions, tools, signal, output }: AdapterStreamOptions<zO, zI>,
{ history, instructions, tools, signal, output, cache: cacheDefault }: AdapterStreamOptions<zO, zI>,
): AdapterStreamIterator {
const normalizedTools = normalizeAnthropicTools(tools);
const anthropicHistory = await getAnthropicHistory({ history, normalizedTools, signal });

// Tools mean an agent loop, which rereads its prefix every turn and profits from caching.
// Without them a run is usually a single call, which would only pay the write premium.
// A cache configured on the model outranks the caller's default, so an explicit ttl survives it.
const cache = options.cache ?? cacheDefault ?? tools.length > 0;
const cacheControl: Anthropic.Messages.CacheControlEphemeral | undefined = cache
? { type: "ephemeral", ttl: typeof cache === "object" ? cache.ttl : undefined }
: undefined;

const structuredOutput = output && createAnthropicCompatibleSchema(output, {
kind: "output",
rootPath: "output",
});

const systemPrompt = getSystemPrompt(instructions, structuredOutput);

// Render order is tools -> system -> messages, so a breakpoint on the system block
// caches the tools with it, and one on the conversation tail caches the turn so far.
if (cacheControl) applyAnthropicCacheBreakpoint(anthropicHistory, cacheControl);

const response = client.beta.messages.stream({
model: options.model,
system: systemPrompt,
// An empty text block is rejected, so a blank prompt stays a bare string (and has nothing to cache).
system: cacheControl && systemPrompt
? [{ type: "text", text: systemPrompt, cache_control: cacheControl }]
: systemPrompt,
messages: anthropicHistory,
tools: normalizedTools.map(({ anthropic }) => anthropic),

Expand Down Expand Up @@ -267,6 +307,8 @@ ${JSON.stringify(structuredOutput.originalJsonSchema, null, 2)}
return {
outputTokens: final.usage.output_tokens,
inputTokens: final.usage.input_tokens,
cacheReadTokens: final.usage.cache_read_input_tokens ?? null,
cacheWriteTokens: final.usage.cache_creation_input_tokens ?? null,
};
},
classifyError(error: unknown): ClassifiedError | null {
Expand Down
17 changes: 17 additions & 0 deletions src/adapters/anthropic/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,23 @@ export function rememberAnthropicReasoningSignature(content: string, signature:
signatureMap.set(content, signature);
}

/**
* Marks the tail of the conversation as a cache breakpoint, so the next call in
* an agent turn reads this turn's prefix instead of reprocessing it. Caching is a
* prefix match, so this must be the final mutation of the history.
*/
export function applyAnthropicCacheBreakpoint(
history: Anthropic.Messages.MessageParam[],
cacheControl: Anthropic.Messages.CacheControlEphemeral,
) {
const content = history.at(-1)?.content;
if (!content || typeof content === "string") return;
const block = content.at(-1);
// Thinking blocks reject a breakpoint; the system/tools prefix still caches without one here.
if (!block || block.type === "thinking" || block.type === "redacted_thinking") return;
block.cache_control = cacheControl;
}

export async function getAnthropicHistory(options: {
history: ChatItem[];
normalizedTools: AnthropicToolMap[];
Expand Down
4 changes: 3 additions & 1 deletion src/adapters/google_genai/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { assert } from "@std/assert";
import type { AdapterStreamIterator } from "../../types.ts";
import { hashString } from "../../util.ts";
import type { Adapter, AdapterStreamOptions } from "../adapter.ts";
import { splitCacheInclusiveUsage } from "../shared/usage.ts";
import { getGoogleGenerateContentAPIHistory, rememberGoogleThoughtSignature } from "./history.ts";
import { normalizeGoogleTools } from "./tools.ts";

Expand Down Expand Up @@ -194,7 +195,8 @@ export function googleGenerateContentAPIModel<zO, zI>(
}

return {
inputTokens: usageMetadata?.promptTokenCount ?? null,
...splitCacheInclusiveUsage(usageMetadata?.promptTokenCount, usageMetadata?.cachedContentTokenCount),
// promptTokenCount is cache-inclusive, so this stays the correct output count.
outputTokens: usageMetadata?.totalTokenCount != null && usageMetadata?.promptTokenCount != null
? usageMetadata.totalTokenCount - usageMetadata.promptTokenCount
: null,
Expand Down
7 changes: 6 additions & 1 deletion src/adapters/open_responses/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { classifyOpenAIError } from "../shared/classify_error.ts";
import { DEFAULT_SUPPORTED_MIME_TYPES } from "../shared/media.ts";
import { createOpenAICompatibleSchema } from "../shared/openai_compatibility.ts";
import { restoreWrappedToolArguments } from "../shared/tools.ts";
import { splitCacheInclusiveUsage } from "../shared/usage.ts";
import { getOpenResponsesHistory } from "./history.ts";
import { normalizeOpenResponsesTools, type OpenResponsesToolMap } from "./tools.ts";
import type { ClientOptions } from "openai";
Expand Down Expand Up @@ -194,7 +195,11 @@ export function openResponsesModel<zO, zI>(options: {

const final = await response.finalResponse();
return {
inputTokens: final.usage?.input_tokens ?? null,
...splitCacheInclusiveUsage(
final.usage?.input_tokens,
final.usage?.input_tokens_details?.cached_tokens,
final.usage?.input_tokens_details?.cache_write_tokens,
),
outputTokens: final.usage?.output_tokens ?? null,
};
},
Expand Down
26 changes: 25 additions & 1 deletion src/adapters/openai/models.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { z } from "zod";
import type { NonReasoningModelSupport, OpenAiModelsMap, ReasoningModelSupport } from "./types.ts";

export type OpenAIReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
export type OpenAIReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
export type OpenAIModelModality = "text" | "image" | "audio" | "video";

type ModelModalitiesOptions = {
Expand Down Expand Up @@ -34,6 +34,30 @@ function reasoning<const T extends readonly [OpenAIReasoningEffort, ...OpenAIRea

const openAiModelsDefinition = {
// Frontier
// `gpt-5.6` is an alias that routes to Sol. Terra trades capability for cost, Luna is the
// fast, high-volume tier. `max` arrived with this generation and is reserved for the
// hardest quality-first work.
"gpt-5.6": reasoning({
levels: ["none", "low", "medium", "high", "xhigh", "max"],
default: "medium",
modalities: ["text", "image"],
}),
"gpt-5.6-sol": reasoning({
levels: ["none", "low", "medium", "high", "xhigh", "max"],
default: "medium",
modalities: ["text", "image"],
}),
"gpt-5.6-terra": reasoning({
levels: ["none", "low", "medium", "high", "xhigh", "max"],
default: "medium",
modalities: ["text", "image"],
}),
"gpt-5.6-luna": reasoning({
levels: ["none", "low", "medium", "high", "xhigh", "max"],
default: "medium",
modalities: ["text", "image"],
}),

"gpt-5.5": reasoning({
levels: ["none", "low", "medium", "high", "xhigh"],
default: "medium",
Expand Down
Loading
Loading