From 077f17345f82896ad78053d87df5ac2119b9304a Mon Sep 17 00:00:00 2001 From: "Matthias G. Eckermann" Date: Fri, 30 Oct 2026 00:06:45 +0100 Subject: [PATCH 1/3] feat(extensions): add preferred-models example /model lists all models of the catalog. To switch between a few models, for example one model through two providers and a local model, a short list is faster. The example preferred-models.go adds the command /preferred for the models in the option preferred-models. The option lists them as provider/model, separated by commas or spaces. /preferred opens a picker and marks the model in use. /preferred 2 switches to the second model in the list. /preferred bedrock switches to the first model whose name contains "bedrock". Tab completes the model names. At startup, Context.Model is only the model ID. Then the configured model is in use, and GetModelCapabilities("") gives its provider if the registry knows the model. After a model switch, Context.Model is provider/model, but GetModelCapabilities("") still describes the configured model. So the example reads Context.Model first. TestPreferredModelsExample runs the command with this behavior of Kit. TestPreferredModelsExampleWithoutList checks the hint when the option is not set. Assisted-by: Claude:claude-opus-5-5 Signed-off-by: Matthias G. Eckermann --- examples/extensions/README.md | 1 + examples/extensions/preferred-models.go | 128 +++++++++++++++++ .../test/preferred_models_yaegi_test.go | 136 ++++++++++++++++++ www/pages/extensions/examples.md | 1 + 4 files changed, 266 insertions(+) create mode 100644 examples/extensions/preferred-models.go create mode 100644 pkg/extensions/test/preferred_models_yaegi_test.go diff --git a/examples/extensions/README.md b/examples/extensions/README.md index 84d5ee91..5626fc11 100644 --- a/examples/extensions/README.md +++ b/examples/extensions/README.md @@ -74,6 +74,7 @@ kit install github.com/mark3labs/kit/examples/extensions --local | `summarize.go` | Summarize conversation | Custom tool with parameters | | `confirm-destructive.go` | Confirm destructive commands | `OnToolCall` blocking | | `lsp-diagnostics.go` | LSP integration | Complex extension, external process | +| `preferred-models.go` | Switch between the models that you list in an option | `RegisterCommand` with `Complete`, `RegisterOption`, `PromptSelect`, `SetModel` | ### Subagents & Background Tasks diff --git a/examples/extensions/preferred-models.go b/examples/extensions/preferred-models.go new file mode 100644 index 00000000..1af1897c --- /dev/null +++ b/examples/extensions/preferred-models.go @@ -0,0 +1,128 @@ +//go:build ignore + +package main + +import ( + "fmt" + "strconv" + "strings" + + "kit/ext" +) + +// preferredModels reads the option preferred-models: provider/model strings, +// separated by commas or spaces. +func preferredModels(ctx ext.Context) []string { + return strings.Fields(strings.ReplaceAll(ctx.GetOption("preferred-models"), ",", " ")) +} + +// currentModel returns the model in use as provider/model. After a model +// switch, ctx.Model is provider/model. At startup, ctx.Model is only the +// model ID. Then the configured model is in use, and the model registry +// gives its provider. If the registry does not know the model, the result +// is only the model ID. +func currentModel(ctx ext.Context) string { + if strings.Contains(ctx.Model, "/") { + return ctx.Model + } + if caps, errMsg := ctx.GetModelCapabilities(""); errMsg == "" && caps.ModelID == ctx.Model { + return caps.Provider + "/" + caps.ModelID + } + return ctx.Model +} + +// pickModel returns the model that arg selects. arg is the number of the +// model in the list, starting at 1, or a part of a model name. A part of a +// name selects the first model that contains it. pickModel returns "" if +// nothing matches. +func pickModel(list []string, arg string) string { + if n, err := strconv.Atoi(arg); err == nil { + if n >= 1 && n <= len(list) { + return list[n-1] + } + return "" + } + for _, m := range list { + if strings.Contains(strings.ToLower(m), strings.ToLower(arg)) { + return m + } + } + return "" +} + +// switchTo switches to model and returns the message for the transcript. +func switchTo(ctx ext.Context, model string) (string, error) { + if model == currentModel(ctx) { + return "Already using " + model, nil + } + if err := ctx.SetModel(model); err != nil { + return "", err + } + return "Switched to " + model, nil +} + +// Init adds the command /preferred. It switches between the models in the +// option preferred-models, which lists provider/model strings separated by +// commas or spaces. Write the list as one string; a YAML list is not read. +// +// options: +// preferred-models: anthropic/claude-opus-5-5, amazon-bedrock/eu.anthropic.claude-opus-5-5 +// +// or for one run: KIT_OPT_PREFERRED_MODELS="ollama/llama3 ollama/qwen3" kit +// +// /preferred select a model from the list +// /preferred 2 switch to the second model in the list +// /preferred bedrock switch to the first model whose name contains "bedrock" +// +// Unlike /model, the list contains only the models that you put in it. +// +// Usage: kit -e examples/extensions/preferred-models.go +func Init(api ext.API) { + api.RegisterOption(ext.OptionDef{ + Name: "preferred-models", + Description: "Models for /preferred, as provider/model, separated by commas", + }) + + api.RegisterCommand(ext.CommandDef{ + Name: "preferred", + Description: "Switch to one of your preferred models", + Execute: func(args string, ctx ext.Context) (string, error) { + list := preferredModels(ctx) + if len(list) == 0 { + return "No preferred models. List them in the option preferred-models, for example:\n options:\n preferred-models: anthropic/claude-opus-5-5, anthropic/claude-sonnet-5-5", nil + } + if arg := strings.TrimSpace(args); arg != "" { + model := pickModel(list, arg) + if model == "" { + return "", fmt.Errorf("no preferred model matches %q", arg) + } + return switchTo(ctx, model) + } + current := currentModel(ctx) + options := make([]string, len(list)) + for i, m := range list { + options[i] = fmt.Sprintf("%d %s", i+1, m) + if m == current { + options[i] += " (current)" + } + } + res := ctx.PromptSelect(ext.PromptSelectConfig{ + Message: "Switch to a preferred model", + Options: options, + }) + if res.Cancelled || res.Index < 0 || res.Index >= len(list) { + return "", nil + } + return switchTo(ctx, list[res.Index]) + }, + Complete: func(prefix string, ctx ext.Context) []string { + var out []string + for _, m := range preferredModels(ctx) { + if strings.HasPrefix(m, prefix) { + out = append(out, m) + } + } + return out + }, + }) +} diff --git a/pkg/extensions/test/preferred_models_yaegi_test.go b/pkg/extensions/test/preferred_models_yaegi_test.go new file mode 100644 index 00000000..4149d43d --- /dev/null +++ b/pkg/extensions/test/preferred_models_yaegi_test.go @@ -0,0 +1,136 @@ +package test + +import ( + "reflect" + "strings" + "testing" + + "github.com/mark3labs/kit/pkg/extensions" +) + +// loadPreferred loads the preferred-models example and returns its command +// /preferred. +func loadPreferred(t *testing.T) (*Harness, extensions.CommandDef) { + t.Helper() + harness := New(t) + harness.LoadFile("../../../examples/extensions/preferred-models.go") + for _, cmd := range harness.RegisteredCommands() { + if cmd.Name == "preferred" && cmd.Execute != nil && cmd.Complete != nil { + return harness, cmd + } + } + t.Fatal("the example does not register /preferred with Execute and Complete") + return nil, extensions.CommandDef{} +} + +// TestPreferredModelsExample runs /preferred as Kit does. At startup, +// Context.Model is only the model ID, and the configured model is in use. +// After a model switch, Context.Model is provider/model, but +// GetModelCapabilities("") still describes the configured model. +func TestPreferredModelsExample(t *testing.T) { + harness, cmd := loadPreferred(t) + + var switched []string // the models given to SetModel + var shown []string // the options of the last picker + pick := -1 // the option that the picker selects, -1 for Esc + ctx := harness.Context().ToContext() + ctx.Model = "claude-opus-5-5" + ctx.GetOption = func(name string) string { + if name == "preferred-models" { + return "anthropic/claude-opus-5-5, amazon-bedrock/eu.anthropic.claude-opus-5-5 ollama/qwen3" + } + return "" + } + ctx.GetModelCapabilities = func(model string) (extensions.ModelCapabilities, string) { + if model != "" { + return extensions.ModelCapabilities{}, "model not found in registry" + } + return extensions.ModelCapabilities{Provider: "anthropic", ModelID: "claude-opus-5-5"}, "" + } + ctx.SetModel = func(model string) error { + switched = append(switched, model) + ctx.Model = model + return nil + } + ctx.PromptSelect = func(cfg extensions.PromptSelectConfig) extensions.PromptSelectResult { + shown = cfg.Options + if pick < 0 { + return extensions.PromptSelectResult{Cancelled: true} + } + return extensions.PromptSelectResult{Value: cfg.Options[pick], Index: pick} + } + run := func(args string) (string, error) { + return cmd.Execute(args, ctx) + } + + // The picker marks the configured model. Esc changes nothing. + if out, err := run(""); out != "" || err != nil { + t.Fatalf("/preferred with Esc = %q, %v; want no output", out, err) + } + want := []string{ + "1 anthropic/claude-opus-5-5 (current)", + "2 amazon-bedrock/eu.anthropic.claude-opus-5-5", + "3 ollama/qwen3", + } + if !reflect.DeepEqual(shown, want) { + t.Fatalf("options at startup = %q, want %q", shown, want) + } + + pick = 2 + if out, err := run(""); out != "Switched to ollama/qwen3" || err != nil { + t.Fatalf("/preferred, third option = %q, %v; want the switch", out, err) + } + + // After the switch, the mark follows Context.Model. + pick = -1 + if _, err := run(""); err != nil { + t.Fatal(err) + } + want[0] = "1 anthropic/claude-opus-5-5" + want[2] = "3 ollama/qwen3 (current)" + if !reflect.DeepEqual(shown, want) { + t.Fatalf("options after the switch = %q, want %q", shown, want) + } + + // A number or a part of a name selects a model. The match ignores case. + for _, c := range []struct{ args, want string }{ + {"1", "Switched to anthropic/claude-opus-5-5"}, + {"BEDROCK", "Switched to amazon-bedrock/eu.anthropic.claude-opus-5-5"}, + {"bedrock", "Already using amazon-bedrock/eu.anthropic.claude-opus-5-5"}, + } { + if out, err := run(c.args); out != c.want || err != nil { + t.Errorf("/preferred %s = %q, %v; want %q", c.args, out, err, c.want) + } + } + for _, args := range []string{"0", "4", "gpt"} { + if _, err := run(args); err == nil || !strings.Contains(err.Error(), "no preferred model matches") { + t.Errorf("/preferred %s gave error %v, want no match", args, err) + } + } + wantSwitched := []string{"ollama/qwen3", "anthropic/claude-opus-5-5", "amazon-bedrock/eu.anthropic.claude-opus-5-5"} + if !reflect.DeepEqual(switched, wantSwitched) { + t.Errorf("SetModel calls = %q, want %q", switched, wantSwitched) + } + + if got := cmd.Complete("oll", ctx); !reflect.DeepEqual(got, []string{"ollama/qwen3"}) { + t.Errorf("Complete(oll) = %q, want [ollama/qwen3]", got) + } + if got := cmd.Complete("", ctx); len(got) != 3 { + t.Errorf("Complete() = %q, want all three models", got) + } +} + +// TestPreferredModelsExampleWithoutList checks that /preferred explains the +// option when it is not set, and opens no picker. +func TestPreferredModelsExampleWithoutList(t *testing.T) { + harness, cmd := loadPreferred(t) + ctx := harness.Context().ToContext() + ctx.PromptSelect = func(extensions.PromptSelectConfig) extensions.PromptSelectResult { + t.Error("the picker opened without a list") + return extensions.PromptSelectResult{Cancelled: true} + } + out, err := cmd.Execute("", ctx) + if err != nil || !strings.Contains(out, "No preferred models") || !strings.Contains(out, "preferred-models:") { + t.Errorf("/preferred without the option = %q, %v; want a hint with the option", out, err) + } +} diff --git a/www/pages/extensions/examples.md b/www/pages/extensions/examples.md index 123c4ccb..6f245573 100644 --- a/www/pages/extensions/examples.md +++ b/www/pages/extensions/examples.md @@ -56,6 +56,7 @@ environment restrictions when input is untrusted. | [`inline-bash.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/inline-bash.go) | Inline bash execution | | [`interactive-shell.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/interactive-shell.go) | Interactive shell integration | | [`notify.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/notify.go) | Desktop notifications | +| [`preferred-models.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/preferred-models.go) | `/preferred` switches between the models that you list in the option `preferred-models` | ## Agent and context From 6a6524de92e2515682ded551585c07c3c9fd7a80 Mon Sep 17 00:00:00 2001 From: "Matthias G. Eckermann" Date: Fri, 30 Oct 2026 00:39:51 +0100 Subject: [PATCH 2/3] fix(extensions): find the current model when its ID contains "/" At startup, ctx.Model is only the model ID, and a model ID can contain "/", as in openrouter/anthropic/claude-opus-5. currentModel took such an ID for provider/model. With this model configured, the picker marked anthropic/claude-opus-5 as the model in use, and /preferred refused to switch to it. The review proposed to ask the registry first. That fixes the start. But after a switch to anthropic/claude-opus-5, ctx.Model equals the model ID of the configured model. The picker then marked the configured model again, and /preferred refused to switch back. So the example now records every switch with OnModelChange: by /preferred, by /model and by other extensions. Until the first switch, it asks the registry for the configured model, and uses it only if its model ID equals ctx.Model. The README row lists OnModelChange too. The tests use this configuration now. A new test loads the example after a switch, as after a reload of the extensions. Assisted-by: Claude:claude-opus-5-5 Signed-off-by: Matthias G. Eckermann --- examples/extensions/README.md | 2 +- examples/extensions/preferred-models.go | 26 +++- .../test/preferred_models_yaegi_test.go | 143 +++++++++++++----- 3 files changed, 121 insertions(+), 50 deletions(-) diff --git a/examples/extensions/README.md b/examples/extensions/README.md index 5626fc11..4c698cea 100644 --- a/examples/extensions/README.md +++ b/examples/extensions/README.md @@ -74,7 +74,7 @@ kit install github.com/mark3labs/kit/examples/extensions --local | `summarize.go` | Summarize conversation | Custom tool with parameters | | `confirm-destructive.go` | Confirm destructive commands | `OnToolCall` blocking | | `lsp-diagnostics.go` | LSP integration | Complex extension, external process | -| `preferred-models.go` | Switch between the models that you list in an option | `RegisterCommand` with `Complete`, `RegisterOption`, `PromptSelect`, `SetModel` | +| `preferred-models.go` | Switch between the models that you list in an option | `RegisterCommand` with `Complete`, `RegisterOption`, `PromptSelect`, `SetModel`, `OnModelChange` | ### Subagents & Background Tasks diff --git a/examples/extensions/preferred-models.go b/examples/extensions/preferred-models.go index 1af1897c..8d80ecad 100644 --- a/examples/extensions/preferred-models.go +++ b/examples/extensions/preferred-models.go @@ -16,14 +16,20 @@ func preferredModels(ctx ext.Context) []string { return strings.Fields(strings.ReplaceAll(ctx.GetOption("preferred-models"), ",", " ")) } -// currentModel returns the model in use as provider/model. After a model -// switch, ctx.Model is provider/model. At startup, ctx.Model is only the -// model ID. Then the configured model is in use, and the model registry -// gives its provider. If the registry does not know the model, the result -// is only the model ID. +// switchedTo is the model of the last model switch, as provider/model. It is +// empty until a switch. OnModelChange sets it. +var switchedTo string + +// currentModel returns the model in use as provider/model. ctx.Model alone +// is not enough: at startup it is only the model ID, and a model ID can +// contain "/", as in openrouter/anthropic/claude-opus-5. Until a switch, the +// configured model is in use, and the model registry gives its provider. The +// model ID must match ctx.Model, because the registry reports the configured +// model also after a switch before this extension loaded. If the registry +// does not know the model, the result is ctx.Model. func currentModel(ctx ext.Context) string { - if strings.Contains(ctx.Model, "/") { - return ctx.Model + if switchedTo != "" { + return switchedTo } if caps, errMsg := ctx.GetModelCapabilities(""); errMsg == "" && caps.ModelID == ctx.Model { return caps.Provider + "/" + caps.ModelID @@ -83,6 +89,12 @@ func Init(api ext.API) { Description: "Models for /preferred, as provider/model, separated by commas", }) + // Remember every switch: by /preferred, by /model and by other + // extensions. + api.OnModelChange(func(e ext.ModelChangeEvent, _ ext.Context) { + switchedTo = e.NewModel + }) + api.RegisterCommand(ext.CommandDef{ Name: "preferred", Description: "Switch to one of your preferred models", diff --git a/pkg/extensions/test/preferred_models_yaegi_test.go b/pkg/extensions/test/preferred_models_yaegi_test.go index 4149d43d..fd8b0598 100644 --- a/pkg/extensions/test/preferred_models_yaegi_test.go +++ b/pkg/extensions/test/preferred_models_yaegi_test.go @@ -8,6 +8,10 @@ import ( "github.com/mark3labs/kit/pkg/extensions" ) +// preferredList is the option value for the preferred-models tests. The +// model ID of the first model is also the name of the second model. +const preferredList = "openrouter/anthropic/claude-opus-5, anthropic/claude-opus-5 ollama/qwen3" + // loadPreferred loads the preferred-models example and returns its command // /preferred. func loadPreferred(t *testing.T) (*Harness, extensions.CommandDef) { @@ -23,34 +27,49 @@ func loadPreferred(t *testing.T) (*Harness, extensions.CommandDef) { return nil, extensions.CommandDef{} } -// TestPreferredModelsExample runs /preferred as Kit does. At startup, -// Context.Model is only the model ID, and the configured model is in use. -// After a model switch, Context.Model is provider/model, but -// GetModelCapabilities("") still describes the configured model. -func TestPreferredModelsExample(t *testing.T) { - harness, cmd := loadPreferred(t) - - var switched []string // the models given to SetModel - var shown []string // the options of the last picker - pick := -1 // the option that the picker selects, -1 for Esc +// preferredContext returns a context for /preferred as Kit gives it. The +// configured model is openrouter/anthropic/claude-opus-5, and model is +// ctx.Model. GetModelCapabilities("") describes the configured model, also +// after a switch. +func preferredContext(harness *Harness, model string) extensions.Context { ctx := harness.Context().ToContext() - ctx.Model = "claude-opus-5-5" + ctx.Model = model ctx.GetOption = func(name string) string { if name == "preferred-models" { - return "anthropic/claude-opus-5-5, amazon-bedrock/eu.anthropic.claude-opus-5-5 ollama/qwen3" + return preferredList } return "" } - ctx.GetModelCapabilities = func(model string) (extensions.ModelCapabilities, string) { - if model != "" { + ctx.GetModelCapabilities = func(name string) (extensions.ModelCapabilities, string) { + if name != "" { return extensions.ModelCapabilities{}, "model not found in registry" } - return extensions.ModelCapabilities{Provider: "anthropic", ModelID: "claude-opus-5-5"}, "" + return extensions.ModelCapabilities{Provider: "openrouter", ModelID: "anthropic/claude-opus-5"}, "" + } + return ctx +} + +// TestPreferredModelsExample runs /preferred as Kit does. At startup, +// ctx.Model is only the model ID: anthropic/claude-opus-5, which is also the +// name of the second model. After a switch, ctx.Model is provider/model, and +// Kit sends a ModelChangeEvent. +func TestPreferredModelsExample(t *testing.T) { + harness, cmd := loadPreferred(t) + + var switched []string // the models given to SetModel + var shown []string // the options of the last picker + pick := -1 // the option that the picker selects, -1 for Esc + ctx := preferredContext(harness, "anthropic/claude-opus-5") + // switchModel does what Kit does for a switch by SetModel or by /model. + switchModel := func(model, source string) error { + previous := ctx.Model + ctx.Model = model + _, err := harness.Emit(extensions.ModelChangeEvent{NewModel: model, PreviousModel: previous, Source: source}) + return err } ctx.SetModel = func(model string) error { switched = append(switched, model) - ctx.Model = model - return nil + return switchModel(model, "extension") } ctx.PromptSelect = func(cfg extensions.PromptSelectConfig) extensions.PromptSelectResult { shown = cfg.Options @@ -62,41 +81,56 @@ func TestPreferredModelsExample(t *testing.T) { run := func(args string) (string, error) { return cmd.Execute(args, ctx) } - - // The picker marks the configured model. Esc changes nothing. - if out, err := run(""); out != "" || err != nil { - t.Fatalf("/preferred with Esc = %q, %v; want no output", out, err) + // checkPicker opens the picker, closes it with Esc, and compares the + // options. + checkPicker := func(when string, want []string) { + t.Helper() + pick = -1 + if out, err := run(""); out != "" || err != nil { + t.Fatalf("/preferred with Esc %s = %q, %v; want no output", when, out, err) + } + if !reflect.DeepEqual(shown, want) { + t.Fatalf("options %s = %q, want %q", when, shown, want) + } } - want := []string{ - "1 anthropic/claude-opus-5-5 (current)", - "2 amazon-bedrock/eu.anthropic.claude-opus-5-5", + onFirst := []string{ + "1 openrouter/anthropic/claude-opus-5 (current)", + "2 anthropic/claude-opus-5", "3 ollama/qwen3", } - if !reflect.DeepEqual(shown, want) { - t.Fatalf("options at startup = %q, want %q", shown, want) + onSecond := []string{ + "1 openrouter/anthropic/claude-opus-5", + "2 anthropic/claude-opus-5 (current)", + "3 ollama/qwen3", } + checkPicker("at startup", onFirst) + for _, c := range []struct{ args, want string }{ + {"2", "Switched to anthropic/claude-opus-5"}, + {"1", "Switched to openrouter/anthropic/claude-opus-5"}, + {"2", "Switched to anthropic/claude-opus-5"}, + } { + if out, err := run(c.args); out != c.want || err != nil { + t.Fatalf("/preferred %s = %q, %v; want %q", c.args, out, err, c.want) + } + } + checkPicker("after a switch", onSecond) + pick = 2 if out, err := run(""); out != "Switched to ollama/qwen3" || err != nil { t.Fatalf("/preferred, third option = %q, %v; want the switch", out, err) } - - // After the switch, the mark follows Context.Model. - pick = -1 - if _, err := run(""); err != nil { + // A switch by /model moves the mark too. + if err := switchModel("anthropic/claude-opus-5", "user"); err != nil { t.Fatal(err) } - want[0] = "1 anthropic/claude-opus-5-5" - want[2] = "3 ollama/qwen3 (current)" - if !reflect.DeepEqual(shown, want) { - t.Fatalf("options after the switch = %q, want %q", shown, want) - } + checkPicker("after /model", onSecond) - // A number or a part of a name selects a model. The match ignores case. + // A part of a name selects the first model that contains it. The match + // ignores case. for _, c := range []struct{ args, want string }{ - {"1", "Switched to anthropic/claude-opus-5-5"}, - {"BEDROCK", "Switched to amazon-bedrock/eu.anthropic.claude-opus-5-5"}, - {"bedrock", "Already using amazon-bedrock/eu.anthropic.claude-opus-5-5"}, + {"OPENROUTER", "Switched to openrouter/anthropic/claude-opus-5"}, + {"openrouter", "Already using openrouter/anthropic/claude-opus-5"}, } { if out, err := run(c.args); out != c.want || err != nil { t.Errorf("/preferred %s = %q, %v; want %q", c.args, out, err, c.want) @@ -107,19 +141,44 @@ func TestPreferredModelsExample(t *testing.T) { t.Errorf("/preferred %s gave error %v, want no match", args, err) } } - wantSwitched := []string{"ollama/qwen3", "anthropic/claude-opus-5-5", "amazon-bedrock/eu.anthropic.claude-opus-5-5"} + wantSwitched := []string{ + "anthropic/claude-opus-5", + "openrouter/anthropic/claude-opus-5", + "anthropic/claude-opus-5", + "ollama/qwen3", + "openrouter/anthropic/claude-opus-5", + } if !reflect.DeepEqual(switched, wantSwitched) { t.Errorf("SetModel calls = %q, want %q", switched, wantSwitched) } - if got := cmd.Complete("oll", ctx); !reflect.DeepEqual(got, []string{"ollama/qwen3"}) { - t.Errorf("Complete(oll) = %q, want [ollama/qwen3]", got) + if got := cmd.Complete("anthropic/", ctx); !reflect.DeepEqual(got, []string{"anthropic/claude-opus-5"}) { + t.Errorf("Complete(anthropic/) = %q, want [anthropic/claude-opus-5]", got) } if got := cmd.Complete("", ctx); len(got) != 3 { t.Errorf("Complete() = %q, want all three models", got) } } +// TestPreferredModelsExampleAfterSwitch loads the example after a switch, +// as after a reload of the extensions. ctx.Model is then provider/model, and +// GetModelCapabilities("") still describes the configured model. +func TestPreferredModelsExampleAfterSwitch(t *testing.T) { + harness, cmd := loadPreferred(t) + ctx := preferredContext(harness, "ollama/qwen3") + var shown []string + ctx.PromptSelect = func(cfg extensions.PromptSelectConfig) extensions.PromptSelectResult { + shown = cfg.Options + return extensions.PromptSelectResult{Cancelled: true} + } + if _, err := cmd.Execute("", ctx); err != nil { + t.Fatal(err) + } + if len(shown) != 3 || shown[2] != "3 ollama/qwen3 (current)" || strings.Contains(shown[0], "(current)") { + t.Errorf("options = %q, want the mark on ollama/qwen3 only", shown) + } +} + // TestPreferredModelsExampleWithoutList checks that /preferred explains the // option when it is not set, and opens no picker. func TestPreferredModelsExampleWithoutList(t *testing.T) { From 8cca638f326a666416700f5c34943dd0a645273b Mon Sep 17 00:00:00 2001 From: "Matthias G. Eckermann" Date: Tue, 3 Nov 2026 12:07:05 +0100 Subject: [PATCH 3/3] fix(extensions): ask Kit for the model in use The example recorded each model switch with OnModelChange. A reload of the extensions loses that record. With the configured model openrouter/anthropic/claude-opus-5 and anthropic/claude-opus-5 in use after a switch, the picker then marked the configured model, and /preferred 1 answered "Already using". #267 makes GetCurrentProvider and GetCurrentModelID report the model in use, also at startup and after a reload. currentModel now joins the two, and the record of switches and its handler are gone. A model that the registry does not know, such as an ollama model, now gets the mark at startup too. The tests give the example a context that behaves like Kit with #267. TestPreferredModelsExampleAfterReload covers the reload. TestPreferredModelsExampleIgnoresInvalidPickerIndex checks that a picker index outside the list switches nothing. Assisted-by: Claude:claude-opus-5-5 Signed-off-by: Matthias G. Eckermann --- examples/extensions/README.md | 2 +- examples/extensions/preferred-models.go | 28 +-- .../test/preferred_models_yaegi_test.go | 182 ++++++++++-------- 3 files changed, 103 insertions(+), 109 deletions(-) diff --git a/examples/extensions/README.md b/examples/extensions/README.md index 4c698cea..84db8d5b 100644 --- a/examples/extensions/README.md +++ b/examples/extensions/README.md @@ -74,7 +74,7 @@ kit install github.com/mark3labs/kit/examples/extensions --local | `summarize.go` | Summarize conversation | Custom tool with parameters | | `confirm-destructive.go` | Confirm destructive commands | `OnToolCall` blocking | | `lsp-diagnostics.go` | LSP integration | Complex extension, external process | -| `preferred-models.go` | Switch between the models that you list in an option | `RegisterCommand` with `Complete`, `RegisterOption`, `PromptSelect`, `SetModel`, `OnModelChange` | +| `preferred-models.go` | Switch between the models that you list in an option | `RegisterCommand` with `Complete`, `RegisterOption`, `PromptSelect`, `SetModel`, `GetCurrentProvider` | ### Subagents & Background Tasks diff --git a/examples/extensions/preferred-models.go b/examples/extensions/preferred-models.go index 8d80ecad..a36c32e5 100644 --- a/examples/extensions/preferred-models.go +++ b/examples/extensions/preferred-models.go @@ -16,25 +16,11 @@ func preferredModels(ctx ext.Context) []string { return strings.Fields(strings.ReplaceAll(ctx.GetOption("preferred-models"), ",", " ")) } -// switchedTo is the model of the last model switch, as provider/model. It is -// empty until a switch. OnModelChange sets it. -var switchedTo string - -// currentModel returns the model in use as provider/model. ctx.Model alone -// is not enough: at startup it is only the model ID, and a model ID can -// contain "/", as in openrouter/anthropic/claude-opus-5. Until a switch, the -// configured model is in use, and the model registry gives its provider. The -// model ID must match ctx.Model, because the registry reports the configured -// model also after a switch before this extension loaded. If the registry -// does not know the model, the result is ctx.Model. +// currentModel returns the model in use as provider/model. ctx.Model is not +// enough: until the first model switch it is only the model ID, and a model +// ID can contain "/", as in openrouter/anthropic/claude-opus-5. func currentModel(ctx ext.Context) string { - if switchedTo != "" { - return switchedTo - } - if caps, errMsg := ctx.GetModelCapabilities(""); errMsg == "" && caps.ModelID == ctx.Model { - return caps.Provider + "/" + caps.ModelID - } - return ctx.Model + return ctx.GetCurrentProvider() + "/" + ctx.GetCurrentModelID() } // pickModel returns the model that arg selects. arg is the number of the @@ -89,12 +75,6 @@ func Init(api ext.API) { Description: "Models for /preferred, as provider/model, separated by commas", }) - // Remember every switch: by /preferred, by /model and by other - // extensions. - api.OnModelChange(func(e ext.ModelChangeEvent, _ ext.Context) { - switchedTo = e.NewModel - }) - api.RegisterCommand(ext.CommandDef{ Name: "preferred", Description: "Switch to one of your preferred models", diff --git a/pkg/extensions/test/preferred_models_yaegi_test.go b/pkg/extensions/test/preferred_models_yaegi_test.go index fd8b0598..8c2a1d65 100644 --- a/pkg/extensions/test/preferred_models_yaegi_test.go +++ b/pkg/extensions/test/preferred_models_yaegi_test.go @@ -27,104 +27,104 @@ func loadPreferred(t *testing.T) (*Harness, extensions.CommandDef) { return nil, extensions.CommandDef{} } -// preferredContext returns a context for /preferred as Kit gives it. The -// configured model is openrouter/anthropic/claude-opus-5, and model is -// ctx.Model. GetModelCapabilities("") describes the configured model, also -// after a switch. -func preferredContext(harness *Harness, model string) extensions.Context { - ctx := harness.Context().ToContext() - ctx.Model = model - ctx.GetOption = func(name string) string { +// preferredKit gives /preferred a context as Kit does. active is the model +// in use. GetCurrentProvider and GetCurrentModelID report it, and SetModel +// switches it. ctx.Model is only the model ID until the first switch, and +// provider/model after it. switched records the models given to SetModel. +type preferredKit struct { + ctx extensions.Context + active string + switched []string +} + +// newPreferredKit returns a preferredKit with active in use. model is the +// value of ctx.Model. +func newPreferredKit(harness *Harness, active, model string) *preferredKit { + k := &preferredKit{ctx: harness.Context().ToContext(), active: active} + k.ctx.Model = model + k.ctx.GetOption = func(name string) string { if name == "preferred-models" { return preferredList } return "" } - ctx.GetModelCapabilities = func(name string) (extensions.ModelCapabilities, string) { - if name != "" { - return extensions.ModelCapabilities{}, "model not found in registry" - } - return extensions.ModelCapabilities{Provider: "openrouter", ModelID: "anthropic/claude-opus-5"}, "" + k.ctx.GetCurrentProvider = func() string { + provider, _, _ := strings.Cut(k.active, "/") + return provider } - return ctx + k.ctx.GetCurrentModelID = func() string { + _, id, _ := strings.Cut(k.active, "/") + return id + } + k.ctx.SetModel = func(model string) error { + k.switched = append(k.switched, model) + k.active = model + k.ctx.Model = model + return nil + } + return k } -// TestPreferredModelsExample runs /preferred as Kit does. At startup, -// ctx.Model is only the model ID: anthropic/claude-opus-5, which is also the -// name of the second model. After a switch, ctx.Model is provider/model, and -// Kit sends a ModelChangeEvent. -func TestPreferredModelsExample(t *testing.T) { - harness, cmd := loadPreferred(t) - - var switched []string // the models given to SetModel - var shown []string // the options of the last picker - pick := -1 // the option that the picker selects, -1 for Esc - ctx := preferredContext(harness, "anthropic/claude-opus-5") - // switchModel does what Kit does for a switch by SetModel or by /model. - switchModel := func(model, source string) error { - previous := ctx.Model - ctx.Model = model - _, err := harness.Emit(extensions.ModelChangeEvent{NewModel: model, PreviousModel: previous, Source: source}) - return err - } - ctx.SetModel = func(model string) error { - switched = append(switched, model) - return switchModel(model, "extension") - } - ctx.PromptSelect = func(cfg extensions.PromptSelectConfig) extensions.PromptSelectResult { +// picker opens the picker with /preferred, selects the option pick (-1 for +// Esc), and returns the options and the output. +func (k *preferredKit) picker(t *testing.T, cmd extensions.CommandDef, pick int) ([]string, string) { + t.Helper() + var shown []string + k.ctx.PromptSelect = func(cfg extensions.PromptSelectConfig) extensions.PromptSelectResult { shown = cfg.Options if pick < 0 { return extensions.PromptSelectResult{Cancelled: true} } return extensions.PromptSelectResult{Value: cfg.Options[pick], Index: pick} } - run := func(args string) (string, error) { - return cmd.Execute(args, ctx) - } - // checkPicker opens the picker, closes it with Esc, and compares the - // options. - checkPicker := func(when string, want []string) { - t.Helper() - pick = -1 - if out, err := run(""); out != "" || err != nil { - t.Fatalf("/preferred with Esc %s = %q, %v; want no output", when, out, err) - } - if !reflect.DeepEqual(shown, want) { - t.Fatalf("options %s = %q, want %q", when, shown, want) - } + out, err := cmd.Execute("", k.ctx) + if err != nil { + t.Fatalf("/preferred: %v", err) } - onFirst := []string{ + return shown, out +} + +var ( + // markFirst and markSecond are the options of the picker with the + // first or the second model in use. + markFirst = []string{ "1 openrouter/anthropic/claude-opus-5 (current)", "2 anthropic/claude-opus-5", "3 ollama/qwen3", } - onSecond := []string{ + markSecond = []string{ "1 openrouter/anthropic/claude-opus-5", "2 anthropic/claude-opus-5 (current)", "3 ollama/qwen3", } +) + +// TestPreferredModelsExample runs /preferred from the start. The configured +// model is openrouter/anthropic/claude-opus-5, so ctx.Model starts as +// anthropic/claude-opus-5, the name of the second model. +func TestPreferredModelsExample(t *testing.T) { + harness, cmd := loadPreferred(t) + k := newPreferredKit(harness, "openrouter/anthropic/claude-opus-5", "anthropic/claude-opus-5") - checkPicker("at startup", onFirst) + if shown, out := k.picker(t, cmd, -1); out != "" || !reflect.DeepEqual(shown, markFirst) { + t.Fatalf("picker at the start = %q, output %q; want %q and no output", shown, out, markFirst) + } for _, c := range []struct{ args, want string }{ {"2", "Switched to anthropic/claude-opus-5"}, {"1", "Switched to openrouter/anthropic/claude-opus-5"}, {"2", "Switched to anthropic/claude-opus-5"}, + {"2", "Already using anthropic/claude-opus-5"}, } { - if out, err := run(c.args); out != c.want || err != nil { + if out, err := cmd.Execute(c.args, k.ctx); out != c.want || err != nil { t.Fatalf("/preferred %s = %q, %v; want %q", c.args, out, err, c.want) } } - checkPicker("after a switch", onSecond) - - pick = 2 - if out, err := run(""); out != "Switched to ollama/qwen3" || err != nil { - t.Fatalf("/preferred, third option = %q, %v; want the switch", out, err) + if shown, _ := k.picker(t, cmd, -1); !reflect.DeepEqual(shown, markSecond) { + t.Fatalf("picker after a switch = %q, want %q", shown, markSecond) } - // A switch by /model moves the mark too. - if err := switchModel("anthropic/claude-opus-5", "user"); err != nil { - t.Fatal(err) + if _, out := k.picker(t, cmd, 2); out != "Switched to ollama/qwen3" { + t.Fatalf("picker, third option = %q, want the switch", out) } - checkPicker("after /model", onSecond) // A part of a name selects the first model that contains it. The match // ignores case. @@ -132,50 +132,64 @@ func TestPreferredModelsExample(t *testing.T) { {"OPENROUTER", "Switched to openrouter/anthropic/claude-opus-5"}, {"openrouter", "Already using openrouter/anthropic/claude-opus-5"}, } { - if out, err := run(c.args); out != c.want || err != nil { + if out, err := cmd.Execute(c.args, k.ctx); out != c.want || err != nil { t.Errorf("/preferred %s = %q, %v; want %q", c.args, out, err, c.want) } } for _, args := range []string{"0", "4", "gpt"} { - if _, err := run(args); err == nil || !strings.Contains(err.Error(), "no preferred model matches") { + if _, err := cmd.Execute(args, k.ctx); err == nil || !strings.Contains(err.Error(), "no preferred model matches") { t.Errorf("/preferred %s gave error %v, want no match", args, err) } } - wantSwitched := []string{ + want := []string{ "anthropic/claude-opus-5", "openrouter/anthropic/claude-opus-5", "anthropic/claude-opus-5", "ollama/qwen3", "openrouter/anthropic/claude-opus-5", } - if !reflect.DeepEqual(switched, wantSwitched) { - t.Errorf("SetModel calls = %q, want %q", switched, wantSwitched) + if !reflect.DeepEqual(k.switched, want) { + t.Errorf("SetModel calls = %q, want %q", k.switched, want) } - if got := cmd.Complete("anthropic/", ctx); !reflect.DeepEqual(got, []string{"anthropic/claude-opus-5"}) { + if got := cmd.Complete("anthropic/", k.ctx); !reflect.DeepEqual(got, []string{"anthropic/claude-opus-5"}) { t.Errorf("Complete(anthropic/) = %q, want [anthropic/claude-opus-5]", got) } - if got := cmd.Complete("", ctx); len(got) != 3 { + if got := cmd.Complete("", k.ctx); len(got) != 3 { t.Errorf("Complete() = %q, want all three models", got) } } -// TestPreferredModelsExampleAfterSwitch loads the example after a switch, -// as after a reload of the extensions. ctx.Model is then provider/model, and -// GetModelCapabilities("") still describes the configured model. -func TestPreferredModelsExampleAfterSwitch(t *testing.T) { +// TestPreferredModelsExampleAfterReload loads the example after a switch to +// anthropic/claude-opus-5, as /reload-ext does. ctx.Model is then the same +// string as at the start with openrouter/anthropic/claude-opus-5. +func TestPreferredModelsExampleAfterReload(t *testing.T) { harness, cmd := loadPreferred(t) - ctx := preferredContext(harness, "ollama/qwen3") - var shown []string - ctx.PromptSelect = func(cfg extensions.PromptSelectConfig) extensions.PromptSelectResult { - shown = cfg.Options - return extensions.PromptSelectResult{Cancelled: true} + k := newPreferredKit(harness, "anthropic/claude-opus-5", "anthropic/claude-opus-5") + + if shown, _ := k.picker(t, cmd, -1); !reflect.DeepEqual(shown, markSecond) { + t.Fatalf("picker after the reload = %q, want %q", shown, markSecond) } - if _, err := cmd.Execute("", ctx); err != nil { - t.Fatal(err) + if out, err := cmd.Execute("1", k.ctx); out != "Switched to openrouter/anthropic/claude-opus-5" || err != nil { + t.Fatalf("/preferred 1 after the reload = %q, %v; want the switch", out, err) + } +} + +// TestPreferredModelsExampleIgnoresInvalidPickerIndex checks that an index +// outside the list switches nothing. +func TestPreferredModelsExampleIgnoresInvalidPickerIndex(t *testing.T) { + harness, cmd := loadPreferred(t) + k := newPreferredKit(harness, "ollama/qwen3", "ollama/qwen3") + for _, pick := range []int{-2, 3, 5} { + k.ctx.PromptSelect = func(extensions.PromptSelectConfig) extensions.PromptSelectResult { + return extensions.PromptSelectResult{Index: pick} + } + if out, err := cmd.Execute("", k.ctx); out != "" || err != nil { + t.Errorf("picker index %d = %q, %v; want no output", pick, out, err) + } } - if len(shown) != 3 || shown[2] != "3 ollama/qwen3 (current)" || strings.Contains(shown[0], "(current)") { - t.Errorf("options = %q, want the mark on ollama/qwen3 only", shown) + if len(k.switched) != 0 { + t.Errorf("SetModel calls = %q, want none", k.switched) } }