| 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 |