Skip to content

Commit b66b2ac

Browse files
committed
fix(models): a llama.cpp user was locked out of their own model list
pkg/models kept its own local-vs-hosted list, and it had drifted from config.IsLocalProvider — whose doc comment already claimed pkg/models deferred to it. `local`, `llamacpp` and the `llama-cpp`/`llama_cpp` spellings were local everywhere else and hosted here. The consequence was not cosmetic: Find fails closed on a missing key, so a llama.cpp user — whose server wants no credential at all — got "auth required" and an EMPTY model list from `slmcode agent list`, the Studio's model picker and the find_models tool. It now defers to the shared predicate. The price preset had drifted the same way, and is fixed differently on purpose. It gains the llama.cpp spellings but deliberately does NOT become IsLocalProvider: that also covers `litellm` and `custom`, which are gateways that can front a paid API, and a confident $0 over a real bill is worse than showing nothing. The comment says so, because the obvious "cleanup" here is to unify them and be wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UiLDxnEu1Gf8Q5hWAFhaAd
1 parent 947b867 commit b66b2ac

4 files changed

Lines changed: 81 additions & 8 deletions

File tree

pkg/config/config.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1661,8 +1661,13 @@ func PricePresetRates(preset, provider string) (prompt, completion float64, ok b
16611661
name = NormalizeProvider(provider)
16621662
}
16631663
switch name {
1664-
case "local", "omlx", "ollama", "lmstudio", "vllm", "mlx":
1665-
return 0, 0, true // explicitly free / local
1664+
case "local", "omlx", "mlx", "ollama", "lmstudio", "vllm", "llamacpp", "llama-cpp", "llama_cpp":
1665+
// Servers that run on your own hardware: the tokens genuinely cost
1666+
// nothing. Deliberately NOT IsLocalProvider, which also covers
1667+
// `litellm` and `custom` — those are gateways that can front a paid
1668+
// API, and reporting a confident $0 for a real bill is worse than
1669+
// reporting nothing, which is what the `false` below produces.
1670+
return 0, 0, true
16661671
case "openai", "gpt":
16671672
// Ballpark GPT-4o-mini class ($/MTok) — not model-perfect; override with price_*.
16681673
return 0.15, 0.60, true

pkg/config/parallel_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,3 +403,24 @@ func TestANormalizedEndpointParses(t *testing.T) {
403403
}
404404
}
405405
}
406+
407+
// ── Free means "runs on your hardware", not "local-ish" ──────────────────
408+
409+
func TestAServerOnYourOwnHardwareCostsNothing(t *testing.T) {
410+
for _, p := range []string{"local", "omlx", "mlx", "ollama", "lmstudio", "vllm", "llamacpp", "llama-cpp"} {
411+
in, out, ok := PricePresetRates("auto", p)
412+
if !ok || in != 0 || out != 0 {
413+
t.Errorf("PricePresetRates(auto, %q) = %v/%v/%v, want a confident zero", p, in, out, ok)
414+
}
415+
}
416+
}
417+
418+
// A gateway can front a paid API. A confident $0 over a real bill is worse than
419+
// showing nothing, which is what "not configured" produces.
420+
func TestAGatewayIsNotAssumedFree(t *testing.T) {
421+
for _, p := range []string{"litellm", "custom"} {
422+
if _, _, ok := PricePresetRates("auto", p); ok {
423+
t.Errorf("%q was assumed free, but it can proxy a paid API", p)
424+
}
425+
}
426+
}

pkg/models/catalog.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -118,13 +118,17 @@ func ResolveAuth(cfg *config.Config) AuthStatus {
118118
return st
119119
}
120120

121+
// requiresAPIKey reports whether this provider needs a credential.
122+
//
123+
// Defers to config.IsLocalProvider, which is the single local-vs-hosted notion
124+
// in the codebase. It used to keep its own list, and the two had drifted:
125+
// `llamacpp` (and `local`, and the `llama-cpp`/`llama_cpp` spellings) were
126+
// local everywhere else and hosted here — so a llama.cpp user, whose server
127+
// wants no credential at all, got "auth required" and an EMPTY model list from
128+
// `slmcode agent list`, the Studio's model picker and the find_models tool,
129+
// because Find fails closed on a missing key.
121130
func requiresAPIKey(provider string) bool {
122-
switch config.NormalizeProvider(provider) {
123-
case "omlx", "ollama", "lmstudio", "vllm", "litellm", "custom":
124-
return false
125-
default:
126-
return true
127-
}
131+
return !config.IsLocalProvider(provider)
128132
}
129133

130134
// APIKeyEnvFor names the environment variable a provider's key lives in.

pkg/models/catalog_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,46 @@ func TestFindFiltersQuery(t *testing.T) {
8181
t.Fatalf("expected empty, got %v", cat.Models)
8282
}
8383
}
84+
85+
// ── One local-vs-hosted notion, not two ──────────────────────────────────
86+
//
87+
// This package kept its own list and it had drifted from config.IsLocalProvider
88+
// — whose doc comment already claimed pkg/models deferred to it. A llama.cpp
89+
// user, whose server wants no credential at all, got "auth required" and an
90+
// EMPTY model list from `slmcode agent list`, the Studio's model picker and the
91+
// find_models tool, because Find fails closed on a missing key.
92+
93+
func TestEveryLocalProviderIsKeyless(t *testing.T) {
94+
for _, p := range []string{
95+
"local", "omlx", "mlx", "ollama", "lmstudio", "lm-studio", "lm_studio",
96+
"vllm", "litellm", "custom", "llamacpp", "llama-cpp", "llama_cpp",
97+
} {
98+
if !config.IsLocalProvider(p) {
99+
t.Fatalf("fixture is wrong: %q is not local, so this proves nothing", p)
100+
}
101+
if requiresAPIKey(p) {
102+
t.Errorf("%q is a local server and needs no credential, but auth is required", p)
103+
}
104+
}
105+
}
106+
107+
func TestEveryHostedProviderNeedsAKey(t *testing.T) {
108+
for _, p := range []string{"openai", "openrouter", "groq", "together", "deepseek", "anthropic", "gemini"} {
109+
if !requiresAPIKey(p) {
110+
t.Errorf("%q is a hosted API and must require a credential", p)
111+
}
112+
}
113+
}
114+
115+
// The regression that motivated it: a llama.cpp user with no key still gets
116+
// their model list.
117+
func TestALlamaCppUserIsNotLockedOutOfTheirOwnModels(t *testing.T) {
118+
cfg := config.Default(t.TempDir())
119+
cfg.Provider = "llamacpp"
120+
cfg.APIKey = ""
121+
122+
st := ResolveAuth(cfg)
123+
if st.Required {
124+
t.Errorf("auth reported as required for a local llama.cpp server: %+v", st)
125+
}
126+
}

0 commit comments

Comments
 (0)