Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e781dd5
fix(cli): prefer a cheap same-vendor small model for auxiliary tasks
maphew Sep 21, 2026
c04c48e
fix(cli): exclude embedding and rerank models from cheapestSmallModel
Sep 21, 2026
6ad333e
test(cli): widen processor-create wait in cancellation race test
maphew Sep 27, 2026
d1f9120
fix(cli): harden non-generative exclusion and drop shared test drift
maphew Sep 28, 2026
20b7648
fix(cli): gate cheap small-model pick on availability and safety
maphew Oct 11, 2026
549678d
fix(cli): wrap loopback small-model skip in annotation block
maphew Oct 13, 2026
f0bcb0a
Merge branch 'main' into fix/small-model-same-vendor
WebReflection Oct 16, 2026
0866402
Merge upstream/main into fix/small-model-same-vendor
maphew Oct 16, 2026
a547ddd
Merge remote-tracking branch 'upstream/main' into fix/small-model-sam…
maphew Oct 17, 2026
4c042c3
fix(cli): keep session-model fallback for local servers on LAN hostnames
maphew Oct 17, 2026
3beb2c6
Merge upstream/main into fix/small-model-same-vendor
maphew Oct 18, 2026
824d806
Merge remote-tracking branch 'upstream/main' into fix/small-model-sam…
maphew Oct 19, 2026
b193c59
Merge upstream/main into fix/small-model-same-vendor
maphew Oct 22, 2026
4fdd395
Merge remote-tracking branch 'upstream/main' into fix/small-model-sam…
maphew Oct 22, 2026
b5a8ddf
test(provider): exercise the id tiebreak and drop an any from the loo…
maphew Oct 22, 2026
69e6bba
docs(changeset): add trailing newline
maphew Oct 22, 2026
eb4c6c1
Merge remote-tracking branch 'upstream/main' into fix/small-model-sam…
maphew Oct 22, 2026
579a031
Merge remote-tracking branch 'upstream/main' into fix/small-model-sam…
maphew Oct 22, 2026
a972f1e
Merge remote-tracking branch 'upstream/main' into fix/small-model-sam…
maphew Oct 23, 2026
8838406
Merge branch 'main' into fix/small-model-same-vendor
maphew Oct 23, 2026
354dc8b
Merge remote-tracking branch 'upstream/main' into fix/small-model-sam…
maphew Oct 24, 2026
f1475ba
Merge remote-tracking branch 'origin/fix/small-model-same-vendor' int…
maphew Oct 24, 2026
ec8c925
Merge remote-tracking branch 'upstream/main' into fix/small-model-sam…
maphew Oct 26, 2026
a3439e9
chore(ci): retrigger checks after unrelated run-stdin timing failure
maphew Oct 26, 2026
f496c3c
chore(ci): retrigger windows unit after runner setup timeout
maphew Oct 26, 2026
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/byok-cheap-small-model.md
Original file line number Diff line number Diff line change
@@ -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.
83 changes: 82 additions & 1 deletion packages/opencode/src/kilocode/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -299,6 +299,87 @@ 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, 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.
*
* 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])|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} ${model.api?.id ?? ""}`)
}),
[(model) => model.cost.input + model.cost.output, "asc"],
[(model) => model.release_date, "desc"],
[(model) => model.id, "desc"],
).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 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: {
id?: unknown
options?: Record<string, unknown>
models: Record<string, Provider.Model>
}) {
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)
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]"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: The loopback guard recognizes only localhost, 127.0.0.1 and ::1/[::1]. A local model server reached over the LAN or via a host alias (http://192.168.x.x:1234, http://host.docker.internal, http://ollama.local, 0.0.0.0, [::]) is treated as remote, so cheapestSmallModel still runs and can name a model the server has not installed - the exact case this guard was added to prevent. Consider also treating private/link-local hosts and .local names as local, or gating the cheap pick on an explicit opt-out.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

} 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
Expand Down
10 changes: 10 additions & 0 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ import {
publicKiloProvider,
kiloSmallModelPriority,
hasKiloCredentials,
cheapestSmallModel,
isLoopbackProvider,
buildTimeoutSignal,
requestTimeout,
wrapFirstByte,
Expand Down Expand Up @@ -2141,6 +2143,14 @@ const layer = Layer.effect(
if (candidates[0]) return candidates[0]
}

// 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.
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
// unconditionally would route auxiliary tasks (session titles, commit messages, branch names)
Expand Down
Loading
Loading