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
15 changes: 14 additions & 1 deletion packages/core/src/session/runner/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ import { Snapshot } from "../../snapshot"
import { makeLocationNode } from "../../effect/app-node"
import { llmClient } from "../../effect/app-node-platform"

const GPT56_PROMPT_CACHE_OPTIONS = { mode: "implicit", ttl: "30m" } as const

const promptCacheOptions = (model: { readonly provider: string; readonly id: string }) =>
model.provider === "openai" && /(?:^|[/.])gpt-5\.6(?:$|[-_/.])/.test(model.id.toLowerCase())
? GPT56_PROMPT_CACHE_OPTIONS
: undefined

/**
* Runs one durable coding-agent Session until it settles.
*
Expand Down Expand Up @@ -202,9 +209,15 @@ const layer = Layer.effect(
const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps
const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agent.info?.permissions)
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
const promptCache = promptCacheOptions(model)
const request = LLM.request({
model,
providerOptions: { openai: { promptCacheKey } },
providerOptions: {
openai: {
promptCacheKey,
...(promptCache ? { promptCacheOptions: promptCache } : {}),
},
},
system: [agent.info?.system, system.baseline]
.filter((part): part is string => part !== undefined && part.length > 0)
.map(SystemPart.make),
Expand Down
32 changes: 32 additions & 0 deletions packages/core/test/session-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2484,6 +2484,38 @@ describe("SessionRunnerLLM", () => {
}),
)

it.effect("adds GPT-5.6 family prompt cache options for OpenAI models", () =>
Effect.gen(function* () {
yield* setup
currentModel = Model.make({ id: "gpt-5.6-luna", provider: "openai", route: OpenAIChat.route })
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run with GPT-5.6 cache" }), resume: false })

requests.length = 0
yield* session.resume(sessionID)

expect(requests[0]?.providerOptions?.openai).toMatchObject({
promptCacheKey: sessionID,
promptCacheOptions: { mode: "implicit", ttl: "30m" },
})
}),
)

it.effect("keeps GPT-5.5 session prompt cache behavior unchanged", () =>
Effect.gen(function* () {
yield* setup
currentModel = Model.make({ id: "gpt-5.5", provider: "openai", route: OpenAIChat.route })
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run with GPT-5.5 cache" }), resume: false })

requests.length = 0
yield* session.resume(sessionID)

expect(requests[0]?.providerOptions?.openai?.promptCacheKey).toBe(sessionID)
expect(requests[0]?.providerOptions?.openai?.promptCacheOptions).toBeUndefined()
}),
)

it.effect("fans out one failed run and allows a later retry", () =>
Effect.gen(function* () {
yield* setup
Expand Down
11 changes: 10 additions & 1 deletion packages/llm/src/protocols/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ export const bodyFields = {
stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })),
store: Schema.optional(Schema.Boolean),
reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
prompt_cache_key: Schema.optional(Schema.String),
prompt_cache_options: Schema.optional(OpenAIOptions.OpenAIPromptCacheOptions),
max_tokens: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Number),
top_p: Schema.optional(Schema.Number),
Expand All @@ -121,6 +123,7 @@ const OpenAIChatUsage = Schema.Struct({
prompt_tokens_details: optionalNull(
Schema.Struct({
cached_tokens: Schema.optional(Schema.Number),
cache_write_tokens: Schema.optional(Schema.Number),
}),
),
completion_tokens_details: optionalNull(
Expand Down Expand Up @@ -332,11 +335,15 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:

const lowerOptions = Effect.fn("OpenAIChat.lowerOptions")(function* (request: LLMRequest) {
const store = OpenAIOptions.store(request)
const promptCacheKey = OpenAIOptions.promptCacheKey(request)
const promptCacheOptions = OpenAIOptions.promptCacheOptions(request)
const reasoningEffort = OpenAIOptions.reasoningEffort(request)
if (reasoningEffort && !OpenAIOptions.isReasoningEffort(reasoningEffort))
return yield* invalid(`OpenAI Chat does not support reasoning effort ${reasoningEffort}`)
return {
...(store !== undefined ? { store } : {}),
...(promptCacheKey ? { prompt_cache_key: promptCacheKey } : {}),
...(promptCacheOptions ? { prompt_cache_options: promptCacheOptions } : {}),
...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
}
})
Expand Down Expand Up @@ -391,13 +398,15 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
if (!usage) return undefined
const cached = usage.prompt_tokens_details?.cached_tokens
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens
const reasoning = usage.completion_tokens_details?.reasoning_tokens
const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, cached)
const nonCached = ProviderShared.subtractTokens(ProviderShared.subtractTokens(usage.prompt_tokens, cached), cacheWrite)
return new Usage({
inputTokens: usage.prompt_tokens,
outputTokens: usage.completion_tokens,
nonCachedInputTokens: nonCached,
cacheReadInputTokens: cached,
cacheWriteInputTokens: cacheWrite,
reasoningTokens: reasoning,
totalTokens: ProviderShared.totalTokens(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens),
providerMetadata: { openai: usage },
Expand Down
14 changes: 12 additions & 2 deletions packages/llm/src/protocols/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ const OpenAIResponsesCoreFields = {
store: Schema.optional(Schema.Boolean),
service_tier: Schema.optional(OpenAIOptions.OpenAIServiceTier),
prompt_cache_key: Schema.optional(Schema.String),
prompt_cache_options: Schema.optional(OpenAIOptions.OpenAIPromptCacheOptions),
include: optionalArray(OpenAIOptions.OpenAIResponseIncludable),
reasoning: Schema.optional(
Schema.Struct({
Expand Down Expand Up @@ -167,7 +168,12 @@ const encodeWebSocketMessage = Schema.encodeSync(Schema.fromJsonString(OpenAIRes

const OpenAIResponsesUsage = Schema.Struct({
input_tokens: Schema.optional(Schema.Number),
input_tokens_details: optionalNull(Schema.Struct({ cached_tokens: Schema.optional(Schema.Number) })),
input_tokens_details: optionalNull(
Schema.Struct({
cached_tokens: Schema.optional(Schema.Number),
cache_write_tokens: Schema.optional(Schema.Number),
}),
),
output_tokens: Schema.optional(Schema.Number),
output_tokens_details: optionalNull(Schema.Struct({ reasoning_tokens: Schema.optional(Schema.Number) })),
total_tokens: Schema.optional(Schema.Number),
Expand Down Expand Up @@ -456,6 +462,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (request: LLMRequest) {
const store = OpenAIOptions.store(request)
const promptCacheKey = OpenAIOptions.promptCacheKey(request)
const promptCacheOptions = OpenAIOptions.promptCacheOptions(request)
const effort = OpenAIOptions.reasoningEffort(request)
if (effort && !OpenAIOptions.isReasoningEffort(effort))
return yield* invalid(`OpenAI Responses does not support reasoning effort ${effort}`)
Expand All @@ -468,6 +475,7 @@ const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (reques
...(instructions ? { instructions } : {}),
...(store !== undefined ? { store } : {}),
...(promptCacheKey ? { prompt_cache_key: promptCacheKey } : {}),
...(promptCacheOptions ? { prompt_cache_options: promptCacheOptions } : {}),
...(include ? { include } : {}),
...(effort || summary ? { reasoning: { effort, summary } } : {}),
...(verbosity ? { text: { verbosity } } : {}),
Expand Down Expand Up @@ -507,13 +515,15 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
const mapUsage = (usage: OpenAIResponsesUsage | null | undefined) => {
if (!usage) return undefined
const cached = usage.input_tokens_details?.cached_tokens
const cacheWrite = usage.input_tokens_details?.cache_write_tokens
const reasoning = usage.output_tokens_details?.reasoning_tokens
const nonCached = ProviderShared.subtractTokens(usage.input_tokens, cached)
const nonCached = ProviderShared.subtractTokens(ProviderShared.subtractTokens(usage.input_tokens, cached), cacheWrite)
return new Usage({
inputTokens: usage.input_tokens,
outputTokens: usage.output_tokens,
nonCachedInputTokens: nonCached,
cacheReadInputTokens: cached,
cacheWriteInputTokens: cacheWrite,
reasoningTokens: reasoning,
totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, usage.total_tokens),
providerMetadata: { openai: usage },
Expand Down
27 changes: 27 additions & 0 deletions packages/llm/src/protocols/utils/openai-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,28 @@ export const OpenAIResponseIncludables = [
export type OpenAIResponseIncludable = (typeof OpenAIResponseIncludables)[number]
export const OpenAIServiceTiers = ["auto", "default", "flex", "priority"] as const
export type OpenAIServiceTier = (typeof OpenAIServiceTiers)[number]
export const OpenAIPromptCacheModes = ["implicit", "explicit"] as const
export type OpenAIPromptCacheMode = (typeof OpenAIPromptCacheModes)[number]
export const OpenAIPromptCacheTTLs = ["30m"] as const
export type OpenAIPromptCacheTTL = (typeof OpenAIPromptCacheTTLs)[number]

const REASONING_EFFORTS = new Set<string>(ReasoningEfforts)
const OPENAI_REASONING_EFFORTS = new Set<string>(OpenAIReasoningEfforts)
const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
const INCLUDABLES = new Set<string>(OpenAIResponseIncludables)
const SERVICE_TIERS = new Set<string>(OpenAIServiceTiers)
const PROMPT_CACHE_MODES = new Set<string>(OpenAIPromptCacheModes)
const PROMPT_CACHE_TTLS = new Set<string>(OpenAIPromptCacheTTLs)

export const OpenAIReasoningEffort = Schema.Literals(OpenAIReasoningEfforts)
export const OpenAITextVerbosity = TextVerbosity
export const OpenAIResponseIncludable = Schema.Literals(OpenAIResponseIncludables)
export const OpenAIServiceTier = Schema.Literals(OpenAIServiceTiers)
export const OpenAIPromptCacheOptions = Schema.Struct({
mode: Schema.optional(Schema.Literals(OpenAIPromptCacheModes)),
ttl: Schema.optional(Schema.Literals(OpenAIPromptCacheTTLs)),
})
export type OpenAIPromptCacheOptions = Schema.Schema.Type<typeof OpenAIPromptCacheOptions>

const isAnyReasoningEffort = (effort: unknown): effort is ReasoningEffort =>
typeof effort === "string" && REASONING_EFFORTS.has(effort)
Expand Down Expand Up @@ -75,6 +86,22 @@ export const promptCacheKey = (request: LLMRequest) => {
return typeof value === "string" ? value : undefined
}

export const promptCacheOptions = (request: LLMRequest): OpenAIPromptCacheOptions | undefined => {
const value = options(request)?.promptCacheOptions
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined
const input = value as Record<string, unknown>
const mode =
typeof input.mode === "string" && PROMPT_CACHE_MODES.has(input.mode)
? (input.mode as OpenAIPromptCacheMode)
: undefined
const ttl =
typeof input.ttl === "string" && PROMPT_CACHE_TTLS.has(input.ttl)
? (input.ttl as OpenAIPromptCacheTTL)
: undefined
if (!mode && !ttl) return undefined
return { mode, ttl }
}

export const textVerbosity = (request: LLMRequest) => {
const value = options(request)?.textVerbosity
return isTextVerbosity(value) ? value : undefined
Expand Down
23 changes: 20 additions & 3 deletions packages/llm/src/providers/openai-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,16 @@ import type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/u

export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options"

export interface OpenAIPromptCacheOptionsInput {
readonly mode?: "implicit" | "explicit"
readonly ttl?: "30m"
}

export interface OpenAIOptionsInput {
readonly [key: string]: unknown
readonly store?: boolean
readonly promptCacheKey?: string
readonly promptCacheOptions?: OpenAIPromptCacheOptionsInput
readonly reasoningEffort?: ReasoningEffort
readonly reasoningSummary?: "auto"
// OpenAI Responses `include` wire field. Mirrors the official SDK's
Expand All @@ -30,6 +36,7 @@ const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): Provide
definedEntries({
store: options?.store,
promptCacheKey: options?.promptCacheKey,
promptCacheOptions: options?.promptCacheOptions,
reasoningEffort: options?.reasoningEffort,
reasoningSummary: options?.reasoningSummary,
include: options?.include,
Expand All @@ -41,6 +48,9 @@ const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): Provide
return { openai }
}

export const isGPT56Family = (modelID: string) =>
/(?:^|[/.])gpt-5\.6(?:$|[-_/.])/.test(modelID.toLowerCase())

export const gpt5DefaultOptions = (
modelID: string,
options: { readonly textVerbosity?: boolean } = {},
Expand All @@ -63,16 +73,23 @@ export const gpt5DefaultOptions = (
})
}

const gpt56DefaultOptions = (modelID: string): ProviderOptions | undefined =>
isGPT56Family(modelID) ? openAIProviderOptions({ promptCacheOptions: { mode: "implicit", ttl: "30m" } }) : undefined

export const openAIDefaultOptions = (
modelID: string,
options: { readonly textVerbosity?: boolean } = {},
options: { readonly textVerbosity?: boolean; readonly promptCaching?: boolean } = {},
): ProviderOptions | undefined =>
mergeProviderOptions(openAIProviderOptions({ store: false }), gpt5DefaultOptions(modelID, options))
mergeProviderOptions(
openAIProviderOptions({ store: false }),
gpt5DefaultOptions(modelID, options),
options.promptCaching === true ? gpt56DefaultOptions(modelID) : undefined,
)

export const withOpenAIOptions = <Options extends { readonly providerOptions?: OpenAIProviderOptionsInput }>(
modelID: string,
options: Options,
defaults: { readonly textVerbosity?: boolean } = {},
defaults: { readonly textVerbosity?: boolean; readonly promptCaching?: boolean } = {},
): Omit<Options, "providerOptions"> & { readonly providerOptions?: ProviderOptions } => {
return {
...options,
Expand Down
11 changes: 8 additions & 3 deletions packages/llm/src/providers/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,15 @@ export const configure = (input: Config = {}) => {
const chatRoute = configuredRoute(OpenAIChat.route, input)
const modelDefaults = defaults(input)
const responses = (id: string | ModelID) =>
responsesRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id })
responsesRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true, promptCaching: true })).model({
id,
})
const responsesWebSocket = (id: string | ModelID) =>
responsesWebSocketRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id })
const chat = (id: string | ModelID) => chatRoute.with(withOpenAIOptions(id, modelDefaults)).model({ id })
responsesWebSocketRoute
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true, promptCaching: true }))
.model({ id })
const chat = (id: string | ModelID) =>
chatRoute.with(withOpenAIOptions(id, modelDefaults, { promptCaching: true })).model({ id })

return {
id,
Expand Down
32 changes: 28 additions & 4 deletions packages/llm/test/provider/openai-chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,15 +98,38 @@ describe("OpenAI Chat route", () => {
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4o-mini"),
prompt: "think",
providerOptions: { openai: { reasoningEffort: "low" } },
providerOptions: {
openai: {
promptCacheKey: "session_123",
promptCacheOptions: { mode: "explicit", ttl: "30m" },
reasoningEffort: "low",
},
},
}),
)

expect(prepared.body.store).toBe(false)
expect(prepared.body.prompt_cache_key).toBe("session_123")
expect(prepared.body.prompt_cache_options).toEqual({ mode: "explicit", ttl: "30m" })
expect(prepared.body.reasoning_effort).toBe("low")
}),
)

it.effect("enables GPT-5.6 family prompt cache options by default", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-5.6-luna"),
prompt: "think",
providerOptions: { openai: { promptCacheKey: "session_123" } },
}),
)

expect(prepared.body.prompt_cache_key).toBe("session_123")
expect(prepared.body.prompt_cache_options).toEqual({ mode: "implicit", ttl: "30m" })
}),
)

it.effect("adds native query params to the Chat Completions URL", () =>
LLMClient.generate(
LLM.updateRequest(request, {
Expand Down Expand Up @@ -486,24 +509,25 @@ describe("OpenAI Chat route", () => {
prompt_tokens: 5,
completion_tokens: 2,
total_tokens: 7,
prompt_tokens_details: { cached_tokens: 1 },
prompt_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 },
completion_tokens_details: { reasoning_tokens: 0 },
}),
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
const usage = new Usage({
inputTokens: 5,
outputTokens: 2,
nonCachedInputTokens: 4,
nonCachedInputTokens: 2,
cacheReadInputTokens: 1,
cacheWriteInputTokens: 2,
reasoningTokens: 0,
totalTokens: 7,
providerMetadata: {
openai: {
prompt_tokens: 5,
completion_tokens: 2,
total_tokens: 7,
prompt_tokens_details: { cached_tokens: 1 },
prompt_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 },
completion_tokens_details: { reasoning_tokens: 0 },
},
},
Expand Down
Loading
Loading