Compare commits

..
Author SHA1 Message Date
Aiden Cline 4c949b0356 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.
2026-09-28 17:58:56 -05:00
9 changed files with 131 additions and 1 deletions
+1
View File
@@ -164,6 +164,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (!formAlreadySettled(error)) throw error
}
formCancelled = true
if (input.compatibility !== "v1") process.exitCode = 1
}
const consume = async () => {
@@ -2117,6 +2117,7 @@ export type ConfigEntry =
name?: string
env?: Array<string>
package?: string
compatibility?: ModelCompatibility
settings?: ConfigProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
@@ -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
+1
View File
@@ -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,
@@ -1,17 +1,22 @@
export * as ConfigProviderOptionsV1 from "./provider-options.js"
import type { Model } from "@opencode/schema/model"
type Options = Readonly<Record<string, unknown>>
export interface ProviderResult {
readonly settings: Record<string, unknown>
readonly headers?: Record<string, string>
readonly body?: Record<string, unknown>
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,
}
}
+16
View File
@@ -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(
@@ -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({
@@ -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
+2
View File
@@ -87,6 +87,8 @@ export class Info extends Schema.Class<Info>("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),
}) {}