v0.4.10 5ac0009

modelsync_test.go RawHistoryFolder

1package agent
2
3import (
4	"context"
5	"encoding/json"
6	"fmt"
7	"io"
8	"net/http"
9	"net/http/httptest"
10	"os"
11	"path/filepath"
12	"runtime"
13	"strings"
14	"testing"
15	"time"
16
17	"github.com/patriceckhart/zot/packages/provider"
18)
19
20func TestRefreshModelsBedrockWithDefaultCLIProfile(t *testing.T) {
21	if runtime.GOOS == "windows" {
22		t.Skip("test uses a shell script to simulate the AWS CLI")
23	}
24	t.Setenv("ZOT_HOME", t.TempDir())
25	t.Setenv("AWS_PROFILE", "")
26	t.Setenv("AWS_ACCESS_KEY_ID", "")
27	t.Setenv("AWS_SECRET_ACCESS_KEY", "")
28	t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "")
29	for _, name := range []string{"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "KIMI_API_KEY", "GOOGLE_API_KEY", "OPENROUTER_API_KEY", "GONDOLA_API_KEY", "YOLO_AUTO_API_KEY"} {
30		t.Setenv(name, "")
31	}
32	bin := t.TempDir()
33	script := "#!/bin/sh\nprintf '%s\\n' '{\"AccessKeyId\":\"AKID\",\"SecretAccessKey\":\"SECRET\"}'\n"
34	if err := os.WriteFile(filepath.Join(bin, "aws"), []byte(script), 0o755); err != nil {
35		t.Fatal(err)
36	}
37	t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
38	orig := http.DefaultTransport
39	t.Cleanup(func() {
40		http.DefaultTransport = orig
41		provider.SetLiveModels(nil)
42	})
43	http.DefaultTransport = yoloAutoTestTransport(func(r *http.Request) (*http.Response, error) {
44		var body string
45		switch r.URL.Path {
46		case "/foundation-models":
47			body = `{"modelSummaries":[{"modelId":"example.cli-only-model","outputModalities":["TEXT"]}]}`
48		case "/inference-profiles":
49			body = `{"inferenceProfileSummaries":[]}`
50		default:
51			return nil, fmt.Errorf("unexpected discovery request: %s", r.URL)
52		}
53		return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
54	})
55
56	refreshModels()
57	if _, err := provider.FindModel("amazon-bedrock", "example.cli-only-model"); err != nil {
58		t.Fatal("default AWS CLI profile was not discovered:", err)
59	}
60	resolved, err := Resolve(Args{Provider: "amazon-bedrock", Model: "example.cli-only-model"}, true)
61	if err != nil {
62		t.Fatal("default AWS CLI profile could not resolve for inference:", err)
63	}
64	if resolved.Credential != "<aws>" {
65		t.Fatalf("inference credential = %q, want AWS sentinel", resolved.Credential)
66	}
67}
68
69func TestRefreshLlamaCPPModelsAddsOnlyLoadedModels(t *testing.T) {
70	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
71		if r.URL.Path != "/models" {
72			http.NotFound(w, r)
73			return
74		}
75		fmt.Fprint(w, `{"data":[{"id":"loaded","status":{"value":"loaded"},"meta":{"n_ctx":32768}},{"id":"offline","status":{"value":"unloaded"}}]}`)
76	}))
77	defer server.Close()
78
79	t.Setenv("ZOT_HOME", t.TempDir())
80	t.Setenv("LLAMA_BASE_URL", "")
81	if err := AuthStoreFor().SetEndpointCredential(provider.LlamaCPPProviderID, server.URL, ""); err != nil {
82		t.Fatal(err)
83	}
84	provider.SetManagedModels(nil)
85	t.Cleanup(func() { provider.SetManagedModels(nil) })
86
87	if err := RefreshLlamaCPPModels(context.Background()); err != nil {
88		t.Fatal(err)
89	}
90	loaded, err := provider.FindModel(provider.LlamaCPPProviderID, "loaded")
91	if err != nil {
92		t.Fatal(err)
93	}
94	if loaded.ContextWindow != 32768 || loaded.BaseURL != server.URL+"/v1" {
95		t.Fatalf("loaded model = %+v", loaded)
96	}
97	if _, err := provider.FindModel(provider.LlamaCPPProviderID, "offline"); err == nil {
98		t.Fatal("unloaded model must not be selectable")
99	}
100}
101
102// TestValidateAndRepairConfig_MismatchedPair simulates the bug from a
103// stale /model switch: provider=anthropic but model=kimi-for-coding
104// (which belongs to provider=kimi). The validator should rewrite the
105// model to anthropic's default and persist.
106func TestValidateAndRepairConfig_MismatchedPair(t *testing.T) {
107	home := t.TempDir()
108	t.Setenv("ZOT_HOME", home)
109
110	must := func(c Config) {
111		t.Helper()
112		b, _ := json.Marshal(c)
113		if err := os.WriteFile(filepath.Join(home, "config.json"), b, 0o644); err != nil {
114			t.Fatal(err)
115		}
116	}
117	must(Config{Provider: "anthropic", Model: "kimi-for-coding"})
118
119	ValidateAndRepairConfig()
120
121	out, err := LoadConfig()
122	if err != nil {
123		t.Fatal(err)
124	}
125	if out.Provider != "anthropic" {
126		t.Errorf("provider not preserved: %q", out.Provider)
127	}
128	if out.Model == "kimi-for-coding" {
129		t.Errorf("model not repaired; still %q", out.Model)
130	}
131	if out.Model == "" {
132		t.Errorf("model not set; expected anthropic default")
133	}
134}
135
136// TestValidateAndRepairConfig_UnknownProvider resets to anthropic and
137// clears the model when the saved provider id isn't recognised
138// (e.g. user removed it from a previous build).
139func TestValidateAndRepairConfig_UnknownProvider(t *testing.T) {
140	home := t.TempDir()
141	t.Setenv("ZOT_HOME", home)
142
143	b, _ := json.Marshal(Config{Provider: "made-up-provider", Model: "some-model"})
144	_ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
145
146	ValidateAndRepairConfig()
147
148	out, _ := LoadConfig()
149	if out.Provider != "anthropic" {
150		t.Errorf("provider not reset: %q", out.Provider)
151	}
152	if out.Model != "" {
153		t.Errorf("model not cleared: %q", out.Model)
154	}
155}
156
157// TestValidateAndRepairConfig_UnknownModel keeps the provider but
158// snaps the model to that provider's default when the saved id is no
159// longer in the catalog.
160func TestValidateAndRepairConfig_UnknownModel(t *testing.T) {
161	home := t.TempDir()
162	t.Setenv("ZOT_HOME", home)
163
164	b, _ := json.Marshal(Config{Provider: "anthropic", Model: "claude-deleted-model"})
165	_ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
166
167	ValidateAndRepairConfig()
168
169	out, _ := LoadConfig()
170	if out.Provider != "anthropic" {
171		t.Errorf("provider changed: %q", out.Provider)
172	}
173	if out.Model == "" || out.Model == "claude-deleted-model" {
174		t.Errorf("model not repaired: %q", out.Model)
175	}
176}
177
178func TestValidateAndRepairConfig_DuplicateModelIDValidForConfiguredProvider(t *testing.T) {
179	home := t.TempDir()
180	t.Setenv("ZOT_HOME", home)
181
182	b, _ := json.Marshal(Config{Provider: "openai-codex", Model: "gpt-5.5"})
183	_ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
184
185	ValidateAndRepairConfig()
186
187	out, _ := LoadConfig()
188	if out.Provider != "openai-codex" {
189		t.Errorf("provider mutated: %q", out.Provider)
190	}
191	if out.Model != "gpt-5.5" {
192		t.Errorf("model mutated: %q", out.Model)
193	}
194}
195
196func TestModelCacheFetchedAtPreservesCatalogFreshness(t *testing.T) {
197	cachedAt := time.Date(2026, time.August, 30, 12, 0, 0, 0, time.UTC)
198	now := cachedAt.Add(time.Hour)
199	cached := provider.ModelCache{FetchedAt: cachedAt}
200
201	if got := modelCacheFetchedAt(cached, false, now); !got.Equal(cachedAt) {
202		t.Fatalf("fresh cache timestamp = %v; want %v", got, cachedAt)
203	}
204	if got := modelCacheFetchedAt(cached, true, now); !got.Equal(now) {
205		t.Fatalf("refreshed cache timestamp = %v; want %v", got, now)
206	}
207}
208
209func TestMergeOpenRouterPresetsReplacesCachedPresets(t *testing.T) {
210	existing := []provider.Model{
211		{Provider: "openrouter", ID: "deepseek/deepseek-v4-flash"},
212		{Provider: "openrouter", ID: "@preset/old"},
213		{Provider: "anthropic", ID: "claude-sonnet-4-5"},
214	}
215	presets := []provider.Model{
216		{Provider: "openrouter", ID: "@preset/flash", DisplayName: "Flash (preset)", ContextWindow: 1000000},
217	}
218	out := mergeOpenRouterPresets(existing, presets)
219	if len(out) != 3 {
220		t.Fatalf("got %d models; want 3", len(out))
221	}
222	var ids []string
223	for _, m := range out {
224		ids = append(ids, m.Provider+"/"+m.ID)
225	}
226	want := []string{"openrouter/deepseek/deepseek-v4-flash", "anthropic/claude-sonnet-4-5", "openrouter/@preset/flash"}
227	for i, id := range want {
228		if ids[i] != id {
229			t.Errorf("ids[%d] = %q; want %q", i, ids[i], id)
230		}
231	}
232}
233
234func TestValidateAndRepairConfig_OpenRouterPreservesRoutedModelID(t *testing.T) {
235	home := t.TempDir()
236	t.Setenv("ZOT_HOME", home)
237
238	want := "deepseek/deepseek-v4-flash"
239	b, _ := json.Marshal(Config{Provider: "openrouter", Model: want})
240	_ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
241
242	ValidateAndRepairConfig()
243
244	out, _ := LoadConfig()
245	if out.Provider != "openrouter" {
246		t.Errorf("provider mutated: %q", out.Provider)
247	}
248	if out.Model != want {
249		t.Errorf("routed model id mutated: got %q, want %q", out.Model, want)
250	}
251}
252
253func TestValidateAndRepairConfig_GatewayPlainUnknownModelStillRepairs(t *testing.T) {
254	home := t.TempDir()
255	t.Setenv("ZOT_HOME", home)
256
257	b, _ := json.Marshal(Config{Provider: "openrouter", Model: "not-a-routed-model"})
258	_ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
259
260	ValidateAndRepairConfig()
261
262	out, _ := LoadConfig()
263	if out.Provider != "openrouter" {
264		t.Errorf("provider mutated: %q", out.Provider)
265	}
266	if out.Model == "not-a-routed-model" || out.Model == "" {
267		t.Errorf("plain unknown gateway model was not repaired: %q", out.Model)
268	}
269}
270
271// TestValidateAndRepairConfig_HappyPath leaves a valid config alone.
272func TestValidateAndRepairConfig_HappyPath(t *testing.T) {
273	home := t.TempDir()
274	t.Setenv("ZOT_HOME", home)
275
276	b, _ := json.Marshal(Config{Provider: "anthropic", Model: "claude-sonnet-4-5"})
277	_ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
278
279	ValidateAndRepairConfig()
280
281	out, _ := LoadConfig()
282	if out.Provider != "anthropic" {
283		t.Errorf("provider mutated: %q", out.Provider)
284	}
285	if out.Model != "claude-sonnet-4-5" {
286		t.Errorf("model mutated: %q", out.Model)
287	}
288}

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