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}