main 4509ec7

usermodels_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close