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/small-model-fallback-requires-kilo-credentials.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Only route auxiliary tasks (session titles, commit messages, branch names) to the cloud kilo-auto/small model when kilo credentials are configured; otherwise fall back to the session's own model so offline and local-only setups keep working.
18 changes: 18 additions & 0 deletions packages/opencode/src/kilocode/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,24 @@ export function kiloSmallModelPriority(providerID: string): string[] | undefined
return undefined
}

/**
* True when the user has kilo credentials: a KILO_API_KEY env var, a stored
* auth entry, or an apiKey in the kilo provider config. Mirrors the hasKey
* check in the kilo custom loader. The kilo provider is autoloaded with an
* anonymous key even without credentials, so this gates the cloud
* kilo-auto/small fallback to users who can actually reach it.
*/
export function hasKiloCredentials(
cfg: { provider?: Record<string, { options?: { apiKey?: string } } | null> },
auth: unknown,
env: Record<string, string | undefined>,
) {
if (env.KILO_API_KEY) return true
if (auth) return true
if (cfg.provider?.["kilo"]?.options?.apiKey) return true
return false
}

// ---------------------------------------------------------------------------
// Fetch timeout wrappers
// Replaces AbortSignal.timeout() with a cancellable setTimeout+AbortController
Expand Down
17 changes: 14 additions & 3 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
patchKiloProviderAuth,
publicKiloProvider,
kiloSmallModelPriority,
hasKiloCredentials,
buildTimeoutSignal,
requestTimeout,
wrapFirstByte,
Expand Down Expand Up @@ -2062,9 +2063,19 @@ const layer = Layer.effect(
if (candidates[0]) return candidates[0]
}

// kilocode_change start - fall back to kilo's auto small model
const kiloFallback = s.providers[ProviderV2.ID.make("kilo")] ?? s.catalog[ProviderV2.ID.make("kilo")]
if (kiloFallback?.models["kilo-auto/small"]) return kiloFallback.models["kilo-auto/small"]
// kilocode_change start - fall back to kilo's auto small model only when the user actually has
// kilo credentials. The kilo provider is always autoloaded (anonymous key), so checking it
// unconditionally would route auxiliary tasks (session titles, commit messages, branch names)
// to the cloud for users without kilo access and break offline/local-only setups.
const kiloFallback = s.providers[ProviderV2.ID.make("kilo")]
if (kiloFallback?.models["kilo-auto/small"]) {
const hasCreds = hasKiloCredentials(
cfg,
yield* auth.get(ProviderV2.ID.make("kilo")).pipe(Effect.orDie),
yield* env.all(),
)
if (hasCreds) return kiloFallback.models["kilo-auto/small"]
}
// kilocode_change end

return undefined
Expand Down
84 changes: 84 additions & 0 deletions packages/opencode/test/kilocode/provider/provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { afterEach, expect } from "bun:test"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Effect } from "effect"
import { Env } from "@/env"
import { Plugin } from "@/plugin/index"
import { Provider } from "@/provider/provider"
import { disposeAllInstances } from "../../fixture/fixture"
import { testEffect } from "../../lib/effect"

const originalEnv = new Map<string, string | undefined>()

const rememberEnv = (key: string) => {
if (!originalEnv.has(key)) originalEnv.set(key, process.env[key])
}

const clearEnv = (key: string) =>
Effect.gen(function* () {
rememberEnv(key)
delete process.env[key]
yield* Env.use.remove(key)
})

afterEach(async () => {
for (const [key, value] of originalEnv) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
originalEnv.clear()
await disposeAllInstances()
})

const it = testEffect(LayerNode.compile(LayerNode.group([Provider.node, Env.node, Plugin.node])))

it.instance(
"getSmallModel returns undefined without kilo credentials when model IDs lack family metadata",
Effect.gen(function* () {
for (const key of ["KILO_API_KEY", "KILO_AUTH_CONTENT", "KILO_CONFIG_CONTENT"]) {
yield* clearEnv(key)
}
const model = yield* Provider.use.getSmallModel(ProviderV2.ID.make("test-provider"))
expect(model).toBeUndefined()
}),
{
config: {
provider: {
"test-provider": {
name: "Test Provider",
npm: "@ai-sdk/openai-compatible",
models: {
"gpt-5-nano": { release_date: "2026-01-01" },
},
options: { apiKey: "test-key" },
},
kilo: null,
},
},
},
)

it.instance(
"getSmallModel falls back to Kilo auto when the kilo provider is configured",
Effect.gen(function* () {
const model = yield* Provider.use.getSmallModel(ProviderV2.ID.make("test-provider"))
expect(model).toMatchObject({ providerID: "kilo", id: "kilo-auto/small" })
}),
{
config: {
provider: {
"test-provider": {
name: "Test Provider",
npm: "@ai-sdk/openai-compatible",
models: {
"gpt-5-nano": { release_date: "2026-01-01" },
},
options: { apiKey: "test-key" },
},
kilo: {
options: { apiKey: "kilo-key" },
},
},
},
},
)
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,8 @@ describe("SessionPrompt compaction safety", () => {
Effect.fnUntraced(function* ({ llm }) {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({})
// Explicit title so the auxiliary title-generation call is skipped and llm.calls stays exact.
const chat = yield* sessions.create({ title: "Pending request replay" })
const old = yield* user(chat.id, "old request")
yield* assistant(chat.id, old.id, {
tokens: { input: 95_000, output: 100, reasoning: 0, cache: { read: 0, write: 0 } },
Expand Down Expand Up @@ -466,7 +467,11 @@ describe("SessionPrompt compaction safety", () => {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const tools = finish !== "stop"
const chat = yield* sessions.create({ permission: [{ permission: "*", pattern: "*", action: "allow" }] })
// Explicit title so the auxiliary title-generation call is skipped and llm.calls stays exact.
const chat = yield* sessions.create({
title: "Completed work no-replay",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
})
yield* llm.push(
(tools ? reply().tool("glob", { pattern: "*.txt" }) : reply().text("answer"))
.finish(finish)
Expand Down Expand Up @@ -500,7 +505,8 @@ describe("SessionPrompt compaction safety", () => {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const compaction = yield* SessionCompaction.Service
const chat = yield* sessions.create({})
// Explicit title so the auxiliary title-generation call is skipped and llm.calls stays exact.
const chat = yield* sessions.create({ title: "Saved marker replay" })
const old = yield* user(chat.id, "old request")
yield* assistant(chat.id, old.id)
const request = yield* user(chat.id, "run the tool")
Expand Down
24 changes: 0 additions & 24 deletions packages/opencode/test/provider/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -769,30 +769,6 @@ it.instance(
},
)

it.instance(
// kilocode_change start - Kilo always has an auto-routed small-model fallback
"getSmallModel falls back to Kilo auto when model IDs lack family metadata",
Effect.gen(function* () {
const model = yield* Provider.use.getSmallModel(ProviderV2.ID.make("test-provider"))
expect(model).toMatchObject({ providerID: "kilo", id: "kilo-auto/small" })
}),
// kilocode_change end
{
config: {
provider: {
"test-provider": {
name: "Test Provider",
npm: "@ai-sdk/openai-compatible",
models: {
"gpt-5-nano": { release_date: "2026-01-01" },
},
options: { apiKey: "test-key" },
},
},
},
},
)

it.instance("getSmallModel skips inferred models for Azure", () =>
Effect.gen(function* () {
yield* set("AZURE_RESOURCE_NAME", "test-resource")
Expand Down
Loading