返回 DeepSeek-Reasonix
fetch_models_test.go
根目录 / internal / provider / openai / fetch_models_test.go
1 package openai
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "net/http/httptest"
9 "strings"
10 "testing"
11
12 "reasonix/internal/netclient"
13 "reasonix/internal/provider"
14 )
15
16 func TestFetchModels(t *testing.T) {
17 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
18 if r.URL.Path != "/models" {
19 http.NotFound(w, r)
20 return
21 }
22 if r.Header.Get("Authorization") != "Bearer test-key" {
23 http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
24 return
25 }
26 json.NewEncoder(w).Encode(map[string]any{
27 "object": "list",
28 "data": []map[string]string{
29 {"id": "model-b", "object": "model"},
30 {"id": "model-a", "object": "model"},
31 },
32 })
33 }))
34 defer srv.Close()
35
36 models, err := FetchModels(context.Background(), srv.URL, "test-key", nil)
37 if err != nil {
38 t.Fatalf("unexpected error: %v", err)
39 }
40 if len(models) != 2 {
41 t.Fatalf("want 2 models, got %d", len(models))
42 }
43 if models[0] != "model-a" || models[1] != "model-b" {
44 t.Errorf("want sorted [model-a model-b], got %v", models)
45 }
46 }
47
48 func TestDiscoveryUnknownAndConflictingDuplicates(t *testing.T) {
49 cases := []struct {
50 name string
51 entries []string
52 state string
53 }{
54 {"missing", []string{`{"id":"x"}`}, "unknown"},
55 {"invalid standard blocks positive alias", []string{`{"id":"x","input_modalities":null,"vision":true}`}, "unknown"},
56 {"invalid array", []string{`{"id":"x","input_modalities":["text",42],"supports_vision":true}`}, "unknown"},
57 {"invalid bool", []string{`{"id":"x","vision":null}`}, "unknown"},
58 {"empty capabilities", []string{`{"id":"x","capabilities":{},"vision":true}`}, "unknown"},
59 {"conflict sticky", []string{`{"id":"x","vision":true}`, `{"id":"x","vision":false}`, `{"id":"x","vision":true}`, `{"id":"x"}`}, "unknown"},
60 {"unknown does not override known", []string{`{"id":"x"}`, `{"id":"x","vision":true}`}, "supported"},
61 {"unknown does not erase negative", []string{`{"id":"x"}`, `{"id":"x","vision":false}`}, "unsupported"},
62 {"standard wins", []string{`{"id":"x","input_modalities":["text"],"vision":true}`}, "unsupported"},
63 }
64 for _, tc := range cases {
65 t.Run(tc.name, func(t *testing.T) {
66 var permutations func([]string, int)
67 permutations = func(entries []string, i int) {
68 if i < len(entries) {
69 for j := i; j < len(entries); j++ {
70 entries[i], entries[j] = entries[j], entries[i]
71 permutations(entries, i+1)
72 entries[i], entries[j] = entries[j], entries[i]
73 }
74 return
75 }
76 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
77 fmt.Fprint(w, `{"data":[`+strings.Join(entries, ",")+`]}`)
78 }))
79 catalog, err := FetchModelCatalog(context.Background(), srv.URL, "", nil)
80 srv.Close()
81 if err != nil || len(catalog) != 1 {
82 t.Fatalf("catalog=%+v err=%v", catalog, err)
83 }
84 state := "unsupported"
85 if catalog[0].InputModalities == nil {
86 state = "unknown"
87 } else if catalog[0].SupportsInput(provider.ModalityImage) {
88 state = "supported"
89 }
90 if state != tc.state {
91 t.Fatalf("entries %v: got %s want %s", entries, state, tc.state)
92 }
93 }
94 permutations(append([]string(nil), tc.entries...), 0)
95 })
96 }
97 }
98
99 func TestFetchModelCatalogParsesInputModalitiesAndAliases(t *testing.T) {
100 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
101 _ = json.NewEncoder(w).Encode(map[string]any{"data": []any{
102 map[string]any{"id": "canonical", "input_modalities": []string{"text", "image"}},
103 map[string]any{"id": "nested", "modalities": map[string]any{"input": []string{"text", "image"}}},
104 map[string]any{"id": "vision-bool", "supports_vision": true},
105 map[string]any{"id": "text-only", "capabilities": map[string]any{"vision": false}},
106 map[string]any{"id": "missing"},
107 }})
108 }))
109 defer srv.Close()
110
111 got, err := FetchModelCatalog(context.Background(), srv.URL, "key", nil)
112 if err != nil {
113 t.Fatalf("FetchModelCatalog: %v", err)
114 }
115 byID := map[string][]string{}
116 for _, model := range got {
117 modalities := make([]string, len(model.InputModalities))
118 for i, modality := range model.InputModalities {
119 modalities[i] = string(modality)
120 }
121 byID[model.ID] = modalities
122 }
123 if got := byID["canonical"]; len(got) != 2 || got[1] != "image" {
124 t.Fatalf("canonical modalities = %v", got)
125 }
126 if got := byID["nested"]; len(got) != 2 || got[1] != "image" {
127 t.Fatalf("nested modalities = %v", got)
128 }
129 if got := byID["vision-bool"]; len(got) != 2 || got[1] != "image" {
130 t.Fatalf("vision bool modalities = %v", got)
131 }
132 if got := byID["text-only"]; len(got) != 1 || got[0] != "text" {
133 t.Fatalf("text-only modalities = %v", got)
134 }
135 if got := byID["missing"]; len(got) != 0 {
136 t.Fatalf("missing modalities = %v, want unknown", got)
137 }
138 }
139
140 func TestFetchModelCatalogCanonicalFieldWinsOverAlias(t *testing.T) {
141 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
142 _ = json.NewEncoder(w).Encode(map[string]any{"data": []any{
143 map[string]any{"id": "model", "input_modalities": []string{"text"}, "supports_vision": true},
144 }})
145 }))
146 defer srv.Close()
147
148 got, err := FetchModelCatalog(context.Background(), srv.URL, "key", nil)
149 if err != nil {
150 t.Fatalf("FetchModelCatalog: %v", err)
151 }
152 if len(got) != 1 || len(got[0].InputModalities) != 1 || got[0].InputModalities[0] != "text" {
153 t.Fatalf("catalog = %+v, canonical field should win", got)
154 }
155 }
156
157 func TestFetchModelsSendsCustomHeaders(t *testing.T) {
158 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
159 if r.Header.Get("HTTP-Referer") != "https://app.example" || r.Header.Get("X-Title") != "Reasonix" {
160 http.Error(w, `{"error":"missing headers"}`, http.StatusForbidden)
161 return
162 }
163 json.NewEncoder(w).Encode(map[string]any{
164 "data": []map[string]string{{"id": "model-a"}},
165 })
166 }))
167 defer srv.Close()
168
169 models, err := FetchModels(context.Background(), srv.URL, "key", map[string]string{
170 "HTTP-Referer": "https://app.example",
171 "X-Title": "Reasonix",
172 })
173 if err != nil {
174 t.Fatalf("FetchModels: %v", err)
175 }
176 if len(models) != 1 || models[0] != "model-a" {
177 t.Fatalf("models = %v, want [model-a]", models)
178 }
179 }
180
181 func TestFetchModelsWithOptionsUsesXAPIKey(t *testing.T) {
182 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
183 if got := r.Header.Get("x-api-key"); got != "anthropic-key" {
184 http.Error(w, `{"error":"missing x-api-key"}`, http.StatusUnauthorized)
185 return
186 }
187 if got := r.Header.Get("Authorization"); got != "" {
188 http.Error(w, `{"error":"unexpected bearer"}`, http.StatusUnauthorized)
189 return
190 }
191 json.NewEncoder(w).Encode(map[string]any{
192 "data": []map[string]string{{"id": "anthropic-model"}},
193 })
194 }))
195 defer srv.Close()
196
197 models, err := FetchModelsWithOptions(context.Background(), srv.URL, "anthropic-key", FetchModelsOptions{
198 AuthMode: ModelFetchAuthXAPIKey,
199 })
200 if err != nil {
201 t.Fatalf("FetchModelsWithOptions: %v", err)
202 }
203 if len(models) != 1 || models[0] != "anthropic-model" {
204 t.Fatalf("models = %v, want [anthropic-model]", models)
205 }
206 }
207
208 func TestApplyAPIKeyHeaderUsesMiMoAPIKeyHeader(t *testing.T) {
209 h := http.Header{}
210 applyAPIKeyHeader(h, "https://api.xiaomimimo.com/v1", "mimo-key")
211 if got := h.Get("api-key"); got != "mimo-key" {
212 t.Fatalf("api-key = %q, want mimo-key", got)
213 }
214 if got := h.Get("Authorization"); got != "" {
215 t.Fatalf("Authorization = %q, want omitted for MiMo", got)
216 }
217
218 h = http.Header{}
219 applyAPIKeyHeader(h, "https://api.deepseek.com", "deepseek-key")
220 if got := h.Get("Authorization"); got != "Bearer deepseek-key" {
221 t.Fatalf("Authorization = %q, want Bearer deepseek-key", got)
222 }
223 if got := h.Get("api-key"); got != "" {
224 t.Fatalf("api-key = %q, want omitted for standard OpenAI-compatible providers", got)
225 }
226 }
227
228 func TestFetchModelsAuthError(t *testing.T) {
229 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
230 http.Error(w, `{"error":{"message":"invalid key"}}`, http.StatusUnauthorized)
231 }))
232 defer srv.Close()
233
234 _, err := FetchModels(context.Background(), srv.URL, "bad-key", nil)
235 if err == nil {
236 t.Fatal("expected error for bad key")
237 }
238 }
239
240 func TestFetchModelsLargeResponse(t *testing.T) {
241 // A model list larger than the old 256 KB cap should succeed.
242 // OpenRouter returns ~531 KB (338 models); this test generates
243 // enough entries to exceed 256 KB and confirms they are all parsed.
244 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
245 data := make([]map[string]string, 8000)
246 for i := range data {
247 data[i] = map[string]string{"id": fmt.Sprintf("model-%04d", i), "object": "model"}
248 }
249 json.NewEncoder(w).Encode(map[string]any{"object": "list", "data": data})
250 }))
251 defer srv.Close()
252
253 models, err := FetchModels(context.Background(), srv.URL, "key", nil)
254 if err != nil {
255 t.Fatalf("unexpected error: %v", err)
256 }
257 if len(models) != 8000 {
258 t.Fatalf("want 8000 models, got %d", len(models))
259 }
260 }
261
262 func TestFetchModelsResponseTooLarge(t *testing.T) {
263 // A response larger than fetchModelsMaxBody should return a clear
264 // error rather than a cryptic JSON parse failure.
265 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
266 w.Header().Set("Content-Type", "application/json")
267 w.WriteHeader(http.StatusOK)
268 padding := strings.Repeat("x", fetchModelsMaxBody+1024)
269 fmt.Fprintf(w, `{"object":"list","data":[{"id":"%s","object":"model"}]}`, padding)
270 }))
271 defer srv.Close()
272
273 _, err := FetchModels(context.Background(), srv.URL, "key", nil)
274 if err == nil {
275 t.Fatal("expected error for oversized response")
276 }
277 if !strings.Contains(err.Error(), "too large") {
278 t.Errorf("error should mention the size limit, got: %v", err)
279 }
280 }
281
282 // TestFetchModelsRoutesThroughConfiguredProxy pins the #9560 fix: model
283 // discovery must ride the same network policy as chat requests. The fake
284 // gateway host only resolves through the proxy, so success proves the proxy
285 // transport was used; the plain spec must fail to reach it directly.
286 func TestFetchModelsRoutesThroughConfiguredProxy(t *testing.T) {
287 const gateway = "http://reasonix-fetch-probe.invalid/v1"
288 proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
289 if !strings.HasPrefix(r.URL.String(), gateway) {
290 http.Error(w, "unexpected proxied target "+r.URL.String(), http.StatusBadRequest)
291 return
292 }
293 if r.Header.Get("Authorization") != "Bearer proxied-key" {
294 http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
295 return
296 }
297 _ = json.NewEncoder(w).Encode(map[string]any{
298 "object": "list",
299 "data": []map[string]string{{"id": "model-a", "object": "model"}},
300 })
301 }))
302 defer proxy.Close()
303
304 spec := netclient.ProxySpec{Mode: netclient.ModeCustom, URL: proxy.URL}
305 if err := netclient.Validate(spec); err != nil {
306 t.Fatalf("proxy spec: %v", err)
307 }
308
309 models, err := FetchModelsWithOptions(context.Background(), gateway, "proxied-key", FetchModelsOptions{Proxy: spec})
310 if err != nil {
311 t.Fatalf("fetch through proxy: %v", err)
312 }
313 if fmt.Sprint(models) != "[model-a]" {
314 t.Fatalf("models = %v, want [model-a]", models)
315 }
316
317 if _, err := FetchModelsWithOptions(context.Background(), gateway, "proxied-key", FetchModelsOptions{}); err == nil {
318 t.Fatal("direct fetch to a proxy-only host must fail, otherwise this test proves nothing")
319 }
320 }
321
321 lines GO