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
32 changes: 22 additions & 10 deletions packages/ai/src/cache-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
// Manual `cache: CacheHint` placements on individual parts are preserved and
// count against the four-breakpoint budget; auto only fills remaining slots.
import { CacheHint, type CachePolicy, type CachePolicyObject } from "./schema/options.js"
import { LLMRequest, Message, ToolDefinition, type ContentPart } from "./schema/messages.js"
import { LLMRequest, Message, ToolDefinition, type ContentPart, type ToolEntry } from "./schema/messages.js"

const AUTO: CachePolicyObject = {
tools: true,
Expand Down Expand Up @@ -50,18 +50,30 @@ interface Budget {
remaining: number
}

const markLastTool = (
tools: ReadonlyArray<ToolDefinition>,
hint: CacheHint,
budget: Budget,
): ReadonlyArray<ToolDefinition> => {
const markLastTool = (tools: ReadonlyArray<ToolEntry>, hint: CacheHint, budget: Budget): ReadonlyArray<ToolEntry> => {
if (tools.length === 0) return tools
const last = tools.length - 1
if (tools[last]!.cache || budget.remaining === 0) return tools
const last = tools.findLastIndex((tool) => tool.type === "tool" || tool.tools.some(hasTool))
if (last === -1) return tools
const target = tools[last]!
if (target.type === "namespace") {
const nested = markLastTool(target.tools, hint, budget)
return nested === target.tools
? tools
: tools.map((tool, index) => (index === last ? { ...target, tools: nested } : tool))
}
if (target.cache || budget.remaining === 0) return tools
budget.remaining -= 1
return tools.map((tool, i) => (i === last ? new ToolDefinition({ ...tool, cache: hint }) : tool))
return tools.map((tool, index) => (index === last ? new ToolDefinition({ ...target, cache: hint }) : tool))
}

const hasTool = (tool: ToolEntry): boolean => tool.type === "tool" || tool.tools.some(hasTool)

const countToolHints = (tools: ReadonlyArray<ToolEntry>): number =>
tools.reduce(
(count, tool) => count + (tool.type === "tool" ? (tool.cache === undefined ? 0 : 1) : countToolHints(tool.tools)),
0,
)

const markSystemBoundaries = (system: LLMRequest["system"], hint: CacheHint, budget: Budget): LLMRequest["system"] => {
if (system.length === 0) return system
let changed = false
Expand Down Expand Up @@ -122,7 +134,7 @@ const markMessages = (
}

const countHints = (request: LLMRequest) =>
request.tools.reduce((count, tool) => count + (tool.cache === undefined ? 0 : 1), 0) +
countToolHints(request.tools) +
request.system.reduce((count, part) => count + (part.cache === undefined ? 0 : 1), 0) +
request.messages.reduce(
(count, message) =>
Expand Down
7 changes: 4 additions & 3 deletions packages/ai/src/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ import {
LanguageModel,
SystemPart,
ToolChoice,
ToolDefinition,
ToolEntry,
type ContentPart,
type LanguageModelProviderOptions,
type ToolEntryInput,
} from "./schema/index.js"
import { make as makeTool, toDefinitions, type ToolSchema } from "./tool.js"

Expand All @@ -27,7 +28,7 @@ export type RequestInput<SelectedLanguageModel extends LanguageModel = LanguageM
readonly system?: string | SystemPart | ReadonlyArray<SystemPart>
readonly prompt?: string | ContentPart | ReadonlyArray<ContentPart>
readonly messages?: ReadonlyArray<Message | Message.Input>
readonly tools?: ReadonlyArray<ToolDefinition.Input>
readonly tools?: ReadonlyArray<ToolEntryInput>
readonly toolChoice?: ToolChoice.Input
readonly generation?: GenerationOptions.Input
readonly providerOptions?: NoInfer<LanguageModelProviderOptions<SelectedLanguageModel>>
Expand Down Expand Up @@ -56,7 +57,7 @@ export const request = <const SelectedLanguageModel extends LanguageModel>(
...rest,
system: SystemPart.content(requestSystem),
messages: [...(messages?.map(Message.make) ?? []), ...(prompt === undefined ? [] : [Message.user(prompt)])],
tools: tools?.map(ToolDefinition.make) ?? [],
tools: tools?.map((tool) => ToolEntry.make(tool)) ?? [],
toolChoice: requestToolChoice ? ToolChoice.make(requestToolChoice) : undefined,
generation: requestGeneration === undefined ? undefined : GenerationOptions.make(requestGeneration),
providerOptions: requestProviderOptions,
Expand Down
8 changes: 5 additions & 3 deletions packages/ai/src/protocols/anthropic-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1009,10 +1009,12 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
// messages. Tools live highest in the cache hierarchy, so when callers
// over-mark we keep their tool hints and shed the message-tail ones first.
const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP)
const flattened = ProviderShared.flattenToolRequest(request)
const definitions = flattened.tools
const tools =
request.tools.length === 0
definitions.length === 0
? undefined
: request.tools.map((tool) =>
: definitions.map((tool) =>
lowerTool(
breakpoints,
tool,
Expand All @@ -1030,7 +1032,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
text: part.text,
cache_control: cacheControl(breakpoints, part.cache),
}))
const messages = yield* lowerMessages(request, breakpoints)
const messages = yield* lowerMessages(flattened.request, breakpoints)
if (breakpoints.dropped > 0) {
yield* Effect.logWarning(
`Anthropic Messages: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${ANTHROPIC_BREAKPOINT_CAP} per request.`,
Expand Down
13 changes: 6 additions & 7 deletions packages/ai/src/protocols/bedrock-converse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,10 +415,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (

// System prompts share the cache-point convention: emit the text block, then
// optionally a positional `cachePoint` marker.
const lowerSystem = (
breakpoints: BedrockCache.Breakpoints,
system: ReadonlyArray<LLMRequest["system"][number]>,
) => {
const lowerSystem = (breakpoints: BedrockCache.Breakpoints, system: ReadonlyArray<LLMRequest["system"][number]>) => {
const content = system
.filter((part) => part.text.length > 0)
.flatMap((part) => textWithCache(breakpoints, part.text, part.cache))
Expand All @@ -427,21 +424,23 @@ const lowerSystem = (

const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) {
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
const flattened = ProviderShared.flattenToolRequest(request)
const tools = flattened.tools
const generation = request.generation
// Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in
// tools → system → messages order to favour the highest-impact prefixes.
const breakpoints = BedrockCache.breakpoints()
const toolConfig = (() => {
if (request.tools.length === 0) return undefined
if (tools.length === 0) return undefined
return {
tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools),
tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, tools),
// Converse has no native "none". Keep definitions stable for prompt
// caching and omit only the unsupported choice.
toolChoice,
}
})()
const system = lowerSystem(breakpoints, request.system)
const messages = yield* lowerMessages(request, breakpoints)
const messages = yield* lowerMessages(flattened.request, breakpoints)
if (breakpoints.dropped > 0) {
yield* Effect.logWarning(
`Bedrock Converse: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${BedrockCache.BEDROCK_BREAKPOINT_CAP} per request.`,
Expand Down
6 changes: 4 additions & 2 deletions packages/ai/src/protocols/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,8 @@ function mapSafetySettings(value: unknown) {

const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
const hasTools = request.tools.length > 0
const flattened = ProviderShared.flattenToolRequest(request)
const tools = flattened.tools
const generation = request.generation
const options = resolveOptions(request)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
Expand All @@ -483,15 +485,15 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque

return {
cachedContent: options.cachedContent,
contents: yield* lowerMessages(request),
contents: yield* lowerMessages(flattened.request),
safetySettings: options.safetySettings,
serviceTier: options.serviceTier,
systemInstruction:
request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] },
tools: hasTools
? [
{
functionDeclarations: request.tools.map((tool) =>
functionDeclarations: tools.map((tool) =>
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
),
},
Expand Down
6 changes: 4 additions & 2 deletions packages/ai/src/protocols/mistral-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,10 +414,12 @@ export const fromRequest = Effect.fn("MistralChat.fromRequest")(function* (reque
tool: (name) => ({ type: "function" as const, function: { name } }),
})
: undefined
const flattened = ProviderShared.flattenToolRequest(request)
const tools = flattened.tools
return {
model: request.model.id,
messages: yield* lowerMessages(request),
tools: request.tools.length > 0 ? request.tools.map(lowerTool) : undefined,
messages: yield* lowerMessages(flattened.request),
tools: tools.length > 0 ? tools.map(lowerTool) : undefined,
tool_choice: toolChoice,
stream: true as const,
max_tokens: request.generation?.maxTokens,
Expand Down
46 changes: 38 additions & 8 deletions packages/ai/src/protocols/open-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ export const InputItem = Schema.Union([
id: Schema.optionalKey(Schema.String),
call_id: Schema.String,
name: Schema.String,
namespace: Schema.optionalKey(Schema.UndefinedOr(Schema.String)),
arguments: Schema.String,
}),
Schema.Struct({
Expand Down Expand Up @@ -287,6 +288,7 @@ export const StreamItem = Schema.StructWithRest(
id: Schema.optional(Schema.String),
call_id: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
namespace: Schema.optional(Schema.String),
arguments: Schema.optional(Schema.String),
encrypted_content: optionalNull(Schema.String),
}),
Expand Down Expand Up @@ -376,6 +378,7 @@ export type Event = Schema.Schema.Type<typeof Event>
export interface ProviderAdapter {
readonly id: string
readonly name: string
readonly toolNamespaceHistory?: boolean
readonly lowerMedia?: (input: {
readonly part: MediaPart
readonly media: ProviderShared.NormalizedMedia
Expand Down Expand Up @@ -462,6 +465,7 @@ const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenRes
...(id === undefined ? {} : { id }),
call_id: part.id,
name: part.name,
namespace: part.namespace,
arguments: ProviderShared.encodeJson(part.input),
}
}
Expand Down Expand Up @@ -742,14 +746,18 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd
adapter: ProviderAdapter,
) {
const generation = request.generation
const flattened = adapter.toolNamespaceHistory === true ? undefined : ProviderShared.flattenToolRequest(request)
const tools =
flattened === undefined ? yield* ProviderShared.requireFlatTools(adapter.name, request.tools) : flattened.tools
const input = flattened?.request ?? request
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
return {
model: request.model.id,
input: yield* lowerMessages(request, adapter),
input: yield* lowerMessages(input, adapter),
tools:
request.tools.length === 0
tools.length === 0
? undefined
: yield* Effect.forEach(request.tools, (tool) =>
: yield* Effect.forEach(tools, (tool) =>
lowerTool(
adapter.name,
tool,
Expand Down Expand Up @@ -1020,11 +1028,20 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
tools: ToolStream.start(state.tools, id, {
id: item.call_id,
name: item.name ?? "",
namespace: item.namespace,
input: item.arguments ?? "",
providerMetadata: metadata,
}),
},
[...events, LLMEvent.toolInputStart({ id: item.call_id, name: item.name ?? "", providerMetadata: metadata })],
[
...events,
LLMEvent.toolInputStart({
id: item.call_id,
name: item.name ?? "",
namespace: item.namespace,
providerMetadata: metadata,
}),
],
]
}

Expand Down Expand Up @@ -1137,14 +1154,19 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
? fallback
: Object.keys(state.tools).find((key) => state.tools[key]?.id === callID)
const id = registered ?? fallback
const pending = registered === undefined ? undefined : state.tools[registered]
const tools =
registered !== undefined
? state.tools
: ToolStream.start(state.tools, id, {
pending === undefined
? ToolStream.start(state.tools, id, {
id: callID,
name: item.name,
namespace: item.namespace,
providerMetadata: metadata,
})
: ToolStream.start(state.tools, id, {
...pending,
namespace: pending.namespace ?? item.namespace,
})
const result =
item.arguments === undefined
? yield* ToolStream.finish(state.id, tools, id)
Expand All @@ -1155,7 +1177,15 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
const resultEvents =
registered !== undefined || finished.length === 0
? finished
: [LLMEvent.toolInputStart({ id: callID, name: item.name, providerMetadata: metadata }), ...finished]
: [
LLMEvent.toolInputStart({
id: callID,
name: item.name,
namespace: item.namespace,
providerMetadata: metadata,
}),
...finished,
]
const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
events.push(...resultEvents)
return [
Expand Down
6 changes: 4 additions & 2 deletions packages/ai/src/protocols/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,8 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
)
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const flattened = ProviderShared.flattenToolRequest(request)
const tools = flattened.tools
const provider = String(request.model.provider)
const baseURL = request.model.route.endpoint.baseURL
const detectedMaxTokensField = detectMaxTokensField(provider, baseURL)
Expand All @@ -751,13 +753,13 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
const hasActiveTools = request.tools.length > 0
return {
model: request.model.id,
messages: yield* lowerMessages(request, options),
messages: yield* lowerMessages(flattened.request, options),
tools:
request.tools.length === 0
? hasHistory
? []
: undefined
: request.tools.map((tool) =>
: tools.map((tool) =>
lowerTool(
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
Expand Down
Loading
Loading