on main help sign in
fix: correct model discovery enrichment for local providers

Local provider enrichers (oMLX, Ollama, LM Studio, LiteLLM) failed to populate context window sizes, so sessions showed 0% usage and auto-summarization was silently skipped. Two root causes: - Enricher request paths doubled the /v1 prefix because base URLs already include it. Strip /v1 for providers whose metadata endpoints live at the server root (Ollama, LM Studio, LiteLLM); keep it for oMLX, whose status endpoint lives under /v1. - LM Studio's model list was decoded from the wrong JSON key, so no models matched and nothing was enriched. Tests now exercise the real wire formats and /v1-prefixed base URLs.

commitcfdca3587fd5d515963db79e15a9f06071b18caa authorKieran Klukas <kieran@dunkirk.sh> dateSat 20 Jun 2026, 16:24 (4 months ago) parentbb44fb1
10 files changed+73−30
internal/discover/discover.go+10 internal/discover/discover_test.go+19 internal/discover/litellm.go+1−1 internal/discover/litellm_test.go+4−2 internal/discover/lmstudio.go+7−6 internal/discover/lmstudio_test.go+19−15 internal/discover/ollama.go+1−1 internal/discover/ollama_test.go+3−1 internal/discover/omlx.go+6−3 internal/discover/omlx_test.go+3−1
internal/discover/discover.go+10
@@ -17,6 +17,16 @@ import (
1717// even if the caller forgets to set a context deadline.
1818var httpClient = &http.Client{Timeout: 10 * time.Second}
1919
20+// stripV1Suffix removes a trailing /v1 from a base URL. Enricher
21+// endpoints (e.g. Ollama's /api/show, LM Studio's /api/v1/models) are
22+// served at the server root, not under the OpenAI-compatible /v1
23+// prefix. Since provider configs typically include /v1 in the base URL
24+// for chat completions, enrichers must strip it before constructing
25+// their own request paths.
26+func stripV1Suffix(baseURL string) string {
27+ return strings.TrimSuffix(strings.TrimRight(baseURL, "/"), "/v1")
28+}
29+
2030// doRequest builds and executes an authenticated HTTP request using the
2131// shared client. It resolves variable references in the base URL, API
2232// key, and extra headers via the provided Resolver. The path is joined
internal/discover/discover_test.go+19
@@ -200,3 +200,22 @@ func TestDiscoverModels_NoAuthWhenNoAPIKey(t *testing.T) {
200200 require.NoError(t, err)
201201 require.Len(t, models, 1)
202202}
203+
204+func TestStripV1Suffix(t *testing.T) {
205+ t.Parallel()
206+ tests := []struct {
207+ input string
208+ want string
209+ }{
210+ {"http://localhost:8000/v1", "http://localhost:8000"},
211+ {"http://localhost:8000/v1/", "http://localhost:8000"},
212+ {"http://localhost:8000", "http://localhost:8000"},
213+ {"http://localhost:8000/", "http://localhost:8000"},
214+ {"http://localhost:8000/api/v1", "http://localhost:8000/api"},
215+ {"", ""},
216+ }
217+ for _, tt := range tests {
218+ got := stripV1Suffix(tt.input)
219+ require.Equal(t, tt.want, got, "stripV1Suffix(%q)", tt.input)
220+ }
221+}
internal/discover/litellm.go+1−1
@@ -41,7 +41,7 @@ func init() {
4141type litellmEnricher struct{}
4242
4343func (e *litellmEnricher) EnrichModels(ctx context.Context, cfg Config, resolver Resolver, models []catwalk.Model) ([]catwalk.Model, error) {
44− resp, err := doRequest(ctx, http.MethodGet, cfg.BaseURL, "/model/info", cfg.APIKey, cfg.ExtraHeaders, resolver, nil)
44+ resp, err := doRequest(ctx, http.MethodGet, stripV1Suffix(cfg.BaseURL), "/model/info", cfg.APIKey, cfg.ExtraHeaders, resolver, nil)
4545 if err != nil {
4646 return models, nil
4747 }
internal/discover/litellm_test.go+4−2
@@ -42,8 +42,10 @@ func TestLitellmEnricher(t *testing.T) {
4242 defer srv.Close()
4343
4444 cfg := Config{
45− ID: "test-litellm",
46− BaseURL: srv.URL,
45+ ID: "test-litellm",
46+ // Base URL includes /v1 (as Crush configures it); the
47+ // enricher strips it so /model/info resolves at the root.
48+ BaseURL: srv.URL + "/v1",
4749 APIKey: "test-key",
4850 }
4951 models := []catwalk.Model{
internal/discover/lmstudio.go+7−6
@@ -13,10 +13,11 @@ func init() {
1313}
1414
1515// lmstudioModelsResponse mirrors the response from LM Studio's native
16−// GET /api/v1/models endpoint. Only the fields we care about are
17−// decoded.
16+// GET /api/v1/models endpoint. The model array is returned under the
17+// "models" key (not "data" like the OpenAI-compatible endpoint). Only
18+// the fields we care about are decoded.
1819type lmstudioModelsResponse struct {
19− Data []lmstudioModelEntry `json:"data"`
20+ Models []lmstudioModelEntry `json:"models"`
2021}
2122
2223// lmstudioModelEntry is a single entry from /api/v1/models.
@@ -44,7 +45,7 @@ type lmstudioInstanceConfig struct {
4445type lmstudioEnricher struct{}
4546
4647func (e *lmstudioEnricher) EnrichModels(ctx context.Context, cfg Config, resolver Resolver, models []catwalk.Model) ([]catwalk.Model, error) {
47− resp, err := doRequest(ctx, http.MethodGet, cfg.BaseURL, "/api/v1/models", cfg.APIKey, cfg.ExtraHeaders, resolver, nil)
48+ resp, err := doRequest(ctx, http.MethodGet, stripV1Suffix(cfg.BaseURL), "/api/v1/models", cfg.APIKey, cfg.ExtraHeaders, resolver, nil)
4849 if err != nil {
4950 return models, nil
5051 }
@@ -60,8 +61,8 @@ func (e *lmstudioEnricher) EnrichModels(ctx context.Context, cfg Config, resolve
6061 }
6162
6263 // Index by key for O(1) lookup.
63− metaByKey := make(map[string]lmstudioModelEntry, len(modelsResp.Data))
64− for _, m := range modelsResp.Data {
64+ metaByKey := make(map[string]lmstudioModelEntry, len(modelsResp.Models))
65+ for _, m := range modelsResp.Models {
6566 metaByKey[m.Key] = m
6667 }
6768
internal/discover/lmstudio_test.go+19−15
@@ -19,24 +19,28 @@ func TestLmstudioEnricher(t *testing.T) {
1919 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2020 require.Equal(t, "/api/v1/models", r.URL.Path)
2121 w.Header().Set("Content-Type", "application/json")
22− json.NewEncoder(w).Encode(lmstudioModelsResponse{
23− Data: []lmstudioModelEntry{
22+ // Raw JSON matching LM Studio's real /api/v1/models wire
23+ // format: array under "models", not "data".
24+ _, _ = w.Write([]byte(`{
25+ "models": [
2426 {
25− Key: "qwen2.5-7b-instruct",
26− DisplayName: "Qwen 2.5 7B Instruct",
27− MaxContextLength: 32768,
27+ "key": "qwen2.5-7b-instruct",
28+ "display_name": "Qwen 2.5 7B Instruct",
29+ "max_context_length": 32768
2830 },
2931 {
30− Key: "llama-3.1-8b",
31− DisplayName: "Llama 3.1 8B",
32− MaxContextLength: 131072,
33− },
34− },
35− })
32+ "key": "llama-3.1-8b",
33+ "display_name": "Llama 3.1 8B",
34+ "max_context_length": 131072
35+ }
36+ ]
37+ }`))
3638 }))
3739 defer srv.Close()
3840
39− cfg := Config{ID: "test-lmstudio", BaseURL: srv.URL}
41+ // Base URL includes /v1 (as Crush configures it); the enricher
42+ // strips it so the native endpoint resolves at the server root.
43+ cfg := Config{ID: "test-lmstudio", BaseURL: srv.URL + "/v1"}
4044 models := []catwalk.Model{
4145 {ID: "qwen2.5-7b-instruct", Name: "qwen2.5-7b-instruct"},
4246 {ID: "llama-3.1-8b", Name: "llama-3.1-8b"},
@@ -60,7 +64,7 @@ func TestLmstudioEnricher(t *testing.T) {
6064 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
6165 w.Header().Set("Content-Type", "application/json")
6266 json.NewEncoder(w).Encode(lmstudioModelsResponse{
63− Data: []lmstudioModelEntry{
67+ Models: []lmstudioModelEntry{
6468 {
6569 Key: "m1",
6670 MaxContextLength: 131072,
@@ -87,7 +91,7 @@ func TestLmstudioEnricher(t *testing.T) {
8791 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
8892 w.Header().Set("Content-Type", "application/json")
8993 json.NewEncoder(w).Encode(lmstudioModelsResponse{
90− Data: []lmstudioModelEntry{
94+ Models: []lmstudioModelEntry{
9195 {
9296 Key: "m1",
9397 DisplayName: "Should Not Override",
@@ -132,7 +136,7 @@ func TestLmstudioEnricher(t *testing.T) {
132136 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
133137 w.Header().Set("Content-Type", "application/json")
134138 json.NewEncoder(w).Encode(lmstudioModelsResponse{
135− Data: []lmstudioModelEntry{
139+ Models: []lmstudioModelEntry{
136140 {Key: "m1", DisplayName: "API Name"},
137141 },
138142 })
internal/discover/ollama.go+1−1
@@ -51,7 +51,7 @@ func (e *ollamaEnricher) EnrichModels(ctx context.Context, cfg Config, resolver
5151 sem <- struct{}{}
5252 defer func() { <-sem }()
5353
54− resp, err := doRequest(ctx, http.MethodPost, cfg.BaseURL, "/api/show",
54+ resp, err := doRequest(ctx, http.MethodPost, stripV1Suffix(cfg.BaseURL), "/api/show",
5555 cfg.APIKey, cfg.ExtraHeaders, resolver,
5656 map[string]string{"model": models[idx].ID})
5757 if err != nil {
internal/discover/ollama_test.go+3−1
@@ -43,7 +43,9 @@ func TestOllamaEnricher(t *testing.T) {
4343 }))
4444 defer srv.Close()
4545
46− cfg := Config{ID: "test-ollama", BaseURL: srv.URL}
46+ // Base URL includes /v1 (as Crush configures it); the enricher
47+ // strips it so /api/show resolves at the server root.
48+ cfg := Config{ID: "test-ollama", BaseURL: srv.URL + "/v1"}
4749 models := []catwalk.Model{
4850 {ID: "llama3:latest", Name: "llama3:latest"},
4951 {ID: "qwen2:latest", Name: "qwen2:latest"},
internal/discover/omlx.go+6−3
@@ -13,7 +13,7 @@ func init() {
1313}
1414
1515// omlxModelsStatusResponse mirrors the response from oMLX's
16−// GET /v1/models/status endpoint, which returns model metadata
16+// GET /models/status endpoint, which returns model metadata
1717// including max_context_window and max_tokens.
1818type omlxModelsStatusResponse struct {
1919 Models []omlxModelStatus `json:"models"`
@@ -26,13 +26,16 @@ type omlxModelStatus struct {
2626 MaxTokens *int64 `json:"max_tokens"`
2727}
2828
29−// omlxEnricher fetches model metadata from oMLX's /v1/models/status
29+// omlxEnricher fetches model metadata from oMLX's /models/status
3030// endpoint and populates context window and max tokens on discovered
3131// models.
3232type omlxEnricher struct{}
3333
3434func (e *omlxEnricher) EnrichModels(ctx context.Context, cfg Config, resolver Resolver, models []catwalk.Model) ([]catwalk.Model, error) {
35− resp, err := doRequest(ctx, http.MethodGet, cfg.BaseURL, "/v1/models/status", cfg.APIKey, cfg.ExtraHeaders, resolver, nil)
35+ // oMLX serves /models/status under the OpenAI-compatible /v1
36+ // namespace, so the path is relative to the configured base URL
37+ // (which already includes /v1) rather than the server root.
38+ resp, err := doRequest(ctx, http.MethodGet, cfg.BaseURL, "/models/status", cfg.APIKey, cfg.ExtraHeaders, resolver, nil)
3639 if err != nil {
3740 return models, nil
3841 }
internal/discover/omlx_test.go+3−1
@@ -17,6 +17,8 @@ func TestOmlxEnricher(t *testing.T) {
1717 t.Run("populates context window and max tokens from /v1/models/status", func(t *testing.T) {
1818 t.Parallel()
1919 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20+ // oMLX serves the status endpoint under /v1 (base URL
21+ // includes /v1), so the resolved path keeps the prefix.
2022 require.Equal(t, "/v1/models/status", r.URL.Path)
2123 w.Header().Set("Content-Type", "application/json")
2224 json.NewEncoder(w).Encode(omlxModelsStatusResponse{
@@ -28,7 +30,7 @@ func TestOmlxEnricher(t *testing.T) {
2830 }))
2931 defer srv.Close()
3032
31− cfg := Config{ID: "test-omlx", BaseURL: srv.URL}
33+ cfg := Config{ID: "test-omlx", BaseURL: srv.URL + "/v1"}
3234 models := []catwalk.Model{
3335 {ID: "qwen3:latest", Name: "qwen3:latest"},
3436 {ID: "llama3:latest", Name: "llama3:latest"},