From 4c949b03569c824e543558ad2b8aca114c09bb4d Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 28 Sep 2026 17:58:56 -0500 Subject: [PATCH] fix(core): map v1 setCacheKey to compatibility V1 provider.options.setCacheKey was passed through as an inert provider setting, so the prompt cache key was never sent for providers that opt in (and could not be disabled for providers that default it on). Lower it to compatibility.supportsPromptCacheKey on the provider, add provider-level compatibility to the config schema, and apply it to every model of the provider with model-level compatibility taking precedence. --- .../client/src/promise/generated/types.ts | 1 + packages/core/src/config/plugin/provider.ts | 9 +++ packages/core/src/v1/config/migrate.ts | 1 + .../core/src/v1/config/provider-options.ts | 11 ++- packages/core/test/config/config.test.ts | 16 ++++ .../core/test/config/provider-options.test.ts | 13 ++++ packages/core/test/config/provider.test.ts | 78 +++++++++++++++++++ packages/schema/src/config/provider.ts | 2 + 8 files changed, 130 insertions(+), 1 deletion(-) diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 4d51de939f7..ccb37b94dce 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -2117,6 +2117,7 @@ export type ConfigEntry = name?: string env?: Array package?: string + compatibility?: ModelCompatibility settings?: ConfigProviderSettings headers?: { [x: string]: string } body?: { [x: string]: JsonValue } diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 41a7f80d4cd..4b944bb4f1f 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -99,6 +99,13 @@ export const Plugin = define({ if (configuredDefault !== undefined) models.default.set(configuredDefault.providerID, configuredDefault.model) for (const [item, definition] of sources.models) { const providerID = definition.providerID + if (item.compatibility !== undefined) + for (const model of models.list(providerID)) { + if (Object.hasOwn(item.models ?? {}, model.id)) continue + models.update(providerID, model.id, (draft) => { + draft.compatibility = { ...draft.compatibility, ...item.compatibility } + }) + } for (const [id, config] of Object.entries(item.models ?? {})) { const source = definition.models.get(id) const inherit = source?.inherit || !models.get(providerID, id) @@ -111,6 +118,8 @@ export const Plugin = define({ if (config.family !== undefined) model.family = config.family if (config.name !== undefined) model.name = config.name if (config.modelID !== undefined) model.modelID = config.modelID + if (item.compatibility !== undefined) + model.compatibility = { ...model.compatibility, ...item.compatibility } if (config.compatibility !== undefined) model.compatibility = { ...model.compatibility, ...config.compatibility } if (config.package !== undefined) model.package = config.package diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index ccc70d87820..cc51e54ccef 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -250,6 +250,7 @@ function migrateStandardProvider(info: ConfigProviderV1.Info) { name: info.name, env: info.env, package: info.npm ? Provider.aisdk(info.npm) : undefined, + compatibility: options.compatibility, settings: info.api ? { ...options.settings, baseURL: info.api } : info.options ? options.settings : undefined, headers: info.options && options.headers, body: info.options && options.body, diff --git a/packages/core/src/v1/config/provider-options.ts b/packages/core/src/v1/config/provider-options.ts index d6752ff03b8..c20d964ffd9 100644 --- a/packages/core/src/v1/config/provider-options.ts +++ b/packages/core/src/v1/config/provider-options.ts @@ -1,17 +1,22 @@ export * as ConfigProviderOptionsV1 from "./provider-options.js" +import type { Model } from "@opencode/schema/model" + type Options = Readonly> export interface ProviderResult { readonly settings: Record readonly headers?: Record readonly body?: Record + readonly compatibility?: Model.Compatibility } export function provider(options: Options): ProviderResult { const headers = options.headers const body = options.body - const settings = Object.fromEntries(Object.entries(options).filter(([key]) => key !== "headers" && key !== "body")) + const settings = Object.fromEntries( + Object.entries(options).filter(([key]) => key !== "headers" && key !== "body" && key !== "setCacheKey"), + ) const headerOverlay = typeof headers === "object" && headers !== null && !Array.isArray(headers) ? Object.fromEntries( @@ -23,6 +28,10 @@ export function provider(options: Options): ProviderResult { settings, headers: headerOverlay, body: bodyOverlay, + // V1 `setCacheKey` was a provider-wide switch for sending the prompt cache key; false also disabled the + // built-in defaults for OpenAI and xAI. + compatibility: + typeof options.setCacheKey === "boolean" ? { supportsPromptCacheKey: options.setCacheKey } : undefined, } } diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index c3deb28d364..53707903bfb 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -835,6 +835,22 @@ describe("Config", () => { expect(migrated.providers?.custom?.models?.boolean?.compatibility).toBeUndefined() }) + test("migrates v1 setCacheKey to provider compatibility", () => { + const migrated = ConfigMigrateV1.migrate({ + provider: { + enabled: { options: { setCacheKey: true, baseURL: "https://enabled.example/v1" } }, + disabled: { options: { setCacheKey: false } }, + unset: { options: { baseURL: "https://unset.example/v1" } }, + }, + }) + + expect(migrated.providers?.enabled?.compatibility).toEqual({ supportsPromptCacheKey: true }) + expect(migrated.providers?.enabled?.settings).toEqual({ baseURL: "https://enabled.example/v1" }) + expect(migrated.providers?.disabled?.compatibility).toEqual({ supportsPromptCacheKey: false }) + expect(migrated.providers?.disabled?.settings).toEqual({}) + expect(migrated.providers?.unset?.compatibility).toBeUndefined() + }) + for (const subtask of [true, false]) { test(`migrates v1 command configuration with subtask: ${subtask}`, () => { expect( diff --git a/packages/core/test/config/provider-options.test.ts b/packages/core/test/config/provider-options.test.ts index 0cb3ada7459..74c6c8cf05d 100644 --- a/packages/core/test/config/provider-options.test.ts +++ b/packages/core/test/config/provider-options.test.ts @@ -24,6 +24,19 @@ describe("ConfigProviderOptionsV1", () => { }) }) + test("lowers setCacheKey to prompt cache key compatibility", () => { + expect(ConfigProviderOptionsV1.provider({ apiKey: "secret", setCacheKey: true })).toEqual({ + settings: { apiKey: "secret" }, + headers: undefined, + body: undefined, + compatibility: { supportsPromptCacheKey: true }, + }) + expect(ConfigProviderOptionsV1.provider({ setCacheKey: false }).compatibility).toEqual({ + supportsPromptCacheKey: false, + }) + expect(ConfigProviderOptionsV1.provider({ apiKey: "secret" }).compatibility).toBeUndefined() + }) + test("keeps model and variant options unchanged", () => { expect( ConfigProviderOptionsV1.model({ diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index f660bb14ba6..e55d7703464 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -420,6 +420,84 @@ describe("ConfigProviderPlugin.Plugin", () => { }), ) + it.effect("applies provider compatibility to every model unless the model overrides it", () => + Effect.gen(function* () { + const providers = yield* Provider.Service + const models = yield* Model.Service + const providerID = Provider.ID.make("custom") + yield* providers.transform((editor) => { + editor.models.update(providerID, Model.ID.make("catalog"), (model) => { + model.compatibility = { requireFinishReason: true } + }) + editor.models.update(Provider.ID.make("other"), Model.ID.make("catalog"), () => {}) + }) + + yield* addPlugin([ + new Document({ + type: "document", + info: decode({ + providers: { + custom: { + compatibility: { supportsPromptCacheKey: true }, + models: { + inherited: {}, + overridden: { compatibility: { supportsPromptCacheKey: false } }, + }, + }, + }, + }), + }), + ]) + + expect((yield* models.get(providerID, Model.ID.make("catalog")))?.compatibility).toEqual({ + requireFinishReason: true, + supportsPromptCacheKey: true, + }) + expect((yield* models.get(providerID, Model.ID.make("inherited")))?.compatibility).toEqual({ + supportsPromptCacheKey: true, + }) + expect((yield* models.get(providerID, Model.ID.make("overridden")))?.compatibility).toEqual({ + supportsPromptCacheKey: false, + }) + expect((yield* models.get(Provider.ID.make("other"), Model.ID.make("catalog")))?.compatibility).toBeUndefined() + }), + ) + + it.effect("maps migrated v1 setCacheKey onto provider models", () => + Effect.gen(function* () { + const providers = yield* Provider.Service + const models = yield* Model.Service + yield* providers.transform((editor) => { + editor.models.update(Provider.ID.make("enabled"), Model.ID.make("catalog"), () => {}) + editor.models.update(Provider.ID.make("disabled"), Model.ID.make("catalog"), (model) => { + model.compatibility = { supportsPromptCacheKey: true } + }) + editor.models.update(Provider.ID.make("unset"), Model.ID.make("catalog"), () => {}) + }) + const result = ConfigNormalize.normalize({ + provider: { + enabled: { options: { setCacheKey: true }, models: { chat: {} } }, + disabled: { options: { setCacheKey: false } }, + unset: { options: { baseURL: "https://unset.example/v1" } }, + }, + }) + if (result.type !== "normalized") throw new Error("Expected normalized config") + expect(result.diagnostics).toEqual([]) + + yield* addPlugin([new Document({ type: "document", info: decode(result.encoded) })]) + + const compatibility = (providerID: string, modelID: string) => + models + .get(Provider.ID.make(providerID), Model.ID.make(modelID)) + .pipe(Effect.map((model) => model?.compatibility?.supportsPromptCacheKey)) + expect(yield* compatibility("enabled", "catalog")).toBe(true) + expect(yield* compatibility("enabled", "chat")).toBe(true) + expect(yield* compatibility("disabled", "catalog")).toBe(false) + expect(yield* compatibility("unset", "catalog")).toBeUndefined() + expect((yield* providers.get(Provider.ID.make("enabled")))?.settings).not.toHaveProperty("setCacheKey") + }), + ) + it.effect("generates variants after rewriting a configured model package", () => Effect.gen(function* () { const models = yield* Model.Service diff --git a/packages/schema/src/config/provider.ts b/packages/schema/src/config/provider.ts index 1ae813582f5..ded10bb7807 100644 --- a/packages/schema/src/config/provider.ts +++ b/packages/schema/src/config/provider.ts @@ -87,6 +87,8 @@ export class Info extends Schema.Class("Config.Provider")({ name: Schema.String.pipe(optional), env: Schema.String.pipe(Schema.Array, optional), package: Schema.String.pipe(optional), + /** Compatibility defaults for every model of this provider. Model-level compatibility takes precedence. */ + compatibility: Compatibility.pipe(optional), ...Overlays, models: Schema.Record(Schema.String, Model).pipe(optional), }) {}