1package provider
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10func TestSetUserModelsPreservesCatalogWithoutLiveOverlay(t *testing.T) {
11 SetLiveModels(nil)
12 SetUserModels(nil)
13 t.Cleanup(func() {
14 SetLiveModels(nil)
15 SetUserModels(nil)
16 })
17
18 SetUserModels([]Model{{
19 Provider: "custom-test",
20 ID: "custom-model",
21 DisplayName: "Custom Model",
22 Source: "user",
23 }})
24
25 if _, err := FindModel("anthropic", "claude-sonnet-4-5"); err != nil {
26 t.Fatalf("built-in model hidden after SetUserModels: %v", err)
27 }
28 if _, err := FindModel("custom-test", "custom-model"); err != nil {
29 t.Fatalf("custom model missing after SetUserModels: %v", err)
30 }
31}
32
33func TestSetUserModelsSurvivesLiveCatalogRefresh(t *testing.T) {
34 SetLiveModels(nil)
35 SetUserModels(nil)
36 t.Cleanup(func() {
37 SetLiveModels(nil)
38 SetUserModels(nil)
39 })
40
41 SetUserModels([]Model{
42 {
43 Provider: "opencode-go",
44 ID: "served-model",
45 DisplayName: "Pinned model",
46 ContextWindow: 123456,
47 MaxOutput: 7890,
48 Reasoning: false,
49 API: APIResponses,
50 BaseURL: "https://proxy.example/v1",
51 },
52 {Provider: "opencode-go", ID: "user-only", DisplayName: "User only"},
53 })
54
55 SetLiveModelsForProviders([]Model{
56 {
57 Provider: "opencode-go",
58 ID: "served-model",
59 DisplayName: "Live model",
60 ContextWindow: 999999,
61 MaxOutput: 99999,
62 Reasoning: true,
63 },
64 }, []string{"opencode-go"})
65
66 got, err := FindModel("opencode-go", "served-model")
67 if err != nil {
68 t.Fatal(err)
69 }
70 if got.DisplayName != "Pinned model" || got.ContextWindow != 123456 || got.MaxOutput != 7890 || got.Reasoning || got.API != APIResponses || got.BaseURL != "https://proxy.example/v1" || got.Source != "user" {
71 t.Fatalf("user override was lost after refresh: %+v", got)
72 }
73 if _, err := FindModel("opencode-go", "user-only"); err != nil {
74 t.Fatalf("user-only model was lost after refresh: %v", err)
75 }
76}
77
78func TestLoadUserModelsRegistersModelLevelBaseURLCustomProvider(t *testing.T) {
79 dir := t.TempDir()
80 path := filepath.Join(dir, "models.json")
81 if err := os.WriteFile(path, []byte(`{
82 "providers": {
83 "model-base-only": {
84 "models": [
85 {"id": "m1", "baseUrl": "https://llm.example.com/v1"}
86 ]
87 }
88 }
89 }`), 0o644); err != nil {
90 t.Fatal(err)
91 }
92
93 models, warnings := LoadUserModelsWithWarnings(path)
94 if len(warnings) != 0 {
95 t.Fatalf("warnings = %v, want none", warnings)
96 }
97 if len(models) != 1 {
98 t.Fatalf("models = %d, want 1", len(models))
99 }
100 cfg, ok := CustomProviders()["model-base-only"]
101 if !ok {
102 t.Fatal("custom provider was not registered")
103 }
104 if cfg.API != "openai" {
105 t.Fatalf("api = %q, want openai", cfg.API)
106 }
107}
108
109func TestLoadUserModelsReasoningLevelMap(t *testing.T) {
110 dir := t.TempDir()
111 path := filepath.Join(dir, "models.json")
112 if err := os.WriteFile(path, []byte(`{
113 "providers": {
114 "company-proxy": {
115 "baseUrl": "https://llm.example.com/v1",
116 "api": "anthropic",
117 "models": [{
118 "id": "reasoning-model",
119 "reasoning": true,
120 "reasoningLevelMap": {"minimal": "low", "max": "off"}
121 }]
122 }
123 }
124 }`), 0o644); err != nil {
125 t.Fatal(err)
126 }
127
128 models, warnings := LoadUserModelsWithWarnings(path)
129 if len(warnings) != 0 {
130 t.Fatalf("warnings = %v, want none", warnings)
131 }
132 if len(models) != 1 {
133 t.Fatalf("models = %d, want 1", len(models))
134 }
135 model := models[0]
136 if model.API != "anthropic" {
137 t.Fatalf("api = %q, want anthropic", model.API)
138 }
139 if model.ReasoningLevelMap["minimum"] != "low" {
140 t.Fatalf("minimum mapping = %q, want low", model.ReasoningLevelMap["minimum"])
141 }
142 if mapped, ok := model.ReasoningLevelMap["max"]; !ok || mapped != "" {
143 t.Fatalf("max mapping = %q, %v, want explicit removal", mapped, ok)
144 }
145}
146
147func TestLoadUserModelsWarnsOnInvalidReasoningLevelMap(t *testing.T) {
148 dir := t.TempDir()
149 path := filepath.Join(dir, "models.json")
150 if err := os.WriteFile(path, []byte(`{
151 "providers": {
152 "custom": {
153 "models": [{
154 "id": "bad-map",
155 "reasoning": true,
156 "reasoningLevelMap": {"turbo": "high", "low": "extreme"}
157 }]
158 }
159 }
160 }`), 0o644); err != nil {
161 t.Fatal(err)
162 }
163
164 models, warnings := LoadUserModelsWithWarnings(path)
165 if len(models) != 1 || len(models[0].ReasoningLevelMap) != 0 {
166 t.Fatalf("invalid map was not discarded: %#v", models)
167 }
168 if len(warnings) != 2 || !strings.Contains(strings.Join(warnings, "\n"), "reasoningLevelMap") {
169 t.Fatalf("warnings = %v, want two reasoningLevelMap warnings", warnings)
170 }
171}
172
173func TestLoadUserModelsAcceptsOpenAIResponsesAPI(t *testing.T) {
174 dir := t.TempDir()
175 path := filepath.Join(dir, "models.json")
176 if err := os.WriteFile(path, []byte(`{
177 "providers": {
178 "company-proxy": {
179 "baseUrl": "https://llm.example.com/v1",
180 "api": "openai-responses",
181 "models": [{"id": "reasoning-model"}]
182 }
183 }
184 }`), 0o644); err != nil {
185 t.Fatal(err)
186 }
187
188 _, warnings := LoadUserModelsWithWarnings(path)
189 if len(warnings) != 0 {
190 t.Fatalf("warnings = %v, want none", warnings)
191 }
192 if cfg := CustomProviders()["company-proxy"]; cfg.API != APIResponses {
193 t.Fatalf("api = %q, want %q", cfg.API, APIResponses)
194 }
195}
196
197func TestLoadUserModelsWarnsOnUnknownAPI(t *testing.T) {
198 dir := t.TempDir()
199 path := filepath.Join(dir, "models.json")
200 if err := os.WriteFile(path, []byte(`{
201 "providers": {
202 "bad-api": {
203 "baseUrl": "https://llm.example.com/v1",
204 "api": "anthropic-message",
205 "models": [{"id": "m1"}]
206 }
207 }
208 }`), 0o644); err != nil {
209 t.Fatal(err)
210 }
211
212 _, warnings := LoadUserModelsWithWarnings(path)
213 if len(warnings) != 1 || !strings.Contains(warnings[0], "unknown api") {
214 t.Fatalf("warnings = %v, want unknown api warning", warnings)
215 }
216 if cfg := CustomProviders()["bad-api"]; cfg.API != "openai" {
217 t.Fatalf("api = %q, want openai", cfg.API)
218 }
219}
220
221func TestUserPriceOverrideClearsCatalogTiers(t *testing.T) {
222 SetLiveModels([]Model{{
223 Provider: ProviderOpenCodeGo,
224 ID: "tiered-model",
225 PriceInput: 1,
226 PriceOutput: 2,
227 PriceTiers: []ModelPriceTier{{InputTokens: 100, PriceInput: 9, PriceOutput: 10}},
228 PriceTierInputTokens: 100,
229 PriceInputAbove: 9,
230 PriceOutputAbove: 10,
231 PriceCacheReadAbove: 11,
232 PriceCacheWriteAbove: 12,
233 }})
234 SetUserModels([]Model{{
235 Provider: ProviderOpenCodeGo,
236 ID: "tiered-model",
237 PriceInput: 3,
238 PriceOutput: 4,
239 }})
240 t.Cleanup(func() {
241 SetLiveModels(nil)
242 SetUserModels(nil)
243 })
244
245 model, err := FindModel(ProviderOpenCodeGo, "tiered-model")
246 if err != nil {
247 t.Fatal(err)
248 }
249 if len(model.PriceTiers) != 0 || model.PriceTierInputTokens != 0 || model.PriceInputAbove != 0 || model.PriceOutputAbove != 0 || model.PriceCacheReadAbove != 0 || model.PriceCacheWriteAbove != 0 {
250 t.Fatalf("user price override retained catalog tiers: %+v", model)
251 }
252 want := 100 * 3 / 1_000_000.0
253 if got := ComputeCost(model, Usage{InputTokens: 100}); got != want {
254 t.Fatalf("cost = %v, want user base price %v", got, want)
255 }
256}
257
258func TestLoadUserModelsKeylessAuthRequiresEndpoint(t *testing.T) {
259 path := filepath.Join(t.TempDir(), "models.json")
260 if err := os.WriteFile(path, []byte(`{"providers":{
261 "keyless":{"baseUrl":"http://localhost:8888/v1","auth":"none","models":[{"id":"m"}]},
262 "unknown":{"baseUrl":"http://localhost:9999/v1","auth":"typo","models":[{"id":"m"}]},
263 "no-url":{"auth":"none","models":[{"id":"m"}]}
264 }}`), 0o644); err != nil {
265 t.Fatal(err)
266 }
267 _, warnings := LoadUserModelsWithWarnings(path)
268 if !CustomProviders()["keyless"].NoAuth || CustomProviders()["unknown"].NoAuth || CustomProviders()["no-url"].NoAuth {
269 t.Fatalf("invalid keyless configuration: %+v", CustomProviders())
270 }
271 if len(warnings) != 2 || !strings.Contains(strings.Join(warnings, "\n"), "unknown auth") || !strings.Contains(strings.Join(warnings, "\n"), "without a baseUrl") {
272 t.Fatalf("warnings = %v, want unknown auth and missing URL warnings", warnings)
273 }
274}
275
276func TestLoadUserModelsDiscoverFlag(t *testing.T) {
277 path := filepath.Join(t.TempDir(), "models.json")
278 if err := os.WriteFile(path, []byte(`{
279 "providers": {
280 "m4": {"baseUrl": "http://127.0.0.1:48000/v1", "discover": true},
281 "m5": {"baseUrl": "http://127.0.0.1:48001/v1", "models": [{"id": "pinned"}]},
282 "no-base": {"discover": true, "models": [{"id": "m1", "baseUrl": "http://127.0.0.1:48002/v1"}]}
283 }
284 }`), 0o644); err != nil {
285 t.Fatal(err)
286 }
287 _, warnings := LoadUserModelsWithWarnings(path)
288 if len(warnings) != 1 || !strings.Contains(warnings[0], `"no-base"`) || !strings.Contains(warnings[0], "discover") {
289 t.Fatalf("warnings = %v, want one discover warning for no-base", warnings)
290 }
291 cps := CustomProviders()
292 if !cps["m4"].Discover {
293 t.Fatal("m4 discovery not enabled")
294 }
295 if cps["m5"].Discover {
296 t.Fatal("m5 discovery enabled without flag")
297 }
298 if cfg, ok := cps["no-base"]; !ok || cfg.Discover {
299 t.Fatalf("no-base = %+v, %v; want registered without discovery", cfg, ok)
300 }
301}
302
303func TestLoadUserModelsBuiltinProviderIgnoresAuthNone(t *testing.T) {
304 path := filepath.Join(t.TempDir(), "models.json")
305 if err := os.WriteFile(path, []byte(`{"providers":{
306 "anthropic":{"baseUrl":"http://localhost:8888/v1","auth":"none","models":[{"id":"m"}]},
307 "local-x":{"baseUrl":"http://localhost:8889/v1","auth":"none","models":[{"id":"m"}]}
308 }}`), 0o644); err != nil {
309 t.Fatal(err)
310 }
311 _, warnings := LoadUserModelsWithWarnings(path)
312 cps := CustomProviders()
313 if cps["anthropic"].NoAuth {
314 t.Fatal("built-in provider accepted auth none")
315 }
316 if !cps["local-x"].NoAuth {
317 t.Fatal("custom provider lost auth none")
318 }
319 if len(warnings) != 1 || !strings.Contains(warnings[0], `"anthropic"`) || !strings.Contains(warnings[0], "built-in") {
320 t.Fatalf("warnings = %v", warnings)
321 }
322}
323
324func TestLoadUserModelsBuiltinBaseURLOnlyKeepsProtocolDefaults(t *testing.T) {
325 SetLiveModels(nil)
326 SetUserModels(nil)
327 t.Cleanup(func() {
328 SetLiveModels(nil)
329 SetUserModels(nil)
330 LoadUserModelsWithWarnings(filepath.Join(t.TempDir(), "missing.json"))
331 })
332 path := filepath.Join(t.TempDir(), "models.json")
333 if err := os.WriteFile(path, []byte(`{"providers":{
334 "github-copilot":{"baseUrl":"https://copilot.proxy.example","models":[
335 {"id":"claude-opus-4.7","reasoning":true},{"id":"gpt-5.5","reasoning":true},{"id":"proxy-only"}]},
336 "company-proxy":{"baseUrl":"https://llm.example.com/v1","models":[{"id":"m"}]}
337 }}`), 0o644); err != nil {
338 t.Fatal(err)
339 }
340 models, warnings := LoadUserModelsWithWarnings(path)
341 if len(warnings) != 0 {
342 t.Fatalf("warnings = %v", warnings)
343 }
344 for _, m := range models {
345 switch m.Provider + "/" + m.ID {
346 case "github-copilot/claude-opus-4.7", "github-copilot/gpt-5.5", "github-copilot/proxy-only":
347 if m.API != "" {
348 t.Errorf("%s/%s API = %q, want empty so built-in routing applies", m.Provider, m.ID, m.API)
349 }
350 case "company-proxy/m":
351 if m.API != "openai" {
352 t.Errorf("custom provider API = %q, want openai default", m.API)
353 }
354 }
355 }
356 SetUserModels(models)
357 for id, want := range map[string]string{"claude-opus-4.7": APIAnthropicMessages, "gpt-5.5": APIResponses, "proxy-only": APICompletions} {
358 got, err := FindModel("github-copilot", id)
359 if err != nil {
360 t.Fatal(err)
361 }
362 if got.API != want || got.BaseURL != "https://copilot.proxy.example" {
363 t.Errorf("%s API=%q baseURL=%q, want API %q with override URL", id, got.API, got.BaseURL, want)
364 }
365 }
366}
367
368func TestLoadUserModelsBuiltinExplicitAPIOverridesDefaults(t *testing.T) {
369 SetLiveModels(nil)
370 SetUserModels(nil)
371 t.Cleanup(func() {
372 SetLiveModels(nil)
373 SetUserModels(nil)
374 LoadUserModelsWithWarnings(filepath.Join(t.TempDir(), "missing.json"))
375 })
376 path := filepath.Join(t.TempDir(), "models.json")
377 if err := os.WriteFile(path, []byte(`{"providers":{
378 "github-copilot":{"baseUrl":"https://copilot.proxy.example","api":"openai","models":[{"id":"claude-opus-4.7","reasoning":true},{"id":"gpt-5.5","reasoning":true}]}
379 }}`), 0o644); err != nil {
380 t.Fatal(err)
381 }
382 models, warnings := LoadUserModelsWithWarnings(path)
383 if len(warnings) != 0 {
384 t.Fatalf("warnings = %v", warnings)
385 }
386 SetUserModels(models)
387 for _, id := range []string{"claude-opus-4.7", "gpt-5.5"} {
388 got, err := FindModel("github-copilot", id)
389 if err != nil {
390 t.Fatal(err)
391 }
392 if got.API != APICompletions {
393 t.Errorf("%s API = %q, want explicit openai alias mapped to %q", id, got.API, APICompletions)
394 }
395 }
396}