返回 DeepSeek-Reasonix
fetch_models.go
根目录 / internal / provider / openai / fetch_models.go
1 package openai
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "net/http"
10 "slices"
11 "sort"
12 "strings"
13 "time"
14
15 "reasonix/internal/netclient"
16 "reasonix/internal/provider"
17 )
18
19 type modelFetchStatusError struct {
20 status int
21 body string
22 }
23
24 type ModelFetchAuthMode string
25
26 const (
27 ModelFetchAuthAuto ModelFetchAuthMode = ""
28 ModelFetchAuthBearer ModelFetchAuthMode = "bearer"
29 ModelFetchAuthXAPIKey ModelFetchAuthMode = "x-api-key"
30
31 // fetchModelsMaxBody caps the response body read from a model-list
32 // endpoint. Large providers like OpenRouter return ~530 KB for 338
33 // models; 2 MiB leaves headroom while keeping memory bounded.
34 fetchModelsMaxBody = 2 << 20 // 2 MiB
35 )
36
37 type FetchModelsOptions struct {
38 Headers map[string]string
39 AuthMode ModelFetchAuthMode
40 // Proxy routes the model-list request through the same transport policy as
41 // chat requests, so a broken proxy surfaces at setup time instead of only
42 // stalling the first chat turn later (#9560).
43 Proxy netclient.ProxySpec
44 }
45
46 func (e modelFetchStatusError) Error() string {
47 return fmt.Sprintf("fetch models: status %d: %s", e.status, strings.TrimSpace(e.body))
48 }
49
50 // IsModelFetchEndpointMiss reports whether a model-list request reached a
51 // plausible endpoint path that the provider does not implement.
52 func IsModelFetchEndpointMiss(err error) bool {
53 var statusErr modelFetchStatusError
54 if !errors.As(err, &statusErr) {
55 return false
56 }
57 return statusErr.status == http.StatusNotFound || statusErr.status == http.StatusMethodNotAllowed
58 }
59
60 // FetchModels calls the OpenAI-compatible GET /models endpoint and returns the
61 // available model IDs.
62 func FetchModels(ctx context.Context, baseURL, apiKey string, headers map[string]string) ([]string, error) {
63 return FetchModelsWithOptions(ctx, baseURL, apiKey, FetchModelsOptions{Headers: headers})
64 }
65
66 // FetchModelCatalog calls the OpenAI-compatible model endpoint and returns
67 // model-level capability metadata. The adapter deliberately uses a
68 // unknown capability when an endpoint omits capability fields;
69 // callers must never infer image support from a model name.
70 func FetchModelCatalog(ctx context.Context, baseURL, apiKey string, headers map[string]string) ([]provider.ModelInfo, error) {
71 return FetchModelCatalogWithOptions(ctx, baseURL, apiKey, FetchModelsOptions{Headers: headers})
72 }
73
74 // FetchModelsWithOptions calls the OpenAI-compatible GET /models endpoint and
75 // returns the available model IDs.
76 func FetchModelsWithOptions(ctx context.Context, baseURL, apiKey string, opts FetchModelsOptions) ([]string, error) {
77 catalog, err := FetchModelCatalogWithOptions(ctx, baseURL, apiKey, opts)
78 if err != nil {
79 return nil, err
80 }
81 ids := make([]string, 0, len(catalog))
82 for _, model := range catalog {
83 ids = append(ids, model.ID)
84 }
85 return ids, nil
86 }
87
88 // FetchModelCatalogWithOptions is the metadata-preserving form of
89 // FetchModelsWithOptions. It keeps the existing request/auth/size behavior.
90 func FetchModelCatalogWithOptions(ctx context.Context, baseURL, apiKey string, opts FetchModelsOptions) ([]provider.ModelInfo, error) {
91 transport, err := netclient.NewTransport(opts.Proxy, netclient.TransportOptions{})
92 if err != nil {
93 return nil, fmt.Errorf("fetch models: network: %w", err)
94 }
95 cli := &http.Client{Timeout: 10 * time.Second, Transport: transport}
96 url := strings.TrimRight(baseURL, "/")
97 if !strings.HasSuffix(url, "/models") {
98 url += "/models"
99 }
100
101 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
102 if err != nil {
103 return nil, fmt.Errorf("fetch models: build request: %w", err)
104 }
105 applyModelFetchAPIKeyHeader(req.Header, baseURL, apiKey, opts.AuthMode)
106 req.Header.Set("Accept", "application/json")
107 applyCustomHeaders(req.Header, opts.Headers)
108
109 resp, err := cli.Do(req)
110 if err != nil {
111 return nil, fmt.Errorf("fetch models: request failed: %w", err)
112 }
113 defer resp.Body.Close()
114
115 body, err := io.ReadAll(io.LimitReader(resp.Body, fetchModelsMaxBody+1))
116 if err != nil {
117 return nil, fmt.Errorf("fetch models: read response: %w", err)
118 }
119 if len(body) > fetchModelsMaxBody {
120 return nil, fmt.Errorf("fetch models: response too large (exceeds %d bytes)", fetchModelsMaxBody)
121 }
122
123 if resp.StatusCode != http.StatusOK {
124 return nil, modelFetchStatusError{status: resp.StatusCode, body: truncateFetchBody(string(body))}
125 }
126
127 var result struct {
128 Data []json.RawMessage `json:"data"`
129 }
130 if err := json.Unmarshal(body, &result); err != nil {
131 return nil, fmt.Errorf("fetch models: decode response: %w", err)
132 }
133
134 modelsByID := make(map[string]provider.ModelInfo, len(result.Data))
135 conflicts := make(map[string]bool)
136 for _, raw := range result.Data {
137 model := parseModelInfo(baseURL, raw)
138 if model.ID == "" {
139 continue
140 }
141 if previous, exists := modelsByID[model.ID]; exists {
142 switch {
143 case conflicts[model.ID]:
144 model.InputModalities = nil
145 case model.InputModalities == nil:
146 model.InputModalities = previous.InputModalities
147 case previous.InputModalities != nil && !sameModalities(previous.InputModalities, model.InputModalities):
148 // A conflict stays unknown for the rest of this response, regardless
149 // of duplicate ordering. Missing metadata is not a negative fact.
150 conflicts[model.ID] = true
151 model.InputModalities = nil
152 }
153 }
154 modelsByID[model.ID] = model
155 }
156 models := make([]provider.ModelInfo, 0, len(modelsByID))
157 for _, model := range modelsByID {
158 models = append(models, model)
159 }
160 sort.Slice(models, func(i, j int) bool { return models[i].ID < models[j].ID })
161 return models, nil
162 }
163
164 func sameModalities(a, b []provider.ModelModality) bool {
165 return len(a) == len(b) && !slices.ContainsFunc(a, func(m provider.ModelModality) bool { return !slices.Contains(b, m) })
166 }
167
168 func parseModelInfo(baseURL string, raw json.RawMessage) provider.ModelInfo {
169 var entry map[string]json.RawMessage
170 if json.Unmarshal(raw, &entry) != nil {
171 return provider.ModelInfo{}
172 }
173 var rawID string
174 _ = json.Unmarshal(entry["id"], &rawID)
175 id := normalizeModelID(baseURL, rawID)
176 if id == "" {
177 return provider.ModelInfo{}
178 }
179 modalities, _ := parseModalities(entry)
180 return provider.ModelInfo{ID: id, InputModalities: modalities}
181 }
182
183 func parseModalities(entry map[string]json.RawMessage) ([]provider.ModelModality, bool) {
184 // Canonical and nested array fields are ordered before compatibility
185 // aliases. Presence with an invalid value is treated as an unsafe
186 // declaration and therefore stays unknown.
187 for _, key := range []string{"input_modalities"} {
188 if raw, present := entry[key]; present {
189 return decodeModalities(raw)
190 }
191 }
192 if raw, present := entry["modalities"]; present {
193 var nested map[string]json.RawMessage
194 if json.Unmarshal(raw, &nested) == nil {
195 if input, ok := nested["input"]; ok {
196 return decodeModalities(input)
197 }
198 }
199 return nil, false
200 }
201 if raw, present := entry["capabilities"]; present {
202 var nested map[string]json.RawMessage
203 if json.Unmarshal(raw, &nested) == nil {
204 if input, ok := nested["input_modalities"]; ok {
205 return decodeModalities(input)
206 }
207 if vision, ok := nested["vision"]; ok {
208 return decodeVisionBool(vision)
209 }
210 }
211 return nil, false
212 }
213 for _, key := range []string{"supports_vision", "vision"} {
214 if raw, present := entry[key]; present {
215 return decodeVisionBool(raw)
216 }
217 }
218 return nil, false
219 }
220
221 func decodeModalities(raw json.RawMessage) ([]provider.ModelModality, bool) {
222 var values []string
223 if json.Unmarshal(raw, &values) != nil || len(values) == 0 {
224 return nil, false
225 }
226 seen := map[provider.ModelModality]bool{}
227 out := make([]provider.ModelModality, 0, len(values))
228 for _, value := range values {
229 modality := provider.ModelModality(strings.ToLower(strings.TrimSpace(value)))
230 if modality != provider.ModalityText && modality != provider.ModalityImage {
231 return nil, false
232 }
233 if !seen[modality] {
234 seen[modality] = true
235 out = append(out, modality)
236 }
237 }
238 // Stable order also makes duplicate merging independent of array order.
239 if len(out) == 2 && out[0] == provider.ModalityImage {
240 out[0], out[1] = out[1], out[0]
241 }
242 return out, len(out) > 0
243 }
244
245 func decodeVisionBool(raw json.RawMessage) ([]provider.ModelModality, bool) {
246 var vision *bool
247 if json.Unmarshal(raw, &vision) != nil || vision == nil {
248 return nil, false
249 }
250 if *vision {
251 return []provider.ModelModality{provider.ModalityText, provider.ModalityImage}, true
252 }
253 return []provider.ModelModality{provider.ModalityText}, true
254 }
255
256 func applyModelFetchAPIKeyHeader(h http.Header, baseURL, apiKey string, mode ModelFetchAuthMode) {
257 apiKey = strings.TrimSpace(apiKey)
258 if apiKey == "" {
259 return
260 }
261 switch mode {
262 case ModelFetchAuthBearer:
263 h.Set("Authorization", "Bearer "+apiKey)
264 case ModelFetchAuthXAPIKey:
265 h.Set("x-api-key", apiKey)
266 default:
267 applyAPIKeyHeader(h, baseURL, apiKey)
268 }
269 }
270
271 func truncateFetchBody(body string) string {
272 body = strings.TrimSpace(body)
273 const max = 512
274 if len([]rune(body)) <= max {
275 return body
276 }
277 r := []rune(body)
278 return string(r[:max]) + "..."
279 }
280
280 lines GO