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+}