From 52acc4d31f06985d4ca5b0b1a9b50e746abd084b Mon Sep 17 00:00:00 2001 From: AbdoKnbGit Date: Fri, 10 Jul 2026 23:32:28 +0200 Subject: [PATCH] feat(llm): support GPT-5.6 prompt caching --- packages/core/src/session/runner/llm.ts | 15 ++++++- packages/core/test/session-runner.test.ts | 32 +++++++++++++++ packages/llm/src/protocols/openai-chat.ts | 11 +++++- .../llm/src/protocols/openai-responses.ts | 14 ++++++- .../llm/src/protocols/utils/openai-options.ts | 27 +++++++++++++ packages/llm/src/providers/openai-options.ts | 23 +++++++++-- packages/llm/src/providers/openai.ts | 11 ++++-- .../llm/test/provider/openai-chat.test.ts | 32 +++++++++++++-- .../test/provider/openai-responses.test.ts | 39 +++++++++++++++++-- packages/opencode/src/provider/transform.ts | 8 ++++ .../src/session/llm/native-runtime.ts | 2 +- .../opencode/test/provider/transform.test.ts | 28 +++++++++++++ 12 files changed, 224 insertions(+), 18 deletions(-) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 72c761e10d93..825130f1ba02 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -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. * @@ -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), diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 0515d55cf5be..00d083dd0a7b 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -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 diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index 9ac85b07b139..1c919ecbed44 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -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), @@ -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( @@ -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 } : {}), } }) @@ -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 }, diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 4936d31c921b..532498f1ff3f 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -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({ @@ -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), @@ -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}`) @@ -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 } } : {}), @@ -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 }, diff --git a/packages/llm/src/protocols/utils/openai-options.ts b/packages/llm/src/protocols/utils/openai-options.ts index 51e56ae21642..2c35034a6d65 100644 --- a/packages/llm/src/protocols/utils/openai-options.ts +++ b/packages/llm/src/protocols/utils/openai-options.ts @@ -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(ReasoningEfforts) const OPENAI_REASONING_EFFORTS = new Set(OpenAIReasoningEfforts) const TEXT_VERBOSITY = new Set(["low", "medium", "high"]) const INCLUDABLES = new Set(OpenAIResponseIncludables) const SERVICE_TIERS = new Set(OpenAIServiceTiers) +const PROMPT_CACHE_MODES = new Set(OpenAIPromptCacheModes) +const PROMPT_CACHE_TTLS = new Set(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 const isAnyReasoningEffort = (effort: unknown): effort is ReasoningEffort => typeof effort === "string" && REASONING_EFFORTS.has(effort) @@ -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 + 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 diff --git a/packages/llm/src/providers/openai-options.ts b/packages/llm/src/providers/openai-options.ts index fb548dd79726..ae10c7f9d8e8 100644 --- a/packages/llm/src/providers/openai-options.ts +++ b/packages/llm/src/providers/openai-options.ts @@ -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 @@ -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, @@ -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 } = {}, @@ -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 = ( modelID: string, options: Options, - defaults: { readonly textVerbosity?: boolean } = {}, + defaults: { readonly textVerbosity?: boolean; readonly promptCaching?: boolean } = {}, ): Omit & { readonly providerOptions?: ProviderOptions } => { return { ...options, diff --git a/packages/llm/src/providers/openai.ts b/packages/llm/src/providers/openai.ts index 098cad84939b..7fa02017f462 100644 --- a/packages/llm/src/providers/openai.ts +++ b/packages/llm/src/providers/openai.ts @@ -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, diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts index b736dc9dd33c..4fd2565f37e5 100644 --- a/packages/llm/test/provider/openai-chat.test.ts +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -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( + 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, { @@ -486,7 +509,7 @@ 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 }, }), ) @@ -494,8 +517,9 @@ describe("OpenAI Chat route", () => { const usage = new Usage({ inputTokens: 5, outputTokens: 2, - nonCachedInputTokens: 4, + nonCachedInputTokens: 2, cacheReadInputTokens: 1, + cacheWriteInputTokens: 2, reasoningTokens: 0, totalTokens: 7, providerMetadata: { @@ -503,7 +527,7 @@ 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 }, }, }, diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index cd8bad51af47..50610578f758 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -555,6 +555,7 @@ describe("OpenAI Responses route", () => { providerOptions: { openai: { promptCacheKey: "session_123", + promptCacheOptions: { mode: "implicit", ttl: "30m" }, reasoningEffort: "high", reasoningSummary: "auto", include: ["reasoning.encrypted_content"], @@ -565,12 +566,43 @@ describe("OpenAI Responses route", () => { expect(prepared.body.store).toBe(false) expect(prepared.body.prompt_cache_key).toBe("session_123") + expect(prepared.body.prompt_cache_options).toEqual({ mode: "implicit", ttl: "30m" }) expect(prepared.body.include).toEqual(["reasoning.encrypted_content"]) expect(prepared.body.reasoning).toEqual({ effort: "high", summary: "auto" }) expect(prepared.body.text).toEqual({ verbosity: "low" }) }), ) + it.effect("enables GPT-5.6 family prompt cache options by default", () => + Effect.gen(function* () { + yield* Effect.forEach(["gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.6-fast"], (id) => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model(id), + 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" }) + }), + ) + + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.5"), + prompt: "think", + providerOptions: { openai: { promptCacheKey: "session_123" } }, + }), + ) + + expect(prepared.body.prompt_cache_key).toBe("session_123") + expect(prepared.body).not.toHaveProperty("prompt_cache_options") + }), + ) + it.effect("accepts the full ResponseIncludable union", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( @@ -705,7 +737,7 @@ describe("OpenAI Responses route", () => { input_tokens: 5, output_tokens: 2, total_tokens: 7, - input_tokens_details: { cached_tokens: 1 }, + input_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 }, output_tokens_details: { reasoning_tokens: 0 }, }, }, @@ -715,8 +747,9 @@ describe("OpenAI Responses route", () => { const usage = new Usage({ inputTokens: 5, outputTokens: 2, - nonCachedInputTokens: 4, + nonCachedInputTokens: 2, cacheReadInputTokens: 1, + cacheWriteInputTokens: 2, reasoningTokens: 0, totalTokens: 7, providerMetadata: { @@ -724,7 +757,7 @@ describe("OpenAI Responses route", () => { input_tokens: 5, output_tokens: 2, total_tokens: 7, - input_tokens_details: { cached_tokens: 1 }, + input_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 }, output_tokens_details: { reasoning_tokens: 0 }, }, }, diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 20c73d021671..15397800a434 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -21,6 +21,11 @@ export const OUTPUT_TOKEN_MAX = 32_000 // needed for stateless multi-turn reasoning (store: false). Hoisted so every // branch that requests it stays in lockstep. const INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] as const +const GPT56_PROMPT_CACHE_OPTIONS = { mode: "implicit", ttl: "30m" } as const + +function isGPT56Family(modelID: string) { + return /(?:^|[/.])gpt-5\.6(?:$|[-_/.])/.test(modelID.toLowerCase()) +} export function sanitizeSurrogates(content: string) { return content.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { expect(result.include).toEqual(["reasoning.encrypted_content"]) }) + test("gpt-5.6 family should use implicit prompt cache options", () => { + const options = ["gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.6-fast"].map((id) => + ProviderTransform.options({ model: createGpt5Model(id), sessionID, providerOptions: {} }), + ) + + expect(options.map((item) => item.promptCacheKey)).toEqual([ + sessionID, + sessionID, + sessionID, + sessionID, + sessionID, + ]) + expect(options.map((item) => item.promptCacheOptions)).toEqual([ + { mode: "implicit", ttl: "30m" }, + { mode: "implicit", ttl: "30m" }, + { mode: "implicit", ttl: "30m" }, + { mode: "implicit", ttl: "30m" }, + { mode: "implicit", ttl: "30m" }, + ]) + }) + + test("gpt-5.5 should keep old prompt cache behavior", () => { + const result = ProviderTransform.options({ model: createGpt5Model("gpt-5.5"), sessionID, providerOptions: {} }) + + expect(result.promptCacheKey).toBe(sessionID) + expect(result.promptCacheOptions).toBeUndefined() + }) + test("Bedrock Mantle gpt-5.5 uses OpenAI Responses defaults", () => { const model = { ...createGpt5Model("openai.gpt-5.5"),