mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-27 12:06:22 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd641b298a |
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
@@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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" },
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user