on main help sign in
fix: correct model discovery enrichment for local providers
cfdca35Kieran Klukas
internal/discover/discover.go+10
@@ -17,6 +17,16 @@ import (
17// even if the caller forgets to set a context deadline.
17// even if the caller forgets to set a context deadline.
18var httpClient = &http.Client{Timeout: 10 * time.Second}
18var httpClient = &http.Client{Timeout: 10 * time.Second}
19
19
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+
20// doRequest builds and executes an authenticated HTTP request using the
30// doRequest builds and executes an authenticated HTTP request using the
21// shared client. It resolves variable references in the base URL, API
31// shared client. It resolves variable references in the base URL, API
22// key, and extra headers via the provided Resolver. The path is joined
32// 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) {
200 require.NoError(t, err)
200 require.NoError(t, err)
201 require.Len(t, models, 1)
201 require.Len(t, models, 1)
202}
202}
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() {
41type litellmEnricher struct{}
41type litellmEnricher struct{}
42
42
43func (e *litellmEnricher) EnrichModels(ctx context.Context, cfg Config, resolver Resolver, models []catwalk.Model) ([]catwalk.Model, error) {
43func (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)
45 if err != nil {
45 if err != nil {
46 return models, nil
46 return models, nil
47 }
47 }
internal/discover/litellm_test.go+4−2
@@ -42,8 +42,10 @@ func TestLitellmEnricher(t *testing.T) {
42 defer srv.Close()
42 defer srv.Close()
43
43
44 cfg := Config{
44 cfg := Config{
45− ID: "test-litellm",
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.
46− BaseURL: srv.URL,
48+ BaseURL: srv.URL + "/v1",
47 APIKey: "test-key",
49 APIKey: "test-key",
48 }
50 }
49 models := []catwalk.Model{
51 models := []catwalk.Model{
internal/discover/lmstudio.go+7−6
@@ -13,10 +13,11 @@ func init() {
13}
13}
14
14
15// lmstudioModelsResponse mirrors the response from LM Studio's native
15// lmstudioModelsResponse mirrors the response from LM Studio's native
16+// GET /api/v1/models endpoint. The model array is returned under the
17+// "models" key (not "data" like the OpenAI-compatible endpoint). Only
16−// GET /api/v1/models endpoint. Only the fields we care about are
18+// the fields we care about are decoded.
17−// decoded.
18type lmstudioModelsResponse struct {
19type lmstudioModelsResponse struct {
19− Data []lmstudioModelEntry `json:"data"`
20+ Models []lmstudioModelEntry `json:"models"`
20}
21}
21
22
22// lmstudioModelEntry is a single entry from /api/v1/models.
23// lmstudioModelEntry is a single entry from /api/v1/models.
@@ -44,7 +45,7 @@ type lmstudioInstanceConfig struct {
44type lmstudioEnricher struct{}
45type lmstudioEnricher struct{}
45
46
46func (e *lmstudioEnricher) EnrichModels(ctx context.Context, cfg Config, resolver Resolver, models []catwalk.Model) ([]catwalk.Model, error) {
47func (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)
48 if err != nil {
49 if err != nil {
49 return models, nil
50 return models, nil
50 }
51 }
@@ -60,8 +61,8 @@ func (e *lmstudioEnricher) EnrichModels(ctx context.Context, cfg Config, resolve
60 }
61 }
61
62
62 // Index by key for O(1) lookup.
63 // Index by key for O(1) lookup.
63− metaByKey := make(map[string]lmstudioModelEntry, len(modelsResp.Data))
64+ metaByKey := make(map[string]lmstudioModelEntry, len(modelsResp.Models))
64− for _, m := range modelsResp.Data {
65+ for _, m := range modelsResp.Models {
65 metaByKey[m.Key] = m
66 metaByKey[m.Key] = m
66 }
67 }
67
68
internal/discover/lmstudio_test.go+19−15
@@ -19,24 +19,28 @@ func TestLmstudioEnricher(t *testing.T) {
19 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
19 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20 require.Equal(t, "/api/v1/models", r.URL.Path)
20 require.Equal(t, "/api/v1/models", r.URL.Path)
21 w.Header().Set("Content-Type", "application/json")
21 w.Header().Set("Content-Type", "application/json")
22− json.NewEncoder(w).Encode(lmstudioModelsResponse{
22+ // Raw JSON matching LM Studio's real /api/v1/models wire
23− Data: []lmstudioModelEntry{
23+ // format: array under "models", not "data".
24+ _, _ = w.Write([]byte(`{
25+ "models": [
24 {
26 {
25− Key: "qwen2.5-7b-instruct",
27+ "key": "qwen2.5-7b-instruct",
26− DisplayName: "Qwen 2.5 7B Instruct",
28+ "display_name": "Qwen 2.5 7B Instruct",
27− MaxContextLength: 32768,
29+ "max_context_length": 32768
28 },
30 },
29 {
31 {
30− Key: "llama-3.1-8b",
32+ "key": "llama-3.1-8b",
31− DisplayName: "Llama 3.1 8B",
33+ "display_name": "Llama 3.1 8B",
32− MaxContextLength: 131072,
34+ "max_context_length": 131072
33− },
35+ }
34− },
36+ ]
35− })
37+ }`))
36 }))
38 }))
37 defer srv.Close()
39 defer srv.Close()
38
40
41+ // Base URL includes /v1 (as Crush configures it); the enricher
42+ // strips it so the native endpoint resolves at the server root.
39− cfg := Config{ID: "test-lmstudio", BaseURL: srv.URL}
43+ cfg := Config{ID: "test-lmstudio", BaseURL: srv.URL + "/v1"}
40 models := []catwalk.Model{
44 models := []catwalk.Model{
41 {ID: "qwen2.5-7b-instruct", Name: "qwen2.5-7b-instruct"},
45 {ID: "qwen2.5-7b-instruct", Name: "qwen2.5-7b-instruct"},
42 {ID: "llama-3.1-8b", Name: "llama-3.1-8b"},
46 {ID: "llama-3.1-8b", Name: "llama-3.1-8b"},
@@ -60,7 +64,7 @@ func TestLmstudioEnricher(t *testing.T) {
60 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
64 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
61 w.Header().Set("Content-Type", "application/json")
65 w.Header().Set("Content-Type", "application/json")
62 json.NewEncoder(w).Encode(lmstudioModelsResponse{
66 json.NewEncoder(w).Encode(lmstudioModelsResponse{
63− Data: []lmstudioModelEntry{
67+ Models: []lmstudioModelEntry{
64 {
68 {
65 Key: "m1",
69 Key: "m1",
66 MaxContextLength: 131072,
70 MaxContextLength: 131072,
@@ -87,7 +91,7 @@ func TestLmstudioEnricher(t *testing.T) {
87 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
91 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
88 w.Header().Set("Content-Type", "application/json")
92 w.Header().Set("Content-Type", "application/json")
89 json.NewEncoder(w).Encode(lmstudioModelsResponse{
93 json.NewEncoder(w).Encode(lmstudioModelsResponse{
90− Data: []lmstudioModelEntry{
94+ Models: []lmstudioModelEntry{
91 {
95 {
92 Key: "m1",
96 Key: "m1",
93 DisplayName: "Should Not Override",
97 DisplayName: "Should Not Override",
@@ -132,7 +136,7 @@ func TestLmstudioEnricher(t *testing.T) {
132 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
136 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
133 w.Header().Set("Content-Type", "application/json")
137 w.Header().Set("Content-Type", "application/json")
134 json.NewEncoder(w).Encode(lmstudioModelsResponse{
138 json.NewEncoder(w).Encode(lmstudioModelsResponse{
135− Data: []lmstudioModelEntry{
139+ Models: []lmstudioModelEntry{
136 {Key: "m1", DisplayName: "API Name"},
140 {Key: "m1", DisplayName: "API Name"},
137 },
141 },
138 })
142 })
internal/discover/ollama.go+1−1
@@ -51,7 +51,7 @@ func (e *ollamaEnricher) EnrichModels(ctx context.Context, cfg Config, resolver
51 sem <- struct{}{}
51 sem <- struct{}{}
52 defer func() { <-sem }()
52 defer func() { <-sem }()
53
53
54− resp, err := doRequest(ctx, http.MethodPost, cfg.BaseURL, "/api/show",
54+ resp, err := doRequest(ctx, http.MethodPost, stripV1Suffix(cfg.BaseURL), "/api/show",
55 cfg.APIKey, cfg.ExtraHeaders, resolver,
55 cfg.APIKey, cfg.ExtraHeaders, resolver,
56 map[string]string{"model": models[idx].ID})
56 map[string]string{"model": models[idx].ID})
57 if err != nil {
57 if err != nil {
internal/discover/ollama_test.go+3−1
@@ -43,7 +43,9 @@ func TestOllamaEnricher(t *testing.T) {
43 }))
43 }))
44 defer srv.Close()
44 defer srv.Close()
45
45
46+ // Base URL includes /v1 (as Crush configures it); the enricher
47+ // strips it so /api/show resolves at the server root.
46− cfg := Config{ID: "test-ollama", BaseURL: srv.URL}
48+ cfg := Config{ID: "test-ollama", BaseURL: srv.URL + "/v1"}
47 models := []catwalk.Model{
49 models := []catwalk.Model{
48 {ID: "llama3:latest", Name: "llama3:latest"},
50 {ID: "llama3:latest", Name: "llama3:latest"},
49 {ID: "qwen2:latest", Name: "qwen2:latest"},
51 {ID: "qwen2:latest", Name: "qwen2:latest"},
internal/discover/omlx.go+6−3
@@ -13,7 +13,7 @@ func init() {
13}
13}
14
14
15// omlxModelsStatusResponse mirrors the response from oMLX's
15// 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
17// including max_context_window and max_tokens.
17// including max_context_window and max_tokens.
18type omlxModelsStatusResponse struct {
18type omlxModelsStatusResponse struct {
19 Models []omlxModelStatus `json:"models"`
19 Models []omlxModelStatus `json:"models"`
@@ -26,13 +26,16 @@ type omlxModelStatus struct {
26 MaxTokens *int64 `json:"max_tokens"`
26 MaxTokens *int64 `json:"max_tokens"`
27}
27}
28
28
29−// omlxEnricher fetches model metadata from oMLX's /v1/models/status
29+// omlxEnricher fetches model metadata from oMLX's /models/status
30// endpoint and populates context window and max tokens on discovered
30// endpoint and populates context window and max tokens on discovered
31// models.
31// models.
32type omlxEnricher struct{}
32type omlxEnricher struct{}
33
33
34func (e *omlxEnricher) EnrichModels(ctx context.Context, cfg Config, resolver Resolver, models []catwalk.Model) ([]catwalk.Model, error) {
34func (e *omlxEnricher) EnrichModels(ctx context.Context, cfg Config, resolver Resolver, models []catwalk.Model) ([]catwalk.Model, error) {
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.
35− resp, err := doRequest(ctx, http.MethodGet, cfg.BaseURL, "/v1/models/status", cfg.APIKey, cfg.ExtraHeaders, resolver, nil)
38+ resp, err := doRequest(ctx, http.MethodGet, cfg.BaseURL, "/models/status", cfg.APIKey, cfg.ExtraHeaders, resolver, nil)
36 if err != nil {
39 if err != nil {
37 return models, nil
40 return models, nil
38 }
41 }
internal/discover/omlx_test.go+3−1
@@ -17,6 +17,8 @@ func TestOmlxEnricher(t *testing.T) {
17 t.Run("populates context window and max tokens from /v1/models/status", func(t *testing.T) {
17 t.Run("populates context window and max tokens from /v1/models/status", func(t *testing.T) {
18 t.Parallel()
18 t.Parallel()
19 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
19 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.
20 require.Equal(t, "/v1/models/status", r.URL.Path)
22 require.Equal(t, "/v1/models/status", r.URL.Path)
21 w.Header().Set("Content-Type", "application/json")
23 w.Header().Set("Content-Type", "application/json")
22 json.NewEncoder(w).Encode(omlxModelsStatusResponse{
24 json.NewEncoder(w).Encode(omlxModelsStatusResponse{
@@ -28,7 +30,7 @@ func TestOmlxEnricher(t *testing.T) {
28 }))
30 }))
29 defer srv.Close()
31 defer srv.Close()
30
32
31− cfg := Config{ID: "test-omlx", BaseURL: srv.URL}
33+ cfg := Config{ID: "test-omlx", BaseURL: srv.URL + "/v1"}
32 models := []catwalk.Model{
34 models := []catwalk.Model{
33 {ID: "qwen3:latest", Name: "qwen3:latest"},
35 {ID: "qwen3:latest", Name: "qwen3:latest"},
34 {ID: "llama3:latest", Name: "llama3:latest"},
36 {ID: "llama3:latest", Name: "llama3:latest"},