Commit e9047e7
Changed files (2)
modules
dev
agents
modules/dev/agents/crush/default.nix
@@ -30,9 +30,13 @@
jail = inputs.jail-nix.lib.init pkgs;
}).mkJailedAgent;
- crush = pkgs.nur.repos.charmbracelet.crush.overrideAttrs (old: {
- meta = (old.meta or {}) // {mainProgram = "crush";};
- });
+ crush = pkgs.crush.overrideAttrs {
+ patches = [
+ # `--reasoning-levels` for crushrc `model add`, from https://github.com/charmbracelet/crush/pull/3857
+ # TODO: drop once upstreamed.
+ ./reasoning-level.patch
+ ];
+ };
checkPath = pkgs.callPackage ./hooks/check_path/_package.nix {};
modules/dev/agents/crush/reasoning-level.patch
@@ -0,0 +1,521 @@
+diff --git a/internal/config/load.go b/internal/config/load.go
+index 3b4b59a770..0c25daecf2 100644
+--- a/internal/config/load.go
++++ b/internal/config/load.go
+@@ -235,18 +235,26 @@ func (c *Config) configureProviders(ctx context.Context, store *ConfigStore, env
+ p.APIKey = config.APIKey
+ }
+ if len(config.Models) > 0 {
++ catalogMap := make(map[string]catwalk.Model, len(p.Models))
++ for _, m := range p.Models {
++ catalogMap[m.ID] = m
++ }
++
+ models := []catwalk.Model{}
+ seen := make(map[string]bool)
+
+- for _, model := range config.Models {
+- if seen[model.ID] {
++ for _, userModel := range config.Models {
++ if seen[userModel.ID] {
+ continue
+ }
+- seen[model.ID] = true
+- if model.Name == "" {
+- model.Name = model.ID
++ seen[userModel.ID] = true
++ if catalogModel, exists := catalogMap[userModel.ID]; exists {
++ userModel = mergeCatalogModel(catalogModel, userModel)
+ }
+- models = append(models, model)
++ if userModel.Name == "" {
++ userModel.Name = userModel.ID
++ }
++ models = append(models, userModel)
+ }
+ for _, model := range p.Models {
+ if seen[model.ID] {
+@@ -514,6 +522,102 @@ func (c *Config) configureProviders(ctx context.Context, store *ConfigStore, env
+ return nil
+ }
+
++// mergeCatalogModel merges a user model override on top of a base catalog model.
++// Fields explicitly set on the override replace the base values; unset fields
++// inherit from the catalog entry.
++func mergeCatalogModel(base, override catwalk.Model) catwalk.Model {
++ merged := base
++
++ if override.Name != "" {
++ merged.Name = override.Name
++ }
++ if override.CostPer1MIn > 0 {
++ merged.CostPer1MIn = override.CostPer1MIn
++ }
++ if override.CostPer1MOut > 0 {
++ merged.CostPer1MOut = override.CostPer1MOut
++ }
++ if override.CostPer1MInCached > 0 {
++ merged.CostPer1MInCached = override.CostPer1MInCached
++ }
++ if override.CostPer1MOutCached > 0 {
++ merged.CostPer1MOutCached = override.CostPer1MOutCached
++ }
++ if override.ContextWindow > 0 {
++ merged.ContextWindow = override.ContextWindow
++ }
++ if override.DefaultMaxTokens > 0 {
++ merged.DefaultMaxTokens = override.DefaultMaxTokens
++ }
++ if override.CanReason {
++ merged.CanReason = true
++ }
++ if override.SupportsImages {
++ merged.SupportsImages = true
++ }
++
++ // Merge reasoning levels: keep base catalog levels, union with override levels.
++ if len(override.ReasoningLevels) > 0 {
++ seen := make(map[string]bool, len(base.ReasoningLevels)+len(override.ReasoningLevels))
++ var mergedLevels []string
++ for _, l := range base.ReasoningLevels {
++ if !seen[l] {
++ seen[l] = true
++ mergedLevels = append(mergedLevels, l)
++ }
++ }
++ for _, l := range override.ReasoningLevels {
++ if !seen[l] {
++ seen[l] = true
++ mergedLevels = append(mergedLevels, l)
++ }
++ }
++ merged.ReasoningLevels = mergedLevels
++ } else if len(base.ReasoningLevels) > 0 {
++ merged.ReasoningLevels = slices.Clone(base.ReasoningLevels)
++ }
++
++ if override.DefaultReasoningEffort != "" {
++ merged.DefaultReasoningEffort = override.DefaultReasoningEffort
++ // Ensure configured default effort is present in ReasoningLevels if levels are used
++ if len(merged.ReasoningLevels) > 0 && !slices.Contains(merged.ReasoningLevels, override.DefaultReasoningEffort) {
++ merged.ReasoningLevels = append(merged.ReasoningLevels, override.DefaultReasoningEffort)
++ }
++ merged.CanReason = true
++ }
++
++ // Merge ModelOptions (temperature, top_p, provider_options, etc.)
++ merged.Options = mergeModelOptions(base.Options, override.Options)
++
++ return merged
++}
++
++func mergeModelOptions(base, override catwalk.ModelOptions) catwalk.ModelOptions {
++ merged := base
++ if override.Temperature != nil {
++ merged.Temperature = override.Temperature
++ }
++ if override.TopP != nil {
++ merged.TopP = override.TopP
++ }
++ if override.TopK != nil {
++ merged.TopK = override.TopK
++ }
++ if override.FrequencyPenalty != nil {
++ merged.FrequencyPenalty = override.FrequencyPenalty
++ }
++ if override.PresencePenalty != nil {
++ merged.PresencePenalty = override.PresencePenalty
++ }
++ if len(override.ProviderOptions) > 0 {
++ opts := make(map[string]any, len(base.ProviderOptions)+len(override.ProviderOptions))
++ maps.Copy(opts, base.ProviderOptions)
++ maps.Copy(opts, override.ProviderOptions)
++ merged.ProviderOptions = opts
++ }
++ return merged
++}
++
+ // applyEnv sets top-level env vars from the config. Keys are sorted for
+ // deterministic ordering so that vars referencing other vars via the
+ // value resolver produce consistent results.
+diff --git a/internal/config/load_test.go b/internal/config/load_test.go
+index 123d2d5ccc..d5e9333351 100644
+--- a/internal/config/load_test.go
++++ b/internal/config/load_test.go
+@@ -498,6 +498,181 @@ func TestConfig_configureProvidersWithOverride(t *testing.T) {
+ require.Equal(t, "Updated", pc.Models[0].Name)
+ }
+
++func TestConfig_configureProviders_MergesCatalogModelMetadata(t *testing.T) {
++ temp := 0.5
++ knownProviders := []catwalk.Provider{
++ {
++ ID: "zai",
++ APIKey: "$ZAI_API_KEY",
++ APIEndpoint: "https://api.zai.com/v1",
++ Models: []catwalk.Model{
++ {
++ ID: "glm-5.3-flash",
++ Name: "GLM-5.3 Flash",
++ CostPer1MIn: 0.1,
++ CostPer1MOut: 0.2,
++ CostPer1MInCached: 0.05,
++ CostPer1MOutCached: 0.1,
++ ContextWindow: 128000,
++ DefaultMaxTokens: 4096,
++ CanReason: true,
++ ReasoningLevels: []string{"low", "high", "max"},
++ DefaultReasoningEffort: "high",
++ SupportsImages: true,
++ Options: catwalk.ModelOptions{
++ Temperature: &temp,
++ },
++ },
++ },
++ },
++ }
++
++ cfg := &Config{
++ Providers: csync.NewMap[string, ProviderConfig](),
++ }
++ cfg.Providers.Set("zai", ProviderConfig{
++ Models: []catwalk.Model{
++ {
++ ID: "glm-5.3-flash",
++ DefaultReasoningEffort: "xhigh",
++ },
++ },
++ })
++ cfg.setDefaults(t.TempDir(), "")
++
++ env := env.NewFromMap(map[string]string{
++ "ZAI_API_KEY": "test-key",
++ })
++ resolver := NewShellVariableResolver(env)
++ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
++ require.NoError(t, err)
++
++ pc, ok := cfg.Providers.Get("zai")
++ require.True(t, ok)
++ require.Len(t, pc.Models, 1)
++
++ m := pc.Models[0]
++ require.Equal(t, "glm-5.3-flash", m.ID)
++ require.Equal(t, "GLM-5.3 Flash", m.Name)
++ require.Equal(t, 0.1, m.CostPer1MIn)
++ require.Equal(t, 0.2, m.CostPer1MOut)
++ require.Equal(t, 0.05, m.CostPer1MInCached)
++ require.Equal(t, 0.1, m.CostPer1MOutCached)
++ require.Equal(t, int64(128000), m.ContextWindow)
++ require.Equal(t, int64(4096), m.DefaultMaxTokens)
++ require.True(t, m.CanReason)
++ require.True(t, m.SupportsImages)
++ require.Equal(t, "xhigh", m.DefaultReasoningEffort)
++ require.Equal(t, []string{"low", "high", "max", "xhigh"}, m.ReasoningLevels)
++ require.NotNil(t, m.Options.Temperature)
++ require.Equal(t, 0.5, *m.Options.Temperature)
++}
++
++func TestConfig_configureProviders_OverridesCatalogModelExplicitFields(t *testing.T) {
++ knownProviders := []catwalk.Provider{
++ {
++ ID: "zai",
++ APIKey: "$ZAI_API_KEY",
++ APIEndpoint: "https://api.zai.com/v1",
++ Models: []catwalk.Model{
++ {
++ ID: "glm-5.3-flash",
++ Name: "GLM-5.3 Flash",
++ ContextWindow: 128000,
++ DefaultMaxTokens: 4096,
++ CanReason: true,
++ ReasoningLevels: []string{"low", "high", "max"},
++ SupportsImages: true,
++ },
++ },
++ },
++ }
++
++ cfg := &Config{
++ Providers: csync.NewMap[string, ProviderConfig](),
++ }
++ cfg.Providers.Set("zai", ProviderConfig{
++ Models: []catwalk.Model{
++ {
++ ID: "glm-5.3-flash",
++ Name: "My Custom GLM",
++ ContextWindow: 65536,
++ ReasoningLevels: []string{"minimal", "extreme"},
++ },
++ },
++ })
++ cfg.setDefaults(t.TempDir(), "")
++
++ env := env.NewFromMap(map[string]string{
++ "ZAI_API_KEY": "test-key",
++ })
++ resolver := NewShellVariableResolver(env)
++ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
++ require.NoError(t, err)
++
++ pc, ok := cfg.Providers.Get("zai")
++ require.True(t, ok)
++ require.Len(t, pc.Models, 1)
++
++ m := pc.Models[0]
++ require.Equal(t, "My Custom GLM", m.Name)
++ require.Equal(t, int64(65536), m.ContextWindow)
++ require.Equal(t, int64(4096), m.DefaultMaxTokens)
++ require.True(t, m.CanReason)
++ require.True(t, m.SupportsImages)
++ require.Equal(t, []string{"low", "high", "max", "minimal", "extreme"}, m.ReasoningLevels)
++}
++
++func TestConfig_configureProviders_ReasoningEffortValidationWithShadowedModel(t *testing.T) {
++ knownProviders := []catwalk.Provider{
++ {
++ ID: "zai",
++ APIKey: "$ZAI_API_KEY",
++ APIEndpoint: "https://api.zai.com/v1",
++ Models: []catwalk.Model{
++ {
++ ID: "glm-5.3-flash",
++ CanReason: true,
++ ReasoningLevels: []string{"low", "high", "max"},
++ },
++ },
++ },
++ }
++
++ cfg := &Config{
++ Providers: csync.NewMap[string, ProviderConfig](),
++ }
++ cfg.Providers.Set("zai", ProviderConfig{
++ Models: []catwalk.Model{
++ {
++ ID: "glm-5.3-flash",
++ DefaultReasoningEffort: "xhigh",
++ },
++ },
++ })
++ cfg.setDefaults(t.TempDir(), "")
++
++ env := env.NewFromMap(map[string]string{
++ "ZAI_API_KEY": "test-key",
++ })
++ resolver := NewShellVariableResolver(env)
++ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
++ require.NoError(t, err)
++
++ // Catalog level should pass
++ require.NoError(t, cfg.ValidateReasoningEffort("zai", "glm-5.3-flash", "low"))
++ require.NoError(t, cfg.ValidateReasoningEffort("zai", "glm-5.3-flash", "high"))
++ require.NoError(t, cfg.ValidateReasoningEffort("zai", "glm-5.3-flash", "max"))
++
++ // User-specified effort level should pass
++ require.NoError(t, cfg.ValidateReasoningEffort("zai", "glm-5.3-flash", "xhigh"))
++
++ // Non-existent level should fail with list of all accepted values
++ err = cfg.ValidateReasoningEffort("zai", "glm-5.3-flash", "invalid")
++ require.Error(t, err)
++ require.Contains(t, err.Error(), "accepted values: low, high, max, xhigh")
++}
++
+ func TestConfig_configureProvidersWithNewProvider(t *testing.T) {
+ knownProviders := []catwalk.Provider{
+ {
+diff --git a/internal/shellconfig/flags.go b/internal/shellconfig/flags.go
+index eeb510b380..10a35ccc24 100644
+--- a/internal/shellconfig/flags.go
++++ b/internal/shellconfig/flags.go
+@@ -28,6 +28,8 @@ const (
+ flagJSONObject
+ // flagJSONAny parses the value as arbitrary JSON.
+ flagJSONAny
++ // flagStringSlice parses comma-separated strings or JSON arrays of strings.
++ flagStringSlice
+ )
+
+ // flagOp is how a parsed flag value is written into the target map.
+@@ -174,6 +176,26 @@ func parseFlagValue(spec flagSpec, args []string, i int) (any, int, error) {
+ }
+ return parsed, i + 2, nil
+
++ case flagStringSlice:
++ v, err := nextArg(args, i, name)
++ if err != nil {
++ return nil, 0, err
++ }
++ if strings.HasPrefix(v, "[") && strings.HasSuffix(v, "]") {
++ var raw []any
++ if err := json.Unmarshal([]byte(v), &raw); err == nil {
++ return raw, i + 2, nil
++ }
++ }
++ var list []any
++ for _, part := range strings.Split(v, ",") {
++ part = strings.TrimSpace(part)
++ if part != "" {
++ list = append(list, part)
++ }
++ }
++ return list, i + 2, nil
++
+ default:
+ return nil, 0, fmt.Errorf("%s: --%s has unknown flag kind", args[0], name)
+ }
+@@ -194,7 +216,11 @@ func storeFlag(target map[string]any, spec flagSpec, val any) {
+ target[spec.jsonKey] = val
+ case opAppend:
+ arr, _ := target[spec.jsonKey].([]any)
+- target[spec.jsonKey] = append(arr, val)
++ if slice, ok := val.([]any); ok {
++ target[spec.jsonKey] = append(arr, slice...)
++ } else {
++ target[spec.jsonKey] = append(arr, val)
++ }
+ case opSetChild:
+ if kv, ok := val.([2]string); ok {
+ childMap(target, spec.child)[kv[0]] = kv[1]
+diff --git a/internal/shellconfig/model.go b/internal/shellconfig/model.go
+index eb8236dfe5..8399a69cdc 100644
+--- a/internal/shellconfig/model.go
++++ b/internal/shellconfig/model.go
+@@ -71,11 +71,13 @@ var modelAddFlags = []flagSpec{
+ {name: "--price-cache-create", jsonKey: "cost_per_1m_in_cached", kind: flagFloat, op: opSet},
+ {name: "--price-cache-hit", jsonKey: "cost_per_1m_out_cached", kind: flagFloat, op: opSet},
+ {name: "--reasoning-effort", jsonKey: "default_reasoning_effort", kind: flagString, op: opSet},
++ {name: "--reasoning-levels", jsonKey: "reasoning_levels", kind: flagStringSlice, op: opAppend},
++ {name: "--reasoning-level", jsonKey: "reasoning_levels", kind: flagStringSlice, op: opAppend},
+ }
+
+ func modelAdd(b *ConfigBuilder, args []string, stderr io.Writer) error {
+ if len(args) < 3 {
+- 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]")
++ 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,...]")
+ }
+ provider, id, ok := splitProviderModel(args[2])
+ if !ok {
+@@ -92,6 +94,41 @@ func modelAdd(b *ConfigBuilder, args []string, stderr io.Writer) error {
+ return err
+ }
+
++ if levels, ok := model["reasoning_levels"].([]any); ok {
++ seen := make(map[any]bool, len(levels))
++ dedup := make([]any, 0, len(levels))
++ for _, l := range levels {
++ if !seen[l] {
++ seen[l] = true
++ dedup = append(dedup, l)
++ }
++ }
++ model["reasoning_levels"] = dedup
++ }
++
++ if levels, ok := model["reasoning_levels"].([]any); ok && len(levels) > 0 {
++ if _, hasCanReason := model["can_reason"]; !hasCanReason {
++ model["can_reason"] = true
++ }
++ if effort, ok := model["default_reasoning_effort"].(string); ok && effort != "" {
++ found := false
++ for _, l := range levels {
++ if s, ok := l.(string); ok && s == effort {
++ found = true
++ break
++ }
++ }
++ if !found {
++ model["reasoning_levels"] = append(levels, effort)
++ }
++ }
++ } else if effort, ok := model["default_reasoning_effort"].(string); ok && effort != "" {
++ model["reasoning_levels"] = []any{effort}
++ if _, hasCanReason := model["can_reason"]; !hasCanReason {
++ model["can_reason"] = true
++ }
++ }
++
+ p := childMap(providers, provider)
+ // Re-adding a model id replaces the existing entry, matching the
+ // update-in-place behavior of `provider add` and `lsp add`.
+diff --git a/internal/shellconfig/model_test.go b/internal/shellconfig/model_test.go
+index add30e9474..4317bfe502 100644
+--- a/internal/shellconfig/model_test.go
++++ b/internal/shellconfig/model_test.go
+@@ -192,3 +192,76 @@ provider rm anthropic`)
+ require.Len(t, models, 1)
+ require.Equal(t, "b", models[0].(map[string]any)["id"])
+ }
++
++func TestModelAdd_ReasoningLevelsCommaSeparated(t *testing.T) {
++ t.Parallel()
++
++ result := loadScript(t, `provider add openai --api-key k
++model add openai/custom-reasoning --reasoning-levels low,high,max --reasoning-effort max`)
++
++ providers := result["providers"].(map[string]any)
++ models := providers["openai"].(map[string]any)["models"].([]any)
++ require.Len(t, models, 1)
++ m := models[0].(map[string]any)
++ require.Equal(t, "custom-reasoning", m["id"])
++ require.Equal(t, true, m["can_reason"])
++ require.Equal(t, "max", m["default_reasoning_effort"])
++ require.Equal(t, []any{"low", "high", "max"}, m["reasoning_levels"])
++}
++
++func TestModelAdd_ReasoningLevelsRepeated(t *testing.T) {
++ t.Parallel()
++
++ result := loadScript(t, `provider add openai --api-key k
++model add openai/custom-reasoning --reasoning-level low --reasoning-levels high,max`)
++
++ providers := result["providers"].(map[string]any)
++ models := providers["openai"].(map[string]any)["models"].([]any)
++ require.Len(t, models, 1)
++ m := models[0].(map[string]any)
++ require.Equal(t, true, m["can_reason"])
++ require.Equal(t, []any{"low", "high", "max"}, m["reasoning_levels"])
++}
++
++func TestModelAdd_ReasoningLevelsJSON(t *testing.T) {
++ t.Parallel()
++
++ result := loadScript(t, `provider add anthropic --api-key k
++model add anthropic/custom-reasoning --reasoning-levels '["low", "high", "xhigh"]'`)
++
++ providers := result["providers"].(map[string]any)
++ models := providers["anthropic"].(map[string]any)["models"].([]any)
++ require.Len(t, models, 1)
++ m := models[0].(map[string]any)
++ require.Equal(t, true, m["can_reason"])
++ require.Equal(t, []any{"low", "high", "xhigh"}, m["reasoning_levels"])
++}
++
++func TestModelAdd_ReasoningEffortAutoPopulatesLevel(t *testing.T) {
++ t.Parallel()
++
++ result := loadScript(t, `provider add openai --api-key k
++model add openai/custom-reasoning --reasoning-effort max`)
++
++ providers := result["providers"].(map[string]any)
++ models := providers["openai"].(map[string]any)["models"].([]any)
++ require.Len(t, models, 1)
++ m := models[0].(map[string]any)
++ require.Equal(t, true, m["can_reason"])
++ require.Equal(t, "max", m["default_reasoning_effort"])
++ require.Equal(t, []any{"max"}, m["reasoning_levels"])
++}
++
++func TestModelAdd_ExplicitCanReasonFalsePreserved(t *testing.T) {
++ t.Parallel()
++
++ result := loadScript(t, `provider add anthropic --api-key k
++model add anthropic/custom-reasoning --can-reason false --reasoning-levels low,high`)
++
++ providers := result["providers"].(map[string]any)
++ models := providers["anthropic"].(map[string]any)["models"].([]any)
++ require.Len(t, models, 1)
++ m := models[0].(map[string]any)
++ require.Equal(t, false, m["can_reason"])
++ require.Equal(t, []any{"low", "high"}, m["reasoning_levels"])
++}