diff --git a/packages/ai/AGENTS.md b/packages/ai/AGENTS.md index c07f3f77fa29..6d56c62a19e5 100644 --- a/packages/ai/AGENTS.md +++ b/packages/ai/AGENTS.md @@ -165,6 +165,10 @@ Native chronological system messages are route/model-specific. Open Responses lo The wrapped-user fallback preserves ordering while visibly lowering authority. Never silently pass a raw chronological `role: "system"` through a route that might reject it. Do not insert raw retrieved documents, tool output, or web content into privileged chronological system updates; keep untrusted content in ordinary user/tool channels. +### Effort Updates + +`Message.effort({ effort, previous })` is a chronological "reasoning effort changed here" marker (`undefined` means the model default). Changing a top-level effort invalidates the whole provider prompt cache, so protocols with a native per-message update (`Protocol.supportsEffortUpdates`) keep the top-level effort at the first marker's `previous` and lower each marker in place: Anthropic Messages emits an empty `role: "system"` message with `output_config.effort` plus the `mid-conversation-output-config-2026-07-01` beta, and OpenAI Responses emits `configuration_update` items. `applyEffortUpdates` runs in `prepareRequest` and strips the markers for every other route, so a protocol without support keeps today's plain top-level behaviour. When the last marker disagrees with the effort the request asks for (reverted or forked history), `resolveEffortUpdates` strips the markers and falls back to a plain top-level change. + ### Tools Tool loops are represented in common messages and events: diff --git a/packages/ai/src/cache-policy.ts b/packages/ai/src/cache-policy.ts index 6889ad0c2941..d2b2e9fdb83b 100644 --- a/packages/ai/src/cache-policy.ts +++ b/packages/ai/src/cache-policy.ts @@ -12,6 +12,7 @@ // 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, type ToolEntry } from "./schema/messages.js" +import { effortUpdate } from "./effort-updates.js" const AUTO: CachePolicyObject = { tools: true, @@ -121,9 +122,15 @@ const markMessages = ( return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint, budget) if (strategy === "latest-assistant") return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint, budget) - const start = Math.max(0, messages.length - strategy.tail) + let start = messages.length + let remaining = strategy.tail + while (remaining > 0 && start > 0) { + start -= 1 + if (effortUpdate(messages[start]!) === undefined) remaining -= 1 + } let next = messages - for (let i = start; i < messages.length; i++) next = markMessageAt(next, i, hint, budget) + for (let i = start; i < messages.length; i++) + if (effortUpdate(messages[i]!) === undefined) next = markMessageAt(next, i, hint, budget) return next } diff --git a/packages/ai/src/effort-updates.ts b/packages/ai/src/effort-updates.ts new file mode 100644 index 000000000000..933afab149e5 --- /dev/null +++ b/packages/ai/src/effort-updates.ts @@ -0,0 +1,27 @@ +// A top-level reasoning effort change invalidates the whole provider prompt cache, so a +// mid-conversation switch travels as a `Message.effort(...)` marker: protocols with a native +// per-message update freeze the top-level effort and lower the markers; every other route strips them. +import { LLMRequest, type EffortPart, type Message } from "./schema/messages.js" + +export const effortUpdate = (message: Message): EffortPart | undefined => { + if (message.role !== "system" || message.content.length !== 1) return undefined + const part = message.content[0] + return part.type === "effort" ? part : undefined +} + +export const stripEffortUpdates = (request: LLMRequest) => { + const messages = request.messages.filter((message) => effortUpdate(message) === undefined) + return messages.length === request.messages.length ? request : LLMRequest.update(request, { messages }) +} + +export const applyEffortUpdates = (request: LLMRequest): LLMRequest => + request.model.route.supportsEffortUpdates?.(request) ? request : stripEffortUpdates(request) + +// The markers must end at `current`: `revert.ts` never touches `session.model` and forks may select +// another variant, so on disagreement fall back to a plain top-level change instead of misreporting effort. +export const resolveEffortUpdates = (request: LLMRequest, current: string | undefined) => { + const updates = request.messages.flatMap((message) => effortUpdate(message) ?? []) + if (updates.length === 0) return { request, effort: current } + if (updates.at(-1)?.effort !== current) return { request: stripEffortUpdates(request), effort: current } + return { request, effort: updates[0]?.previous } +} diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts index 1c4c32da3b80..6a3e687a7e8f 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -27,6 +27,7 @@ import { } from "../schema/index.js" import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js" import { classifyProviderFailure } from "../provider-error.js" +import { effortUpdate, resolveEffortUpdates } from "../effort-updates.js" import * as Cache from "./utils/cache.js" import { Lifecycle } from "./utils/lifecycle.js" import { ToolSchemaProjection } from "./utils/tool-schema.js" @@ -36,6 +37,7 @@ const ADAPTER = "anthropic-messages" export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1" export const PATH = "/messages" export const DEFAULT_MAX_TOKENS = 32_000 +const DEFAULT_EFFORT = "high" const SSE_EVENTS = new Set([ "message", @@ -286,7 +288,11 @@ type AnthropicToolResultBlock = Schema.Schema.Type @@ -877,6 +883,12 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* ( for (const [index, message] of request.messages.entries()) { if (message.role === "system") { + const update = effortUpdate(message) + if (update) { + // Accepted at any position, so the text-update placement rules do not apply. + messages.push({ role: "system", content: [], output_config: { effort: update.effort ?? DEFAULT_EFFORT } }) + continue + } if (splitsLocalToolResults(request.messages, index)) return yield* invalid("Anthropic Messages system updates cannot split a local tool call from its tool result") if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request, index)) { @@ -1034,18 +1046,11 @@ const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* ( ProviderShared.isRecord(rawOutputConfig) && ProviderShared.isRecord(rawOutputConfig.format) ? (rawOutputConfig.format as { type: "json_schema"; schema: Record }) : undefined - const output_config = - outputConfigEffort === undefined && outputConfigFormat === undefined - ? undefined - : { - ...(outputConfigEffort === undefined ? {} : { effort: outputConfigEffort }), - ...(outputConfigFormat === undefined ? {} : { format: outputConfigFormat }), - } const thinking = yield* resolveThinking(input?.thinking) return { thinking: applyThinkingBindingDefault(request.model, thinking), effort: outputConfigEffort, - output_config, + format: outputConfigFormat, service_tier, metadata, container, @@ -1054,15 +1059,30 @@ const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* ( } }) +// Accept gateway namespaces and Vertex suffixes without treating a snapshot date as a minor version. +const claudeVersion = (id: string) => { + const match = /(?:^|[./])claude-(?[a-z]+)-(?\d+)(?:[.-](?\d{1,2}))?(?:$|[-:@])/.exec( + id.toLowerCase(), + )?.groups + if (!match) return undefined + return { family: match.family, major: Number(match.major), minor: Number(match.minor ?? 0) } +} + const supportsThinkingBlockBinding = (model: LLMRequest["model"]) => { const override = model.compatibility?.supportsThinkingBlockBinding if (override !== undefined) return override - // Accept gateway namespaces and Vertex suffixes without treating a snapshot date as a minor version. - const version = /(?:^|[./])claude-[a-z]+-(?\d+)(?:[.-](?\d{1,2}))?(?:$|[-:@])/i.exec(model.id)?.groups - if (!version) return false - const major = Number(version.major) - const minor = Number(version.minor ?? 0) - return major > 5 || (major === 5 && minor >= 1) + const version = claudeVersion(model.id) + return version !== undefined && (version.major > 5 || (version.major === 5 && version.minor >= 1)) +} + +const supportsEffortUpdates = (model: LLMRequest["model"]) => { + const override = model.compatibility?.supportsEffortUpdates + if (override !== undefined) return override + const version = claudeVersion(model.id) + if (version === undefined) return false + if (version.family === "opus") return version.major >= 5 + if (version.family !== "fable" && version.family !== "mythos") return false + return version.major > 5 || (version.major === 5 && version.minor >= 1) } const applyThinkingBindingDefault = (model: LLMRequest["model"], thinking: AnthropicThinking | undefined) => { @@ -1104,13 +1124,15 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques const management = yield* ProviderShared.validateWith( Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)), )(request.providerOptions?.contextManagement) + const options = yield* resolveOptions(request) + const updates = resolveEffortUpdates(request, options.effort) const generation = request.generation const toolSchemaCompatibility = request.model.compatibility?.toolSchema // Allocate the 4-breakpoint budget in invalidation order: tools → system → // 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 flattened = ProviderShared.flattenToolRequest(updates.request) const tools = flattened.tools.length === 0 ? undefined @@ -1138,7 +1160,13 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques `Anthropic Messages: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${ANTHROPIC_BREAKPOINT_CAP} per request.`, ) } - const options = yield* resolveOptions(request) + const output_config = + updates.effort === undefined && options.format === undefined + ? undefined + : { + ...(updates.effort === undefined ? {} : { effort: updates.effort }), + ...(options.format === undefined ? {} : { format: options.format }), + } const body = { model: request.model.id, system, @@ -1152,7 +1180,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques top_k: generation?.topK, stop_sequences: generation?.stop, thinking: options.thinking, - output_config: options.output_config, + output_config, // top-level passthrough per SDK MessageCreateParamsBase:4638,4643,4649,4654,4670 cache_control: options.cache_control, container: options.container, @@ -1677,6 +1705,7 @@ export const protocol = Protocol.make({ }), step, }, + supportsEffortUpdates: (request) => supportsEffortUpdates(request.model), }) export const transport = < @@ -1715,6 +1744,9 @@ function requiredBetaHeaders(body: Pick message.role === "system" && message.output_config !== undefined)) + betas.push("mid-conversation-output-config-2026-07-01") + const thinking = body.thinking if (thinking && thinking.type !== "disabled" && thinking.block_binding) betas.push("thinking-binding-controls-2026-08-01") diff --git a/packages/ai/src/protocols/open-responses.ts b/packages/ai/src/protocols/open-responses.ts index c1ff98ba2de1..55c634432b0d 100644 --- a/packages/ai/src/protocols/open-responses.ts +++ b/packages/ai/src/protocols/open-responses.ts @@ -20,6 +20,7 @@ import { } from "../schema/index.js" import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js" import { classifyProviderFailure } from "../provider-error.js" +import { effortUpdate } from "../effort-updates.js" import { OpenResponsesOptions } from "./utils/open-responses-options.js" import { Lifecycle } from "./utils/lifecycle.js" import { ToolSchemaProjection } from "./utils/tool-schema.js" @@ -164,6 +165,13 @@ export const CompactionItem = Schema.Struct({ encrypted_content: Schema.String, }) +// Kept out of the baseline `InputItem` union: only the OpenAI extension accepts it. +export const ConfigurationUpdate = Schema.Struct({ + type: Schema.Literal("configuration_update"), + reasoning: Schema.Struct({ effort: OpenResponsesOptions.ReasoningEffort }), +}) +type ConfigurationUpdate = Schema.Schema.Type + export const InputItem = Schema.Union([ CompactionItem, Schema.Struct({ role: Schema.tag("system"), content: Schema.String }), @@ -208,6 +216,7 @@ export type HostedToolReplayItem = { type LoweredInputItem = | OpenResponsesInputItem | HostedToolReplayItem + | ConfigurationUpdate | { readonly type: "message" readonly id?: string @@ -634,6 +643,8 @@ const lowerToolResultOutput = Effect.fnUntraced(function* ( return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, adapter)) }) +const DEFAULT_EFFORT = "medium" + const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* ( request: LLMRequest, adapter: ProviderAdapter, @@ -646,6 +657,14 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* ( Schema.decodeUnknownEffect(Schema.UndefinedOr(MessageMetadata)), )(message.providerMetadata?.[providerMetadataKey]) if (message.role === "system") { + const update = effortUpdate(message) + if (update) { + // Consecutive updates are rejected, so a newer one replaces its predecessor. + const last = input.at(-1) + if (last !== undefined && "type" in last && last.type === "configuration_update") input.pop() + input.push({ type: "configuration_update", reasoning: { effort: update.effort ?? DEFAULT_EFFORT } }) + continue + } input.push({ role: "developer", content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(adapter.name, message)), @@ -789,8 +808,7 @@ export const lowerConversation = Effect.fn("OpenResponses.lowerConversation")(fu } }) -export const lowerGeneration = (request: LLMRequest) => { - const options = OpenResponsesOptions.resolve(request) +export const lowerGeneration = (request: LLMRequest, options = OpenResponsesOptions.resolve(request)) => { const generation = request.generation const cacheKey = ProviderShared.promptCacheKey(request) const parallelToolCalls = resolveParallelToolCalls(request) diff --git a/packages/ai/src/protocols/openai-responses.ts b/packages/ai/src/protocols/openai-responses.ts index d848cde2fc00..910e0e74fd70 100644 --- a/packages/ai/src/protocols/openai-responses.ts +++ b/packages/ai/src/protocols/openai-responses.ts @@ -6,7 +6,9 @@ import { Endpoint } from "../route/endpoint.js" import { Protocol } from "../route/protocol.js" import { HttpTransport } from "../route/transport/index.js" import { LLMRequest, mergeJsonRecords, type JsonSchema, type ToolDefinition, type ToolEntry } from "../schema/index.js" +import { resolveEffortUpdates } from "../effort-updates.js" import { OpenResponses } from "./open-responses.js" +import { OpenResponsesOptions } from "./utils/open-responses-options.js" import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js" import { OpenAIImage } from "./utils/openai-image.js" import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js" @@ -94,9 +96,15 @@ const OpenAIResponsesToolChoice = Schema.Union([ Schema.Struct({ type: Schema.tag("image_generation") }), ]) +const OpenAIResponsesInputItem = Schema.Union([ + OpenResponses.InputItem, + OpenAIResponsesHostedToolItem, + OpenResponses.ConfigurationUpdate, +]) + const OpenAIResponsesCoreFields = { ...OpenResponses.coreFields, - input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem])), + input: Schema.Array(OpenAIResponsesInputItem), tools: optionalArray(OpenAIResponsesTools), tool_choice: Schema.optional(OpenAIResponsesToolChoice), context_management: Schema.optional( @@ -119,7 +127,7 @@ export type OpenAIResponsesBody = Schema.Schema.Type export const CompactionTrigger = Schema.Struct({ type: Schema.Literal("compaction_trigger") }) const CheckpointBody = Schema.Struct({ ...OpenAIResponsesBody.fields, - input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem, CompactionTrigger])), + input: Schema.Array(Schema.Union([OpenAIResponsesInputItem, CompactionTrigger])), store: Schema.Literal(false), prompt_cache_retention: optionalNull(Schema.String), prompt_cache_options: optionalNull( @@ -133,6 +141,14 @@ const adapter = { restoreHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined), } satisfies OpenResponses.ProviderAdapter +// Only GPT-6 Astra accepts `configuration_update`, and never alongside automatic `context_management` compaction. +const supportsEffortUpdates = (request: LLMRequest) => { + if (request.providerOptions?.contextManagement !== undefined) return false + const override = request.model.compatibility?.supportsEffortUpdates + if (override !== undefined) return override + return /(?:^|\/)gpt-6-astra$/i.test(request.model.id) +} + const nativeImageToolInput = (tool: ToolDefinition) => { const native = tool.native?.openai return ProviderShared.isRecord(native) && native.type === "image_generation" ? native : undefined @@ -189,10 +205,12 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: const management = yield* ProviderShared.validateWith( Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)), )(request.providerOptions?.contextManagement) + const options = OpenResponsesOptions.resolve(request) + const updates = resolveEffortUpdates(request, options.reasoningEffort) const toolSchemaCompatibility = request.model.compatibility?.toolSchema return yield* decodeBody({ - ...(yield* OpenResponses.lowerConversation(request, adapter)), - ...OpenResponses.lowerGeneration(request), + ...(yield* OpenResponses.lowerConversation(updates.request, adapter)), + ...OpenResponses.lowerGeneration(request, { ...options, reasoningEffort: updates.effort }), context_management: management?.map((edit) => ({ type: edit.type, compact_threshold: edit.compactThreshold })), tools: request.tools.length === 0 @@ -295,6 +313,7 @@ export const protocol = Protocol.make({ step, terminal: OpenResponses.terminal, }, + supportsEffortUpdates, }) const endpoint = Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }) diff --git a/packages/ai/src/protocols/utils/open-responses-options.ts b/packages/ai/src/protocols/utils/open-responses-options.ts index 3c85adb9121f..e3ac2886efab 100644 --- a/packages/ai/src/protocols/utils/open-responses-options.ts +++ b/packages/ai/src/protocols/utils/open-responses-options.ts @@ -1,12 +1,7 @@ import { Option, Schema } from "effect" -import type { LLMRequest } from "../../schema/index.js" +import { ReasoningEffort, ReasoningEfforts, type LLMRequest } from "../../schema/index.js" -export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const -export type ReasoningEffort = (typeof ReasoningEfforts)[number] | (string & {}) -export const ReasoningEffort = Schema.declare( - (value): value is ReasoningEffort => typeof value === "string", - { title: "ReasoningEffort" }, -) +export { ReasoningEffort, ReasoningEfforts } export const TextVerbosities = ["low", "medium", "high"] as const export type TextVerbosity = (typeof TextVerbosities)[number] | (string & {}) diff --git a/packages/ai/src/protocols/utils/responses-compaction.ts b/packages/ai/src/protocols/utils/responses-compaction.ts index e60fe1c1ca16..8dba3efbe6b3 100644 --- a/packages/ai/src/protocols/utils/responses-compaction.ts +++ b/packages/ai/src/protocols/utils/responses-compaction.ts @@ -11,6 +11,7 @@ import { mergeJsonRecords, } from "../../schema/index.js" import type { CompactOperation } from "../../route/client.js" +import { stripEffortUpdates } from "../../effort-updates.js" import { Endpoint } from "../../route/endpoint.js" import { RequestExecutor } from "../../route/executor.js" import { HttpTransport } from "../../route/transport/index.js" @@ -75,7 +76,8 @@ const Response = Schema.Struct({ export const make = (adapter: OpenResponses.ProviderAdapter): CompactOperation => Effect.fn("ResponsesCompaction.execute")(function* (request, executor, options) { const route = request.model.route - const native = yield* OpenResponses.lowerConversation(request, adapter) + // The standalone compaction endpoint rejects histories containing configuration updates. + const native = yield* OpenResponses.lowerConversation(stripEffortUpdates(request), adapter) const body = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Body))( mergeJsonRecords( { diff --git a/packages/ai/src/route/client.ts b/packages/ai/src/route/client.ts index f86b6e097c6c..b7592d0f6eb3 100644 --- a/packages/ai/src/route/client.ts +++ b/packages/ai/src/route/client.ts @@ -7,6 +7,7 @@ import { HttpTransport } from "./transport/index.js" import type { HttpMiddleware, Transport, TransportRuntime, WebSocketChannelExecutor } from "./transport/index.js" import type { Protocol } from "./protocol.js" import { applyCachePolicy } from "../cache-policy.js" +import { applyEffortUpdates } from "../effort-updates.js" import { normalizeToolHistory } from "../tool-history.js" import { sanitizeSurrogates } from "../utils/sanitize.js" import * as ProviderShared from "../protocols/shared.js" @@ -55,6 +56,7 @@ export interface Route< readonly transport: Transport readonly defaults: RouteDefaults readonly body: RouteBody + readonly supportsEffortUpdates?: (request: LLMRequest) => boolean readonly with: { ( patch: RoutePatch & { readonly compact: Next }, @@ -388,6 +390,7 @@ function makeFromTransport( transport: routeInput.transport, defaults: routeInput.defaults ?? {}, body: protocol.body, + supportsEffortUpdates: protocol.supportsEffortUpdates, with: (patch: RoutePatch) => { const { compact, id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch return build({ @@ -558,7 +561,9 @@ const prepareRequest = (request: LLMRequest) => { [...new Map(tools.map((tool) => [`${tool.type}:${tool.name}`, tool])).values()].map((tool) => tool.type === "tool" ? tool : { ...tool, tools: dedupe(tool.tools) }, ) - const resolved = applyCachePolicy(LLMRequest.update(sanitized, { tools: dedupe(sanitized.tools) })) + const resolved = applyCachePolicy( + applyEffortUpdates(LLMRequest.update(sanitized, { tools: dedupe(sanitized.tools) })), + ) const headers = resolved.model.route.headers?.({ request: resolved }) return headers === undefined ? resolved diff --git a/packages/ai/src/route/protocol.ts b/packages/ai/src/route/protocol.ts index f3a844ec351c..329c96923e63 100644 --- a/packages/ai/src/route/protocol.ts +++ b/packages/ai/src/route/protocol.ts @@ -41,6 +41,8 @@ export interface Protocol { readonly body: ProtocolBody /** Response side: streaming state machine. */ readonly stream: ProtocolStream + /** Whether `body.from` lowers `Message.effort(...)` markers; wrappers around another `body.from` must forward it. */ + readonly supportsEffortUpdates?: (request: LLMRequest) => boolean } export interface ProtocolBody { diff --git a/packages/ai/src/schema/messages.ts b/packages/ai/src/schema/messages.ts index bf3aa6256c39..6f79fd81a4a9 100644 --- a/packages/ai/src/schema/messages.ts +++ b/packages/ai/src/schema/messages.ts @@ -9,6 +9,7 @@ import { LanguageModelSchema, type LanguageModel, ProviderOptions, + ReasoningEffort, } from "./options.js" import { ProviderID } from "./ids.js" @@ -217,6 +218,14 @@ export const CompactionPart = Object.assign(compactionPartSchema, { Schema.decodeUnknownSync(compactionPartSchema)({ type: "compaction", ...input }), }) +/** Reasoning effort changed here, from `previous` to `effort`; `undefined` is the model default. */ +export const EffortPart = Schema.Struct({ + type: Schema.Literal("effort"), + effort: Schema.optional(ReasoningEffort), + previous: Schema.optional(ReasoningEffort), +}).annotate({ identifier: "LLM.Content.Effort" }) +export type EffortPart = Schema.Schema.Type + export const ContentPart = Schema.Union([ TextPart, MediaPart, @@ -224,6 +233,7 @@ export const ContentPart = Schema.Union([ ToolResultPart, ReasoningPart, CompactionPart, + EffortPart, ]).pipe(Schema.toTaggedUnion("type")) export type ContentPart = Schema.Schema.Type @@ -265,6 +275,9 @@ export namespace Message { */ export const system = (content: SystemContentInput) => make({ role: "system", content }) + export const effort = (input: { readonly effort?: ReasoningEffort; readonly previous?: ReasoningEffort }) => + make({ role: "system", content: [{ type: "effort", effort: input.effort, previous: input.previous }] }) + export const tool = (result: ToolResultPart | Parameters[0]) => make({ role: "tool", content: ["type" in result ? result : ToolResultPart.make(result)] }) } diff --git a/packages/ai/src/schema/options.ts b/packages/ai/src/schema/options.ts index 27a16fbe3c83..43ce5ac5060a 100644 --- a/packages/ai/src/schema/options.ts +++ b/packages/ai/src/schema/options.ts @@ -140,6 +140,14 @@ export namespace LanguageModelDefaults { } } +/** Ordered lowest to highest. */ +export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const +export type ReasoningEffort = (typeof ReasoningEfforts)[number] | (string & {}) +export const ReasoningEffort = Schema.declare( + (value): value is ReasoningEffort => typeof value === "string", + { title: "ReasoningEffort" }, +) + export const LanguageModelToolSchemaCompatibility = Schema.Literals(["gemini", "moonshot"]) export type LanguageModelToolSchemaCompatibility = Schema.Schema.Type @@ -165,6 +173,8 @@ export class LanguageModelCompatibility extends Schema.Class + AnthropicMessages.route + .with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") }) + .model({ id, compatibility }) + +const openai = (id: string, compatibility?: { readonly supportsEffortUpdates?: boolean }) => + OpenAIResponses.route + .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) + .model({ id, compatibility }) + +const opus5 = anthropic("claude-opus-5") +const astra = openai("gpt-6-astra") + +const lowFromHigh = Message.effort({ effort: "low", previous: "high" }) +const conversation = [Message.user("Before."), lowFromHigh, Message.user("After.")] + +const systemMessages = (body: AnthropicMessages.AnthropicMessagesBody) => + body.messages.filter((message) => message.role === "system") + +const updates = (body: OpenAIResponses.OpenAIResponsesBody) => + body.input.filter((item) => "type" in item && item.type === "configuration_update") + +describe("applyEffortUpdates", () => { + test("keeps the request identity without markers and for protocols that lower them", () => { + const plain = LLM.request({ model: opus5, prompt: "hi" }) + expect(applyEffortUpdates(plain)).toBe(plain) + + const supported = LLM.request({ model: opus5, messages: conversation, providerOptions: { effort: "low" } }) + expect(applyEffortUpdates(supported)).toBe(supported) + }) + + it.effect("compiles markers away for protocols without per-message effort", () => + Effect.gen(function* () { + const model = Gemini.route + .with({ + endpoint: { baseURL: "https://generativelanguage.test/v1beta/" }, + auth: Auth.header("x-goog-api-key", "test"), + }) + .model({ id: "gemini-3.5-flash" }) + const withMarkers = yield* compileRequest(LLM.request({ model, messages: conversation })) + const withoutMarkers = yield* compileRequest( + LLM.request({ model, messages: [Message.user("Before."), Message.user("After.")] }), + ) + + expect(withMarkers.body).toEqual(withoutMarkers.body) + }), + ) +}) + +describe("cache policy", () => { + it.effect("walks the tail breakpoint back past a trailing effort marker", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model: opus5, + messages: [Message.user("first"), Message.assistant("reply"), Message.user("latest"), lowFromHigh], + providerOptions: { effort: "low" }, + cache: "auto", + }), + ) + + expect(prepared.body.messages).toEqual([ + { role: "user", content: [{ type: "text", text: "first" }] }, + { role: "assistant", content: [{ type: "text", text: "reply" }] }, + { role: "user", content: [{ type: "text", text: "latest", cache_control: { type: "ephemeral" } }] }, + { role: "system", content: [], output_config: { effort: "low" } }, + ]) + }), + ) +}) + +describe("Anthropic Messages effort updates", () => { + it.effect("lowers markers to per-turn system messages and freezes the top-level effort", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ model: opus5, messages: conversation, providerOptions: { effort: "low" }, cache: "none" }), + ) + + expect(prepared.body.output_config).toEqual({ effort: "high" }) + expect(prepared.body.messages).toEqual([ + { role: "user", content: [{ type: "text", text: "Before." }] }, + { role: "system", content: [], output_config: { effort: "low" } }, + { role: "user", content: [{ type: "text", text: "After." }] }, + ]) + }), + ) + + it.effect("omits the frozen effort for the model default and sends `high` for a switch back to it", () => + Effect.gen(function* () { + const format = { type: "json_schema" as const, schema: { type: "object" } } + const prepared = yield* compileRequest( + LLM.request({ + model: opus5, + messages: [ + Message.user("One."), + Message.effort({ effort: "low" }), + Message.user("Two."), + Message.effort({ previous: "low" }), + Message.user("Three."), + ], + providerOptions: { output_config: { format } }, + cache: "none", + }), + ) + + expect(prepared.body.output_config).toEqual({ format }) + expect(systemMessages(prepared.body)).toEqual([ + { role: "system", content: [], output_config: { effort: "low" } }, + { role: "system", content: [], output_config: { effort: "high" } }, + ]) + }), + ) + + it.effect("requests the mid-conversation output config beta only when markers are sent", () => + Effect.gen(function* () { + for (const [model, betas] of [ + [opus5, "existing-beta,mid-conversation-output-config-2026-07-01"], + [anthropic("claude-sonnet-5"), "existing-beta"], + ] as const) { + const request = LLM.request({ + model, + messages: conversation, + providerOptions: { effort: "low" }, + http: { headers: { "anthropic-beta": "existing-beta" } }, + }) + const compiled = yield* compileRequest(request) + const prepared = yield* AnthropicMessages.route.prepareTransport(compiled.body, request) + expect(prepared.request.headers["anthropic-beta"]).toBe(betas) + } + }), + ) + + it.effect("accepts a marker between a tool call and its result", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model: opus5, + messages: [ + Message.user("Weather?"), + Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]), + lowFromHigh, + Message.tool({ id: "call_1", name: "lookup", result: { temp: 72 } }), + ], + providerOptions: { effort: "low" }, + cache: "none", + }), + ) + + expect(prepared.body.messages).toEqual([ + { role: "user", content: [{ type: "text", text: "Weather?" }] }, + { role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }] }, + { role: "system", content: [], output_config: { effort: "low" } }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '{"temp":72}' }] }, + ]) + }), + ) + + it.effect("falls back to a plain top-level effort when history drifted from the current effort", () => + Effect.gen(function* () { + const drifted = yield* compileRequest( + LLM.request({ model: opus5, messages: conversation, providerOptions: { effort: "medium" }, cache: "none" }), + ) + const plain = yield* compileRequest( + LLM.request({ + model: opus5, + messages: [Message.user("Before."), Message.user("After.")], + providerOptions: { effort: "medium" }, + cache: "none", + }), + ) + + expect(drifted.body).toEqual(plain.body) + expect(drifted.body.output_config).toEqual({ effort: "medium" }) + }), + ) + + for (const [id, supported] of [ + ["claude-opus-5", true], + ["claude-opus-5-20260901", true], + ["anthropic/claude-opus-5", true], + ["claude-fable-5-1", true], + ["claude-mythos-5-1", true], + ["claude-fable-5", false], + ["claude-opus-4-8", false], + ["claude-sonnet-5", false], + ["kimi-k2.5", false], + ] as const) { + it.effect(`${supported ? "lowers" : "strips"} markers for ${id}`, () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ model: anthropic(id), messages: conversation, providerOptions: { effort: "low" } }), + ) + + expect(systemMessages(prepared.body)).toHaveLength(supported ? 1 : 0) + expect(prepared.body.output_config).toEqual({ effort: supported ? "high" : "low" }) + }), + ) + } + + it.effect("honors the compatibility override in both directions", () => + Effect.gen(function* () { + const enabled = yield* compileRequest( + LLM.request({ + model: anthropic("claude-sonnet-5", { supportsEffortUpdates: true }), + messages: conversation, + providerOptions: { effort: "low" }, + }), + ) + const disabled = yield* compileRequest( + LLM.request({ + model: anthropic("claude-opus-5", { supportsEffortUpdates: false }), + messages: conversation, + providerOptions: { effort: "low" }, + }), + ) + + expect(systemMessages(enabled.body)).toHaveLength(1) + expect(systemMessages(disabled.body)).toHaveLength(0) + }), + ) + + it.effect("strips markers on the Vertex Anthropic route, whose protocol wrapper does not forward support", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model: GoogleVertexMessages.configure({ accessToken: "test", location: "global", project: "test" }).model( + "claude-opus-5", + ), + messages: conversation, + providerOptions: { effort: "low" }, + }), + ) + + expect(systemMessages(prepared.body)).toHaveLength(0) + expect(prepared.body.output_config).toEqual({ effort: "low" }) + }), + ) +}) + +describe("OpenAI Responses effort updates", () => { + it.effect("lowers markers to configuration_update items and freezes reasoning.effort", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ model: astra, messages: conversation, providerOptions: { reasoningEffort: "low" } }), + ) + + expect(prepared.body.reasoning).toEqual({ effort: "high" }) + expect(prepared.body.input).toEqual([ + { role: "user", content: [{ type: "input_text", text: "Before." }] }, + { type: "configuration_update", reasoning: { effort: "low" } }, + { role: "user", content: [{ type: "input_text", text: "After." }] }, + ]) + }), + ) + + it.effect("coalesces consecutive updates so the newest wins", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model: astra, + messages: [ + Message.user("Before."), + Message.effort({ effort: "low", previous: "medium" }), + Message.effort({ effort: "xhigh", previous: "low" }), + Message.user("After."), + ], + providerOptions: { reasoningEffort: "xhigh" }, + }), + ) + + expect(prepared.body.reasoning).toEqual({ effort: "medium" }) + expect(prepared.body.input).toEqual([ + { role: "user", content: [{ type: "input_text", text: "Before." }] }, + { type: "configuration_update", reasoning: { effort: "xhigh" } }, + { role: "user", content: [{ type: "input_text", text: "After." }] }, + ]) + }), + ) + + it.effect("omits reasoning.effort for the model default and sends `medium` for a switch back to it", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model: astra, + messages: [ + Message.user("One."), + Message.effort({ effort: "low" }), + Message.user("Two."), + Message.effort({ previous: "low" }), + Message.user("Three."), + ], + }), + ) + + expect(prepared.body.reasoning).toBeUndefined() + expect(updates(prepared.body)).toEqual([ + { type: "configuration_update", reasoning: { effort: "low" } }, + { type: "configuration_update", reasoning: { effort: "medium" } }, + ]) + }), + ) + + it.effect("falls back to a plain top-level effort when history drifted from the current effort", () => + Effect.gen(function* () { + const drifted = yield* compileRequest( + LLM.request({ model: astra, messages: conversation, providerOptions: { reasoningEffort: "xhigh" } }), + ) + const plain = yield* compileRequest( + LLM.request({ + model: astra, + messages: [Message.user("Before."), Message.user("After.")], + providerOptions: { reasoningEffort: "xhigh" }, + }), + ) + + expect(drifted.body).toEqual(plain.body) + expect(drifted.body.reasoning).toEqual({ effort: "xhigh" }) + }), + ) + + it.effect("strips markers when automatic context management is enabled", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model: astra, + messages: conversation, + providerOptions: { reasoningEffort: "low", contextManagement: [{ type: "compaction" }] }, + }), + ) + + expect(updates(prepared.body)).toEqual([]) + expect(prepared.body.reasoning).toEqual({ effort: "low" }) + expect(prepared.body.context_management).toEqual([{ type: "compaction" }]) + }), + ) + + for (const [id, supported] of [ + ["gpt-6-astra", true], + ["openai/gpt-6-astra", true], + ["gpt-6-astra-2026-09-01", false], + ["gpt-5.6-sol", false], + ] as const) { + it.effect(`${supported ? "lowers" : "strips"} markers for ${id}`, () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ model: openai(id), messages: conversation, providerOptions: { reasoningEffort: "low" } }), + ) + + expect(updates(prepared.body)).toHaveLength(supported ? 1 : 0) + expect(prepared.body.reasoning).toEqual({ effort: supported ? "high" : "low" }) + }), + ) + } + + it.effect("honors the compatibility override in both directions", () => + Effect.gen(function* () { + const enabled = yield* compileRequest( + LLM.request({ + model: openai("gpt-5.5", { supportsEffortUpdates: true }), + messages: conversation, + providerOptions: { reasoningEffort: "low" }, + }), + ) + const disabled = yield* compileRequest( + LLM.request({ + model: openai("gpt-6-astra", { supportsEffortUpdates: false }), + messages: conversation, + providerOptions: { reasoningEffort: "low" }, + }), + ) + + expect(updates(enabled.body)).toHaveLength(1) + expect(updates(disabled.body)).toHaveLength(0) + }), + ) + + const checkpoint = { type: "compaction", id: "cmp_1", encrypted_content: "opaque" } + const compactRequest = LLM.request({ + model: OpenAI.configure({ apiKey: "fixture" }).responses("gpt-6-astra"), + messages: conversation, + providerOptions: { reasoningEffort: "low" }, + }) + + testEffect( + dynamicResponse(({ text, respond }) => + Effect.sync(() => { + const body = JSON.parse(text) + expect(body.reasoning).toEqual({ effort: "high" }) + expect(body.input).toEqual([ + { role: "user", content: [{ type: "input_text", text: "Before." }] }, + { type: "configuration_update", reasoning: { effort: "low" } }, + { role: "user", content: [{ type: "input_text", text: "After." }] }, + { type: "compaction_trigger" }, + ]) + return respond(sseEvents({ type: "response.completed", response: { id: "resp_1", output: [checkpoint] } }), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ).effect("keeps configuration updates in the checkpoint body", () => + Effect.gen(function* () { + const result = yield* LLMClient.compact(compactRequest, { mechanism: "trigger" }) + expect(result.checkpoint.encrypted).toBe("opaque") + }), + ) + + testEffect( + dynamicResponse(({ request, text, respond }) => + Effect.sync(() => { + expect(new URL(request.url).pathname).toEndWith("/responses/compact") + expect(JSON.parse(text).input).toEqual([ + { role: "user", content: [{ type: "input_text", text: "Before." }] }, + { role: "user", content: [{ type: "input_text", text: "After." }] }, + ]) + return respond(JSON.stringify({ object: "response.compaction", output: [checkpoint] })) + }), + ), + ).effect("drops markers from the compaction endpoint body", () => + Effect.gen(function* () { + const result = yield* LLMClient.compact(compactRequest, { mechanism: "endpoint" }) + expect(result.replacement.map((message) => message.content[0]?.type)).toEqual(["compaction"]) + }), + ) +}) diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 089ade21cadf..28e2cfef8fa2 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -547,6 +547,14 @@ function assistantPart(part: ContentPart): AssistantContent { ] case "tool-result": return toolResultPart(part) + case "effort": + throw ProviderShared.unsupportedContent("AI SDK", "assistant", [ + "text", + "media", + "reasoning", + "tool-call", + "tool-result", + ]) } } diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 848ed8e9851f..57620a14a95b 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -196,6 +196,7 @@ const estimateMedia = (mime: string) => { const estimatePart = (part: ContentPart): number => { // Encrypted checkpoints have no locally measurable token size. if (part.type === "compaction") return Token.estimate(part.text ?? "") + if (part.type === "effort") return 0 if (part.type === "text" || part.type === "reasoning") return Token.estimate(part.text) if (part.type === "media") return estimateMedia(part.mediaType) if (part.type === "tool-call") return Token.estimate(part.name + (JSON.stringify(part.input) ?? "")) diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index 37840abc0c04..b95e3c48cdeb 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -1,4 +1,11 @@ -import { Message, ToolCallPart, ToolResultPart, type ContentPart, type ProviderMetadata } from "@opencode/ai" +import { + Message, + ReasoningEfforts, + ToolCallPart, + ToolResultPart, + type ContentPart, + type ProviderMetadata, +} from "@opencode/ai" import type { Model } from "@opencode/schema/model" import { Option, Schema } from "effect" import { fileURLToPath } from "url" @@ -222,11 +229,31 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider ] } +const EFFORT_VARIANTS = new Set(ReasoningEfforts) + +// Budget and toggle variants reuse these names; the protocol's drift check catches ones that are not effort options. +const variantEffort = (variant: Model.VariantID | undefined) => { + if (variant === undefined || variant === "default") return { effort: undefined } + return EFFORT_VARIANTS.has(variant) ? { effort: variant } : undefined +} + +const modelSwitched = (message: SessionMessage.ModelSelected, model: Model.Ref): Message[] => { + const previous = message.previous + if (previous === undefined) return [] + const same = (ref: Model.Ref) => ref.providerID === model.providerID && ref.id === model.id + if (!same(message.model) || !same(previous)) return [] + const to = variantEffort(message.model.variant) + const from = variantEffort(previous.variant) + if (to === undefined || from === undefined) return [] + return [Message.effort({ effort: to.effort, previous: from.effort })] +} + function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMetadataKey: string): Message[] { switch (message.type) { case "agent-switched": - case "model-switched": return [] + case "model-switched": + return modelSwitched(message, model) case "location-switched": return [ Message.make({ diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 2f51aa49a616..907bbf978c54 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -202,6 +202,44 @@ Recent work ]) }) + describe("model-switched", () => { + const ref = (variant?: string) => + Model.Ref.make({ + id: Model.ID.make("model"), + providerID: Provider.ID.make("provider"), + ...(variant === undefined ? {} : { variant: Model.VariantID.make(variant) }), + }) + const switched = (to: Model.Ref, previous?: Model.Ref) => + SessionMessage.ModelSelected.make({ + id: id("model"), + type: "model-switched", + model: to, + previous, + time: { created }, + }) + + test("records a same-model effort switch as an effort update", () => { + expect(toLLMMessages([switched(ref("low"), ref("high"))], ref("low"))).toEqual([ + Message.effort({ effort: "low", previous: "high" }), + ]) + }) + + test("maps the default variant and no variant to the model default effort", () => { + expect(toLLMMessages([switched(ref("low"), ref())], ref("low"))).toEqual([Message.effort({ effort: "low" })]) + expect(toLLMMessages([switched(ref("default"), ref("max"))], ref())).toEqual([ + Message.effort({ previous: "max" }), + ]) + }) + + test("ignores switches that are not effort changes on the requested model", () => { + const other = Model.Ref.make({ id: Model.ID.make("other"), providerID: Provider.ID.make("provider") }) + expect(toLLMMessages([switched(ref("low"))], ref("low"))).toEqual([]) + expect(toLLMMessages([switched(ref("low"), other)], ref("low"))).toEqual([]) + expect(toLLMMessages([switched(ref("thinking"), ref("high"))], ref("thinking"))).toEqual([]) + expect(toLLMMessages([switched(ref("low"), ref("high"))], other)).toEqual([]) + }) + }) + test("lowers text attachments after the prompt in one user message", () => { const file = FileAttachment.make({ data: Base64.make(Buffer.from("export const value = 1").toString("base64")), diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 69608204ac65..866dc3482b2c 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -1996,6 +1996,39 @@ describe("SessionRunnerLLM", () => { yield* s.runPrompt("Fourth") }) + scenario("records a same-model effort switch as a cache-preserving effort update", function* (s) { + s.currentModel = LanguageModel.make({ id: "claude-opus-5", provider: "anthropic", route: AnthropicMessages.route }) + const model = { id: ID.make("claude-opus-5"), providerID: Provider.ID.make("anthropic") } + yield* s.bus.publish(SessionEvent.ModelSelected, { + sessionID, + model: { ...model, variant: Model.VariantID.make("high") }, + }) + yield* s.llm.push(TestLLM.text("Earlier answer", "text-effort-high")) + yield* s.runPrompt("First") + yield* s.bus.publish(SessionEvent.ModelSelected, { + sessionID, + model: { ...model, variant: Model.VariantID.make("low") }, + }) + // The selected variant reaches the request as the provider effort option. + s.currentModel = LanguageModel.update(s.currentModel, { defaults: { providerOptions: { effort: "low" } } }) + yield* s.llm.push(TestLLM.text("Later answer", "text-effort-low")) + yield* s.runPrompt("Second") + + expect(messageRoles(s.requests[1])).toEqual(["user", "assistant", "system", "user"]) + expect(s.requests[1]?.messages[2]).toEqual(Message.effort({ effort: "low", previous: "high" })) + + const compiled = yield* compileRequest(s.requests[1]!) + expect(compiled.body).toMatchObject({ + output_config: { effort: "high" }, + messages: [ + { role: "user" }, + { role: "assistant" }, + { role: "system", content: [], output_config: { effort: "low" } }, + { role: "user" }, + ], + }) + }) + scenario("preserves instruction values while a source is temporarily unavailable", function* (s) { yield* s.runPrompt("First") yield* s.bus.publish(SessionEvent.ModelSelected, {