From e781dd5ab5c50cf1e0d68f47ee5582808b7ee71c Mon Sep 17 00:00:00 2001 From: matt wilkie Date: Sun, 20 Sep 2026 21:08:09 -0700 Subject: [PATCH 01/11] fix(cli): prefer a cheap same-vendor small model for auxiliary tasks - Add cheapestSmallModel helper to pick the cheapest chat-capable model from the provider's own catalog - Update getSmallModel to use cheapestSmallModel before falling back to Kilo's auto small model - Add comprehensive tests for the new behavior - Update existing test to reflect new expected behavior --- .changeset/byok-cheap-small-model.md | 5 ++++ .../src/kilocode/provider/provider.ts | 22 ++++++++++++++++- packages/opencode/src/provider/provider.ts | 4 ++++ .../test/kilocode/provider/provider.test.ts | 8 +++---- .../opencode/test/provider/provider.test.ts | 24 ++++++++++++++++++- 5 files changed, 57 insertions(+), 6 deletions(-) create mode 100644 .changeset/byok-cheap-small-model.md diff --git a/.changeset/byok-cheap-small-model.md b/.changeset/byok-cheap-small-model.md new file mode 100644 index 000000000000..9b4d5e805a2c --- /dev/null +++ b/.changeset/byok-cheap-small-model.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Prefer the session provider's cheapest chat-capable model for auxiliary tasks (session titles, prompt enhance, commit messages, branch names) instead of falling back to Kilo's auto small model. BYOK users no longer silently draw Kilo credits when their configured provider has no recognized small-model family. \ No newline at end of file diff --git a/packages/opencode/src/kilocode/provider/provider.ts b/packages/opencode/src/kilocode/provider/provider.ts index e0ae755451ed..6462703c86dd 100644 --- a/packages/opencode/src/kilocode/provider/provider.ts +++ b/packages/opencode/src/kilocode/provider/provider.ts @@ -14,7 +14,7 @@ import { optionalOmitUndefined } from "@opencode-ai/core/schema" import { ProviderError } from "@/provider/error" import { Effect, Schema } from "effect" import type { LanguageModelV3 } from "@ai-sdk/provider" -import { mapValues, omit, pickBy } from "remeda" +import { mapValues, omit, pickBy, sortBy } from "remeda" import { reasoningSummary } from "./reasoning-summary" import type { Provider } from "@/provider/provider" import type { Auth } from "@/auth" @@ -299,6 +299,26 @@ export function kiloSmallModelPriority(providerID: string): string[] | undefined return undefined } +/** + * Picks the cheapest chat-capable model from the provider's own catalog as the + * small model for auxiliary tasks (session titles, prompt enhance, commit + * messages, branch names). Keeps those calls on the provider the user + * configured instead of falling through to Kilo's auto small model, which + * draws Kilo credits in BYOK setups. Chat-capable means text in and text out; + * tool calling is not required (e.g. Perplexity sonar and morph chat models + * advertise tool_call false). Models with no text output (image, audio, video) + * are skipped. Missing cost data sorts as free; ties break by release date + * and id, matching the catalog ordering in getSmallModel. + */ +export function cheapestSmallModel(models: Provider.Model[]) { + return sortBy( + models.filter((model) => model.capabilities.input.text && model.capabilities.output.text), + [(model) => model.cost.input + model.cost.output, "asc"], + [(model) => model.release_date, "desc"], + [(model) => model.id, "desc"], + ).at(0) +} + /** * 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 diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 15e9813dc522..f26f429b4642 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -45,6 +45,7 @@ import { publicKiloProvider, kiloSmallModelPriority, hasKiloCredentials, + cheapestSmallModel, buildTimeoutSignal, requestTimeout, wrapFirstByte, @@ -2131,6 +2132,9 @@ const layer = Layer.effect( if (candidates[0]) return candidates[0] } + const cheap = cheapestSmallModel(Object.values(provider.models)) // kilocode_change + if (cheap) return cheap // kilocode_change + // 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) diff --git a/packages/opencode/test/kilocode/provider/provider.test.ts b/packages/opencode/test/kilocode/provider/provider.test.ts index 4d53ddb9fc9f..916fea23c583 100644 --- a/packages/opencode/test/kilocode/provider/provider.test.ts +++ b/packages/opencode/test/kilocode/provider/provider.test.ts @@ -33,13 +33,13 @@ afterEach(async () => { 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", + "getSmallModel picks provider model 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() + expect(model).toMatchObject({ providerID: "test-provider", id: "gpt-5-nano" }) }), { config: { @@ -59,10 +59,10 @@ it.instance( ) it.instance( - "getSmallModel falls back to Kilo auto when the kilo provider is configured", + "getSmallModel picks provider model over kilo auto", Effect.gen(function* () { const model = yield* Provider.use.getSmallModel(ProviderV2.ID.make("test-provider")) - expect(model).toMatchObject({ providerID: "kilo", id: "kilo-auto/small" }) + expect(model).toMatchObject({ providerID: "test-provider", id: "gpt-5-nano" }) }), { config: { diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 50bd38c951fd..10c72f279859 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -769,6 +769,29 @@ it.instance( }, ) +// kilocode_change start +it.instance( + "getSmallModel picks the provider's own model when model IDs lack family metadata", + Effect.gen(function* () { + const model = yield* Provider.use.getSmallModel(ProviderV2.ID.make("test-provider")) + expect(model).toMatchObject({ providerID: "test-provider", id: "gpt-5-nano" }) + }), + { + 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" }, + }, + }, + }, + }, +) +// kilocode_change end it.instance("getSmallModel skips inferred models for Azure", () => Effect.gen(function* () { yield* set("AZURE_RESOURCE_NAME", "test-resource") @@ -777,7 +800,6 @@ it.instance("getSmallModel skips inferred models for Azure", () => expect(model).toBeUndefined() }), ) - it.instance("getSmallModel skips inferred models for Azure Cognitive Services", () => Effect.gen(function* () { yield* set("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "test-resource") From c04c48ecb4ed42ecc24e398771cf7d5f9e39b842 Mon Sep 17 00:00:00 2001 From: Kilo Code Cloud Date: Mon, 21 Sep 2026 19:09:19 +0000 Subject: [PATCH 02/11] fix(cli): exclude embedding and rerank models from cheapestSmallModel Embedding and rerank models pass the text-in/text-out filter but cannot serve auxiliary chat tasks, so a free nomic-embed-text would beat every real chat model on price. Also restores the #13192 contract tests (undefined without kilo credentials, kilo-auto/small with credentials) and adds direct coverage for cheapestSmallModel ordering, tie-breaks and the family-preference precedence in getSmallModel. --- .../src/kilocode/provider/provider.ts | 11 +- .../test/kilocode/provider/provider.test.ts | 170 +++++++++++++++++- 2 files changed, 177 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/kilocode/provider/provider.ts b/packages/opencode/src/kilocode/provider/provider.ts index 6462703c86dd..b777a361ba99 100644 --- a/packages/opencode/src/kilocode/provider/provider.ts +++ b/packages/opencode/src/kilocode/provider/provider.ts @@ -307,12 +307,17 @@ export function kiloSmallModelPriority(providerID: string): string[] | undefined * draws Kilo credits in BYOK setups. Chat-capable means text in and text out; * tool calling is not required (e.g. Perplexity sonar and morph chat models * advertise tool_call false). Models with no text output (image, audio, video) - * are skipped. Missing cost data sorts as free; ties break by release date - * and id, matching the catalog ordering in getSmallModel. + * are skipped, and so are embedding and rerank models: they also take text in + * and return text scores, but requests to them cannot produce titles or commit + * messages. Missing cost data sorts as free; ties break by release date and + * id, matching the catalog ordering in getSmallModel. */ export function cheapestSmallModel(models: Provider.Model[]) { return sortBy( - models.filter((model) => model.capabilities.input.text && model.capabilities.output.text), + models.filter((model) => { + if (!model.capabilities.input.text || !model.capabilities.output.text) return false + return !/(embed|rerank)/.test(`${model.family} ${model.id}`) + }), [(model) => model.cost.input + model.cost.output, "asc"], [(model) => model.release_date, "desc"], [(model) => model.id, "desc"], diff --git a/packages/opencode/test/kilocode/provider/provider.test.ts b/packages/opencode/test/kilocode/provider/provider.test.ts index 916fea23c583..107f16621360 100644 --- a/packages/opencode/test/kilocode/provider/provider.test.ts +++ b/packages/opencode/test/kilocode/provider/provider.test.ts @@ -1,10 +1,11 @@ -import { afterEach, expect } from "bun:test" +import { afterEach, describe, expect, test } 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 { cheapestSmallModel } from "@/kilocode/provider/provider" import { disposeAllInstances } from "../../fixture/fixture" import { testEffect } from "../../lib/effect" @@ -82,3 +83,170 @@ it.instance( }, }, ) + +it.instance( + "getSmallModel prefers family match over a cheaper unlisted model", + Effect.gen(function* () { + const model = yield* Provider.use.getSmallModel(ProviderV2.ID.make("test-provider")) + expect(model).toMatchObject({ providerID: "test-provider", id: "new-flash" }) + }), + { + config: { + provider: { + "test-provider": { + name: "Test Provider", + npm: "@ai-sdk/openai-compatible", + models: { + "new-flash": { family: "gemini-flash", release_date: "2026-01-01", cost: { input: 3, output: 15 } }, + "cheap-thing": { release_date: "2026-06-01", cost: { input: 0, output: 0 } }, + }, + options: { apiKey: "test-key" }, + }, + }, + }, + }, +) + +// The #13192 contract: without kilo credentials the cloud fallback stays +// unreachable, so auxiliary tasks fall back to the session's own model +// (getSmallModel returning undefined). +it.instance( + "getSmallModel returns undefined without kilo credentials when the provider has no chat-capable model", + 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: { + "nomic-embed-text": { release_date: "2026-01-01", modalities: { input: ["text"], output: [] } }, + }, + options: { apiKey: "test-key" }, + }, + kilo: null, + }, + }, + }, +) + +it.instance( + "getSmallModel falls back to kilo-auto/small with kilo credentials when the provider has no chat-capable model", + 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: { + "nomic-embed-text": { release_date: "2026-01-01", modalities: { input: ["text"], output: [] } }, + }, + options: { apiKey: "test-key" }, + }, + kilo: { + options: { apiKey: "kilo-key" }, + }, + }, + }, + }, +) + +describe("cheapestSmallModel", () => { + const model = (over: { id: string } & Record): Provider.Model => + ({ + providerID: "test", + name: over.id, + family: "", + capabilities: { + toolcall: true, + attachment: false, + reasoning: false, + temperature: true, + input: { text: true, image: false, audio: false, video: false, pdf: false }, + output: { text: true, image: false, audio: false, video: false, pdf: false }, + }, + cost: { input: 1, output: 1, cache: { read: 0, write: 0 } }, + release_date: "2026-01-01", + options: {}, + headers: {}, + ...over, + }) as Provider.Model + + test("picks the cheapest text model", () => { + const picked = cheapestSmallModel([ + model({ id: "premium", cost: { input: 3, output: 15, cache: { read: 0, write: 0 } } }), + model({ id: "budget", cost: { input: 0.1, output: 0.2, cache: { read: 0, write: 0 } } }), + model({ id: "mid", cost: { input: 0.5, output: 1, cache: { read: 0, write: 0 } } }), + ]) + expect(picked).toMatchObject({ id: "budget" }) + }) + + test("breaks cost ties by release date, then id", () => { + const same = model({ id: "b", release_date: "2026-01-01" }) + const newer = model({ id: "a", release_date: "2026-06-01" }) + const sameAsNewer = model({ id: "z", release_date: "2026-06-01" }) + expect(cheapestSmallModel([same, newer])).toMatchObject({ id: "a" }) + expect(cheapestSmallModel([same, sameAsNewer])).toMatchObject({ id: "z" }) + }) + + test("skips embedding and rerank models even when they are free", () => { + const picked = cheapestSmallModel([ + model({ + id: "nomic-embed-text", + family: "nomic-embed", + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + }), + model({ id: "jina-reranker-v3", cost: { input: 0, output: 0, cache: { read: 0, write: 0 } } }), + model({ id: "gpt-oss-120b", cost: { input: 0.15, output: 0.6, cache: { read: 0, write: 0 } } }), + ]) + expect(picked).toMatchObject({ id: "gpt-oss-120b" }) + }) + + test("keeps tool-less chat models like sonar", () => { + const picked = cheapestSmallModel([ + model({ + id: "sonar", + family: "sonar", + capabilities: { + toolcall: false, + attachment: false, + reasoning: false, + temperature: true, + input: { text: true, image: false, audio: false, video: false, pdf: false }, + output: { text: true, image: false, audio: false, video: false, pdf: false }, + }, + cost: { input: 1, output: 1, cache: { read: 0, write: 0 } }, + }), + ]) + expect(picked).toMatchObject({ id: "sonar" }) + }) + + test("skips models without text output", () => { + const picked = cheapestSmallModel([ + model({ + id: "image-gen", + capabilities: { + toolcall: true, + attachment: false, + reasoning: false, + temperature: true, + input: { text: true, image: false, audio: false, video: false, pdf: false }, + output: { text: false, image: true, audio: false, video: false, pdf: false }, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + }), + model({ id: "chat", cost: { input: 0.5, output: 0.5, cache: { read: 0, write: 0 } } }), + ]) + expect(picked).toMatchObject({ id: "chat" }) + }) +}) From 6ad333e7a4b74315c59265ce8d1d9972d0e4c267 Mon Sep 17 00:00:00 2001 From: matt wilkie Date: Sun, 27 Sep 2026 03:46:28 -0700 Subject: [PATCH 03/11] test(cli): widen processor-create wait in cancellation race test The two awaitWithTimeout calls relied on the 2 second default, which is too tight on loaded CI runners: the readiness signal never arrived within the window and the test failed with 'processor.create did not start'. Pass an explicit 10 seconds, matching the rest of the file, and raise the test budget to 20 seconds so the inner timeout reports first. --- packages/opencode/test/session/prompt.test.ts | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 836c33d01f82..60822346792d 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1835,7 +1835,13 @@ raceNoLLMServer.instance( const firstCreate = yield* Deferred.make() processorCreateStarted.push(firstCreate) const first = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* awaitWithTimeout(Deferred.await(firstCreate), "processor.create did not start for first turn") + // kilocode_change start - explicit budget: the 2s default starves on loaded CI runners + yield* awaitWithTimeout( + Deferred.await(firstCreate), + "processor.create did not start for first turn", + "10 seconds", + ) + // kilocode_change end yield* prompt.cancel(chat.id) const firstExit = yield* Fiber.await(first) @@ -1861,7 +1867,13 @@ raceNoLLMServer.instance( const secondCreate = yield* Deferred.make() processorCreateStarted.push(secondCreate) const second = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* awaitWithTimeout(Deferred.await(secondCreate), "processor.create did not start for second turn") + // kilocode_change start - explicit budget: the 2s default starves on loaded CI runners + yield* awaitWithTimeout( + Deferred.await(secondCreate), + "processor.create did not start for second turn", + "10 seconds", + ) + // kilocode_change end yield* prompt.cancel(chat.id) const secondExit = yield* Fiber.await(second) @@ -1896,7 +1908,9 @@ raceNoLLMServer.instance( } }), { config: cfg }, - 10_000, + // kilocode_change start - headroom for the two explicit waits above + 20_000, + // kilocode_change end ) noLLMServer.instance( From d1f91204650f97d238af9fc2cf4a040b40d895ef Mon Sep 17 00:00:00 2001 From: matt wilkie Date: Mon, 28 Sep 2026 13:17:21 -0700 Subject: [PATCH 04/11] fix(cli): harden non-generative exclusion and drop shared test drift --- .../src/kilocode/provider/provider.ts | 11 ++++++++- .../test/kilocode/provider/provider.test.ts | 19 ++++++++++----- .../opencode/test/provider/provider.test.ts | 24 +------------------ packages/opencode/test/session/prompt.test.ts | 20 +++------------- 4 files changed, 27 insertions(+), 47 deletions(-) diff --git a/packages/opencode/src/kilocode/provider/provider.ts b/packages/opencode/src/kilocode/provider/provider.ts index b777a361ba99..61f8ade6cb16 100644 --- a/packages/opencode/src/kilocode/provider/provider.ts +++ b/packages/opencode/src/kilocode/provider/provider.ts @@ -311,12 +311,21 @@ export function kiloSmallModelPriority(providerID: string): string[] | undefined * and return text scores, but requests to them cannot produce titles or commit * messages. Missing cost data sorts as free; ties break by release date and * id, matching the catalog ordering in getSmallModel. + * + * Non-generative exclusion is a case-insensitive id/family heuristic: besides + * the obvious embed/rerank substrings it covers common embedding families + * (bge, gte, e5, uae, instructor, colbert, voyage, sentence-similarity) whose + * ids carry no embed/rerank token (e.g. intfloat/e5-mistral-7b-instruct, + * bge-m3, Qwen/Qwen3-Reranker-0.6B). */ +const NON_GENERATIVE_RE = + /embed|rerank|sentence[-_ ]similarity|text-embedding|bge|gte-?(?:large|base|small)?|e5-|-e5\b|\buae\b|instructor|colbert|voyage(?![a-z])/i + export function cheapestSmallModel(models: Provider.Model[]) { return sortBy( models.filter((model) => { if (!model.capabilities.input.text || !model.capabilities.output.text) return false - return !/(embed|rerank)/.test(`${model.family} ${model.id}`) + return !NON_GENERATIVE_RE.test(`${model.family} ${model.id}`) }), [(model) => model.cost.input + model.cost.output, "asc"], [(model) => model.release_date, "desc"], diff --git a/packages/opencode/test/kilocode/provider/provider.test.ts b/packages/opencode/test/kilocode/provider/provider.test.ts index 107f16621360..ed1232d0f8c4 100644 --- a/packages/opencode/test/kilocode/provider/provider.test.ts +++ b/packages/opencode/test/kilocode/provider/provider.test.ts @@ -200,18 +200,25 @@ describe("cheapestSmallModel", () => { }) test("skips embedding and rerank models even when they are free", () => { + const free = { input: 0, output: 0, cache: { read: 0, write: 0 } } const picked = cheapestSmallModel([ - model({ - id: "nomic-embed-text", - family: "nomic-embed", - cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, - }), - model({ id: "jina-reranker-v3", cost: { input: 0, output: 0, cache: { read: 0, write: 0 } } }), + model({ id: "nomic-embed-text", family: "nomic-embed", cost: free }), + model({ id: "jina-reranker-v3", cost: free }), + model({ id: "Qwen/Qwen3-Reranker-0.6B", cost: free }), + model({ id: "intfloat/e5-mistral-7b-instruct", cost: free }), + model({ id: "bge-m3", cost: free }), model({ id: "gpt-oss-120b", cost: { input: 0.15, output: 0.6, cache: { read: 0, write: 0 } } }), ]) expect(picked).toMatchObject({ id: "gpt-oss-120b" }) }) + test("keeps similarly-named chat models", () => { + const picked = cheapestSmallModel([ + model({ id: "voyager-chat", cost: { input: 0.5, output: 0.5, cache: { read: 0, write: 0 } } }), + ]) + expect(picked).toMatchObject({ id: "voyager-chat" }) + }) + test("keeps tool-less chat models like sonar", () => { const picked = cheapestSmallModel([ model({ diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 10c72f279859..50bd38c951fd 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -769,29 +769,6 @@ it.instance( }, ) -// kilocode_change start -it.instance( - "getSmallModel picks the provider's own model when model IDs lack family metadata", - Effect.gen(function* () { - const model = yield* Provider.use.getSmallModel(ProviderV2.ID.make("test-provider")) - expect(model).toMatchObject({ providerID: "test-provider", id: "gpt-5-nano" }) - }), - { - 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" }, - }, - }, - }, - }, -) -// kilocode_change end it.instance("getSmallModel skips inferred models for Azure", () => Effect.gen(function* () { yield* set("AZURE_RESOURCE_NAME", "test-resource") @@ -800,6 +777,7 @@ it.instance("getSmallModel skips inferred models for Azure", () => expect(model).toBeUndefined() }), ) + it.instance("getSmallModel skips inferred models for Azure Cognitive Services", () => Effect.gen(function* () { yield* set("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "test-resource") diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 60822346792d..836c33d01f82 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1835,13 +1835,7 @@ raceNoLLMServer.instance( const firstCreate = yield* Deferred.make() processorCreateStarted.push(firstCreate) const first = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - // kilocode_change start - explicit budget: the 2s default starves on loaded CI runners - yield* awaitWithTimeout( - Deferred.await(firstCreate), - "processor.create did not start for first turn", - "10 seconds", - ) - // kilocode_change end + yield* awaitWithTimeout(Deferred.await(firstCreate), "processor.create did not start for first turn") yield* prompt.cancel(chat.id) const firstExit = yield* Fiber.await(first) @@ -1867,13 +1861,7 @@ raceNoLLMServer.instance( const secondCreate = yield* Deferred.make() processorCreateStarted.push(secondCreate) const second = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - // kilocode_change start - explicit budget: the 2s default starves on loaded CI runners - yield* awaitWithTimeout( - Deferred.await(secondCreate), - "processor.create did not start for second turn", - "10 seconds", - ) - // kilocode_change end + yield* awaitWithTimeout(Deferred.await(secondCreate), "processor.create did not start for second turn") yield* prompt.cancel(chat.id) const secondExit = yield* Fiber.await(second) @@ -1908,9 +1896,7 @@ raceNoLLMServer.instance( } }), { config: cfg }, - // kilocode_change start - headroom for the two explicit waits above - 20_000, - // kilocode_change end + 10_000, ) noLLMServer.instance( From 20b764847828b503960165d7e8125fbb9671ee5f Mon Sep 17 00:00:00 2001 From: maphew <486200+maphew@users.noreply.github.com> Date: Sun, 11 Oct 2026 05:00:59 +0000 Subject: [PATCH 05/11] fix(cli): gate cheap small-model pick on availability and safety --- .../src/kilocode/provider/provider.ts | 45 +++++++++++-- packages/opencode/src/provider/provider.ts | 9 ++- .../test/kilocode/provider/provider.test.ts | 67 ++++++++++++++++++- 3 files changed, 111 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/kilocode/provider/provider.ts b/packages/opencode/src/kilocode/provider/provider.ts index 61f8ade6cb16..ac6a0a61db43 100644 --- a/packages/opencode/src/kilocode/provider/provider.ts +++ b/packages/opencode/src/kilocode/provider/provider.ts @@ -312,20 +312,25 @@ export function kiloSmallModelPriority(providerID: string): string[] | undefined * messages. Missing cost data sorts as free; ties break by release date and * id, matching the catalog ordering in getSmallModel. * - * Non-generative exclusion is a case-insensitive id/family heuristic: besides - * the obvious embed/rerank substrings it covers common embedding families - * (bge, gte, e5, uae, instructor, colbert, voyage, sentence-similarity) whose - * ids carry no embed/rerank token (e.g. intfloat/e5-mistral-7b-instruct, - * bge-m3, Qwen/Qwen3-Reranker-0.6B). + * Non-generative exclusion is a case-insensitive id/family/api-id heuristic: + * besides the obvious embed/rerank substrings it covers common embedding + * families (bge, gte, e5, uae, instructor, colbert, voyage, + * sentence-similarity) whose ids carry no embed/rerank token (e.g. + * intfloat/e5-mistral-7b-instruct, bge-m3, Qwen/Qwen3-Reranker-0.6B), plus + * safety/classification models (guard, safeguard, moderation, shield, + * llama-guard, shieldgemma) whose text output is a label rather than + * generated prose. The api id is checked alongside the alias/family so an + * alias like `vectors` pointing at `nomic-embed-text` cannot bypass the + * exclusion. */ const NON_GENERATIVE_RE = - /embed|rerank|sentence[-_ ]similarity|text-embedding|bge|gte-?(?:large|base|small)?|e5-|-e5\b|\buae\b|instructor|colbert|voyage(?![a-z])/i + /embed|rerank|sentence[-_ ]similarity|text-embedding|bge|gte-?(?:large|base|small)?|e5-|-e5\b|\buae\b|instructor|colbert|voyage(?![a-z])|guard|safeguard|moderation|shield|llama[-_]?guard|shieldgemma|toxic|harm|pii|nsfw/i export function cheapestSmallModel(models: Provider.Model[]) { return sortBy( models.filter((model) => { if (!model.capabilities.input.text || !model.capabilities.output.text) return false - return !NON_GENERATIVE_RE.test(`${model.family} ${model.id}`) + return !NON_GENERATIVE_RE.test(`${model.family} ${model.id} ${model.api?.id ?? ""}`) }), [(model) => model.cost.input + model.cost.output, "asc"], [(model) => model.release_date, "desc"], @@ -333,6 +338,32 @@ export function cheapestSmallModel(models: Provider.Model[]) { ).at(0) } +/** + * True when the provider routes to a local server (LM Studio, Ollama, ...). + * Local catalogs list every known model, but the server only serves installed + * ones — the cheapest catalog entry can name a model the user never installed + * (e.g. openai/gpt-oss-20b while the session uses Qwen). Callers fall back to + * the session's own model when getSmallModel returns undefined, so the cheap + * pick is skipped for loopback providers. An explicit non-loopback baseURL + * means the provider points at a remote server and the cheap pick applies. + */ +export function isLoopbackProvider(provider: { options?: Record; models: Record }) { + const raw = provider.options?.baseURL ?? provider.options?.baseUrl + if (typeof raw === "string" && raw !== "") return isLoopbackURL(raw) + const urls = Object.values(provider.models).map((model) => model.api?.url).filter(Boolean) + if (urls.length === 0) return false + return urls.every(isLoopbackURL) +} + +function isLoopbackURL(raw: string) { + try { + const host = new URL(raw).hostname.toLowerCase() + return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]" + } catch { + return false + } +} + /** * 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 diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index f26f429b4642..75cef956d3b4 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -46,6 +46,7 @@ import { kiloSmallModelPriority, hasKiloCredentials, cheapestSmallModel, + isLoopbackProvider, buildTimeoutSignal, requestTimeout, wrapFirstByte, @@ -2132,8 +2133,12 @@ const layer = Layer.effect( if (candidates[0]) return candidates[0] } - const cheap = cheapestSmallModel(Object.values(provider.models)) // kilocode_change - if (cheap) return cheap // kilocode_change + const cheap = // kilocode_change - skip loopback providers: their catalog lists + // every known model but the server only serves installed ones, so the + // cheapest entry can name a model the user never installed. Returning + // undefined keeps the session-model fallback in the callers. + (isLoopbackProvider(provider) ? undefined : cheapestSmallModel(Object.values(provider.models))) + if (cheap) return cheap // 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 diff --git a/packages/opencode/test/kilocode/provider/provider.test.ts b/packages/opencode/test/kilocode/provider/provider.test.ts index ed1232d0f8c4..c0e4f2f59c75 100644 --- a/packages/opencode/test/kilocode/provider/provider.test.ts +++ b/packages/opencode/test/kilocode/provider/provider.test.ts @@ -5,7 +5,7 @@ import { Effect } from "effect" import { Env } from "@/env" import { Plugin } from "@/plugin/index" import { Provider } from "@/provider/provider" -import { cheapestSmallModel } from "@/kilocode/provider/provider" +import { cheapestSmallModel, isLoopbackProvider } from "@/kilocode/provider/provider" import { disposeAllInstances } from "../../fixture/fixture" import { testEffect } from "../../lib/effect" @@ -110,6 +110,34 @@ it.instance( // The #13192 contract: without kilo credentials the cloud fallback stays // unreachable, so auxiliary tasks fall back to the session's own model // (getSmallModel returning undefined). +it.instance( + "getSmallModel returns undefined for loopback providers so callers keep the session model", + 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("lmstudio")) + expect(model).toBeUndefined() + }), + { + config: { + provider: { + lmstudio: { + name: "LM Studio", + npm: "@ai-sdk/openai-compatible", + models: { + // Catalog lists every known model, but only installed ones are + // served. The cheap pick must not name an uninstalled model. + "openai/gpt-oss-20b": { release_date: "2026-01-01" }, + "qwen/qwen3-30b-a3b-2507": { release_date: "2026-02-01" }, + }, + options: { baseURL: "http://localhost:1234/v1" }, + }, + kilo: null, + }, + }, + }, +) it.instance( "getSmallModel returns undefined without kilo credentials when the provider has no chat-capable model", Effect.gen(function* () { @@ -256,4 +284,41 @@ describe("cheapestSmallModel", () => { ]) expect(picked).toMatchObject({ id: "chat" }) }) + + test("skips safety and classification models even when they are free", () => { + const free = { input: 0, output: 0, cache: { read: 0, write: 0 } } + const picked = cheapestSmallModel([ + model({ id: "gpt-oss-safeguard-120b", cost: free }), + model({ id: "qwen3guard-gen-8b", cost: free }), + model({ id: "llama-guard-4-12b", cost: free }), + model({ id: "chat", cost: { input: 0.5, output: 0.5, cache: { read: 0, write: 0 } } }), + ]) + expect(picked).toMatchObject({ id: "chat" }) + }) + + test("checks the api id so an alias cannot bypass the embedding exclusion", () => { + const free = { input: 0, output: 0, cache: { read: 0, write: 0 } } + const picked = cheapestSmallModel([ + model({ id: "vectors", api: { id: "nomic-embed-text" }, cost: free }), + model({ id: "chat", api: { id: "chat" }, cost: { input: 0.5, output: 0.5, cache: { read: 0, write: 0 } } }), + ]) + expect(picked).toMatchObject({ id: "chat" }) + }) +}) + +describe("isLoopbackProvider", () => { + test("loopback baseURL marks a local provider", () => { + expect(isLoopbackProvider({ options: { baseURL: "http://localhost:1234/v1" }, models: {} })).toBe(true) + expect(isLoopbackProvider({ options: { baseURL: "http://127.0.0.1:11434/v1" }, models: {} })).toBe(true) + }) + + test("remote baseURL is not local even for a local-sounding provider", () => { + expect(isLoopbackProvider({ options: { baseURL: "https://api.openai.com/v1" }, models: {} })).toBe(false) + }) + + test("loopback model urls mark a local provider without a baseURL", () => { + const url = "http://127.0.0.1:1234/v1" + expect(isLoopbackProvider({ options: {}, models: { a: { api: { url } } as any } })).toBe(true) + expect(isLoopbackProvider({ options: {}, models: {} })).toBe(false) + }) }) From 549678dd17b1ca697ac2924be8b51c9e3f184906 Mon Sep 17 00:00:00 2001 From: matt wilkie Date: Tue, 13 Oct 2026 13:18:56 -0700 Subject: [PATCH 06/11] fix(cli): wrap loopback small-model skip in annotation block --- packages/opencode/src/provider/provider.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 75cef956d3b4..8fcd90849346 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -2133,12 +2133,13 @@ const layer = Layer.effect( if (candidates[0]) return candidates[0] } - const cheap = // kilocode_change - skip loopback providers: their catalog lists - // every known model but the server only serves installed ones, so the - // cheapest entry can name a model the user never installed. Returning - // undefined keeps the session-model fallback in the callers. - (isLoopbackProvider(provider) ? undefined : cheapestSmallModel(Object.values(provider.models))) + // kilocode_change start - skip loopback providers: their catalog lists + // every known model but the server only serves installed ones, so the + // cheapest entry can name a model the user never installed. Returning + // undefined keeps the session-model fallback in the callers. + const cheap = isLoopbackProvider(provider) ? undefined : cheapestSmallModel(Object.values(provider.models)) if (cheap) return cheap + // kilocode_change end // 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 From 4c042c39e49a46fba063ad9bcc569a443619a9f4 Mon Sep 17 00:00:00 2001 From: matt wilkie Date: Sat, 17 Oct 2026 07:40:04 -0700 Subject: [PATCH 07/11] fix(cli): keep session-model fallback for local servers on LAN hostnames --- .../src/kilocode/provider/provider.ts | 22 ++++++++++++++++--- packages/opencode/src/provider/provider.ts | 2 +- .../test/kilocode/provider/provider.test.ts | 20 +++++++++++++++++ 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/kilocode/provider/provider.ts b/packages/opencode/src/kilocode/provider/provider.ts index ac6a0a61db43..25bdc0310313 100644 --- a/packages/opencode/src/kilocode/provider/provider.ts +++ b/packages/opencode/src/kilocode/provider/provider.ts @@ -338,16 +338,32 @@ export function cheapestSmallModel(models: Provider.Model[]) { ).at(0) } +/** + * Provider ids that always front a local model server (LM Studio, Ollama, + * Atomic Chat, Anaconda Desktop). Their catalogs list every known model, but + * the server only serves installed ones — regardless of which hostname the + * user points them at (localhost, a LAN ip, host.docker.internal, ...). + */ +const LOCAL_SERVER_IDS = new Set(["lmstudio", "ollama", "atomic-chat", "anaconda-desktop"]) + /** * True when the provider routes to a local server (LM Studio, Ollama, ...). * Local catalogs list every known model, but the server only serves installed * ones — the cheapest catalog entry can name a model the user never installed * (e.g. openai/gpt-oss-20b while the session uses Qwen). Callers fall back to * the session's own model when getSmallModel returns undefined, so the cheap - * pick is skipped for loopback providers. An explicit non-loopback baseURL - * means the provider points at a remote server and the cheap pick applies. + * pick is skipped for local providers. A hostname check alone is not enough: + * a local server remains local when reached over the LAN + * (192.168.x.x, host.docker.internal, ollama.local), so known local-server + * ids always take the safe fallback. Only an unrelated remote endpoint keeps + * the cheap pick. */ -export function isLoopbackProvider(provider: { options?: Record; models: Record }) { +export function isLoopbackProvider(provider: { + id?: unknown + options?: Record + models: Record +}) { + if (typeof provider.id === "string" && LOCAL_SERVER_IDS.has(provider.id)) return true const raw = provider.options?.baseURL ?? provider.options?.baseUrl if (typeof raw === "string" && raw !== "") return isLoopbackURL(raw) const urls = Object.values(provider.models).map((model) => model.api?.url).filter(Boolean) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 8a0c14958595..2b8c1cac7e3d 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -2143,7 +2143,7 @@ const layer = Layer.effect( if (candidates[0]) return candidates[0] } - // kilocode_change start - skip loopback providers: their catalog lists + // kilocode_change start - skip local-server providers: their catalog lists // every known model but the server only serves installed ones, so the // cheapest entry can name a model the user never installed. Returning // undefined keeps the session-model fallback in the callers. diff --git a/packages/opencode/test/kilocode/provider/provider.test.ts b/packages/opencode/test/kilocode/provider/provider.test.ts index c0e4f2f59c75..3b91795f6d8b 100644 --- a/packages/opencode/test/kilocode/provider/provider.test.ts +++ b/packages/opencode/test/kilocode/provider/provider.test.ts @@ -321,4 +321,24 @@ describe("isLoopbackProvider", () => { expect(isLoopbackProvider({ options: {}, models: { a: { api: { url } } as any } })).toBe(true) expect(isLoopbackProvider({ options: {}, models: {} })).toBe(false) }) + + test("known local-server ids stay local on LAN and custom hostnames", () => { + // A local server remains install-gated when reached over the LAN: the + // catalog still lists models the user never installed. + for (const id of ["lmstudio", "ollama", "atomic-chat", "anaconda-desktop"]) { + for (const baseURL of [ + "http://192.168.1.50:1234/v1", + "http://host.docker.internal:11434/v1", + "http://ollama.local:11434/v1", + ]) { + expect(isLoopbackProvider({ id, options: { baseURL }, models: {} })).toBe(true) + } + } + }) + + test("unknown ids on remote hosts keep the cheap catalog pick", () => { + expect(isLoopbackProvider({ id: "openai", options: { baseURL: "https://api.openai.com/v1" }, models: {} })).toBe( + false, + ) + }) }) From b5a8ddf8b639baadb5e892fb4ce2581227918317 Mon Sep 17 00:00:00 2001 From: matt wilkie Date: Thu, 22 Oct 2026 13:37:13 -0700 Subject: [PATCH 08/11] test(provider): exercise the id tiebreak and drop an any from the loopback guard --- packages/opencode/src/kilocode/provider/provider.ts | 2 +- packages/opencode/test/kilocode/provider/provider.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/kilocode/provider/provider.ts b/packages/opencode/src/kilocode/provider/provider.ts index 25bdc0310313..fd1a7dd8a627 100644 --- a/packages/opencode/src/kilocode/provider/provider.ts +++ b/packages/opencode/src/kilocode/provider/provider.ts @@ -360,7 +360,7 @@ const LOCAL_SERVER_IDS = new Set(["lmstudio", "ollama", "atomic-chat", "anaconda */ export function isLoopbackProvider(provider: { id?: unknown - options?: Record + options?: Record models: Record }) { if (typeof provider.id === "string" && LOCAL_SERVER_IDS.has(provider.id)) return true diff --git a/packages/opencode/test/kilocode/provider/provider.test.ts b/packages/opencode/test/kilocode/provider/provider.test.ts index 3b91795f6d8b..2396053c6c8e 100644 --- a/packages/opencode/test/kilocode/provider/provider.test.ts +++ b/packages/opencode/test/kilocode/provider/provider.test.ts @@ -224,7 +224,7 @@ describe("cheapestSmallModel", () => { const newer = model({ id: "a", release_date: "2026-06-01" }) const sameAsNewer = model({ id: "z", release_date: "2026-06-01" }) expect(cheapestSmallModel([same, newer])).toMatchObject({ id: "a" }) - expect(cheapestSmallModel([same, sameAsNewer])).toMatchObject({ id: "z" }) + expect(cheapestSmallModel([newer, sameAsNewer])).toMatchObject({ id: "z" }) }) test("skips embedding and rerank models even when they are free", () => { From 69e6bba8cef83e42a484ee207e3419a44a69e314 Mon Sep 17 00:00:00 2001 From: matt wilkie Date: Thu, 22 Oct 2026 13:51:25 -0700 Subject: [PATCH 09/11] docs(changeset): add trailing newline --- .changeset/byok-cheap-small-model.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/byok-cheap-small-model.md b/.changeset/byok-cheap-small-model.md index 9b4d5e805a2c..4c8ddcb10093 100644 --- a/.changeset/byok-cheap-small-model.md +++ b/.changeset/byok-cheap-small-model.md @@ -2,4 +2,4 @@ "@kilocode/cli": patch --- -Prefer the session provider's cheapest chat-capable model for auxiliary tasks (session titles, prompt enhance, commit messages, branch names) instead of falling back to Kilo's auto small model. BYOK users no longer silently draw Kilo credits when their configured provider has no recognized small-model family. \ No newline at end of file +Prefer the session provider's cheapest chat-capable model for auxiliary tasks (session titles, prompt enhance, commit messages, branch names) instead of falling back to Kilo's auto small model. BYOK users no longer silently draw Kilo credits when their configured provider has no recognized small-model family. From a3439e972cde26985172f4150ed50b3e5f520183 Mon Sep 17 00:00:00 2001 From: matt wilkie Date: Mon, 26 Oct 2026 07:37:29 -0700 Subject: [PATCH 10/11] chore(ci): retrigger checks after unrelated run-stdin timing failure From f496c3c29eac3c445a90102777418b0e1f5f99e8 Mon Sep 17 00:00:00 2001 From: matt wilkie Date: Mon, 26 Oct 2026 07:49:48 -0700 Subject: [PATCH 11/11] chore(ci): retrigger windows unit after runner setup timeout