Skip to content
Merged
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
45 changes: 45 additions & 0 deletions packages/ai/changes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,50 @@
# changes.md — ai

## Browser-safe prompt-cache TTL resolver (2026-07-28)

### What changed

- `src/utils/prompt-cache-ttl.ts` (new): `resolvePromptCacheTtlSeconds(model, env?) -> number | undefined`
plus `PROMPT_CACHE_TTL_SHORT_SECONDS` (300) / `PROMPT_CACHE_TTL_LONG_SECONDS` (3600). It mirrors EACH
target API's own `resolveCacheRetention` precedence verbatim rather than inventing a unified one:
anthropic-messages falls back to `"long"` and honors the bare `process.env.PI_CACHE_RETENTION`
set-but-not-long branch; openai-completions / openai-responses / bedrock fall back to `"short"`;
pi-messages returns `undefined` (backend default). Retention `"none"` and every API with unknown cache
semantics (google, mistral, pi-messages, unknown) resolve to `undefined`.
- The pure compat predicates the resolver needs moved INTO that browser-safe utility and the API modules now
import them from there and re-export for their existing consumers: `getAnthropicCompat` +
`isAnthropicApiBaseUrl` (from `src/api/anthropic-messages.ts`), the resolved-compat getter (from
`src/api/openai-completions.ts`), and `supportsPromptCaching` (from `src/api/bedrock-converse-stream.ts`).
- `src/index.ts` exports the new module from the browser-safe root surface.

### Why

- senpi sizes how long its `bash` tool and omo's `task` tool may block in the foreground on the active model's
prompt-cache lifetime. That lifetime is already decided per provider inside this package, so one shared
resolver here is the single source of truth instead of a table duplicated in every consumer.

### Why the compat predicates had to move rather than be imported

- The root surface is browser-safe. Importing `supportsPromptCaching` directly from
`src/api/bedrock-converse-stream.ts` pulled the AWS SDK (`@smithy/node-http-handler`, `agent-base`,
`http-proxy-agent`) into the browser bundle and broke `npm run check:browser-smoke` with 18 unresolved
`node:*` errors. Moving the pure predicates into the utility and re-exporting from the API modules keeps
one definition with no divergence risk, and keeps the root import graph free of Node-only dependencies.

### Modified upstream files

- `src/api/anthropic-messages.ts`
- `src/api/bedrock-converse-stream.ts`
- `src/api/openai-completions.ts`
- `src/index.ts`

### Expected merge conflict zones

- MEDIUM: each API module's `resolveCacheRetention` / compat-getter region, where the local definition became
an import + re-export. If upstream edits those predicates, port the edit into
`src/utils/prompt-cache-ttl.ts` so the resolver and the adapters stay in agreement.


## Cover Claude Opus 5 in Anthropic adaptive-thinking metadata (2026-07-25)

### What changed
Expand Down
60 changes: 3 additions & 57 deletions packages/ai/src/api/anthropic-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import type {
} from "@anthropic-ai/sdk/resources/messages.js";
import { calculateCost } from "../models.ts";
import type {
AnthropicMessagesCompat,
Api,
AssistantMessage,
AssistantStopDetails,
Expand Down Expand Up @@ -37,6 +36,7 @@ import { splitDeferredTools } from "../utils/deferred-tools.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { headersToRecord, providerHeadersToRecord } from "../utils/headers.ts";
import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse.ts";
import { getAnthropicCompat, isAnthropicApiBaseUrl } from "../utils/prompt-cache-ttl.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts";
import { retryProviderRequest } from "../utils/provider-retry.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
Expand All @@ -60,6 +60,8 @@ import {
} from "./simple-options.ts";
import { transformMessages } from "./transform-messages.ts";

export { getAnthropicCompat } from "../utils/prompt-cache-ttl.ts";

/**
* Resolve cache retention preference.
* Defaults to the provided fallback and uses PI_CACHE_RETENTION for backward compatibility.
Expand All @@ -81,14 +83,6 @@ function resolveCacheRetention(
return fallback;
}

function isAnthropicApiBaseUrl(baseUrl: string): boolean {
try {
return new URL(baseUrl).hostname === "api.anthropic.com";
} catch {
return false;
}
}

function getCacheControl(
model: Model<"anthropic-messages">,
cacheRetention?: CacheRetention,
Expand Down Expand Up @@ -260,7 +254,6 @@ const NATIVE_XHIGH_EFFORT_MODEL_MARKERS = [
* gateway rows carry no generated compat, so the family fact has to live here as well.
*/
const DISABLED_THINKING_REJECTING_MODEL_MARKERS = ["fable-5", "mythos-5"] as const;
const CLAUDE_FABLE_OR_MYTHOS_MODEL_ID = /^claude-(?:fable|mythos)(?:-|$)/i;
const UNSUPPORTED_NATIVE_COMPUTER_TOOL_MODEL_MARKERS = [
"opus-4-6",
"opus-4.6",
Expand Down Expand Up @@ -294,53 +287,6 @@ function isInvalidUnsignedThinkingSignatureError(error: unknown): boolean {
);
}

function getAnthropicCompat(
model: Model<"anthropic-messages">,
): Required<Omit<AnthropicMessagesCompat, "forceAdaptiveThinking">> {
// Auto-detect session affinity and cache control support from provider
const isFireworks = model.provider === "fireworks";
const isCloudflareAiGatewayAnthropic =
model.provider === "cloudflare-ai-gateway" && model.baseUrl.includes("anthropic");
const isXiaomi = model.provider === "xiaomi" || model.provider.startsWith("xiaomi-token-plan-");
return {
supportsEagerToolInputStreaming: model.compat?.supportsEagerToolInputStreaming ?? !isFireworks,
supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? !isFireworks,
sendSessionAffinityHeaders:
model.compat?.sendSessionAffinityHeaders ?? !!(isFireworks || isCloudflareAiGatewayAnthropic),
supportsCacheControlOnTools: model.compat?.supportsCacheControlOnTools ?? !isFireworks,
supportsDisabledThinking: model.compat?.supportsDisabledThinking ?? !isXiaomi,
supportsTemperature: model.compat?.supportsTemperature ?? true,
supportsToolChoice: model.compat?.supportsToolChoice ?? true,
supportsForcedToolChoice:
model.compat?.supportsForcedToolChoice ?? !CLAUDE_FABLE_OR_MYTHOS_MODEL_ID.test(model.id),
allowEmptySignature: model.compat?.allowEmptySignature ?? false,
unsignedThinkingReplay:
model.compat?.unsignedThinkingReplay ?? (model.compat?.allowEmptySignature ? "empty-signature" : "text"),
supportsStrictTools: model.compat?.supportsStrictTools ?? false,
supportsToolReferences: model.compat?.supportsToolReferences ?? defaultSupportsToolReferences(model),
// Default: first-party Anthropic only. Anthropic-compatible providers
// (kimi-coding, fireworks, copilot, gateways) may execute the server-side
// search but reject the replayed server_tool_use / web_search_tool_result
// blocks on the next request (kimi-coding 400s with `tool_call_id is not
// found`).
supportsWebSearch: model.compat?.supportsWebSearch ?? isAnthropicApiBaseUrl(model.baseUrl),
};
}

/**
* Default for `supportsToolReferences`: first-party Anthropic models except
* Haiku (rejects client-side tool_reference blocks) and models that predate
* tool search (Claude 3.x, Opus/Sonnet 4.0, Opus 4.1).
*/
function defaultSupportsToolReferences(model: Model<"anthropic-messages">): boolean {
if (model.provider !== "anthropic" || model.id.includes("haiku")) return false;
const version = model.id.match(/^claude-(?:opus|sonnet|fable)-(\d+)(?:-(\d+))?(?:-|$)/);
if (!version) return false;
const major = Number(version[1]);
const minor = version[2] && version[2].length < 8 ? Number(version[2]) : 0;
return major > 4 || (major === 4 && minor >= 5);
}

export interface AnthropicOptions extends StreamOptions {
/**
* Enable extended thinking.
Expand Down
47 changes: 6 additions & 41 deletions packages/ai/src/api/bedrock-converse-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { providerHeadersToRecord } from "../utils/headers.ts";
import { parseStreamingJson } from "../utils/json-parse.ts";
import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts";
import {
getBedrockModelMatchCandidates as getModelMatchCandidates,
supportsPromptCaching,
} from "../utils/prompt-cache-ttl.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import { normalizeToolCallId } from "../utils/tool-call-id.ts";
Expand All @@ -67,6 +71,8 @@ import {
} from "./simple-options.ts";
import { transformMessages } from "./transform-messages.ts";

export { supportsPromptCaching } from "../utils/prompt-cache-ttl.ts";

export type BedrockThinkingDisplay = "summarized" | "omitted";

export interface BedrockOptions extends StreamOptions {
Expand Down Expand Up @@ -584,14 +590,6 @@ function handleContentBlockStop(
* Checks both model ID and model name to support application inference profiles
* whose ARNs don't contain the model name.
*/
function getModelMatchCandidates(modelId: string, modelName?: string): string[] {
const values = modelName ? [modelId, modelName] : [modelId];
return values.flatMap((value) => {
const lower = value.toLowerCase();
return [lower, lower.replace(/[\s_.:]+/g, "-")];
});
}

function supportsAdaptiveThinking(modelId: string, modelName?: string): boolean {
const candidates = getModelMatchCandidates(modelId, modelName);
return candidates.some(
Expand Down Expand Up @@ -690,39 +688,6 @@ function isAnthropicClaudeModel(model: Model<"bedrock-converse-stream">): boolea
);
}

/**
* Check if the model supports prompt caching.
* Supported: Claude 3.5 Haiku, Claude 3.7 Sonnet, Claude 4.x models, Claude 5 models
*
* For base models and system-defined inference profiles the model ID / ARN
* contains the model name, so we can decide locally.
*
* For application inference profiles (whose ARNs don't contain the model name),
* also checks model.name which is user-controlled via models.json or registerProvider.
* As a last resort, set AWS_BEDROCK_FORCE_CACHE=1 to enable cache points.
* Amazon Nova models have automatic caching and don't need explicit cache points.
*/
function supportsPromptCaching(model: Model<"bedrock-converse-stream">, env?: ProviderEnv): boolean {
const candidates = getModelMatchCandidates(model.id, model.name);

const hasClaudeRef = candidates.some((s) => s.includes("claude"));
if (!hasClaudeRef) {
// Application inference profiles don't contain the model name in the ARN.
// Allow users to force cache points via environment variable.
if (getProviderEnvValue("AWS_BEDROCK_FORCE_CACHE", env) === "1") return true;
return false;
}
// Claude 5 models (fable-5, opus-5, sonnet-5)
if (candidates.some((s) => s.includes("fable-5") || s.includes("opus-5") || s.includes("sonnet-5"))) return true;
// Claude 4.x models (opus-4, sonnet-4, haiku-4)
if (candidates.some((s) => s.includes("-4-"))) return true;
// Claude 3.7 Sonnet
if (candidates.some((s) => s.includes("claude-3-7-sonnet"))) return true;
// Claude 3.5 Haiku
if (candidates.some((s) => s.includes("claude-3-5-haiku"))) return true;
return false;
}

/**
* Check if the model supports thinking signatures in reasoningContent.
* Only Anthropic Claude models support the signature field.
Expand Down
Loading
Loading