Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-models-forget.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Allow reasoning to be removed from custom provider models after it has been enabled.
34 changes: 25 additions & 9 deletions packages/kilo-vscode/src/shared/custom-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,37 +149,53 @@ export function sanitizeCustomProviderConfig(provider: unknown): { value: Saniti
}

type AnyRecord = Record<string, unknown>
type ProviderPatch = Omit<SanitizedProviderConfig, "models"> & {
models: Record<
string,
null | {
name: string
reasoning?: true | null
variants?: Record<string, VariantConfig | null>
}
>
}

function isRecord(v: unknown): v is AnyRecord {
return !!v && typeof v === "object" && !Array.isArray(v)
}

/**
* Build a provider patch that includes null sentinels for models and variants
* Build a provider patch that includes null sentinels for model properties
* that existed in the previous config but are absent from the new one. The CLI
* `config.update` endpoint deep-merges the payload with the existing config;
* without explicit nulls, removed entries would persist on disk.
*/
export function withCustomProviderDeletions(existing: unknown, next: SanitizedProviderConfig): SanitizedProviderConfig {
if (!isRecord(existing)) return next
const oldModels = isRecord(existing.models) ? existing.models : {}
const patched: AnyRecord = { ...next.models }
const patched: ProviderPatch["models"] = { ...next.models }

for (const id of Object.keys(oldModels)) {
if (!(id in patched)) {
patched[id] = null
continue
}
const oldModel = oldModels[id]
const oldVariants = isRecord(oldModel) && isRecord(oldModel.variants) ? oldModel.variants : {}
const newModel = patched[id]
if (!isRecord(newModel)) continue
if (!isRecord(oldModel) || !isRecord(newModel)) continue
const oldVariants = isRecord(oldModel.variants) ? oldModel.variants : {}
const newVariants = isRecord(newModel.variants) ? newModel.variants : {}
const removedVariants = Object.keys(oldVariants).filter((v) => !(v in newVariants))
if (removedVariants.length === 0) continue
const nulls = Object.fromEntries(removedVariants.map((v) => [v, null]))
patched[id] = { ...newModel, variants: { ...newVariants, ...nulls } }
const removed = Object.keys(oldVariants).filter((variant) => !(variant in newVariants))
const variants =
removed.length > 0
? { ...newVariants, ...Object.fromEntries(removed.map((variant) => [variant, null])) }
: newModel.variants
patched[id] = {
...newModel,
...(variants ? { variants } : {}),
...(oldModel.reasoning !== undefined && newModel.reasoning === undefined ? { reasoning: null } : {}),
}
}

return { ...next, models: patched as SanitizedProviderConfig["models"] }
return { ...next, models: patched } as SanitizedProviderConfig
}
11 changes: 7 additions & 4 deletions packages/kilo-vscode/tests/unit/custom-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,11 +166,12 @@ describe("withCustomProviderDeletions", () => {
expect(models.gone).toBeNull()
})

it("emits null for variants removed from a surviving model", () => {
it("emits null for reasoning and variants removed from a surviving model", () => {
const existing = {
models: {
keep: {
name: "Keep",
reasoning: true,
variants: { high: { reasoningEffort: "high" }, low: { reasoningEffort: "low" } },
},
},
Expand All @@ -182,9 +183,11 @@ describe("withCustomProviderDeletions", () => {
},
} as typeof baseNext
const result = withCustomProviderDeletions(existing, next)
const model = (result.models as Record<string, { variants: Record<string, unknown> }>).keep
expect(model.variants.high).toEqual({ reasoningEffort: "high" })
expect(model.variants.low).toBeNull()
const model = (result.models as Record<string, { reasoning?: boolean | null; variants?: Record<string, unknown> }>)
.keep
expect(model.reasoning).toBeNull()
expect(model.variants?.high).toEqual({ reasoningEffort: "high" })
expect(model.variants?.low).toBeNull()
})

it("does not touch variants on a model that is being deleted", () => {
Expand Down
14 changes: 5 additions & 9 deletions packages/kilo-vscode/tests/unit/provider-actions-save.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ describe("saveCustomProvider", () => {
expect(payload.myprovider.models["model-gone"]).toBeNull()
})

it("emits null sentinels for variants removed from a model that still exists", async () => {
it("emits null sentinels when reasoning and variants are removed from a model", async () => {
const existing = {
disabled_providers: [],
provider: {
Expand All @@ -270,11 +270,7 @@ describe("saveCustomProvider", () => {
name: "My Provider",
options: { baseURL: "https://example.com/v1" },
models: {
"model-1": {
name: "Model One",
reasoning: true,
variants: { high: { reasoningEffort: "high" } },
},
"model-1": { name: "Model One" },
},
}
await saveCustomProvider(ctx, "req", "myprovider", next, undefined, false, null, setCachedConfig)
Expand All @@ -283,11 +279,11 @@ describe("saveCustomProvider", () => {
const model = (
calls.config[0].config.provider as Record<
string,
{ models: Record<string, { variants?: Record<string, unknown> }> }
{ models: Record<string, { reasoning?: boolean | null; variants?: Record<string, unknown> }> }
>
).myprovider.models["model-1"]
expect(model.variants).toBeDefined()
expect(model.variants?.high).toBeDefined()
expect(model.reasoning).toBeNull()
expect(model.variants?.high).toBeNull()
expect(model.variants?.low).toBeNull()
})

Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/config/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const Model = Schema.Struct({
ai_sdk_provider: Schema.optional(Schema.Literals(AI_SDK_PROVIDERS)), // kilocode_change
release_date: Schema.optional(Schema.String),
attachment: Schema.optional(Schema.Boolean),
reasoning: Schema.optional(Schema.Boolean),
reasoning: Schema.optional(Schema.NullOr(Schema.Boolean)), // kilocode_change - allow null so reasoning can be removed via stripNulls on save
temperature: Schema.optional(Schema.Boolean),
tool_call: Schema.optional(Schema.Boolean),
interleaved: Schema.optional(
Expand Down
55 changes: 55 additions & 0 deletions packages/opencode/test/kilocode/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,61 @@ describe("kilocode indexing config", () => {
})
})

describe("custom provider model config", () => {
test("persists and removes reasoning across a global config reload", async () => {
await using globalTmp = await tmpdir()
const file = path.join(globalTmp.path, "kilo.json")
const prev = Global.Path.config
;(Global.Path as { config: string }).config = globalTmp.path
await clear()
await disposeAllInstances()

try {
await writeConfig(globalTmp.path, {
provider: {
custom: {
name: "Custom",
models: { model: { name: "Model" } },
},
},
})
await saveGlobal(
decode({
provider: {
custom: {
models: { model: { reasoning: true } },
},
},
}),
)
const added = JSON.parse(await Bun.file(file).text())
expect(added.provider.custom.models.model.reasoning).toBe(true)

await saveGlobal(
decode({
provider: {
custom: {
models: { model: { reasoning: null } },
},
},
}),
)
const written = JSON.parse(await Bun.file(file).text())
expect(written.provider.custom.models.model).not.toHaveProperty("reasoning")

await clear()
const reloaded = await Effect.runPromise(
Config.Service.use((svc) => svc.getGlobal()).pipe(Effect.scoped, Effect.provide(layer)),
)
expect(reloaded.provider?.custom?.models?.model?.reasoning).toBeUndefined()
} finally {
;(Global.Path as { config: string }).config = prev
await clear()
await disposeAllInstances()
}
})
})

describe("subagent variant overrides", () => {
test("removes one model override without removing sibling models", () => {
const patch = decode({
Expand Down
Loading