返回 DeepSeek-Reasonix
fetch_test.go
根目录 / internal / config / fetch_test.go
1 package config
2
3 import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "slices"
9 "testing"
10 )
11
12 func TestBuildModelFetchURLs(t *testing.T) {
13 tests := []struct {
14 name string
15 base string
16 override string
17 want []string
18 }{
19 {
20 name: "root endpoint keeps legacy models path first",
21 base: "https://api.deepseek.com",
22 want: []string{"https://api.deepseek.com/models", "https://api.deepseek.com/v1/models"},
23 },
24 {
25 name: "versioned endpoint uses models under version",
26 base: "https://api.example.com/v1",
27 want: []string{"https://api.example.com/v1/models"},
28 },
29 {
30 name: "non-v1 version keeps v1 fallback",
31 base: "https://open.bigmodel.cn/api/coding/paas/v4",
32 want: []string{
33 "https://open.bigmodel.cn/api/coding/paas/v4/models",
34 "https://open.bigmodel.cn/api/coding/paas/v4/v1/models",
35 },
36 },
37 {
38 name: "anthropic compatible subpath adds root candidates",
39 base: "https://api.deepseek.com/anthropic",
40 want: []string{
41 "https://api.deepseek.com/anthropic/models",
42 "https://api.deepseek.com/anthropic/v1/models",
43 "https://api.deepseek.com/models",
44 "https://api.deepseek.com/v1/models",
45 },
46 },
47 {
48 name: "override wins",
49 base: "https://api.deepseek.com",
50 override: "https://api.deepseek.com/custom/models",
51 want: []string{"https://api.deepseek.com/custom/models"},
52 },
53 {
54 name: "third-party override keeps exact query and slash",
55 base: "https://api.deepseek.com",
56 override: "https://api.deepseek.com/custom/models/?token=1",
57 want: []string{"https://api.deepseek.com/custom/models/?token=1"},
58 },
59 {
60 name: "tokenrhythm missing v1 base is unique canonical models",
61 base: "https://tokenrhythm.studio",
62 want: []string{"https://tokenrhythm.studio/v1/models"},
63 },
64 {
65 name: "tokenrhythm correct base is unique canonical models",
66 base: "https://tokenrhythm.studio/v1",
67 want: []string{"https://tokenrhythm.studio/v1/models"},
68 },
69 {
70 name: "tokenrhythm chat url as base is unique canonical models",
71 base: "https://tokenrhythm.studio/v1/chat/completions",
72 want: []string{"https://tokenrhythm.studio/v1/models"},
73 },
74 {
75 name: "tokenrhythm wrong models override is unique canonical models",
76 base: "https://example.invalid/v1",
77 override: "https://tokenrhythm.studio/v1/v1/models/",
78 want: []string{"https://tokenrhythm.studio/v1/models"},
79 },
80 {
81 name: "tokenrhythm unknown override stays exact",
82 base: "https://tokenrhythm.studio/v1",
83 override: "https://tokenrhythm.studio/openai/models",
84 want: []string{"https://tokenrhythm.studio/openai/models"},
85 },
86 {
87 name: "stepfun anthropic-docs base is unique canonical models",
88 base: "https://api.stepfun.com/step_plan",
89 want: []string{"https://api.stepfun.com/step_plan/v1/models"},
90 },
91 {
92 name: "stepfun correct base is unique canonical models",
93 base: "https://api.stepfun.com/step_plan/v1",
94 want: []string{"https://api.stepfun.com/step_plan/v1/models"},
95 },
96 {
97 name: "stepfun global host base is unique canonical models",
98 base: "https://api.stepfun.ai/step_plan",
99 want: []string{"https://api.stepfun.ai/step_plan/v1/models"},
100 },
101 {
102 name: "stepfun standard api root keeps legacy candidates",
103 base: "https://api.stepfun.com",
104 want: []string{"https://api.stepfun.com/models", "https://api.stepfun.com/v1/models"},
105 },
106 {
107 name: "stepfun models override is unique canonical models",
108 base: "https://example.invalid/v1",
109 override: "https://api.stepfun.com/step_plan",
110 want: []string{"https://api.stepfun.com/step_plan/v1/models"},
111 },
112 }
113 for _, tt := range tests {
114 t.Run(tt.name, func(t *testing.T) {
115 got, err := BuildModelFetchURLs(tt.base, tt.override)
116 if err != nil {
117 t.Fatalf("BuildModelFetchURLs: %v", err)
118 }
119 if len(got) != len(tt.want) {
120 t.Fatalf("got %v, want %v", got, tt.want)
121 }
122 for i := range got {
123 if got[i] != tt.want[i] {
124 t.Fatalf("got %v, want %v", got, tt.want)
125 }
126 }
127 })
128 }
129 }
130
131 func TestProviderFetchModelsFallsBackToV1Models(t *testing.T) {
132 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
133 if r.URL.Path == "/models" {
134 http.NotFound(w, r)
135 return
136 }
137 if r.URL.Path != "/v1/models" {
138 t.Fatalf("unexpected path %s", r.URL.Path)
139 }
140 if r.Header.Get("Authorization") != "Bearer test-key" {
141 http.Error(w, "bad key", http.StatusUnauthorized)
142 return
143 }
144 _ = json.NewEncoder(w).Encode(map[string]any{
145 "data": []map[string]string{{"id": "model-b"}, {"id": "model-a"}},
146 })
147 }))
148 defer srv.Close()
149
150 p := ProviderEntry{Name: "test", BaseURL: srv.URL, APIKeyEnv: "FETCH_MODELS_TEST_KEY", resolvedAPIKey: "test-key"}
151 got, err := p.FetchModels(context.Background())
152 if err != nil {
153 t.Fatalf("FetchModels: %v", err)
154 }
155 if len(got) != 2 || got[0] != "model-a" || got[1] != "model-b" {
156 t.Fatalf("got %v, want [model-a model-b]", got)
157 }
158 }
159
160 func TestProviderFetchModelsContinuesAfterRootAuthFailure(t *testing.T) {
161 var paths []string
162 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
163 paths = append(paths, r.URL.Path)
164 switch r.URL.Path {
165 case "/models":
166 http.Error(w, `{"error":"wrong endpoint"}`, http.StatusUnauthorized)
167 case "/v1/models":
168 if r.Header.Get("Authorization") != "Bearer test-key" {
169 http.Error(w, "bad key", http.StatusUnauthorized)
170 return
171 }
172 _ = json.NewEncoder(w).Encode(map[string]any{
173 "data": []map[string]string{{"id": "model-a"}},
174 })
175 default:
176 t.Fatalf("unexpected path %s", r.URL.Path)
177 }
178 }))
179 defer srv.Close()
180
181 p := ProviderEntry{Name: "test", BaseURL: srv.URL, APIKeyEnv: "FETCH_MODELS_TEST_KEY", resolvedAPIKey: "test-key"}
182 got, err := p.FetchModels(context.Background())
183 if err != nil {
184 t.Fatalf("FetchModels: %v", err)
185 }
186 if len(got) != 1 || got[0] != "model-a" {
187 t.Fatalf("got %v, want [model-a]", got)
188 }
189 if len(paths) != 2 || paths[0] != "/models" || paths[1] != "/v1/models" {
190 t.Fatalf("paths = %v, want [/models /v1/models]", paths)
191 }
192 }
193
194 func TestProviderFetchModelsUsesSetupProbeEnv(t *testing.T) {
195 const key = "FETCH_MODELS_PROBE_KEY"
196 t.Setenv(key, "probe-key")
197 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
198 if r.Header.Get("Authorization") != "Bearer probe-key" {
199 http.Error(w, "bad key", http.StatusUnauthorized)
200 return
201 }
202 _ = json.NewEncoder(w).Encode(map[string]any{
203 "data": []map[string]string{{"id": "probe-model"}},
204 })
205 }))
206 defer srv.Close()
207
208 p := ProviderEntry{Name: "probe", BaseURL: srv.URL, APIKeyEnv: key}
209 p.ResolveAPIKeyFromProcessEnvForProbe()
210 got, err := p.FetchModels(context.Background())
211 if err != nil {
212 t.Fatalf("FetchModels: %v", err)
213 }
214 if len(got) != 1 || got[0] != "probe-model" {
215 t.Fatalf("models = %v, want [probe-model]", got)
216 }
217 }
218
219 func TestProviderFetchModelsAllowsNoAuthEndpoint(t *testing.T) {
220 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
221 if r.Header.Get("Authorization") != "" {
222 http.Error(w, "unexpected auth header", http.StatusBadRequest)
223 return
224 }
225 _ = json.NewEncoder(w).Encode(map[string]any{
226 "data": []map[string]string{{"id": "local-b"}, {"id": "local-a"}},
227 })
228 }))
229 defer srv.Close()
230
231 p := ProviderEntry{Name: "local", BaseURL: srv.URL}
232 got, err := p.FetchModels(context.Background())
233 if err != nil {
234 t.Fatalf("FetchModels no-auth: %v", err)
235 }
236 if len(got) != 2 || got[0] != "local-a" || got[1] != "local-b" {
237 t.Fatalf("got %v, want [local-a local-b]", got)
238 }
239 }
240
241 func TestProviderFetchModelsUsesAnthropicAuthMode(t *testing.T) {
242 tests := []struct {
243 name string
244 authHeader bool
245 assertAuth func(t *testing.T, r *http.Request)
246 }{
247 {
248 name: "x-api-key",
249 authHeader: false,
250 assertAuth: func(t *testing.T, r *http.Request) {
251 t.Helper()
252 if got := r.Header.Get("x-api-key"); got != "anthropic-key" {
253 t.Fatalf("x-api-key = %q, want anthropic-key", got)
254 }
255 if got := r.Header.Get("Authorization"); got != "" {
256 t.Fatalf("Authorization = %q, want omitted", got)
257 }
258 },
259 },
260 {
261 name: "bearer",
262 authHeader: true,
263 assertAuth: func(t *testing.T, r *http.Request) {
264 t.Helper()
265 if got := r.Header.Get("Authorization"); got != "Bearer anthropic-key" {
266 t.Fatalf("Authorization = %q, want Bearer anthropic-key", got)
267 }
268 if got := r.Header.Get("x-api-key"); got != "" {
269 t.Fatalf("x-api-key = %q, want omitted", got)
270 }
271 },
272 },
273 }
274
275 for _, tt := range tests {
276 t.Run(tt.name, func(t *testing.T) {
277 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
278 if r.URL.Path != "/anthropic/models" {
279 t.Fatalf("unexpected path %s", r.URL.Path)
280 }
281 tt.assertAuth(t, r)
282 _ = json.NewEncoder(w).Encode(map[string]any{
283 "data": []map[string]string{{"id": "anthropic-model"}},
284 })
285 }))
286 defer srv.Close()
287
288 p := ProviderEntry{
289 Name: "anthropic-compatible",
290 Kind: "anthropic",
291 BaseURL: srv.URL + "/anthropic",
292 APIKeyEnv: "ANTHROPIC_COMPATIBLE_KEY",
293 AuthHeader: tt.authHeader,
294 resolvedAPIKey: "anthropic-key",
295 }
296 got, err := p.FetchModels(context.Background())
297 if err != nil {
298 t.Fatalf("FetchModels: %v", err)
299 }
300 if len(got) != 1 || got[0] != "anthropic-model" {
301 t.Fatalf("got %v, want [anthropic-model]", got)
302 }
303 })
304 }
305 }
306
307 func TestProviderFetchModelsFiltersOfficialOpenCodeGoCatalogByWireFormat(t *testing.T) {
308 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
309 _ = json.NewEncoder(w).Encode(map[string]any{
310 "data": []map[string]string{
311 {"id": "grok-4.5"},
312 {"id": "qwen3.7-plus"},
313 {"id": "minimax-m3"},
314 {"id": "glm-5.2"},
315 },
316 })
317 }))
318 defer srv.Close()
319
320 p := ProviderEntry{
321 Name: "opencode-go-anthropic",
322 Kind: "anthropic",
323 BaseURL: "https://opencode.ai/zen/go",
324 ModelsURL: srv.URL,
325 }
326 got, err := p.FetchModels(context.Background())
327 if err != nil {
328 t.Fatalf("FetchModels: %v", err)
329 }
330 want := []string{"minimax-m3", "qwen3.7-plus"}
331 if !slices.Equal(got, want) {
332 t.Fatalf("models = %v, want %v", got, want)
333 }
334 }
335
336 func TestProviderFetchModelsKeepsOpenCodeGoVisionModelOnChatRoute(t *testing.T) {
337 // This guards the DeepSeek vision catalog integration from #9717.
338 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
339 _ = json.NewEncoder(w).Encode(map[string]any{"data": []map[string]string{
340 {"id": "deepseek-v4-flash"},
341 {"id": "deepseek-v4-flash-vision-exp"},
342 }})
343 }))
344 defer srv.Close()
345
346 p := ProviderEntry{Name: "opencode-go", Kind: "openai", BaseURL: "https://opencode.ai/zen/go/v1", ModelsURL: srv.URL}
347 got, err := p.FetchModels(context.Background())
348 if err != nil {
349 t.Fatalf("FetchModels: %v", err)
350 }
351 want := []string{"deepseek-v4-flash", "deepseek-v4-flash-vision-exp"}
352 if !slices.Equal(got, want) {
353 t.Fatalf("models = %v, want %v", got, want)
354 }
355 }
356
356 lines GO