main
1diff --git a/internal/config/load.go b/internal/config/load.go
2index 3b4b59a770..0c25daecf2 100644
3--- a/internal/config/load.go
4+++ b/internal/config/load.go
5@@ -235,18 +235,26 @@ func (c *Config) configureProviders(ctx context.Context, store *ConfigStore, env
6 p.APIKey = config.APIKey
7 }
8 if len(config.Models) > 0 {
9+ catalogMap := make(map[string]catwalk.Model, len(p.Models))
10+ for _, m := range p.Models {
11+ catalogMap[m.ID] = m
12+ }
13+
14 models := []catwalk.Model{}
15 seen := make(map[string]bool)
16
17- for _, model := range config.Models {
18- if seen[model.ID] {
19+ for _, userModel := range config.Models {
20+ if seen[userModel.ID] {
21 continue
22 }
23- seen[model.ID] = true
24- if model.Name == "" {
25- model.Name = model.ID
26+ seen[userModel.ID] = true
27+ if catalogModel, exists := catalogMap[userModel.ID]; exists {
28+ userModel = mergeCatalogModel(catalogModel, userModel)
29 }
30- models = append(models, model)
31+ if userModel.Name == "" {
32+ userModel.Name = userModel.ID
33+ }
34+ models = append(models, userModel)
35 }
36 for _, model := range p.Models {
37 if seen[model.ID] {
38@@ -514,6 +522,102 @@ func (c *Config) configureProviders(ctx context.Context, store *ConfigStore, env
39 return nil
40 }
41
42+// mergeCatalogModel merges a user model override on top of a base catalog model.
43+// Fields explicitly set on the override replace the base values; unset fields
44+// inherit from the catalog entry.
45+func mergeCatalogModel(base, override catwalk.Model) catwalk.Model {
46+ merged := base
47+
48+ if override.Name != "" {
49+ merged.Name = override.Name
50+ }
51+ if override.CostPer1MIn > 0 {
52+ merged.CostPer1MIn = override.CostPer1MIn
53+ }
54+ if override.CostPer1MOut > 0 {
55+ merged.CostPer1MOut = override.CostPer1MOut
56+ }
57+ if override.CostPer1MInCached > 0 {
58+ merged.CostPer1MInCached = override.CostPer1MInCached
59+ }
60+ if override.CostPer1MOutCached > 0 {
61+ merged.CostPer1MOutCached = override.CostPer1MOutCached
62+ }
63+ if override.ContextWindow > 0 {
64+ merged.ContextWindow = override.ContextWindow
65+ }
66+ if override.DefaultMaxTokens > 0 {
67+ merged.DefaultMaxTokens = override.DefaultMaxTokens
68+ }
69+ if override.CanReason {
70+ merged.CanReason = true
71+ }
72+ if override.SupportsImages {
73+ merged.SupportsImages = true
74+ }
75+
76+ // Merge reasoning levels: keep base catalog levels, union with override levels.
77+ if len(override.ReasoningLevels) > 0 {
78+ seen := make(map[string]bool, len(base.ReasoningLevels)+len(override.ReasoningLevels))
79+ var mergedLevels []string
80+ for _, l := range base.ReasoningLevels {
81+ if !seen[l] {
82+ seen[l] = true
83+ mergedLevels = append(mergedLevels, l)
84+ }
85+ }
86+ for _, l := range override.ReasoningLevels {
87+ if !seen[l] {
88+ seen[l] = true
89+ mergedLevels = append(mergedLevels, l)
90+ }
91+ }
92+ merged.ReasoningLevels = mergedLevels
93+ } else if len(base.ReasoningLevels) > 0 {
94+ merged.ReasoningLevels = slices.Clone(base.ReasoningLevels)
95+ }
96+
97+ if override.DefaultReasoningEffort != "" {
98+ merged.DefaultReasoningEffort = override.DefaultReasoningEffort
99+ // Ensure configured default effort is present in ReasoningLevels if levels are used
100+ if len(merged.ReasoningLevels) > 0 && !slices.Contains(merged.ReasoningLevels, override.DefaultReasoningEffort) {
101+ merged.ReasoningLevels = append(merged.ReasoningLevels, override.DefaultReasoningEffort)
102+ }
103+ merged.CanReason = true
104+ }
105+
106+ // Merge ModelOptions (temperature, top_p, provider_options, etc.)
107+ merged.Options = mergeModelOptions(base.Options, override.Options)
108+
109+ return merged
110+}
111+
112+func mergeModelOptions(base, override catwalk.ModelOptions) catwalk.ModelOptions {
113+ merged := base
114+ if override.Temperature != nil {
115+ merged.Temperature = override.Temperature
116+ }
117+ if override.TopP != nil {
118+ merged.TopP = override.TopP
119+ }
120+ if override.TopK != nil {
121+ merged.TopK = override.TopK
122+ }
123+ if override.FrequencyPenalty != nil {
124+ merged.FrequencyPenalty = override.FrequencyPenalty
125+ }
126+ if override.PresencePenalty != nil {
127+ merged.PresencePenalty = override.PresencePenalty
128+ }
129+ if len(override.ProviderOptions) > 0 {
130+ opts := make(map[string]any, len(base.ProviderOptions)+len(override.ProviderOptions))
131+ maps.Copy(opts, base.ProviderOptions)
132+ maps.Copy(opts, override.ProviderOptions)
133+ merged.ProviderOptions = opts
134+ }
135+ return merged
136+}
137+
138 // applyEnv sets top-level env vars from the config. Keys are sorted for
139 // deterministic ordering so that vars referencing other vars via the
140 // value resolver produce consistent results.
141diff --git a/internal/config/load_test.go b/internal/config/load_test.go
142index 123d2d5ccc..d5e9333351 100644
143--- a/internal/config/load_test.go
144+++ b/internal/config/load_test.go
145@@ -498,6 +498,181 @@ func TestConfig_configureProvidersWithOverride(t *testing.T) {
146 require.Equal(t, "Updated", pc.Models[0].Name)
147 }
148
149+func TestConfig_configureProviders_MergesCatalogModelMetadata(t *testing.T) {
150+ temp := 0.5
151+ knownProviders := []catwalk.Provider{
152+ {
153+ ID: "zai",
154+ APIKey: "$ZAI_API_KEY",
155+ APIEndpoint: "https://api.zai.com/v1",
156+ Models: []catwalk.Model{
157+ {
158+ ID: "glm-5.3-flash",
159+ Name: "GLM-5.3 Flash",
160+ CostPer1MIn: 0.1,
161+ CostPer1MOut: 0.2,
162+ CostPer1MInCached: 0.05,
163+ CostPer1MOutCached: 0.1,
164+ ContextWindow: 128000,
165+ DefaultMaxTokens: 4096,
166+ CanReason: true,
167+ ReasoningLevels: []string{"low", "high", "max"},
168+ DefaultReasoningEffort: "high",
169+ SupportsImages: true,
170+ Options: catwalk.ModelOptions{
171+ Temperature: &temp,
172+ },
173+ },
174+ },
175+ },
176+ }
177+
178+ cfg := &Config{
179+ Providers: csync.NewMap[string, ProviderConfig](),
180+ }
181+ cfg.Providers.Set("zai", ProviderConfig{
182+ Models: []catwalk.Model{
183+ {
184+ ID: "glm-5.3-flash",
185+ DefaultReasoningEffort: "xhigh",
186+ },
187+ },
188+ })
189+ cfg.setDefaults(t.TempDir(), "")
190+
191+ env := env.NewFromMap(map[string]string{
192+ "ZAI_API_KEY": "test-key",
193+ })
194+ resolver := NewShellVariableResolver(env)
195+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
196+ require.NoError(t, err)
197+
198+ pc, ok := cfg.Providers.Get("zai")
199+ require.True(t, ok)
200+ require.Len(t, pc.Models, 1)
201+
202+ m := pc.Models[0]
203+ require.Equal(t, "glm-5.3-flash", m.ID)
204+ require.Equal(t, "GLM-5.3 Flash", m.Name)
205+ require.Equal(t, 0.1, m.CostPer1MIn)
206+ require.Equal(t, 0.2, m.CostPer1MOut)
207+ require.Equal(t, 0.05, m.CostPer1MInCached)
208+ require.Equal(t, 0.1, m.CostPer1MOutCached)
209+ require.Equal(t, int64(128000), m.ContextWindow)
210+ require.Equal(t, int64(4096), m.DefaultMaxTokens)
211+ require.True(t, m.CanReason)
212+ require.True(t, m.SupportsImages)
213+ require.Equal(t, "xhigh", m.DefaultReasoningEffort)
214+ require.Equal(t, []string{"low", "high", "max", "xhigh"}, m.ReasoningLevels)
215+ require.NotNil(t, m.Options.Temperature)
216+ require.Equal(t, 0.5, *m.Options.Temperature)
217+}
218+
219+func TestConfig_configureProviders_OverridesCatalogModelExplicitFields(t *testing.T) {
220+ knownProviders := []catwalk.Provider{
221+ {
222+ ID: "zai",
223+ APIKey: "$ZAI_API_KEY",
224+ APIEndpoint: "https://api.zai.com/v1",
225+ Models: []catwalk.Model{
226+ {
227+ ID: "glm-5.3-flash",
228+ Name: "GLM-5.3 Flash",
229+ ContextWindow: 128000,
230+ DefaultMaxTokens: 4096,
231+ CanReason: true,
232+ ReasoningLevels: []string{"low", "high", "max"},
233+ SupportsImages: true,
234+ },
235+ },
236+ },
237+ }
238+
239+ cfg := &Config{
240+ Providers: csync.NewMap[string, ProviderConfig](),
241+ }
242+ cfg.Providers.Set("zai", ProviderConfig{
243+ Models: []catwalk.Model{
244+ {
245+ ID: "glm-5.3-flash",
246+ Name: "My Custom GLM",
247+ ContextWindow: 65536,
248+ ReasoningLevels: []string{"minimal", "extreme"},
249+ },
250+ },
251+ })
252+ cfg.setDefaults(t.TempDir(), "")
253+
254+ env := env.NewFromMap(map[string]string{
255+ "ZAI_API_KEY": "test-key",
256+ })
257+ resolver := NewShellVariableResolver(env)
258+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
259+ require.NoError(t, err)
260+
261+ pc, ok := cfg.Providers.Get("zai")
262+ require.True(t, ok)
263+ require.Len(t, pc.Models, 1)
264+
265+ m := pc.Models[0]
266+ require.Equal(t, "My Custom GLM", m.Name)
267+ require.Equal(t, int64(65536), m.ContextWindow)
268+ require.Equal(t, int64(4096), m.DefaultMaxTokens)
269+ require.True(t, m.CanReason)
270+ require.True(t, m.SupportsImages)
271+ require.Equal(t, []string{"low", "high", "max", "minimal", "extreme"}, m.ReasoningLevels)
272+}
273+
274+func TestConfig_configureProviders_ReasoningEffortValidationWithShadowedModel(t *testing.T) {
275+ knownProviders := []catwalk.Provider{
276+ {
277+ ID: "zai",
278+ APIKey: "$ZAI_API_KEY",
279+ APIEndpoint: "https://api.zai.com/v1",
280+ Models: []catwalk.Model{
281+ {
282+ ID: "glm-5.3-flash",
283+ CanReason: true,
284+ ReasoningLevels: []string{"low", "high", "max"},
285+ },
286+ },
287+ },
288+ }
289+
290+ cfg := &Config{
291+ Providers: csync.NewMap[string, ProviderConfig](),
292+ }
293+ cfg.Providers.Set("zai", ProviderConfig{
294+ Models: []catwalk.Model{
295+ {
296+ ID: "glm-5.3-flash",
297+ DefaultReasoningEffort: "xhigh",
298+ },
299+ },
300+ })
301+ cfg.setDefaults(t.TempDir(), "")
302+
303+ env := env.NewFromMap(map[string]string{
304+ "ZAI_API_KEY": "test-key",
305+ })
306+ resolver := NewShellVariableResolver(env)
307+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
308+ require.NoError(t, err)
309+
310+ // Catalog level should pass
311+ require.NoError(t, cfg.ValidateReasoningEffort("zai", "glm-5.3-flash", "low"))
312+ require.NoError(t, cfg.ValidateReasoningEffort("zai", "glm-5.3-flash", "high"))
313+ require.NoError(t, cfg.ValidateReasoningEffort("zai", "glm-5.3-flash", "max"))
314+
315+ // User-specified effort level should pass
316+ require.NoError(t, cfg.ValidateReasoningEffort("zai", "glm-5.3-flash", "xhigh"))
317+
318+ // Non-existent level should fail with list of all accepted values
319+ err = cfg.ValidateReasoningEffort("zai", "glm-5.3-flash", "invalid")
320+ require.Error(t, err)
321+ require.Contains(t, err.Error(), "accepted values: low, high, max, xhigh")
322+}
323+
324 func TestConfig_configureProvidersWithNewProvider(t *testing.T) {
325 knownProviders := []catwalk.Provider{
326 {
327diff --git a/internal/shellconfig/flags.go b/internal/shellconfig/flags.go
328index eeb510b380..10a35ccc24 100644
329--- a/internal/shellconfig/flags.go
330+++ b/internal/shellconfig/flags.go
331@@ -28,6 +28,8 @@ const (
332 flagJSONObject
333 // flagJSONAny parses the value as arbitrary JSON.
334 flagJSONAny
335+ // flagStringSlice parses comma-separated strings or JSON arrays of strings.
336+ flagStringSlice
337 )
338
339 // flagOp is how a parsed flag value is written into the target map.
340@@ -174,6 +176,26 @@ func parseFlagValue(spec flagSpec, args []string, i int) (any, int, error) {
341 }
342 return parsed, i + 2, nil
343
344+ case flagStringSlice:
345+ v, err := nextArg(args, i, name)
346+ if err != nil {
347+ return nil, 0, err
348+ }
349+ if strings.HasPrefix(v, "[") && strings.HasSuffix(v, "]") {
350+ var raw []any
351+ if err := json.Unmarshal([]byte(v), &raw); err == nil {
352+ return raw, i + 2, nil
353+ }
354+ }
355+ var list []any
356+ for _, part := range strings.Split(v, ",") {
357+ part = strings.TrimSpace(part)
358+ if part != "" {
359+ list = append(list, part)
360+ }
361+ }
362+ return list, i + 2, nil
363+
364 default:
365 return nil, 0, fmt.Errorf("%s: --%s has unknown flag kind", args[0], name)
366 }
367@@ -194,7 +216,11 @@ func storeFlag(target map[string]any, spec flagSpec, val any) {
368 target[spec.jsonKey] = val
369 case opAppend:
370 arr, _ := target[spec.jsonKey].([]any)
371- target[spec.jsonKey] = append(arr, val)
372+ if slice, ok := val.([]any); ok {
373+ target[spec.jsonKey] = append(arr, slice...)
374+ } else {
375+ target[spec.jsonKey] = append(arr, val)
376+ }
377 case opSetChild:
378 if kv, ok := val.([2]string); ok {
379 childMap(target, spec.child)[kv[0]] = kv[1]
380diff --git a/internal/shellconfig/model.go b/internal/shellconfig/model.go
381index eb8236dfe5..8399a69cdc 100644
382--- a/internal/shellconfig/model.go
383+++ b/internal/shellconfig/model.go
384@@ -71,11 +71,13 @@ var modelAddFlags = []flagSpec{
385 {name: "--price-cache-create", jsonKey: "cost_per_1m_in_cached", kind: flagFloat, op: opSet},
386 {name: "--price-cache-hit", jsonKey: "cost_per_1m_out_cached", kind: flagFloat, op: opSet},
387 {name: "--reasoning-effort", jsonKey: "default_reasoning_effort", kind: flagString, op: opSet},
388+ {name: "--reasoning-levels", jsonKey: "reasoning_levels", kind: flagStringSlice, op: opAppend},
389+ {name: "--reasoning-level", jsonKey: "reasoning_levels", kind: flagStringSlice, op: opAppend},
390 }
391
392 func modelAdd(b *ConfigBuilder, args []string, stderr io.Writer) error {
393 if len(args) < 3 {
394- return usage(stderr, "usage: model add <provider>/<id> [--name NAME] [--context-window N] [--default-max-tokens N] [--can-reason true|false] [--supports-images true|false] [--price-input F] [--price-output F] [--price-cache-create F] [--price-cache-hit F] [--reasoning-effort low|medium|high]")
395+ return usage(stderr, "usage: model add <provider>/<id> [--name NAME] [--context-window N] [--default-max-tokens N] [--can-reason true|false] [--supports-images true|false] [--price-input F] [--price-output F] [--price-cache-create F] [--price-cache-hit F] [--reasoning-effort EFFORT] [--reasoning-levels LEVEL,...]")
396 }
397 provider, id, ok := splitProviderModel(args[2])
398 if !ok {
399@@ -92,6 +94,41 @@ func modelAdd(b *ConfigBuilder, args []string, stderr io.Writer) error {
400 return err
401 }
402
403+ if levels, ok := model["reasoning_levels"].([]any); ok {
404+ seen := make(map[any]bool, len(levels))
405+ dedup := make([]any, 0, len(levels))
406+ for _, l := range levels {
407+ if !seen[l] {
408+ seen[l] = true
409+ dedup = append(dedup, l)
410+ }
411+ }
412+ model["reasoning_levels"] = dedup
413+ }
414+
415+ if levels, ok := model["reasoning_levels"].([]any); ok && len(levels) > 0 {
416+ if _, hasCanReason := model["can_reason"]; !hasCanReason {
417+ model["can_reason"] = true
418+ }
419+ if effort, ok := model["default_reasoning_effort"].(string); ok && effort != "" {
420+ found := false
421+ for _, l := range levels {
422+ if s, ok := l.(string); ok && s == effort {
423+ found = true
424+ break
425+ }
426+ }
427+ if !found {
428+ model["reasoning_levels"] = append(levels, effort)
429+ }
430+ }
431+ } else if effort, ok := model["default_reasoning_effort"].(string); ok && effort != "" {
432+ model["reasoning_levels"] = []any{effort}
433+ if _, hasCanReason := model["can_reason"]; !hasCanReason {
434+ model["can_reason"] = true
435+ }
436+ }
437+
438 p := childMap(providers, provider)
439 // Re-adding a model id replaces the existing entry, matching the
440 // update-in-place behavior of `provider add` and `lsp add`.
441diff --git a/internal/shellconfig/model_test.go b/internal/shellconfig/model_test.go
442index add30e9474..4317bfe502 100644
443--- a/internal/shellconfig/model_test.go
444+++ b/internal/shellconfig/model_test.go
445@@ -192,3 +192,76 @@ provider rm anthropic`)
446 require.Len(t, models, 1)
447 require.Equal(t, "b", models[0].(map[string]any)["id"])
448 }
449+
450+func TestModelAdd_ReasoningLevelsCommaSeparated(t *testing.T) {
451+ t.Parallel()
452+
453+ result := loadScript(t, `provider add openai --api-key k
454+model add openai/custom-reasoning --reasoning-levels low,high,max --reasoning-effort max`)
455+
456+ providers := result["providers"].(map[string]any)
457+ models := providers["openai"].(map[string]any)["models"].([]any)
458+ require.Len(t, models, 1)
459+ m := models[0].(map[string]any)
460+ require.Equal(t, "custom-reasoning", m["id"])
461+ require.Equal(t, true, m["can_reason"])
462+ require.Equal(t, "max", m["default_reasoning_effort"])
463+ require.Equal(t, []any{"low", "high", "max"}, m["reasoning_levels"])
464+}
465+
466+func TestModelAdd_ReasoningLevelsRepeated(t *testing.T) {
467+ t.Parallel()
468+
469+ result := loadScript(t, `provider add openai --api-key k
470+model add openai/custom-reasoning --reasoning-level low --reasoning-levels high,max`)
471+
472+ providers := result["providers"].(map[string]any)
473+ models := providers["openai"].(map[string]any)["models"].([]any)
474+ require.Len(t, models, 1)
475+ m := models[0].(map[string]any)
476+ require.Equal(t, true, m["can_reason"])
477+ require.Equal(t, []any{"low", "high", "max"}, m["reasoning_levels"])
478+}
479+
480+func TestModelAdd_ReasoningLevelsJSON(t *testing.T) {
481+ t.Parallel()
482+
483+ result := loadScript(t, `provider add anthropic --api-key k
484+model add anthropic/custom-reasoning --reasoning-levels '["low", "high", "xhigh"]'`)
485+
486+ providers := result["providers"].(map[string]any)
487+ models := providers["anthropic"].(map[string]any)["models"].([]any)
488+ require.Len(t, models, 1)
489+ m := models[0].(map[string]any)
490+ require.Equal(t, true, m["can_reason"])
491+ require.Equal(t, []any{"low", "high", "xhigh"}, m["reasoning_levels"])
492+}
493+
494+func TestModelAdd_ReasoningEffortAutoPopulatesLevel(t *testing.T) {
495+ t.Parallel()
496+
497+ result := loadScript(t, `provider add openai --api-key k
498+model add openai/custom-reasoning --reasoning-effort max`)
499+
500+ providers := result["providers"].(map[string]any)
501+ models := providers["openai"].(map[string]any)["models"].([]any)
502+ require.Len(t, models, 1)
503+ m := models[0].(map[string]any)
504+ require.Equal(t, true, m["can_reason"])
505+ require.Equal(t, "max", m["default_reasoning_effort"])
506+ require.Equal(t, []any{"max"}, m["reasoning_levels"])
507+}
508+
509+func TestModelAdd_ExplicitCanReasonFalsePreserved(t *testing.T) {
510+ t.Parallel()
511+
512+ result := loadScript(t, `provider add anthropic --api-key k
513+model add anthropic/custom-reasoning --can-reason false --reasoning-levels low,high`)
514+
515+ providers := result["providers"].(map[string]any)
516+ models := providers["anthropic"].(map[string]any)["models"].([]any)
517+ require.Len(t, models, 1)
518+ m := models[0].(map[string]any)
519+ require.Equal(t, false, m["can_reason"])
520+ require.Equal(t, []any{"low", "high"}, m["reasoning_levels"])
521+}