diff --git a/.changeset/small-model-fallback-requires-kilo-credentials.md b/.changeset/small-model-fallback-requires-kilo-credentials.md new file mode 100644 index 00000000000..57375c667e9 --- /dev/null +++ b/.changeset/small-model-fallback-requires-kilo-credentials.md @@ -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. diff --git a/packages/opencode/src/kilocode/provider/provider.ts b/packages/opencode/src/kilocode/provider/provider.ts index 756ae0e4cbc..e0ae755451e 100644 --- a/packages/opencode/src/kilocode/provider/provider.ts +++ b/packages/opencode/src/kilocode/provider/provider.ts @@ -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 }, + auth: unknown, + env: Record, +) { + 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 diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 670635c3179..f6240f33c90 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -43,6 +43,7 @@ import { patchKiloProviderAuth, publicKiloProvider, kiloSmallModelPriority, + hasKiloCredentials, buildTimeoutSignal, requestTimeout, wrapFirstByte, @@ -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 diff --git a/packages/opencode/test/kilocode/provider/provider.test.ts b/packages/opencode/test/kilocode/provider/provider.test.ts new file mode 100644 index 00000000000..4d53ddb9fc9 --- /dev/null +++ b/packages/opencode/test/kilocode/provider/provider.test.ts @@ -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() + +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" }, + }, + }, + }, + }, +) diff --git a/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts b/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts index 95e9004b5c6..9351e8d68d1 100644 --- a/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts @@ -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 } }, @@ -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) @@ -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") diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 23e8b49b71a..267186e6bc5 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -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")