detached 97b6cc7

fix: honor Yolo-Auto account limits and reasoning levels

commit
97b6cc78872d771c2b7a2e6a919a522d23dc78b9
author
patriceckhart · · 2026-09-23 15:02 UTC
parent
52c93f8
signature
unsigned
10 files · +267 -30
M docs/providers.md +14 -7
@@ -202,13 +202,20 @@ show instructions and should be configured with environment variables.
202202 When Gondola credentials are available, zot refreshes its public text-model
203203 catalog in the background and adds the discovered models to `/model`.
204204
205-Yolo-Auto is an OpenAI-compatible gateway with `yolo` and `yolo-small` model
206-aliases and flat-rate plans, so no per-token prices are shown. Those two aliases
207-ship with the provider's documented conservative client limits. With
208-`YOLO_AUTO_API_KEY` set, zot lists `/v1/models` in the background and adds every
209-model the key can reach to `/model`, using the plan-bounded context window the
210-endpoint reports; `yolo` is used when no model is given. Create a key at
211-https://yolo-auto.com and see https://yolo-auto.com/docs for the API reference.
205+Yolo-Auto is an OpenAI-compatible gateway with flat-rate plans, so no per-token
206+prices are shown. The default model, `qwen3.8-flash`, is available on free and
207+paid plans. The optional `yolo` route requires a paid plan. These models and
208+`yolo-small` ship with conservative client limits.
209+
210+With `YOLO_AUTO_API_KEY` set or a key saved through `/login`, zot lists
211+`/v1/models` in the background on each catalog refresh, independently of the
212+six-hour public catalog cache. Discovered models use the key's plan-bounded
213+context windows and advertised reasoning levels, replacing the seed metadata.
214+The last discovered metadata is cached for startup resolution until the
215+background refresh completes. If discovery fails, zot falls back to the seed
216+models and conservative limits instead of retaining the previous key's live
217+metadata. Create a key at https://yolo-auto.com and see
218+https://yolo-auto.com/docs for the API reference.
212219
213220 Example:
214221
@@ -202,13 +202,20 @@ show instructions and should be configured with environment variables.
202 When Gondola credentials are available, zot refreshes its public text-model202 When Gondola credentials are available, zot refreshes its public text-model
203 catalog in the background and adds the discovered models to `/model`.203 catalog in the background and adds the discovered models to `/model`.
204 204
205-Yolo-Auto is an OpenAI-compatible gateway with `yolo` and `yolo-small` model205+Yolo-Auto is an OpenAI-compatible gateway with flat-rate plans, so no per-token
206-aliases and flat-rate plans, so no per-token prices are shown. Those two aliases206+prices are shown. The default model, `qwen3.8-flash`, is available on free and
207-ship with the provider's documented conservative client limits. With207+paid plans. The optional `yolo` route requires a paid plan. These models and
208-`YOLO_AUTO_API_KEY` set, zot lists `/v1/models` in the background and adds every208+`yolo-small` ship with conservative client limits.
209-model the key can reach to `/model`, using the plan-bounded context window the209+
210-endpoint reports; `yolo` is used when no model is given. Create a key at210+With `YOLO_AUTO_API_KEY` set or a key saved through `/login`, zot lists
211-https://yolo-auto.com and see https://yolo-auto.com/docs for the API reference.211+`/v1/models` in the background on each catalog refresh, independently of the
212+six-hour public catalog cache. Discovered models use the key's plan-bounded
213+context windows and advertised reasoning levels, replacing the seed metadata.
214+The last discovered metadata is cached for startup resolution until the
215+background refresh completes. If discovery fails, zot falls back to the seed
216+models and conservative limits instead of retaining the previous key's live
217+metadata. Create a key at https://yolo-auto.com and see
218+https://yolo-auto.com/docs for the API reference.
212 219
213 Example:220 Example:
214 221
M packages/agent/build.go +1 -1
@@ -194,7 +194,7 @@ func defaultModelForProvider(prov string) string {
194194 case "gondola":
195195 return "kimi-k3"
196196 case "yolo-auto":
197- return "yolo"
197+ return "qwen3.8-flash"
198198 case "mistral":
199199 return "mistral-large-latest"
200200 case "zai":
@@ -194,7 +194,7 @@ func defaultModelForProvider(prov string) string {
194 case "gondola":194 case "gondola":
195 return "kimi-k3"195 return "kimi-k3"
196 case "yolo-auto":196 case "yolo-auto":
197- return "yolo"197+ return "qwen3.8-flash"
198 case "mistral":198 case "mistral":
199 return "mistral-large-latest"199 return "mistral-large-latest"
200 case "zai":200 case "zai":
M packages/agent/modelsync.go +28 -11
@@ -206,9 +206,17 @@ func refreshModels() {
206206 cached, _ := provider.LoadCache(ModelCachePath())
207207 cacheFresh := cached.IsFresh()
208208
209+ // Refresh per-key limits even while the public catalog cache is fresh.
210+ // Use a separate deadline so slow public discovery cannot starve this call.
211+ yoloCtx, yoloCancel := context.WithTimeout(context.Background(), 15*time.Second)
212+ var yoloModels []provider.Model
213+ if cred, _, err := resolveCredentialForBackground(yoloCtx, "yolo-auto"); err == nil {
214+ yoloModels, _ = provider.DiscoverYoloAuto(yoloCtx, cred, "")
215+ }
216+ yoloCancel()
217+
209218 ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
210219 defer cancel()
211-
212220 var all []provider.Model
213221 var openrouterCred string
214222 var haveOpenRouter bool
@@ -261,13 +269,6 @@ func refreshModels() {
261269 all = append(all, live...)
262270 }
263271 }
264- if cred, _, err := resolveCredentialForBackground(ctx, "yolo-auto"); err == nil {
265- // Yolo-Auto's /v1/models answers per key. Gate discovery on a
266- // credential so its models only fill the picker for key holders.
267- if live, err := provider.DiscoverYoloAuto(ctx, cred, ""); err == nil {
268- all = append(all, live...)
269- }
270- }
271272 }
272273
273274 // Presets are per-account and require auth. Fetch them even when the
@@ -279,18 +280,34 @@ func refreshModels() {
279280 }
280281 }
281282
282- if len(all) == 0 {
283- return
284- }
283+ // Do not carry forward another key's models or limits, even on failure.
284+ all = mergeYoloAutoModels(all, yoloModels)
285285 provider.SetLiveModels(all)
286286 // Keep explicit metadata overrides above refreshed server catalogs.
287287 LoadUserModels()
288+ if len(all) == 0 {
289+ // Clear old account entries without marking an empty catalog fresh.
290+ _ = provider.SaveCache(ModelCachePath(), provider.ModelCache{})
291+ return
292+ }
288293 _ = provider.SaveCache(ModelCachePath(), provider.ModelCache{
289294 FetchedAt: modelCacheFetchedAt(cached, !cacheFresh, time.Now().UTC()),
290295 Models: all,
291296 })
292297 }
293298
299+// mergeYoloAutoModels replaces account metadata without disturbing the public
300+// catalog. Cached entries aid offline resolution, but never suppress discovery.
301+func mergeYoloAutoModels(existing, live []provider.Model) []provider.Model {
302+ out := make([]provider.Model, 0, len(existing)+len(live))
303+ for _, model := range existing {
304+ if model.Provider != "yolo-auto" {
305+ out = append(out, model)
306+ }
307+ }
308+ return append(out, live...)
309+}
310+
294311 func modelCacheFetchedAt(cached provider.ModelCache, catalogRefreshed bool, now time.Time) time.Time {
295312 if catalogRefreshed {
296313 return now
@@ -206,9 +206,17 @@ func refreshModels() {
206 cached, _ := provider.LoadCache(ModelCachePath())206 cached, _ := provider.LoadCache(ModelCachePath())
207 cacheFresh := cached.IsFresh()207 cacheFresh := cached.IsFresh()
208 208
209+ // Refresh per-key limits even while the public catalog cache is fresh.
210+ // Use a separate deadline so slow public discovery cannot starve this call.
211+ yoloCtx, yoloCancel := context.WithTimeout(context.Background(), 15*time.Second)
212+ var yoloModels []provider.Model
213+ if cred, _, err := resolveCredentialForBackground(yoloCtx, "yolo-auto"); err == nil {
214+ yoloModels, _ = provider.DiscoverYoloAuto(yoloCtx, cred, "")
215+ }
216+ yoloCancel()
217+
209 ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)218 ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
210 defer cancel()219 defer cancel()
211-
212 var all []provider.Model220 var all []provider.Model
213 var openrouterCred string221 var openrouterCred string
214 var haveOpenRouter bool222 var haveOpenRouter bool
@@ -261,13 +269,6 @@ func refreshModels() {
261 all = append(all, live...)269 all = append(all, live...)
262 }270 }
263 }271 }
264- if cred, _, err := resolveCredentialForBackground(ctx, "yolo-auto"); err == nil {
265- // Yolo-Auto's /v1/models answers per key. Gate discovery on a
266- // credential so its models only fill the picker for key holders.
267- if live, err := provider.DiscoverYoloAuto(ctx, cred, ""); err == nil {
268- all = append(all, live...)
269- }
270- }
271 }272 }
272 273
273 // Presets are per-account and require auth. Fetch them even when the274 // Presets are per-account and require auth. Fetch them even when the
@@ -279,18 +280,34 @@ func refreshModels() {
279 }280 }
280 }281 }
281 282
282- if len(all) == 0 {283+ // Do not carry forward another key's models or limits, even on failure.
283- return284+ all = mergeYoloAutoModels(all, yoloModels)
284- }
285 provider.SetLiveModels(all)285 provider.SetLiveModels(all)
286 // Keep explicit metadata overrides above refreshed server catalogs.286 // Keep explicit metadata overrides above refreshed server catalogs.
287 LoadUserModels()287 LoadUserModels()
288+ if len(all) == 0 {
289+ // Clear old account entries without marking an empty catalog fresh.
290+ _ = provider.SaveCache(ModelCachePath(), provider.ModelCache{})
291+ return
292+ }
288 _ = provider.SaveCache(ModelCachePath(), provider.ModelCache{293 _ = provider.SaveCache(ModelCachePath(), provider.ModelCache{
289 FetchedAt: modelCacheFetchedAt(cached, !cacheFresh, time.Now().UTC()),294 FetchedAt: modelCacheFetchedAt(cached, !cacheFresh, time.Now().UTC()),
290 Models: all,295 Models: all,
291 })296 })
292 }297 }
293 298
299+// mergeYoloAutoModels replaces account metadata without disturbing the public
300+// catalog. Cached entries aid offline resolution, but never suppress discovery.
301+func mergeYoloAutoModels(existing, live []provider.Model) []provider.Model {
302+ out := make([]provider.Model, 0, len(existing)+len(live))
303+ for _, model := range existing {
304+ if model.Provider != "yolo-auto" {
305+ out = append(out, model)
306+ }
307+ }
308+ return append(out, live...)
309+}
310+
294 func modelCacheFetchedAt(cached provider.ModelCache, catalogRefreshed bool, now time.Time) time.Time {311 func modelCacheFetchedAt(cached provider.ModelCache, catalogRefreshed bool, now time.Time) time.Time {
295 if catalogRefreshed {312 if catalogRefreshed {
296 return now313 return now
A packages/agent/yolo_auto_test.go +126 -0
@@ -0,0 +1,126 @@
1+package agent
2+
3+import (
4+ "fmt"
5+ "io"
6+ "net/http"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/patriceckhart/zot/packages/provider"
12+)
13+
14+type yoloAutoTestTransport func(*http.Request) (*http.Response, error)
15+
16+func (f yoloAutoTestTransport) RoundTrip(r *http.Request) (*http.Response, error) {
17+ return f(r)
18+}
19+
20+func TestYoloAutoDefaultAvailableOffline(t *testing.T) {
21+ id := defaultModelForProvider("yolo-auto")
22+ if id != "qwen3.8-flash" {
23+ t.Fatalf("default = %q, want free-plan model", id)
24+ }
25+ if _, err := provider.FindModel("yolo-auto", id); err != nil {
26+ t.Fatal(err)
27+ }
28+}
29+
30+func TestYoloAutoRefreshIgnoresSharedCache(t *testing.T) {
31+ t.Setenv("ZOT_HOME", t.TempDir())
32+ t.Setenv("OPENROUTER_API_KEY", "")
33+ t.Setenv("YOLO_AUTO_API_KEY", "test-pro")
34+ cachedAt := time.Now().UTC()
35+ if err := provider.SaveCache(ModelCachePath(), provider.ModelCache{
36+ FetchedAt: cachedAt,
37+ Models: []provider.Model{
38+ {Provider: "openai", ID: "cached-public-model"},
39+ {Provider: "yolo-auto", ID: "old-account-model", ContextWindow: 262144},
40+ },
41+ }); err != nil {
42+ t.Fatal(err)
43+ }
44+ provider.SetLiveModels(nil)
45+ t.Cleanup(func() { provider.SetLiveModels(nil) })
46+ LoadCachedModels()
47+
48+ original := http.DefaultTransport
49+ t.Cleanup(func() { http.DefaultTransport = original })
50+ calls := 0
51+ http.DefaultTransport = yoloAutoTestTransport(func(r *http.Request) (*http.Response, error) {
52+ if r.URL.String() != "https://yolo-auto.com/v1/models" {
53+ return nil, fmt.Errorf("unexpected discovery request: %s", r.URL)
54+ }
55+ calls++
56+ body := `{"data":[{"id":"qwen3.8-flash","context_length":262144,"thinking":["high"]},{"id":"pro-only"}]}`
57+ switch r.Header.Get("Authorization") {
58+ case "Bearer test-pro":
59+ case "Bearer test-free":
60+ body = `{"data":[{"id":"qwen3.8-flash","context_length":131072,"thinking":["high"]}]}`
61+ default:
62+ return nil, fmt.Errorf("unexpected credential")
63+ }
64+ return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
65+ })
66+
67+ for _, tc := range []struct {
68+ key string
69+ window int
70+ }{
71+ {"test-pro", 262144},
72+ {"test-free", 131072},
73+ } {
74+ t.Setenv("YOLO_AUTO_API_KEY", tc.key)
75+ refreshModels()
76+ model, err := provider.FindModel("yolo-auto", "qwen3.8-flash")
77+ if err != nil {
78+ t.Fatal(err)
79+ }
80+ if model.ContextWindow != tc.window {
81+ t.Errorf("context = %d, want %d", model.ContextWindow, tc.window)
82+ }
83+ }
84+ if calls != 2 {
85+ t.Errorf("discovery calls = %d, want 2 despite fresh cache", calls)
86+ }
87+ if _, err := provider.FindModel("yolo-auto", "pro-only"); err == nil {
88+ t.Error("previous account model survived refresh")
89+ }
90+ cached, err := provider.LoadCache(ModelCachePath())
91+ if err != nil {
92+ t.Fatal(err)
93+ }
94+ if !cached.FetchedAt.Equal(cachedAt) {
95+ t.Error("account refresh extended public cache lifetime")
96+ }
97+ for _, model := range cached.Models {
98+ if model.Provider == "yolo-auto" && (model.ID != "qwen3.8-flash" || model.ContextWindow != 131072) {
99+ t.Errorf("previous account metadata survived in cache: %+v", model)
100+ }
101+ }
102+ if _, err := provider.FindModel("yolo-auto", "old-account-model"); err == nil {
103+ t.Error("previous cached account model survived refresh")
104+ }
105+ if _, err := provider.FindModel("openai", "cached-public-model"); err != nil {
106+ t.Error("unrelated cached model lost")
107+ }
108+
109+ // Failed discovery and logout must drop the previous account's metadata.
110+ for _, key := range []string{"test-invalid", ""} {
111+ t.Setenv("YOLO_AUTO_API_KEY", "test-pro")
112+ refreshModels()
113+ t.Setenv("YOLO_AUTO_API_KEY", key)
114+ refreshModels()
115+ model, err := provider.FindModel("yolo-auto", "qwen3.8-flash")
116+ if err != nil {
117+ t.Fatal(err)
118+ }
119+ if model.Source != "catalog" || len(model.ReasoningLevelMap) != 0 {
120+ t.Errorf("account metadata survived failed discovery or logout: %+v", model)
121+ }
122+ if _, err := provider.FindModel("yolo-auto", "pro-only"); err == nil {
123+ t.Error("previous account model survived failed discovery or logout")
124+ }
125+ }
126+}
@@ -0,0 +1,126 @@
1+package agent
2+
3+import (
4+ "fmt"
5+ "io"
6+ "net/http"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/patriceckhart/zot/packages/provider"
12+)
13+
14+type yoloAutoTestTransport func(*http.Request) (*http.Response, error)
15+
16+func (f yoloAutoTestTransport) RoundTrip(r *http.Request) (*http.Response, error) {
17+ return f(r)
18+}
19+
20+func TestYoloAutoDefaultAvailableOffline(t *testing.T) {
21+ id := defaultModelForProvider("yolo-auto")
22+ if id != "qwen3.8-flash" {
23+ t.Fatalf("default = %q, want free-plan model", id)
24+ }
25+ if _, err := provider.FindModel("yolo-auto", id); err != nil {
26+ t.Fatal(err)
27+ }
28+}
29+
30+func TestYoloAutoRefreshIgnoresSharedCache(t *testing.T) {
31+ t.Setenv("ZOT_HOME", t.TempDir())
32+ t.Setenv("OPENROUTER_API_KEY", "")
33+ t.Setenv("YOLO_AUTO_API_KEY", "test-pro")
34+ cachedAt := time.Now().UTC()
35+ if err := provider.SaveCache(ModelCachePath(), provider.ModelCache{
36+ FetchedAt: cachedAt,
37+ Models: []provider.Model{
38+ {Provider: "openai", ID: "cached-public-model"},
39+ {Provider: "yolo-auto", ID: "old-account-model", ContextWindow: 262144},
40+ },
41+ }); err != nil {
42+ t.Fatal(err)
43+ }
44+ provider.SetLiveModels(nil)
45+ t.Cleanup(func() { provider.SetLiveModels(nil) })
46+ LoadCachedModels()
47+
48+ original := http.DefaultTransport
49+ t.Cleanup(func() { http.DefaultTransport = original })
50+ calls := 0
51+ http.DefaultTransport = yoloAutoTestTransport(func(r *http.Request) (*http.Response, error) {
52+ if r.URL.String() != "https://yolo-auto.com/v1/models" {
53+ return nil, fmt.Errorf("unexpected discovery request: %s", r.URL)
54+ }
55+ calls++
56+ body := `{"data":[{"id":"qwen3.8-flash","context_length":262144,"thinking":["high"]},{"id":"pro-only"}]}`
57+ switch r.Header.Get("Authorization") {
58+ case "Bearer test-pro":
59+ case "Bearer test-free":
60+ body = `{"data":[{"id":"qwen3.8-flash","context_length":131072,"thinking":["high"]}]}`
61+ default:
62+ return nil, fmt.Errorf("unexpected credential")
63+ }
64+ return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
65+ })
66+
67+ for _, tc := range []struct {
68+ key string
69+ window int
70+ }{
71+ {"test-pro", 262144},
72+ {"test-free", 131072},
73+ } {
74+ t.Setenv("YOLO_AUTO_API_KEY", tc.key)
75+ refreshModels()
76+ model, err := provider.FindModel("yolo-auto", "qwen3.8-flash")
77+ if err != nil {
78+ t.Fatal(err)
79+ }
80+ if model.ContextWindow != tc.window {
81+ t.Errorf("context = %d, want %d", model.ContextWindow, tc.window)
82+ }
83+ }
84+ if calls != 2 {
85+ t.Errorf("discovery calls = %d, want 2 despite fresh cache", calls)
86+ }
87+ if _, err := provider.FindModel("yolo-auto", "pro-only"); err == nil {
88+ t.Error("previous account model survived refresh")
89+ }
90+ cached, err := provider.LoadCache(ModelCachePath())
91+ if err != nil {
92+ t.Fatal(err)
93+ }
94+ if !cached.FetchedAt.Equal(cachedAt) {
95+ t.Error("account refresh extended public cache lifetime")
96+ }
97+ for _, model := range cached.Models {
98+ if model.Provider == "yolo-auto" && (model.ID != "qwen3.8-flash" || model.ContextWindow != 131072) {
99+ t.Errorf("previous account metadata survived in cache: %+v", model)
100+ }
101+ }
102+ if _, err := provider.FindModel("yolo-auto", "old-account-model"); err == nil {
103+ t.Error("previous cached account model survived refresh")
104+ }
105+ if _, err := provider.FindModel("openai", "cached-public-model"); err != nil {
106+ t.Error("unrelated cached model lost")
107+ }
108+
109+ // Failed discovery and logout must drop the previous account's metadata.
110+ for _, key := range []string{"test-invalid", ""} {
111+ t.Setenv("YOLO_AUTO_API_KEY", "test-pro")
112+ refreshModels()
113+ t.Setenv("YOLO_AUTO_API_KEY", key)
114+ refreshModels()
115+ model, err := provider.FindModel("yolo-auto", "qwen3.8-flash")
116+ if err != nil {
117+ t.Fatal(err)
118+ }
119+ if model.Source != "catalog" || len(model.ReasoningLevelMap) != 0 {
120+ t.Errorf("account metadata survived failed discovery or logout: %+v", model)
121+ }
122+ if _, err := provider.FindModel("yolo-auto", "pro-only"); err == nil {
123+ t.Error("previous account model survived failed discovery or logout")
124+ }
125+ }
126+}
M packages/provider/discover.go +25 -8
@@ -517,19 +517,36 @@ func DiscoverYoloAuto(ctx context.Context, apiKey, baseURL string) ([]Model, err
517517 contextWindow = yoloAutoContextWindow
518518 }
519519 out = append(out, Model{
520- Provider: "yolo-auto",
521- ID: id,
522- DisplayName: id,
523- ContextWindow: contextWindow,
524- MaxOutput: yoloAutoMaxOutput,
525- Reasoning: len(d.Thinking) > 0,
526- BaseURL: baseURL,
527- Source: "live",
520+ Provider: "yolo-auto",
521+ ID: id,
522+ DisplayName: id,
523+ ContextWindow: contextWindow,
524+ MaxOutput: yoloAutoMaxOutput,
525+ Reasoning: len(d.Thinking) > 0,
526+ ReasoningLevelMap: yoloAutoReasoningLevels(d.Thinking),
527+ BaseURL: baseURL,
528+ Source: "live",
528529 })
529530 }
530531 return out, nil
531532 }
532533
534+// yoloAutoReasoningLevels restricts the generic chat-completions defaults to
535+// the endpoint's advertised efforts, including minimal and xhigh when offered.
536+func yoloAutoReasoningLevels(thinking []string) map[string]string {
537+ levels := make(map[string]string)
538+ for _, level := range reasoningLevelOrder[1:] {
539+ levels[level] = ""
540+ }
541+ for _, effort := range thinking {
542+ level := NormalizeReasoning(effort)
543+ if _, known := levels[level]; known {
544+ levels[level] = level
545+ }
546+ }
547+ return levels
548+}
549+
533550 // perMillionTokens converts a per-token USD price string to USD per 1M
534551 // tokens. Empty or unparseable values become 0.
535552 func perMillionTokens(s string) float64 {
@@ -517,19 +517,36 @@ func DiscoverYoloAuto(ctx context.Context, apiKey, baseURL string) ([]Model, err
517 contextWindow = yoloAutoContextWindow517 contextWindow = yoloAutoContextWindow
518 }518 }
519 out = append(out, Model{519 out = append(out, Model{
520- Provider: "yolo-auto",520+ Provider: "yolo-auto",
521- ID: id,521+ ID: id,
522- DisplayName: id,522+ DisplayName: id,
523- ContextWindow: contextWindow,523+ ContextWindow: contextWindow,
524- MaxOutput: yoloAutoMaxOutput,524+ MaxOutput: yoloAutoMaxOutput,
525- Reasoning: len(d.Thinking) > 0,525+ Reasoning: len(d.Thinking) > 0,
526- BaseURL: baseURL,526+ ReasoningLevelMap: yoloAutoReasoningLevels(d.Thinking),
527- Source: "live",527+ BaseURL: baseURL,
528+ Source: "live",
528 })529 })
529 }530 }
530 return out, nil531 return out, nil
531 }532 }
532 533
534+// yoloAutoReasoningLevels restricts the generic chat-completions defaults to
535+// the endpoint's advertised efforts, including minimal and xhigh when offered.
536+func yoloAutoReasoningLevels(thinking []string) map[string]string {
537+ levels := make(map[string]string)
538+ for _, level := range reasoningLevelOrder[1:] {
539+ levels[level] = ""
540+ }
541+ for _, effort := range thinking {
542+ level := NormalizeReasoning(effort)
543+ if _, known := levels[level]; known {
544+ levels[level] = level
545+ }
546+ }
547+ return levels
548+}
549+
533 // perMillionTokens converts a per-token USD price string to USD per 1M550 // perMillionTokens converts a per-token USD price string to USD per 1M
534 // tokens. Empty or unparseable values become 0.551 // tokens. Empty or unparseable values become 0.
535 func perMillionTokens(s string) float64 {552 func perMillionTokens(s string) float64 {
M packages/provider/discover_yolo_auto_test.go +47 -0
@@ -5,6 +5,7 @@ import (
55 "fmt"
66 "net/http"
77 "net/http/httptest"
8+ "slices"
89 "testing"
910 )
1011
@@ -65,6 +66,52 @@ func TestDiscoverYoloAutoFallsBackToDocumentedLimits(t *testing.T) {
6566 }
6667 }
6768
69+func TestYoloAutoDiscoveryReachesActiveCatalogAndWire(t *testing.T) {
70+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
71+ fmt.Fprint(w, `{"data":[
72+ {"id":"yolo","context_length":262144,"thinking":["minimal","low","medium","high","xhigh"]},
73+ {"id":"qwen3.8-flash","context_length":65536,"thinking":["high"]},
74+ {"id":"yolo-small","max_model_len":65536,"thinking":null}
75+ ]}`)
76+ }))
77+ defer srv.Close()
78+ models, err := DiscoverYoloAuto(context.Background(), "test", srv.URL)
79+ if err != nil {
80+ t.Fatal(err)
81+ }
82+ SetLiveModels(models)
83+ t.Cleanup(func() { SetLiveModels(nil) })
84+ for _, discovered := range models {
85+ active, err := FindModel("yolo-auto", discovered.ID)
86+ if err != nil {
87+ t.Fatal(err)
88+ }
89+ if active.ContextWindow != discovered.ContextWindow || active.Reasoning != discovered.Reasoning || active.BaseURL != srv.URL {
90+ t.Errorf("active metadata does not match discovery: %+v", active)
91+ }
92+ if active.ID == "yolo" && !slices.Equal(AvailableReasoningLevels(active), []string{"", "minimum", "low", "medium", "high", "xhigh"}) {
93+ t.Errorf("available levels = %q", AvailableReasoningLevels(active))
94+ }
95+ }
96+ client := NewYoloAuto("test", srv.URL).(*openaiClient)
97+ for _, tc := range []struct{ model, level, want string }{
98+ {"yolo", "minimum", "minimal"},
99+ {"yolo", "xhigh", "xhigh"},
100+ {"yolo", "max", "xhigh"},
101+ {"yolo", "off", ""},
102+ {"qwen3.8-flash", "low", "high"},
103+ {"yolo-small", "high", ""},
104+ } {
105+ wire, err := client.buildRequest(Request{Model: tc.model, Reasoning: tc.level})
106+ if err != nil {
107+ t.Fatal(err)
108+ }
109+ if wire.ReasoningEffort != tc.want {
110+ t.Errorf("%s/%s effort = %q, want %q", tc.model, tc.level, wire.ReasoningEffort, tc.want)
111+ }
112+ }
113+}
114+
68115 func TestDiscoverYoloAutoReportsHTTPError(t *testing.T) {
69116 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
70117 http.Error(w, "unauthorized", http.StatusUnauthorized)
@@ -5,6 +5,7 @@ import (
5 "fmt"5 "fmt"
6 "net/http"6 "net/http"
7 "net/http/httptest"7 "net/http/httptest"
8+ "slices"
8 "testing"9 "testing"
9 )10 )
10 11
@@ -65,6 +66,52 @@ func TestDiscoverYoloAutoFallsBackToDocumentedLimits(t *testing.T) {
65 }66 }
66 }67 }
67 68
69+func TestYoloAutoDiscoveryReachesActiveCatalogAndWire(t *testing.T) {
70+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
71+ fmt.Fprint(w, `{"data":[
72+ {"id":"yolo","context_length":262144,"thinking":["minimal","low","medium","high","xhigh"]},
73+ {"id":"qwen3.8-flash","context_length":65536,"thinking":["high"]},
74+ {"id":"yolo-small","max_model_len":65536,"thinking":null}
75+ ]}`)
76+ }))
77+ defer srv.Close()
78+ models, err := DiscoverYoloAuto(context.Background(), "test", srv.URL)
79+ if err != nil {
80+ t.Fatal(err)
81+ }
82+ SetLiveModels(models)
83+ t.Cleanup(func() { SetLiveModels(nil) })
84+ for _, discovered := range models {
85+ active, err := FindModel("yolo-auto", discovered.ID)
86+ if err != nil {
87+ t.Fatal(err)
88+ }
89+ if active.ContextWindow != discovered.ContextWindow || active.Reasoning != discovered.Reasoning || active.BaseURL != srv.URL {
90+ t.Errorf("active metadata does not match discovery: %+v", active)
91+ }
92+ if active.ID == "yolo" && !slices.Equal(AvailableReasoningLevels(active), []string{"", "minimum", "low", "medium", "high", "xhigh"}) {
93+ t.Errorf("available levels = %q", AvailableReasoningLevels(active))
94+ }
95+ }
96+ client := NewYoloAuto("test", srv.URL).(*openaiClient)
97+ for _, tc := range []struct{ model, level, want string }{
98+ {"yolo", "minimum", "minimal"},
99+ {"yolo", "xhigh", "xhigh"},
100+ {"yolo", "max", "xhigh"},
101+ {"yolo", "off", ""},
102+ {"qwen3.8-flash", "low", "high"},
103+ {"yolo-small", "high", ""},
104+ } {
105+ wire, err := client.buildRequest(Request{Model: tc.model, Reasoning: tc.level})
106+ if err != nil {
107+ t.Fatal(err)
108+ }
109+ if wire.ReasoningEffort != tc.want {
110+ t.Errorf("%s/%s effort = %q, want %q", tc.model, tc.level, wire.ReasoningEffort, tc.want)
111+ }
112+ }
113+}
114+
68 func TestDiscoverYoloAutoReportsHTTPError(t *testing.T) {115 func TestDiscoverYoloAutoReportsHTTPError(t *testing.T) {
69 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {116 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
70 http.Error(w, "unauthorized", http.StatusUnauthorized)117 http.Error(w, "unauthorized", http.StatusUnauthorized)
M packages/provider/merge.go +6 -0
@@ -28,6 +28,12 @@ func MergeCatalog(live []Model) []Model {
2828 for _, l := range live {
2929 k := byKey(l.Provider, l.ID)
3030 if s, ok := staticIndex[k]; ok {
31+ // Yolo-Auto reports authoritative, account-specific limits and
32+ // capabilities. Its seed entries are only offline fallbacks.
33+ if l.Provider == "yolo-auto" {
34+ staticIndex[k] = l
35+ continue
36+ }
3137 // Keep static prices & context window (live endpoint rarely
3238 // exposes these), but mark as live and non-speculative.
3339 s.Source = "live"
@@ -28,6 +28,12 @@ func MergeCatalog(live []Model) []Model {
28 for _, l := range live {28 for _, l := range live {
29 k := byKey(l.Provider, l.ID)29 k := byKey(l.Provider, l.ID)
30 if s, ok := staticIndex[k]; ok {30 if s, ok := staticIndex[k]; ok {
31+ // Yolo-Auto reports authoritative, account-specific limits and
32+ // capabilities. Its seed entries are only offline fallbacks.
33+ if l.Provider == "yolo-auto" {
34+ staticIndex[k] = l
35+ continue
36+ }
31 // Keep static prices & context window (live endpoint rarely37 // Keep static prices & context window (live endpoint rarely
32 // exposes these), but mark as live and non-speculative.38 // exposes these), but mark as live and non-speculative.
33 s.Source = "live"39 s.Source = "live"
M packages/provider/models.go +7 -1
@@ -273,13 +273,19 @@ var Catalog = []Model{
273273 },
274274
275275 // ---- Yolo-Auto ----
276- // Seed entries only: `yolo` is the default returned by
276+ // Seed entries only: `qwen3.8-flash` is the free and paid default returned by
277277 // defaultModelForProvider so it resolves offline. Yolo-Auto is an
278278 // OpenAI-compatible gateway on flat-rate plans with no per-token
279279 // billing, so no per-token prices are listed. The limits are the
280280 // provider's documented conservative client settings; the full catalog,
281281 // including each key's plan-bounded window, comes from /v1/models
282282 // (DiscoverYoloAuto).
283+ {
284+ Provider: "yolo-auto", ID: "qwen3.8-flash", DisplayName: "Qwen3.8 Flash",
285+ ContextWindow: yoloAutoContextWindow, MaxOutput: yoloAutoMaxOutput,
286+ Reasoning: true,
287+ BaseURL: yoloAutoDefaultBaseURL,
288+ },
283289 {
284290 Provider: "yolo-auto", ID: "yolo", DisplayName: "Yolo",
285291 ContextWindow: yoloAutoContextWindow, MaxOutput: yoloAutoMaxOutput,
@@ -273,13 +273,19 @@ var Catalog = []Model{
273 },273 },
274 274
275 // ---- Yolo-Auto ----275 // ---- Yolo-Auto ----
276- // Seed entries only: `yolo` is the default returned by276+ // Seed entries only: `qwen3.8-flash` is the free and paid default returned by
277 // defaultModelForProvider so it resolves offline. Yolo-Auto is an277 // defaultModelForProvider so it resolves offline. Yolo-Auto is an
278 // OpenAI-compatible gateway on flat-rate plans with no per-token278 // OpenAI-compatible gateway on flat-rate plans with no per-token
279 // billing, so no per-token prices are listed. The limits are the279 // billing, so no per-token prices are listed. The limits are the
280 // provider's documented conservative client settings; the full catalog,280 // provider's documented conservative client settings; the full catalog,
281 // including each key's plan-bounded window, comes from /v1/models281 // including each key's plan-bounded window, comes from /v1/models
282 // (DiscoverYoloAuto).282 // (DiscoverYoloAuto).
283+ {
284+ Provider: "yolo-auto", ID: "qwen3.8-flash", DisplayName: "Qwen3.8 Flash",
285+ ContextWindow: yoloAutoContextWindow, MaxOutput: yoloAutoMaxOutput,
286+ Reasoning: true,
287+ BaseURL: yoloAutoDefaultBaseURL,
288+ },
283 {289 {
284 Provider: "yolo-auto", ID: "yolo", DisplayName: "Yolo",290 Provider: "yolo-auto", ID: "yolo", DisplayName: "Yolo",
285 ContextWindow: yoloAutoContextWindow, MaxOutput: yoloAutoMaxOutput,291 ContextWindow: yoloAutoContextWindow, MaxOutput: yoloAutoMaxOutput,
M packages/provider/openai.go +4 -0
@@ -275,6 +275,10 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
275275 // values and take precedence over conservative protocol defaults.
276276 effort = reasoning
277277 }
278+ if c.Name() == "yolo-auto" && effort == "minimum" {
279+ // The advertised wire value is minimal, not zot's UI spelling.
280+ effort = "minimal"
281+ }
278282 if effort != "" {
279283 out.ReasoningEffort = effort
280284 }
@@ -275,6 +275,10 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
275 // values and take precedence over conservative protocol defaults.275 // values and take precedence over conservative protocol defaults.
276 effort = reasoning276 effort = reasoning
277 }277 }
278+ if c.Name() == "yolo-auto" && effort == "minimum" {
279+ // The advertised wire value is minimal, not zot's UI spelling.
280+ effort = "minimal"
281+ }
278 if effort != "" {282 if effort != "" {
279 out.ReasoningEffort = effort283 out.ReasoningEffort = effort
280 }284 }
M packages/provider/provider_test.go +9 -2
@@ -512,8 +512,15 @@ func TestGondolaSeedCatalog(t *testing.T) {
512512 }
513513
514514 func TestYoloAutoSeedCatalog(t *testing.T) {
515- // `yolo` is the model returned when no --model is given, so it has to
516- // resolve from the baked-in catalog without a live refresh.
515+ // The free-plan default must resolve without a live refresh.
516+ flash, err := FindModel("yolo-auto", "qwen3.8-flash")
517+ if err != nil {
518+ t.Fatal(err)
519+ }
520+ if flash.ContextWindow != yoloAutoContextWindow || flash.MaxOutput != yoloAutoMaxOutput ||
521+ !flash.Reasoning || flash.BaseURL != yoloAutoDefaultBaseURL {
522+ t.Fatalf("unexpected Qwen model: %+v", flash)
523+ }
517524 yolo, err := FindModel("yolo-auto", "yolo")
518525 if err != nil {
519526 t.Fatal(err)
@@ -512,8 +512,15 @@ func TestGondolaSeedCatalog(t *testing.T) {
512 }512 }
513 513
514 func TestYoloAutoSeedCatalog(t *testing.T) {514 func TestYoloAutoSeedCatalog(t *testing.T) {
515- // `yolo` is the model returned when no --model is given, so it has to515+ // The free-plan default must resolve without a live refresh.
516- // resolve from the baked-in catalog without a live refresh.516+ flash, err := FindModel("yolo-auto", "qwen3.8-flash")
517+ if err != nil {
518+ t.Fatal(err)
519+ }
520+ if flash.ContextWindow != yoloAutoContextWindow || flash.MaxOutput != yoloAutoMaxOutput ||
521+ !flash.Reasoning || flash.BaseURL != yoloAutoDefaultBaseURL {
522+ t.Fatalf("unexpected Qwen model: %+v", flash)
523+ }
517 yolo, err := FindModel("yolo-auto", "yolo")524 yolo, err := FindModel("yolo-auto", "yolo")
518 if err != nil {525 if err != nil {
519 t.Fatal(err)526 t.Fatal(err)

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