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-reason.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---

Support multiple provider APIs, adaptive thinking, split reasoning, and output effort variants for custom providers.
4 changes: 2 additions & 2 deletions packages/kilo-vscode/src/provider-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
sanitizeCustomProviderConfig,
withCustomProviderDeletions,
} from "./shared/custom-provider"
import { CUSTOM_PROVIDER_PACKAGE, KILO_AUTO, parseModelString } from "./shared/provider-model"
import { isCustomProviderPackage, KILO_AUTO, parseModelString } from "./shared/provider-model"
import { configFeatures } from "./features"

/**
Expand All @@ -33,7 +33,7 @@ function record(value: unknown): value is Record<string, unknown> {
}

function customProvider(config: unknown) {
return record(config) && config.npm === CUSTOM_PROVIDER_PACKAGE
return record(config) && isCustomProviderPackage(config.npm)
}

function same(a: unknown, b: unknown): boolean {
Expand Down
44 changes: 29 additions & 15 deletions packages/kilo-vscode/src/shared/custom-provider.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from "zod"
import { CUSTOM_PROVIDER_PACKAGE, PROVIDER_ID_PATTERN } from "./provider-model"
import { CUSTOM_PROVIDER_PACKAGE, CUSTOM_PROVIDER_PACKAGES, PROVIDER_ID_PATTERN } from "./provider-model"
import type { CustomProviderPackage } from "./provider-model"

const INVALID_PROVIDER_ID = "Invalid provider ID"
const INVALID_ENV = "Invalid environment variable name"
Expand All @@ -13,16 +14,18 @@ export const EnvSchema = z

const VariantConfigSchema = z.object({
enable_thinking: z.boolean().optional(),
thinking: z.object({ type: z.enum(["enabled", "disabled"]) }).optional(),
thinking: z.object({ type: z.enum(["enabled", "disabled", "adaptive"]) }).optional(),
reasoning_split: z.boolean().optional(),
reasoningEffort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh"]).optional(),
effort: z.enum(["low", "medium", "high", "xhigh", "max"]).optional(),
chat_template_args: z.object({ enable_thinking: z.boolean() }).optional(),
})

export type VariantConfig = z.infer<typeof VariantConfigSchema>

export const CustomProviderConfigSchema = z
.object({
npm: z.string().optional(),
npm: z.enum(CUSTOM_PROVIDER_PACKAGES).default(CUSTOM_PROVIDER_PACKAGE),
name: z.string().trim().min(1).max(200),
env: z.array(EnvSchema).max(1).optional(),
options: z
Expand Down Expand Up @@ -53,7 +56,7 @@ export const CustomProviderConfigSchema = z
.strict()

export type SanitizedProviderConfig = {
npm: typeof CUSTOM_PROVIDER_PACKAGE
npm: CustomProviderPackage
name: string
env?: string[]
options: {
Expand Down Expand Up @@ -118,7 +121,7 @@ export function normalizeCustomProviderConfig(
: undefined

return {
npm: CUSTOM_PROVIDER_PACKAGE,
npm: config.npm,
name: config.name.trim(),
...(config.env ? { env: config.env.map((item) => item.trim()) } : {}),
options: {
Expand Down Expand Up @@ -149,13 +152,14 @@ export function sanitizeCustomProviderConfig(provider: unknown): { value: Saniti
}

type AnyRecord = Record<string, unknown>
type VariantPatch = Partial<{ [Key in keyof VariantConfig]: VariantConfig[Key] | null }>
type ProviderPatch = Omit<SanitizedProviderConfig, "models"> & {
models: Record<
string,
null | {
name: string
reasoning?: true | null
variants?: Record<string, VariantConfig | null>
variants?: Record<string, VariantConfig | VariantPatch | null>
}
>
}
Expand All @@ -165,10 +169,11 @@ function isRecord(v: unknown): v is AnyRecord {
}

/**
* 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.
* Build a provider patch that includes null sentinels for model properties,
* variants, and variant options 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
Expand All @@ -185,11 +190,20 @@ export function withCustomProviderDeletions(existing: unknown, next: SanitizedPr
if (!isRecord(oldModel) || !isRecord(newModel)) continue
const oldVariants = isRecord(oldModel.variants) ? oldModel.variants : {}
const newVariants = isRecord(newModel.variants) ? newModel.variants : {}
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
const changes: Record<string, VariantPatch | null> = {}
for (const [name, oldVariant] of Object.entries(oldVariants)) {
if (!(name in newVariants)) {
changes[name] = null
continue
}
const newVariant = newVariants[name]
if (!isRecord(oldVariant) || !isRecord(newVariant)) continue
const removed = Object.keys(oldVariant).filter((key) => !(key in newVariant))
if (removed.length === 0) continue
const nulls = Object.fromEntries(removed.map((key) => [key, null]))
changes[name] = { ...newVariant, ...nulls } as VariantPatch
}
const variants = Object.keys(changes).length > 0 ? { ...newVariants, ...changes } : newModel.variants
patched[id] = {
...newModel,
...(variants ? { variants } : {}),
Expand Down
8 changes: 7 additions & 1 deletion packages/kilo-vscode/src/shared/provider-model.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export const KILO_PROVIDER_ID = "kilo"
export const KILO_AUTO = { providerID: KILO_PROVIDER_ID, modelID: "kilo-auto/free" } as const
export const CUSTOM_PROVIDER_PACKAGE = "@ai-sdk/openai-compatible"
export const CUSTOM_PROVIDER_PACKAGES = ["@ai-sdk/openai-compatible", "@ai-sdk/openai", "@ai-sdk/anthropic"] as const
export type CustomProviderPackage = (typeof CUSTOM_PROVIDER_PACKAGES)[number]
export const CUSTOM_PROVIDER_PACKAGE: CustomProviderPackage = "@ai-sdk/openai-compatible"
export const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/

export const PROVIDER_PRIORITY = [
Expand All @@ -13,6 +15,10 @@ export const PROVIDER_PRIORITY = [
"vercel",
] as const

export function isCustomProviderPackage(value: unknown): value is CustomProviderPackage {
return CUSTOM_PROVIDER_PACKAGES.includes(value as CustomProviderPackage)
}

export function parseModelString(raw: string | undefined | null) {
if (!raw) return null
const slash = raw.indexOf("/")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ function base(): FormState {
return {
providerID: "my-provider",
name: "My Provider",
npm: "@ai-sdk/openai-compatible",
baseURL: "https://example.com/v1",
apiKey: "",
models: [{ id: "model-1", name: "Model One", reasoning: false, variants: [] }],
Expand All @@ -28,6 +29,13 @@ function args(form: FormState) {
}

describe("validateCustomProvider – variant name validation", () => {
it("persists the selected provider package", () => {
const form = base()
form.npm = "@ai-sdk/openai"

expect(validateCustomProvider(args(form)).result?.config.npm).toBe("@ai-sdk/openai")
})

it("allows reconnecting a disabled provider id", () => {
const form = base()
const out = validateCustomProvider({
Expand Down Expand Up @@ -56,6 +64,8 @@ describe("validateCustomProvider – variant name validation", () => {
name: "fast",
enableThinking: undefined,
thinking: undefined,
splitReasoning: undefined,
outputEffort: undefined,
reasoningEffort: undefined,
chatTemplateArgs: undefined,
},
Expand All @@ -73,6 +83,8 @@ describe("validateCustomProvider – variant name validation", () => {
name: "",
enableThinking: undefined,
thinking: undefined,
splitReasoning: undefined,
outputEffort: undefined,
reasoningEffort: undefined,
chatTemplateArgs: undefined,
},
Expand All @@ -90,6 +102,8 @@ describe("validateCustomProvider – variant name validation", () => {
name: " ",
enableThinking: undefined,
thinking: undefined,
splitReasoning: undefined,
outputEffort: undefined,
reasoningEffort: undefined,
chatTemplateArgs: undefined,
},
Expand All @@ -107,13 +121,17 @@ describe("validateCustomProvider – variant name validation", () => {
name: "fast",
enableThinking: undefined,
thinking: undefined,
splitReasoning: undefined,
outputEffort: undefined,
reasoningEffort: undefined,
chatTemplateArgs: undefined,
},
{
name: "fast",
enableThinking: undefined,
thinking: undefined,
splitReasoning: undefined,
outputEffort: undefined,
reasoningEffort: undefined,
chatTemplateArgs: undefined,
},
Expand All @@ -131,6 +149,8 @@ describe("validateCustomProvider – variant name validation", () => {
name: "",
enableThinking: undefined,
thinking: undefined,
splitReasoning: undefined,
outputEffort: undefined,
reasoningEffort: undefined,
chatTemplateArgs: undefined,
},
Expand All @@ -148,11 +168,27 @@ describe("validateCustomProvider – variant name validation", () => {
const form = base()
form.models[0].reasoning = true
form.models[0].variants = [
{ name: "eco", enableThinking: true, thinking: undefined, reasoningEffort: "low", chatTemplateArgs: undefined },
{
name: "eco",
enableThinking: true,
thinking: "adaptive",
splitReasoning: false,
outputEffort: "max",
reasoningEffort: "low",
chatTemplateArgs: undefined,
},
]
const out = validateCustomProvider(args(form))
expect(out.result).toBeDefined()
const saved = out.result!.config.models["model-1"] as Record<string, unknown>
expect(saved.variants).toEqual({ eco: { enable_thinking: true, reasoningEffort: "low" } })
expect(saved.variants).toEqual({
eco: {
enable_thinking: true,
thinking: { type: "adaptive" },
reasoning_split: false,
effort: "max",
reasoningEffort: "low",
},
})
})
})
71 changes: 65 additions & 6 deletions packages/kilo-vscode/tests/unit/custom-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ import {
validateProviderID,
withCustomProviderDeletions,
} from "../../src/shared/custom-provider"
import { isCustomProviderPackage } from "../../src/shared/provider-model"

describe("isCustomProviderPackage", () => {
it("recognizes supported custom provider packages", () => {
expect(isCustomProviderPackage("@ai-sdk/openai-compatible")).toBe(true)
expect(isCustomProviderPackage("@ai-sdk/openai")).toBe(true)
expect(isCustomProviderPackage("@ai-sdk/anthropic")).toBe(true)
expect(isCustomProviderPackage("malicious-package")).toBe(false)
})
})

describe("validateProviderID", () => {
it("accepts valid provider ids", () => {
Expand Down Expand Up @@ -64,9 +74,9 @@ describe("resolveCustomProviderKey", () => {
})

describe("sanitizeCustomProviderConfig", () => {
it("normalizes config and forces the approved package", () => {
it("normalizes config and preserves an approved package", () => {
const result = sanitizeCustomProviderConfig({
npm: "malicious-package",
npm: "@ai-sdk/anthropic",
name: " My Provider ",
env: [" MY_PROVIDER_KEY "],
options: {
Expand All @@ -83,7 +93,7 @@ describe("sanitizeCustomProviderConfig", () => {

expect(result).toEqual({
value: {
npm: "@ai-sdk/openai-compatible",
npm: "@ai-sdk/anthropic",
name: "My Provider",
env: ["MY_PROVIDER_KEY"],
options: {
Expand All @@ -100,15 +110,31 @@ describe("sanitizeCustomProviderConfig", () => {
})
})

it("accepts models with chat_template_args variant", () => {
it("rejects unapproved packages", () => {
const result = sanitizeCustomProviderConfig({
npm: "malicious-package",
name: "Bad Provider",
options: { baseURL: "https://example.com/v1" },
models: { "model-1": { name: "Model One" } },
})

expect("error" in result ? result.error : "").toContain("Invalid enum value")
})

it("accepts supported thinking variant options", () => {
const result = sanitizeCustomProviderConfig({
name: "Thinking Provider",
options: { baseURL: "https://example.com/v1" },
models: {
"model-1": {
name: "Model One",
variants: {
thinking: { chat_template_args: { enable_thinking: true } },
thinking: {
thinking: { type: "adaptive" },
reasoning_split: true,
effort: "max",
chat_template_args: { enable_thinking: true },
},
},
},
},
Expand All @@ -123,7 +149,12 @@ describe("sanitizeCustomProviderConfig", () => {
"model-1": {
name: "Model One",
variants: {
thinking: { chat_template_args: { enable_thinking: true } },
thinking: {
thinking: { type: "adaptive" },
reasoning_split: true,
effort: "max",
chat_template_args: { enable_thinking: true },
},
},
},
},
Expand Down Expand Up @@ -190,6 +221,34 @@ describe("withCustomProviderDeletions", () => {
expect(model.variants?.low).toBeNull()
})

it("emits null when reasoning is disabled on a surviving model", () => {
const existing = { models: { keep: { name: "Keep", reasoning: true } } }
const result = withCustomProviderDeletions(existing, baseNext)
expect(result.models.keep).toEqual({ name: "Keep", reasoning: null })
})

it("emits null for options removed from a surviving variant", () => {
const existing = {
models: {
keep: {
name: "Keep",
variants: {
thinking: { thinking: { type: "adaptive" }, reasoning_split: true, reasoningEffort: "high" },
},
},
},
}
const next = {
...baseNext,
models: {
keep: { name: "Keep", variants: { thinking: { reasoningEffort: "high" } } },
},
} as typeof baseNext
const result = withCustomProviderDeletions(existing, next)
const model = (result.models as Record<string, { variants: Record<string, unknown> }>).keep
expect(model.variants.thinking).toEqual({ reasoningEffort: "high", thinking: null, reasoning_split: null })
})

it("does not touch variants on a model that is being deleted", () => {
const existing = { models: { gone: { name: "Gone", variants: { a: {} } } } }
const result = withCustomProviderDeletions(existing, baseNext)
Expand Down
Loading
Loading