1package provider
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "net/url"
9 "strings"
10 "time"
11)
12
13const LMStudioProviderID = "lmstudio"
14
15// NormalizeLMStudioURL accepts a server root or either API base URL.
16func NormalizeLMStudioURL(value string) (string, error) {
17 u, err := url.Parse(strings.TrimSpace(value))
18 if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
19 return "", fmt.Errorf("LM Studio server URL must be an http or https URL without credentials, query, or fragment")
20 }
21 u.Path = strings.TrimRight(u.Path, "/")
22 if strings.HasSuffix(u.Path, "/api/v1") {
23 u.Path = strings.TrimSuffix(u.Path, "/api/v1")
24 } else {
25 u.Path = strings.TrimSuffix(u.Path, "/v1")
26 }
27 u.RawPath = ""
28 return strings.TrimRight(u.String(), "/"), nil
29}
30
31// DiscoverLMStudio lists models without loading them. Only older servers that
32// lack the native endpoint fall back to the less descriptive OpenAI endpoint.
33func DiscoverLMStudio(ctx context.Context, serverURL, apiKey string) ([]Model, error) {
34 root, err := NormalizeLMStudioURL(serverURL)
35 if err != nil {
36 return nil, err
37 }
38 client := &http.Client{Timeout: 3 * time.Second}
39 get := func(path string, out any) (int, error) {
40 req, err := http.NewRequestWithContext(ctx, http.MethodGet, root+path, nil)
41 if err != nil {
42 return 0, err
43 }
44 if apiKey != "" {
45 req.Header.Set("Authorization", "Bearer "+apiKey)
46 }
47 resp, err := client.Do(req)
48 if err != nil {
49 if ctx.Err() != nil {
50 return 0, ctx.Err()
51 }
52 return 0, fmt.Errorf("LM Studio discovery request failed")
53 }
54 defer resp.Body.Close()
55 if resp.StatusCode != http.StatusOK {
56 return resp.StatusCode, fmt.Errorf("LM Studio discovery HTTP %d", resp.StatusCode)
57 }
58 if err := json.NewDecoder(http.MaxBytesReader(nil, resp.Body, 8<<20)).Decode(out); err != nil {
59 return resp.StatusCode, fmt.Errorf("LM Studio discovery returned invalid model data")
60 }
61 return resp.StatusCode, nil
62 }
63 var native struct {
64 Models []struct {
65 Type string `json:"type"`
66 Key string `json:"key"`
67 Name string `json:"display_name"`
68 Context int `json:"max_context_length"`
69 Loaded []struct {
70 ID string `json:"id"`
71 Config struct {
72 Context int `json:"context_length"`
73 } `json:"config"`
74 } `json:"loaded_instances"`
75 } `json:"models"`
76 }
77 status, err := get("/api/v1/models", &native)
78 var models []Model
79 seen := map[string]bool{}
80 add := func(id, name string, contextWindow int) {
81 if strings.TrimSpace(id) == "" || seen[id] {
82 return
83 }
84 seen[id] = true
85 if name == "" {
86 name = id
87 }
88 if contextWindow <= 0 {
89 contextWindow = 32768
90 }
91 maxOutput := min(4096, max(1, contextWindow/4))
92 models = append(models, Model{Provider: LMStudioProviderID, ID: id, DisplayName: name, ContextWindow: contextWindow, MaxOutput: maxOutput, BaseURL: root + "/v1", Source: "live"})
93 }
94 if status == http.StatusNotFound || status == http.StatusMethodNotAllowed || status == http.StatusNotImplemented {
95 var fallback struct {
96 Data []struct {
97 ID string `json:"id"`
98 } `json:"data"`
99 }
100 if _, err := get("/v1/models", &fallback); err != nil {
101 return nil, err
102 }
103 if fallback.Data == nil {
104 return nil, fmt.Errorf("LM Studio discovery response is missing data")
105 }
106 for _, m := range fallback.Data {
107 if strings.Contains(strings.ToLower(m.ID), "embed") {
108 continue
109 }
110 add(m.ID, m.ID, 0)
111 }
112 return models, nil
113 }
114 if err != nil {
115 return nil, err
116 }
117 if native.Models == nil {
118 return nil, fmt.Errorf("LM Studio discovery response is missing models")
119 }
120 for _, m := range native.Models {
121 if m.Type != "llm" {
122 continue
123 }
124 if len(m.Loaded) == 0 {
125 add(m.Key, m.Name, m.Context)
126 continue
127 }
128 for _, instance := range m.Loaded {
129 id := instance.ID
130 if id == "" {
131 id = m.Key
132 }
133 // Unknown loaded context uses a conservative default, not the
134 // model's theoretical training limit.
135 add(id, m.Name, instance.Config.Context)
136 }
137 }
138 return models, nil
139}