Compare commits

...
Author SHA1 Message Date
Aiden Cline dd641b298a fix(ai): preserve top-level prompt cache key defaults 2026-08-26 16:14:36 -05:00
11 changed files with 155 additions and 6 deletions
+2
View File
@@ -376,6 +376,8 @@ Request options in order of stability:
Route/provider defaults are overridden by request-level values for each axis.
`promptCacheKey` belongs at the top level, not inside `providerOptions`. It can also be set in provider configuration (for example, `OpenAI.configure({ promptCacheKey: "shared-prefix" })`) or `LanguageModel.defaults`. The effective key is resolved in request > model defaults > route defaults order. For Chat and Responses, `cache: "none"` suppresses the wire cache key even when a default is configured.
The selected model supplies the provider-specific option type, so per-request overrides stay flat while the canonical runtime request remains provider-neutral:
```ts
+4
View File
@@ -73,6 +73,7 @@ export type RouteLanguageModelInput = Omit<LanguageModel.Input, "provider" | "ro
export type RouteRoutedLanguageModelInput = Omit<LanguageModel.Input, "route">
export interface RouteDefaults {
readonly promptCacheKey?: string
readonly headers?: Record<string, string>
readonly generation?: GenerationOptions
readonly providerOptions?: ProviderOptions
@@ -80,6 +81,7 @@ export interface RouteDefaults {
}
export interface RouteDefaultsInput {
readonly promptCacheKey?: string
readonly headers?: Record<string, string>
readonly generation?: GenerationOptions.Input
readonly providerOptions?: ProviderOptions
@@ -117,6 +119,7 @@ const mergeRouteDefaults = (base: RouteDefaults | undefined, patch: RouteDefault
return {
...base,
...patch,
promptCacheKey: patch.promptCacheKey ?? base?.promptCacheKey,
headers,
generation: mergeGenerationOptions(generationOptions(base?.generation), generationOptions(patch.generation)),
providerOptions: mergeProviderOptions(base?.providerOptions, patch.providerOptions),
@@ -172,6 +175,7 @@ const resolveRequestOptions = (request: LLMRequest) => {
const modelDefaults = request.model.defaults
const generation = mergeGenerationOptions(routeDefaults.generation, modelDefaults?.generation, request.generation)
return LLMRequest.update(request, {
promptCacheKey: request.promptCacheKey ?? modelDefaults?.promptCacheKey ?? routeDefaults.promptCacheKey,
generation: generation ?? new GenerationOptions({}),
providerOptions: mergeProviderOptions(
routeDefaults.providerOptions,
+3
View File
@@ -115,6 +115,7 @@ export const mergeGenerationOptions = (...items: ReadonlyArray<GenerationOptions
}
export class LanguageModelDefaults extends Schema.Class<LanguageModelDefaults>("LLM.LanguageModelDefaults")({
promptCacheKey: Schema.optional(Schema.String),
generation: Schema.optional(GenerationOptions),
providerOptions: Schema.optional(ProviderOptions),
http: Schema.optional(HttpOptions),
@@ -124,6 +125,7 @@ export namespace LanguageModelDefaults {
export type Input =
| LanguageModelDefaults
| {
readonly promptCacheKey?: string
readonly generation?: GenerationOptions.Input
readonly providerOptions?: ProviderOptions
readonly http?: HttpOptions.Input
@@ -133,6 +135,7 @@ export namespace LanguageModelDefaults {
export const make = (input: Input) => {
if (input instanceof LanguageModelDefaults) return input
return new LanguageModelDefaults({
promptCacheKey: input.promptCacheKey,
generation: input.generation === undefined ? undefined : GenerationOptions.make(input.generation),
providerOptions: input.providerOptions,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
+33 -2
View File
@@ -1,8 +1,8 @@
import { describe, expect, test } from "bun:test"
import { Effect, Ref, Schema } from "effect"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLM, Message, ToolCallPart, mergeProviderOptions } from "../src/index.js"
import { AnthropicMessages, OpenAIChat } from "../src/protocols.js"
import { LLM, LLMRequest, Message, ToolCallPart, mergeProviderOptions } from "../src/index.js"
import { AnthropicMessages, OpenAIChat, OpenAIResponses } from "../src/protocols.js"
import { Auth, LLMClient } from "../src/route.js"
import { compileRequest } from "../src/route/client.js"
import { it } from "./lib/effect.js"
@@ -14,6 +14,37 @@ const TargetJson = Schema.fromJsonString(Schema.Unknown)
const decodeJson = Schema.decodeUnknownSync(TargetJson)
describe("request option precedence", () => {
it.effect("resolves top-level prompt cache keys from request, model, and route defaults", () =>
Effect.gen(function* () {
for (const route of [OpenAIChat.route, OpenAIResponses.route]) {
for (const [routeKey, modelKey, requestKey, expected] of [
[undefined, undefined, undefined, undefined],
["route", undefined, undefined, "route"],
["route", "model", undefined, "model"],
["route", "model", "request", "request"],
["route", "", undefined, ""],
["route", "model", "", ""],
]) {
const model = route
.with({ auth: Auth.bearer("test"), promptCacheKey: routeKey })
.with({ headers: { "x-test": "value" }, promptCacheKey: undefined })
.model({ id: "gpt-4o-mini", defaults: { promptCacheKey: modelKey } })
const request = LLM.request({ model, prompt: "Hi", promptCacheKey: requestKey })
const prepared = yield* compileRequest(request)
const disabled = yield* compileRequest(LLMRequest.update(request, { cache: "none" }))
expect(model.route.defaults.promptCacheKey).toBe(routeKey)
expect(model.defaults?.promptCacheKey).toBe(modelKey)
if (expected) expect(prepared.body).toMatchObject({ prompt_cache_key: expected })
if (!expected) expect(prepared.body).not.toHaveProperty("prompt_cache_key")
expect(disabled.body).not.toHaveProperty("prompt_cache_key")
expect(request.promptCacheKey).toBe(requestKey)
expect(request.providerOptions).toBeUndefined()
}
}
}),
)
test("deep-merges provider option records and replaces arrays, primitives, and null", () => {
const merged = mergeProviderOptions(
{
+3
View File
@@ -121,6 +121,7 @@ describe("llm constructors", () => {
const model = chatRoute.model({
id: "kimi-k2",
defaults: {
promptCacheKey: "model-cache",
generation: { maxTokens: 1_024, stop: ["END"] },
providerOptions: { parallelToolCalls: false },
http: { body: { extra_body: true } },
@@ -129,6 +130,8 @@ describe("llm constructors", () => {
})
const request = LLM.request({ model, prompt: "Say hello." })
expect(request.model.defaults?.promptCacheKey).toBe("model-cache")
expect(request.promptCacheKey).toBeUndefined()
expect(request.model.defaults?.generation).toEqual({ maxTokens: 1_024, stop: ["END"] })
expect(request.model.defaults?.providerOptions).toEqual({ parallelToolCalls: false })
expect(request.model.defaults?.http).toEqual({ body: { extra_body: true } })
+4 -2
View File
@@ -148,7 +148,9 @@ export function map(input: MapInput): Mapping | undefined {
}
function mapProviderOptions(settings: Readonly<Record<string, unknown>>, excluded: ReadonlyArray<string>) {
const options = Object.fromEntries(Object.entries(settings).filter(([name]) => !excluded.includes(name)))
const options = Object.fromEntries(
Object.entries(settings).filter(([name]) => name !== "promptCacheKey" && !excluded.includes(name)),
)
if (Object.keys(options).length === 0) return {}
return { providerOptions: options }
}
@@ -278,7 +280,6 @@ function mapOpenAIOptions(settings: Readonly<Record<string, unknown>>) {
...(typeof settings.reasoningSummary === "string" ? { reasoningSummary: settings.reasoningSummary } : {}),
...(Array.isArray(settings.include) ? { include: settings.include } : {}),
...(typeof settings.store === "boolean" ? { store: settings.store } : {}),
...(typeof settings.promptCacheKey === "string" ? { promptCacheKey: settings.promptCacheKey } : {}),
...(typeof settings.textVerbosity === "string" ? { textVerbosity: settings.textVerbosity } : {}),
...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}),
}
@@ -289,6 +290,7 @@ function mapOpenAIOptions(settings: Readonly<Record<string, unknown>>) {
function mapBaseSettings(settings: Readonly<Record<string, unknown>>) {
return {
...(typeof settings.baseURL === "string" ? { baseURL: settings.baseURL } : {}),
...(typeof settings.promptCacheKey === "string" ? { promptCacheKey: settings.promptCacheKey } : {}),
}
}
+4
View File
@@ -170,6 +170,10 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
return LanguageModel.update(runtime, {
provider: resolved.providerID,
defaults:
typeof mapped.promptCacheKey === "string"
? { ...runtime.defaults, promptCacheKey: mapped.promptCacheKey }
: runtime.defaults,
compatibility: resolved.compatibility
? Object.assign({}, runtime.compatibility, resolved.compatibility)
: runtime.compatibility,
+4 -1
View File
@@ -328,7 +328,10 @@ export const layer = Layer.effect(
headers: sessionHeaders(session, app),
},
// TODO: Persist cache lineage so nested forks reuse the root session's cache key.
promptCacheKey: promptCacheKey(session.fork?.sessionID ?? session.id),
promptCacheKey:
model.defaults?.promptCacheKey ??
model.route.defaults.promptCacheKey ??
promptCacheKey(session.fork?.sessionID ?? session.id),
system: context.system,
messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
+20
View File
@@ -5,6 +5,26 @@ const map = (packageName: string, settings: Readonly<Record<string, unknown>>, m
AISDKNative.map({ packageName, settings, modelID, providerID: "test-provider" })
describe("AISDKNative", () => {
test("keeps configured prompt cache keys in common settings rather than provider options", () => {
for (const name of [
"@ai-sdk/openai",
"@ai-sdk/openai-compatible",
"@ai-sdk/azure",
"@ai-sdk/amazon-bedrock/mantle",
"@ai-sdk/anthropic",
"@ai-sdk/google",
"@ai-sdk/google-vertex",
"@ai-sdk/xai",
"@openrouter/ai-sdk-provider",
]) {
for (const promptCacheKey of ["configured", "", undefined, 123]) {
const mapped = map(name, { baseURL: "https://provider.test/v1", promptCacheKey })
expect(mapped?.settings.promptCacheKey).toBe(typeof promptCacheKey === "string" ? promptCacheKey : undefined)
expect(mapped?.settings.providerOptions ?? {}).not.toHaveProperty("promptCacheKey")
}
}
})
test("maps OpenAI-family packages and request options to native providers", () => {
expect(
map("@ai-sdk/openai", {
+40 -1
View File
@@ -385,6 +385,43 @@ describe("ModelResolver", () => {
),
)
it.effect("carries configured prompt cache keys into native request defaults", () =>
Effect.gen(function* () {
for (const [packageName, modelID, useCompletionUrls] of [
[Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), "openai.gpt-oss-120b", false],
[Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), "openai.gpt-oss-safeguard-20b", false],
[Provider.aisdk("@ai-sdk/openai"), "gpt-4o-mini", false],
[Provider.aisdk("@ai-sdk/openai-compatible"), "model", false],
[Provider.aisdk("@ai-sdk/azure"), "deployment", false],
[Provider.aisdk("@ai-sdk/azure"), "deployment", true],
["@opencode-ai/ai/providers/openai", "gpt-4o-mini", false],
] as const) {
const resolved = yield* ModelResolver.fromCatalogModel(
model(packageName, {
modelID,
settings: {
apiKey: "test",
baseURL: "https://provider.test/v1",
promptCacheKey: "configured-cache",
useCompletionUrls,
},
}),
)
expect(resolved.defaults?.promptCacheKey).toBe("configured-cache")
expect(resolved.route.defaults.providerOptions ?? {}).not.toHaveProperty("promptCacheKey")
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hi" }))
const overridden = yield* compileRequest(
LLM.request({ model: resolved, prompt: "Hi", promptCacheKey: "request-cache" }),
)
const disabled = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hi", cache: "none" }))
expect(prepared.body).toMatchObject({ prompt_cache_key: "configured-cache" })
expect(overridden.body).toMatchObject({ prompt_cache_key: "request-cache" })
expect(disabled.body).not.toHaveProperty("prompt_cache_key")
}
}),
)
it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
@@ -468,12 +505,13 @@ describe("ModelResolver", () => {
it.effect("overlays selected OpenAI variant settings and bodies", () =>
Effect.gen(function* () {
const catalog = model(Provider.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
settings: { baseURL: "https://openai.example/v1", promptCacheKey: "base-cache" },
variants: [
{
id: VariantID.make("xhigh"),
settings: {
reasoningEffort: "xhigh",
promptCacheKey: "variant-cache",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
@@ -507,6 +545,7 @@ describe("ModelResolver", () => {
})
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
expect(prepared.body).toMatchObject({
prompt_cache_key: "variant-cache",
include: ["reasoning.encrypted_content"],
reasoning: { effort: "xhigh", summary: "auto" },
})
+38
View File
@@ -4142,6 +4142,44 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("prefers configured prompt cache keys over the automatic session key", () =>
Effect.gen(function* () {
yield* setup
const context = yield* SessionContext.Service
const modelRequests = yield* SessionModelRequest.Service
const selected = yield* context.select(sessionID)
const database = yield* Database.Service
const bus = yield* Bus.Service
yield* InstructionState.prepare(database.db, bus, selected.instructions, sessionID)
const loaded = yield* context.load(selected)
for (const [routeKey, modelKey, expected] of [
[undefined, undefined, sessionID],
["route-cache", undefined, "route-cache"],
["route-cache", "model-cache", "model-cache"],
["route-cache", "", ""],
]) {
const prepared = yield* modelRequests.prepare({
scope: {
session: loaded.session,
agentID: loaded.agent.id,
model: {
...loaded.model,
model: LanguageModel.update(loaded.model.model, {
route: loaded.model.model.route.with({ promptCacheKey: routeKey }),
defaults: { promptCacheKey: modelKey },
}),
},
tools: loaded.tools,
},
transcript: { system: [], messages: [] },
})
expect(prepared.request.promptCacheKey).toBe(expected)
expect(prepared.request.providerOptions ?? {}).not.toHaveProperty("promptCacheKey")
}
}),
)
it.effect("bounds 64-character session prompt cache keys", () =>
Effect.gen(function* () {
const session = yield* setup