launch: add Muse Code integration (#17594)

* launch: add Muse Code integration

Add `ollama launch muse` for Meta's Muse Code CLI.

Muse only takes a model catalog from settings.json (normally it fetches one from its provider and refuses to start otherwise), and that file's endpoint_transport is a global provider switch. So the integration writes a settings file under its own config root (~/.ollama/launch/muse-config via XDG_CONFIG_HOME), leaving a Meta-backed muse install untouched, and re-seeds it from muse's own persisted copy on later runs.

The launched model is preloaded so its catalog row carries the context length the server actually allocated, not the trained maximum; the loaded-context helpers move from cmd/agent_tui.go into cmd/launch for reuse.

Muse sends reasoning efforts outside Ollama's scale (minimal, xhigh, ultra), which were hard 400s; clamp them to the nearest tier in one helper shared by the chat and responses converters.

The registry entry stays Hidden (alias "muse-code"), like kimi and vscode.

* review comments

* skip muse test on windows (unsupported platform)
This commit is contained in:
Daniel Hiltgen
2026-08-13 13:10:29 -07:00
committed by GitHub
parent 9a56a0e845
commit 01d04d50f8
13 changed files with 1198 additions and 92 deletions
+3 -56
View File
@@ -321,7 +321,7 @@ func agentContextWindowForModel(ctx context.Context, client *api.Client, modelNa
if client == nil || strings.TrimSpace(modelName) == "" {
return fallback
}
if tokens := loadedContextWindowForModel(ctx, client, modelName); tokens > 0 {
if tokens := launch.LoadedContextWindow(ctx, client, modelName); tokens > 0 {
return tokens
}
if modelref.HasExplicitCloudSource(modelName) {
@@ -355,40 +355,13 @@ func contextWindowFromRecommendations(modelName string, recommendations []api.Mo
if rec.ContextLength <= 0 {
continue
}
if sameModelRef(modelName, rec.Model) {
if launch.SameModelRef(modelName, rec.Model) {
return rec.ContextLength
}
}
return 0
}
func sameModelRef(a, b string) bool {
a = comparableModelRef(a)
b = comparableModelRef(b)
if strings.EqualFold(a, b) {
return true
}
pa, errA := modelref.ParseRef(a)
pb, errB := modelref.ParseRef(b)
if errA != nil || errB != nil {
return false
}
if !strings.EqualFold(pa.Base, pb.Base) {
return false
}
return pa.Source == pb.Source ||
pa.Source == modelref.ModelSourceUnspecified ||
pb.Source == modelref.ModelSourceUnspecified
}
func comparableModelRef(value string) string {
value = strings.TrimSpace(value)
if strings.HasSuffix(strings.ToLower(value), ":latest") {
return strings.TrimSpace(value[:len(value)-len(":latest")])
}
return value
}
func showResponseContextWindow(resp *api.ShowResponse) int {
if resp == nil {
return 0
@@ -460,33 +433,7 @@ func preloadAgentModelIfLocal(ctx context.Context, client *api.Client, opts agen
}); err != nil {
return 0, err
}
return loadedContextWindowForModel(ctx, client, modelName), nil
}
func loadedContextWindowForModel(ctx context.Context, client *api.Client, modelName string) int {
if client == nil || strings.TrimSpace(modelName) == "" {
return 0
}
resp, err := client.ListRunning(ctx)
if err != nil {
return 0
}
return processContextWindowForModel(modelName, resp)
}
func processContextWindowForModel(modelName string, resp *api.ProcessResponse) int {
if resp == nil {
return 0
}
for _, running := range resp.Models {
if running.ContextLength <= 0 {
continue
}
if sameModelRef(modelName, running.Name) || sameModelRef(modelName, running.Model) {
return running.ContextLength
}
}
return 0
return launch.LoadedContextWindow(ctx, client, modelName), nil
}
func agentModelOptions(ctx context.Context, client *api.Client) ([]agentchat.ModelOption, error) {
-12
View File
@@ -170,18 +170,6 @@ func TestShowResponseContextWindowReadsArchitectureContextLength(t *testing.T) {
}
}
func TestProcessContextWindowForModelMatchesLatestAlias(t *testing.T) {
got := processContextWindowForModel("ornith", &api.ProcessResponse{
Models: []api.ProcessModelResponse{
{Name: "other:latest", Model: "other:latest", ContextLength: 32768},
{Name: "ornith:latest", Model: "ornith:latest", ContextLength: 262144},
},
})
if got != 262144 {
t.Fatalf("context window = %d, want 262144", got)
}
}
func TestSaveLastAgentModel(t *testing.T) {
setCmdTestHome(t, t.TempDir())
+68
View File
@@ -0,0 +1,68 @@
package launch
import (
"context"
"strings"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/internal/modelref"
)
// LoadedContextWindow reports the context length model is currently running
// with, per the server's process list — the size the scheduler actually
// allocated, which VRAM fit or server configuration may hold below the
// model's trained maximum. Returns 0 when it cannot be determined.
func LoadedContextWindow(ctx context.Context, client *api.Client, model string) int {
if client == nil || strings.TrimSpace(model) == "" {
return 0
}
resp, err := client.ListRunning(ctx)
if err != nil {
return 0
}
return processContextWindow(model, resp)
}
func processContextWindow(model string, resp *api.ProcessResponse) int {
if resp == nil {
return 0
}
for _, running := range resp.Models {
if running.ContextLength <= 0 {
continue
}
if SameModelRef(model, running.Name) || SameModelRef(model, running.Model) {
return running.ContextLength
}
}
return 0
}
// SameModelRef reports whether two references name the same model, tolerating
// an explicit ":latest" tag and an unspecified source on either side.
func SameModelRef(a, b string) bool {
a = comparableModelRef(a)
b = comparableModelRef(b)
if strings.EqualFold(a, b) {
return true
}
pa, errA := modelref.ParseRef(a)
pb, errB := modelref.ParseRef(b)
if errA != nil || errB != nil {
return false
}
if !strings.EqualFold(pa.Base, pb.Base) {
return false
}
return pa.Source == pb.Source ||
pa.Source == modelref.ModelSourceUnspecified ||
pb.Source == modelref.ModelSourceUnspecified
}
func comparableModelRef(value string) string {
value = strings.TrimSpace(value)
if strings.HasSuffix(strings.ToLower(value), ":latest") {
return strings.TrimSpace(value[:len(value)-len(":latest")])
}
return value
}
+19
View File
@@ -0,0 +1,19 @@
package launch
import (
"testing"
"github.com/ollama/ollama/api"
)
func TestProcessContextWindowMatchesLatestAlias(t *testing.T) {
got := processContextWindow("ornith", &api.ProcessResponse{
Models: []api.ProcessModelResponse{
{Name: "other:latest", Model: "other:latest", ContextLength: 32768},
{Name: "ornith:latest", Model: "ornith:latest", ContextLength: 262144},
},
})
if got != 262144 {
t.Fatalf("context window = %d, want 262144", got)
}
}
+4 -2
View File
@@ -64,6 +64,8 @@ func TestIntegrationLookup(t *testing.T) {
{"codex app gui alias", "codex-gui", true, "ChatGPT"},
{"hermes desktop", "hermes-desktop", true, "Hermes Desktop"},
{"kimi", "kimi", true, "Kimi Code CLI"},
{"muse", "muse", true, "Muse Code"},
{"muse alias", "muse-code", true, "Muse Code"},
{"droid", "droid", true, "Droid"},
{"opencode", "opencode", true, "OpenCode"},
{"omp", "omp", true, "OMP"},
@@ -86,7 +88,7 @@ func TestIntegrationLookup(t *testing.T) {
}
func TestIntegrationRegistry(t *testing.T) {
expectedIntegrations := []string{"claude", "claude-desktop", "cline", "codex", "chatgpt", "kimi", "droid", "opencode", "omp", "hermes", "hermes-desktop", "pool", "qwen"}
expectedIntegrations := []string{"claude", "claude-desktop", "cline", "codex", "chatgpt", "kimi", "muse", "droid", "opencode", "omp", "hermes", "hermes-desktop", "pool", "qwen"}
for _, name := range expectedIntegrations {
t.Run(name, func(t *testing.T) {
r, ok := integrations[name]
@@ -127,7 +129,7 @@ func TestChatGPTMigratesLegacyCodexAppLaunchConfig(t *testing.T) {
func TestHiddenIntegrationsExcludedFromVisibleLists(t *testing.T) {
for _, info := range ListIntegrationInfos() {
switch info.Name {
case "vscode", "kimi":
case "vscode", "kimi", "muse":
t.Fatalf("hidden integration %q should not appear in ListIntegrationInfos", info.Name)
}
}
+1
View File
@@ -297,6 +297,7 @@ Supported integrations:
omp OMP
droid Droid
kimi Kimi Code CLI
muse Muse Code (aliases: muse-code)
pi Pi
pool Pool
cline Cline
+403
View File
@@ -0,0 +1,403 @@
package launch
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"time"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/cmd/internal/fileutil"
"github.com/ollama/ollama/envconfig"
)
// Muse implements Runner and Editor for Meta's Muse Code CLI.
//
// Muse has no flag or environment variable that can supply a model catalog, and
// it will not start without one: its provider fetches <origin>/muse-code/models
// before the first inference call, reusing only the scheme, host and port of the
// configured base URL. The undocumented model_catalog key in settings.json is
// the one way to satisfy that without standing up the endpoint, so launch has to
// write a settings file.
//
// That file is also where the base URL lives, and endpoint_transport is a single
// global provider switch rather than an additive model list — writing it into
// ~/.config/muse/settings.json would repoint the user's whole muse install. So
// launch keeps its own config root and passes it to muse as XDG_CONFIG_HOME,
// leaving a Meta-backed muse and `ollama launch muse` free to coexist.
type Muse struct{}
const (
// Muse checks every catalog row against the session's provider and profile
// and drops the ones that disagree; "tbh" is the profile its sessions run
// under.
museProviderID = "meta"
museProfileID = "tbh"
// Every row needs a context and an output limit, and the inventory only
// reports an output limit for cloud models, so local models land on these.
museFallbackContextLimit = 32768
museFallbackOutputLimit = 32768
// museLoadTimeout bounds the default model's preload; a cold load of a
// large model is tens of seconds, and on timeout the row falls back to
// the inventory value rather than blocking the launch.
museLoadTimeout = 5 * time.Minute
museRowDescription = "Served by Ollama"
)
var museGOOS = runtime.GOOS
// museInstallCommand runs Meta's official installer, which places the muse
// launcher in ~/.local/bin and downloads the matching binary next to it.
var museInstallCommand = []string{"bash", "-c", "curl -fsSL https://dev.meta.ai/install.sh | bash"}
// ensureMuseInstalled returns the muse binary path, offering to run the
// official installer when it is missing and verifying the binary afterward.
func ensureMuseInstalled() (string, error) {
if path, err := findMuse(); err == nil {
return path, nil
}
ok, err := ConfirmPrompt("Muse is not installed. Install now?")
if err != nil {
return "", err
}
if !ok {
return "", fmt.Errorf("muse installation cancelled")
}
fmt.Fprintf(os.Stderr, "\nInstalling Muse...\n")
cmd := exec.Command(museInstallCommand[0], museInstallCommand[1:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("muse installation failed: %w", err)
}
path, err := findMuse()
if err != nil {
return "", fmt.Errorf("muse installer finished but the binary was not found")
}
return path, nil
}
// museCatalogRow is one row of muse's settings-side model catalog. Muse requires
// model_id, provider_id, profile_id and both limits; the rest only affect how
// the model reads in muse's picker.
type museCatalogRow struct {
ModelID string `json:"model_id"`
ProviderID string `json:"provider_id"`
ProfileID string `json:"profile_id"`
DisplayLabel string `json:"display_label"`
Visibility string `json:"visibility"`
DisplayOrder int `json:"display_order"`
IsDefault bool `json:"is_default"`
ContextLimit int `json:"context_limit"`
OutputLimit int `json:"output_limit"`
Description string `json:"description"`
}
func (m *Muse) String() string { return "Muse Code" }
func (m *Muse) Supported() error {
if museGOOS == "windows" {
return fmt.Errorf("Warning: Muse is not currently supported on Windows")
}
return nil
}
func (m *Muse) Run(model string, models []LaunchModel, args []string) error {
if err := m.Supported(); err != nil {
return err
}
if strings.TrimSpace(model) == "" {
return fmt.Errorf("model is required")
}
bin, err := ensureMuseInstalled()
if err != nil {
return err
}
runModels := museApplyLoadedContext(museRunModels(model, models))
// When Edit already wrote this catalog, Run only refreshes loaded limits.
// Preserve Edit's backup by avoiding a second backup for that refresh.
backup := !slices.Equal(m.Models(), launchModelNames(runModels))
if err := writeMuseSettingsFile(runModels, backup); err != nil {
return fmt.Errorf("failed to configure muse: %w", err)
}
configHome, err := museConfigHome()
if err != nil {
return err
}
cmd := exec.Command(bin, args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = append(os.Environ(), "XDG_CONFIG_HOME="+configHome)
return cmd.Run()
}
func (m *Muse) Edit(models []LaunchModel) error {
return writeMuseSettings(models)
}
func (m *Muse) Paths() []string {
settingsPath, err := museSettingsPath()
if err != nil {
return nil
}
if _, err := os.Stat(settingsPath); err != nil {
return nil
}
return []string{settingsPath}
}
func (m *Muse) Models() []string {
settingsPath, err := museSettingsPath()
if err != nil {
return nil
}
data, err := os.ReadFile(settingsPath)
if err != nil {
return nil
}
var settings struct {
ModelCatalog []museCatalogRow `json:"model_catalog"`
}
if err := json.Unmarshal(data, &settings); err != nil {
return nil
}
var models []string
for _, row := range settings.ModelCatalog {
if row.ModelID != "" {
models = append(models, row.ModelID)
}
}
return models
}
// findMuse locates the muse launcher, which installs itself into ~/.local/bin
// and is not always on PATH.
func findMuse() (string, error) {
if path, err := exec.LookPath("muse"); err == nil {
return path, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
fallback := filepath.Join(home, ".local", "bin", "muse")
if info, err := os.Stat(fallback); err != nil || info.IsDir() {
return "", fmt.Errorf("muse binary not found")
}
return fallback, nil
}
// museConfigHome is the XDG_CONFIG_HOME handed to muse at launch. Muse reads
// settings.json from $XDG_CONFIG_HOME/muse, so the file sits one level deeper.
func museConfigHome() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".ollama", "launch", "muse-config"), nil
}
func museSettingsPath() (string, error) {
configHome, err := museConfigHome()
if err != nil {
return "", err
}
return filepath.Join(configHome, "muse", "settings.json"), nil
}
// museUserSettingsPath is where muse keeps settings.json when it runs on its own.
func museUserSettingsPath() (string, error) {
if configHome := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); configHome != "" {
return filepath.Join(configHome, "muse", "settings.json"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".config", "muse", "settings.json"), nil
}
// museBaseSettings returns the document the generated config is layered onto.
//
// Muse persists its own settings back into whichever config root it was given,
// so once launch's file exists it becomes the base and everything muse recorded
// there survives the next launch. Before that, the user's own settings seed it so
// a first launch keeps their skills, hooks, MCP servers and TUI preferences.
// A launch-owned file that exists but cannot be parsed is an error: falling
// through would rewrite it and discard whatever muse persisted there.
func museBaseSettings() (map[string]any, error) {
if path, err := museSettingsPath(); err == nil {
settings, err := fileutil.ReadJSON(path)
switch {
case err == nil && settings != nil:
return settings, nil
case err != nil && !os.IsNotExist(err):
return nil, fmt.Errorf("read muse settings %s: %w", path, err)
}
}
if path, err := museUserSettingsPath(); err == nil {
if settings, err := fileutil.ReadJSON(path); err == nil && settings != nil {
return settings, nil
}
}
return map[string]any{}, nil
}
// writeMuseSettings regenerates the settings file launch owns, replacing only
// the keys that decide which provider and models muse talks to.
func writeMuseSettings(models []LaunchModel) error {
return writeMuseSettingsFile(models, true)
}
func writeMuseSettingsFile(models []LaunchModel, backup bool) error {
if len(models) == 0 {
return nil
}
settingsPath, err := museSettingsPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil {
return err
}
settings, err := museBaseSettings()
if err != nil {
return err
}
if _, ok := settings["schema_version"]; !ok {
settings["schema_version"] = 1
}
settings["provider"] = museProviderID
settings["model"] = models[0].Name
settings["endpoint_transport"] = map[string]any{
"base_url": envconfig.ConnectableHost().String() + "/v1",
// Ollama wants no credential, and muse refuses to start on the default
// "bearer" unless one is configured.
"auth": "none",
}
settings["model_catalog"] = museCatalogRows(models)
data, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return err
}
if backup {
return fileutil.WriteWithBackup(settingsPath, data, "muse")
}
return os.WriteFile(settingsPath, data, 0o600)
}
func museCatalogRows(models []LaunchModel) []museCatalogRow {
rows := make([]museCatalogRow, 0, len(models))
for i, model := range models {
contextLimit := model.ContextLength
if contextLimit <= 0 {
contextLimit = museFallbackContextLimit
}
outputLimit := model.MaxOutputTokens
if outputLimit <= 0 {
outputLimit = min(contextLimit, museFallbackOutputLimit)
}
rows = append(rows, museCatalogRow{
ModelID: model.Name,
ProviderID: museProviderID,
ProfileID: museProfileID,
DisplayLabel: model.Name,
Visibility: "visible",
DisplayOrder: i,
IsDefault: i == 0,
ContextLimit: contextLimit,
OutputLimit: outputLimit,
Description: museRowDescription,
})
}
return rows
}
// museApplyLoadedContext overwrites the launched model's context length with
// the size the server actually loaded it at. Muse budgets prompt packing and
// compaction against the catalog row, and VRAM fit or server configuration may
// hold the loaded size below the model's trained maximum — which is all the
// inventory knows. Run-only on the selected model: editing the config must
// not load anything, and the other picker rows are not worth a load each.
func museApplyLoadedContext(models []LaunchModel) []LaunchModel {
if len(models) == 0 || models[0].Remote {
return models
}
models = cloneLaunchModels(models)
if n := museLoadedContextLength(models[0].Name); n > 0 {
models[0].ContextLength = n
}
return models
}
// museLoadedContextLength reports the effective context length of a model as
// the server loaded it, swappable so tests never touch a live server.
var museLoadedContextLength = loadedContextLength
// loadedContextLength loads model and reads the running instance's context
// length from the process list — the size the scheduler actually allocated.
// An empty generate request is ollama's load-only call: it returns once the
// model is resident without generating tokens, and the launch pays a load the
// first muse request would otherwise pay. Returns 0 when anything fails, and
// the caller keeps the inventory value.
func loadedContextLength(model string) int {
client, err := api.ClientFromEnvironment()
if err != nil {
return 0
}
ctx, cancel := context.WithTimeout(context.Background(), museLoadTimeout)
defer cancel()
if err := client.Generate(ctx, &api.GenerateRequest{Model: model}, func(api.GenerateResponse) error { return nil }); err != nil {
return 0
}
return LoadedContextWindow(ctx, client, model)
}
// museRunModels puts the model being launched first, since muse takes its
// default from the first visible row, and keeps the rest of the selection
// available in muse's picker.
func museRunModels(primary string, models []LaunchModel) []LaunchModel {
resolved := make([]LaunchModel, 0, len(models)+1)
appendModel := func(name string) {
if name == "" || hasLaunchModel(resolved, name) {
return
}
if model, ok := findLaunchModel(models, name); ok {
resolved = append(resolved, model)
return
}
resolved = append(resolved, fallbackLaunchModel(name))
}
appendModel(primary)
for _, model := range models {
appendModel(model.Name)
}
return resolved
}
+633
View File
@@ -0,0 +1,633 @@
package launch
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/ollama/ollama/cmd/internal/fileutil"
)
// museGeneratedSettings is the launch-owned view of the file muse reads.
type museGeneratedSettings struct {
SchemaVersion int `json:"schema_version"`
Provider string `json:"provider"`
Model string `json:"model"`
Transport struct {
BaseURL string `json:"base_url"`
Auth string `json:"auth"`
} `json:"endpoint_transport"`
ModelCatalog []museCatalogRow `json:"model_catalog"`
MCPServers map[string]any `json:"mcp_servers"`
TUI map[string]any `json:"tui"`
}
func readMuseSettings(t *testing.T) museGeneratedSettings {
t.Helper()
path, err := museSettingsPath()
if err != nil {
t.Fatalf("museSettingsPath() error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("failed to read generated settings: %v", err)
}
var settings museGeneratedSettings
if err := json.Unmarshal(data, &settings); err != nil {
t.Fatalf("generated settings is not valid JSON: %v", err)
}
return settings
}
// stubMuseLoadedContext pins the default model's loaded-context probe so tests
// never load a model on a live server; 0 means "probe failed, keep inventory".
func stubMuseLoadedContext(t *testing.T, n int) {
t.Helper()
prev := museLoadedContextLength
museLoadedContextLength = func(string) int { return n }
t.Cleanup(func() { museLoadedContextLength = prev })
}
func TestMuseWriteSettings_BuildsCatalog(t *testing.T) {
setTestHome(t, t.TempDir())
t.Setenv("OLLAMA_HOST", "127.0.0.1:11434")
models := []LaunchModel{
{Name: "gpt-oss:20b", ContextLength: 131072, MaxOutputTokens: 32768},
{Name: "qwen3:8b"},
}
if err := writeMuseSettings(models); err != nil {
t.Fatalf("writeMuseSettings() error = %v", err)
}
settings := readMuseSettings(t)
if settings.Model != "gpt-oss:20b" {
t.Errorf("model = %q, want gpt-oss:20b", settings.Model)
}
if settings.Provider != museProviderID {
t.Errorf("provider = %q, want %q", settings.Provider, museProviderID)
}
if want := "http://127.0.0.1:11434/v1"; settings.Transport.BaseURL != want {
t.Errorf("base_url = %q, want %q", settings.Transport.BaseURL, want)
}
// Anything but "none" makes muse demand a credential it will never need.
if settings.Transport.Auth != "none" {
t.Errorf("auth = %q, want none", settings.Transport.Auth)
}
if len(settings.ModelCatalog) != 2 {
t.Fatalf("model_catalog has %d rows, want 2", len(settings.ModelCatalog))
}
first := settings.ModelCatalog[0]
if first.ModelID != "gpt-oss:20b" || first.DisplayOrder != 0 || !first.IsDefault {
t.Errorf("first row = %+v, want gpt-oss:20b as the default row", first)
}
if first.ContextLimit != 131072 || first.OutputLimit != 32768 {
t.Errorf("first row limits = %d/%d, want 131072/32768", first.ContextLimit, first.OutputLimit)
}
second := settings.ModelCatalog[1]
if second.ModelID != "qwen3:8b" || second.DisplayOrder != 1 || second.IsDefault {
t.Errorf("second row = %+v, want qwen3:8b as a non-default row", second)
}
if second.ContextLimit != museFallbackContextLimit || second.OutputLimit != museFallbackOutputLimit {
t.Errorf("second row limits = %d/%d, want the fallbacks %d/%d",
second.ContextLimit, second.OutputLimit, museFallbackContextLimit, museFallbackOutputLimit)
}
// Rows that disagree with the session's provider or profile are dropped by
// muse, which then falls back to a catalog fetch Ollama cannot serve.
for _, row := range settings.ModelCatalog {
if row.ProviderID != museProviderID {
t.Errorf("row %q provider_id = %q, want %q", row.ModelID, row.ProviderID, museProviderID)
}
if row.ProfileID != museProfileID {
t.Errorf("row %q profile_id = %q, want %q", row.ModelID, row.ProfileID, museProfileID)
}
if row.Visibility != "visible" {
t.Errorf("row %q visibility = %q, want visible", row.ModelID, row.Visibility)
}
}
}
func TestMuseWriteSettings_ClampsOutputLimitToContext(t *testing.T) {
setTestHome(t, t.TempDir())
if err := writeMuseSettings([]LaunchModel{{Name: "tiny:1b", ContextLength: 4096}}); err != nil {
t.Fatalf("writeMuseSettings() error = %v", err)
}
row := readMuseSettings(t).ModelCatalog[0]
if row.ContextLimit != 4096 || row.OutputLimit != 4096 {
t.Errorf("limits = %d/%d, want 4096/4096", row.ContextLimit, row.OutputLimit)
}
}
// TestMuseApplyLoadedContext pins the property that the launched model's row
// carries the context the server actually loaded it with, not the trained
// maximum from the inventory: muse budgets prompt packing and compaction
// against this row, and the loaded size is what requests really get.
func TestMuseApplyLoadedContext(t *testing.T) {
setTestHome(t, t.TempDir())
stubMuseLoadedContext(t, 8192)
models := []LaunchModel{
{Name: "gpt-oss:20b", ContextLength: 131072},
{Name: "qwen3:8b", ContextLength: 131072},
}
if err := writeMuseSettings(museApplyLoadedContext(models)); err != nil {
t.Fatalf("writeMuseSettings() error = %v", err)
}
rows := readMuseSettings(t).ModelCatalog
if rows[0].ContextLimit != 8192 || rows[0].OutputLimit != 8192 {
t.Errorf("default row limits = %d/%d, want the loaded 8192/8192", rows[0].ContextLimit, rows[0].OutputLimit)
}
// Only the launched model is preloaded; other rows keep inventory values.
if rows[1].ContextLimit != 131072 {
t.Errorf("second row context = %d, want the inventory 131072", rows[1].ContextLimit)
}
// The probe result lands in a copy, not the caller's slice.
if models[0].ContextLength != 131072 {
t.Errorf("caller's model mutated to ContextLength=%d", models[0].ContextLength)
}
}
// TestMuseApplyLoadedContext_SkipsRemoteAndEmpty: a remote (cloud) launch
// target must not be preloaded, and an empty selection passes through.
func TestMuseApplyLoadedContext_SkipsRemoteAndEmpty(t *testing.T) {
prev := museLoadedContextLength
museLoadedContextLength = func(string) int {
t.Fatal("probe must not run for a remote model")
return 0
}
t.Cleanup(func() { museLoadedContextLength = prev })
models := museApplyLoadedContext([]LaunchModel{{Name: "big:cloud", Remote: true, ContextLength: 65536}})
if models[0].ContextLength != 65536 {
t.Errorf("remote model context = %d, want untouched 65536", models[0].ContextLength)
}
if got := museApplyLoadedContext(nil); got != nil {
t.Errorf("nil models = %v, want nil", got)
}
}
func TestMuseWriteSettings_KeepsUserPreferences(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
t.Setenv("XDG_CONFIG_HOME", "")
userSettings := map[string]any{
"schema_version": 2,
"provider": "echo",
"model": "muse-large",
"endpoint_transport": map[string]any{
"base_url": "https://api.meta.ai/v1",
"auth": "bearer",
},
"mcp_servers": map[string]any{"github": map[string]any{"transport": "stdio"}},
"tui": map[string]any{"theme": "dark"},
}
userPath := filepath.Join(home, ".config", "muse", "settings.json")
if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil {
t.Fatal(err)
}
data, err := json.Marshal(userSettings)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(userPath, data, 0o644); err != nil {
t.Fatal(err)
}
if err := writeMuseSettings([]LaunchModel{{Name: "gpt-oss:20b"}}); err != nil {
t.Fatalf("writeMuseSettings() error = %v", err)
}
settings := readMuseSettings(t)
if settings.MCPServers["github"] == nil {
t.Error("mcp_servers was not carried over from the user's settings")
}
if settings.TUI["theme"] != "dark" {
t.Errorf("tui.theme = %v, want dark", settings.TUI["theme"])
}
if settings.SchemaVersion != 2 {
t.Errorf("schema_version = %d, want the user's 2", settings.SchemaVersion)
}
if settings.Provider != museProviderID {
t.Errorf("provider = %q, want it replaced with %q", settings.Provider, museProviderID)
}
if settings.Model != "gpt-oss:20b" {
t.Errorf("model = %q, want gpt-oss:20b", settings.Model)
}
if strings.Contains(settings.Transport.BaseURL, "meta.ai") {
t.Errorf("base_url = %q, want it repointed at Ollama", settings.Transport.BaseURL)
}
// The user's own settings must be left exactly as they were.
after, err := os.ReadFile(userPath)
if err != nil {
t.Fatal(err)
}
if string(after) != string(data) {
t.Errorf("user settings were modified:\n got: %s\nwant: %s", after, data)
}
}
func TestMuseWriteSettings_KeepsWhatMusePersisted(t *testing.T) {
setTestHome(t, t.TempDir())
if err := writeMuseSettings([]LaunchModel{{Name: "gpt-oss:20b"}}); err != nil {
t.Fatalf("writeMuseSettings() error = %v", err)
}
// Muse writes its own settings back into the config root it was handed.
settingsPath, err := museSettingsPath()
if err != nil {
t.Fatal(err)
}
settings, err := fileutil.ReadJSON(settingsPath)
if err != nil {
t.Fatal(err)
}
settings["tui"] = map[string]any{"foreign_context_notice_shown": true}
data, err := json.Marshal(settings)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(settingsPath, data, 0o644); err != nil {
t.Fatal(err)
}
if err := writeMuseSettings([]LaunchModel{{Name: "qwen3:8b"}}); err != nil {
t.Fatalf("writeMuseSettings() error = %v", err)
}
got := readMuseSettings(t)
if got.TUI["foreign_context_notice_shown"] != true {
t.Errorf("tui = %v, want muse's persisted settings kept", got.TUI)
}
if got.Model != "qwen3:8b" {
t.Errorf("model = %q, want qwen3:8b", got.Model)
}
if diff := compareStrings(museCatalogModelIDs(got.ModelCatalog), []string{"qwen3:8b"}); diff != "" {
t.Errorf("model_catalog mismatch: %s", diff)
}
}
func museCatalogModelIDs(rows []museCatalogRow) []string {
ids := make([]string, 0, len(rows))
for _, row := range rows {
ids = append(ids, row.ModelID)
}
return ids
}
func TestMuseRunModels(t *testing.T) {
selection := []LaunchModel{
{Name: "gpt-oss:20b"},
{Name: "qwen3:8b"},
}
tests := []struct {
name string
primary string
models []LaunchModel
want []string
}{
{
name: "primary already first",
primary: "gpt-oss:20b",
models: selection,
want: []string{"gpt-oss:20b", "qwen3:8b"},
},
{
name: "primary moves to front",
primary: "qwen3:8b",
models: selection,
want: []string{"qwen3:8b", "gpt-oss:20b"},
},
{
name: "primary outside the selection",
primary: "llama3.2",
models: selection,
want: []string{"llama3.2", "gpt-oss:20b", "qwen3:8b"},
},
{
name: "no selection",
primary: "llama3.2",
want: []string{"llama3.2"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := launchModelNames(museRunModels(tt.primary, tt.models))
if diff := compareStrings(got, tt.want); diff != "" {
t.Errorf("museRunModels(%q) mismatch: %s", tt.primary, diff)
}
})
}
}
func TestMusePathsAndModels(t *testing.T) {
setTestHome(t, t.TempDir())
m := &Muse{}
if paths := m.Paths(); paths != nil {
t.Errorf("Paths() before configuring = %v, want nil", paths)
}
if models := m.Models(); models != nil {
t.Errorf("Models() before configuring = %v, want nil", models)
}
if err := m.Edit([]LaunchModel{{Name: "gpt-oss:20b"}, {Name: "qwen3:8b"}}); err != nil {
t.Fatalf("Edit() error = %v", err)
}
settingsPath, err := museSettingsPath()
if err != nil {
t.Fatal(err)
}
if diff := compareStrings(m.Paths(), []string{settingsPath}); diff != "" {
t.Errorf("Paths() mismatch: %s", diff)
}
if diff := compareStrings(m.Models(), []string{"gpt-oss:20b", "qwen3:8b"}); diff != "" {
t.Errorf("Models() mismatch: %s", diff)
}
}
func TestMuseRun_PointsMuseAtLaunchConfig(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell fake binary")
}
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
stubMuseLoadedContext(t, 0)
t.Setenv("OLLAMA_HOST", "127.0.0.1:11434")
logPath := filepath.Join(tmpDir, "muse-invocation.log")
script := fmt.Sprintf(`#!/bin/sh
printf "%%s\n" "$XDG_CONFIG_HOME" >> %q
for arg in "$@"; do
printf "%%s\n" "$arg" >> %q
done
exit 0
`, logPath, logPath)
if err := os.WriteFile(filepath.Join(tmpDir, "muse"), []byte(script), 0o755); err != nil {
t.Fatalf("failed to write fake muse: %v", err)
}
t.Setenv("PATH", tmpDir)
m := &Muse{}
if err := m.Run("qwen3:8b", testLaunchModels("gpt-oss:20b", "qwen3:8b"), []string{"--trust-workspace"}); err != nil {
t.Fatalf("Run() error = %v", err)
}
data, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("failed to read invocation log: %v", err)
}
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
if len(lines) != 2 {
t.Fatalf("invocation log = %v, want the config home and one arg", lines)
}
configHome, err := museConfigHome()
if err != nil {
t.Fatal(err)
}
if lines[0] != configHome {
t.Errorf("XDG_CONFIG_HOME = %q, want %q", lines[0], configHome)
}
if lines[1] != "--trust-workspace" {
t.Errorf("extra args = %v, want [--trust-workspace]", lines[1:])
}
// Run configures muse itself, so the launched model leads the catalog even
// when Edit never ran.
settings := readMuseSettings(t)
if settings.Model != "qwen3:8b" {
t.Errorf("model = %q, want qwen3:8b", settings.Model)
}
if len(settings.ModelCatalog) != 2 || !settings.ModelCatalog[0].IsDefault ||
settings.ModelCatalog[0].ModelID != "qwen3:8b" {
t.Errorf("model_catalog = %+v, want qwen3:8b first and default", settings.ModelCatalog)
}
}
func TestMuseRun_PreservesPreEditBackup(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell fake binary")
}
home := t.TempDir()
setTestHome(t, home)
stubMuseLoadedContext(t, 8192)
writeFakeBinary(t, home, "muse")
t.Setenv("PATH", home)
settingsPath, err := museSettingsPath()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil {
t.Fatal(err)
}
original := []byte(`{"schema_version":1,"mcp_servers":{"original":{"transport":"stdio"}}}`)
if err := os.WriteFile(settingsPath, original, 0o644); err != nil {
t.Fatal(err)
}
m := &Muse{}
models := []LaunchModel{{Name: "gpt-oss:20b", ContextLength: 131072}}
if err := m.Edit(models); err != nil {
t.Fatalf("Edit() error = %v", err)
}
if err := m.Run("gpt-oss:20b", models, nil); err != nil {
t.Fatalf("Run() error = %v", err)
}
settings := readMuseSettings(t)
if settings.ModelCatalog[0].ContextLimit != 8192 {
t.Fatalf("context limit = %d, want loaded context 8192", settings.ModelCatalog[0].ContextLimit)
}
backups, err := filepath.Glob(filepath.Join(fileutil.BackupDir(), "muse", "settings.json.*"))
if err != nil {
t.Fatal(err)
}
if len(backups) != 1 {
t.Fatalf("backup count = %d, want 1", len(backups))
}
data, err := os.ReadFile(backups[0])
if err != nil {
t.Fatal(err)
}
if string(data) != string(original) {
t.Fatalf("pre-Edit settings backup = %s, want %s", data, original)
}
}
func TestMuseRun_RequiresModel(t *testing.T) {
setTestHome(t, t.TempDir())
if err := (&Muse{}).Run("", nil, nil); err == nil {
t.Error("Run() without a model = nil, want an error")
}
}
func TestMuseSupported(t *testing.T) {
oldGOOS := museGOOS
t.Cleanup(func() { museGOOS = oldGOOS })
m := &Muse{}
for _, goos := range []string{"darwin", "linux"} {
museGOOS = goos
if err := m.Supported(); err != nil {
t.Errorf("Supported() on %s = %v, want nil", goos, err)
}
}
museGOOS = "windows"
if err := m.Supported(); err == nil {
t.Error("Supported() on windows = nil, want an error")
}
}
func TestMuseBaseSettings_MalformedLaunchFileFails(t *testing.T) {
setTestHome(t, t.TempDir())
settingsPath, err := museSettingsPath()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil {
t.Fatal(err)
}
malformed := []byte("{ this is not json")
if err := os.WriteFile(settingsPath, malformed, 0o644); err != nil {
t.Fatal(err)
}
if err := writeMuseSettings([]LaunchModel{{Name: "gpt-oss:20b"}}); err == nil {
t.Fatal("writeMuseSettings() = nil, want parse error for malformed launch-owned settings")
}
data, err := os.ReadFile(settingsPath)
if err != nil {
t.Fatal(err)
}
if string(data) != string(malformed) {
t.Fatalf("malformed settings were rewritten to %q", data)
}
}
func TestEnsureMuseInstalled(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Muse is not supported on Windows")
}
withConfirm := func(t *testing.T, fn func(prompt string) (bool, error)) {
t.Helper()
oldConfirm := DefaultConfirmPrompt
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
return fn(prompt)
}
t.Cleanup(func() { DefaultConfirmPrompt = oldConfirm })
}
stubInstaller := func(t *testing.T, script string) {
t.Helper()
oldCommand := museInstallCommand
// Absolute shell path and explicit PATH: these tests clear PATH to
// hide any real muse, which also hides the script's own utilities.
museInstallCommand = []string{"/bin/sh", "-c", "PATH=/usr/bin:/bin; " + script}
t.Cleanup(func() { museInstallCommand = oldCommand })
}
t.Run("already installed skips prompt", func(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
t.Setenv("PATH", t.TempDir())
binDir := filepath.Join(home, ".local", "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatal(err)
}
writeFakeBinary(t, binDir, "muse")
withConfirm(t, func(prompt string) (bool, error) {
t.Fatalf("did not expect prompt, got %q", prompt)
return false, nil
})
bin, err := ensureMuseInstalled()
if err != nil {
t.Fatalf("ensureMuseInstalled() error = %v", err)
}
if bin != filepath.Join(binDir, "muse") {
t.Fatalf("bin = %q, want %q", bin, filepath.Join(binDir, "muse"))
}
})
t.Run("installs after confirmation and verifies binary", func(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
t.Setenv("PATH", t.TempDir())
binDir := filepath.Join(home, ".local", "bin")
stubInstaller(t, fmt.Sprintf("mkdir -p %q && printf '#!/bin/sh\n' > %q && chmod +x %q",
binDir, filepath.Join(binDir, "muse"), filepath.Join(binDir, "muse")))
prompted := false
withConfirm(t, func(prompt string) (bool, error) {
prompted = true
return true, nil
})
bin, err := ensureMuseInstalled()
if err != nil {
t.Fatalf("ensureMuseInstalled() error = %v", err)
}
if !prompted {
t.Fatal("expected an install confirmation prompt")
}
if bin != filepath.Join(binDir, "muse") {
t.Fatalf("bin = %q, want %q", bin, filepath.Join(binDir, "muse"))
}
})
t.Run("declined prompt cancels", func(t *testing.T) {
setTestHome(t, t.TempDir())
t.Setenv("PATH", t.TempDir())
stubInstaller(t, "exit 0")
withConfirm(t, func(prompt string) (bool, error) {
return false, nil
})
if _, err := ensureMuseInstalled(); err == nil {
t.Fatal("ensureMuseInstalled() = nil, want cancellation error")
}
})
t.Run("installer without binary fails verification", func(t *testing.T) {
setTestHome(t, t.TempDir())
t.Setenv("PATH", t.TempDir())
stubInstaller(t, "exit 0")
withConfirm(t, func(prompt string) (bool, error) {
return true, nil
})
if _, err := ensureMuseInstalled(); err == nil {
t.Fatal("ensureMuseInstalled() = nil, want verification error")
}
})
}
+18
View File
@@ -123,6 +123,24 @@ var integrationSpecs = []*IntegrationSpec{
URL: "https://moonshotai.github.io/kimi-cli/en/guides/getting-started.html",
},
},
{
Name: "muse",
Runner: &Muse{},
Aliases: []string{"muse-code"},
Description: "Meta's agentic coding CLI",
Hidden: true,
Install: IntegrationInstallSpec{
CheckInstalled: func() bool {
_, err := findMuse()
return err == nil
},
EnsureInstalled: func() error {
_, err := ensureMuseInstalled()
return err
},
Command: museInstallCommand,
},
},
{
Name: "copilot",
Runner: &Copilot{},
+28 -12
View File
@@ -11,7 +11,6 @@ import (
"fmt"
"log/slog"
"net/http"
"slices"
"strings"
"time"
@@ -521,6 +520,31 @@ func ToModel(r api.ShowResponse, m string) Model {
}
}
// thinkFromReasoningEffort converts an OpenAI reasoning effort to the equivalent
// Ollama think value. An empty effort leaves thinking at the model's default.
//
// OpenAI's scale extends past both ends of Ollama's ("minimal" below "low",
// "xhigh" above "high"), and clients built on it add tiers of their own
// ("ultra"). Clamp those to the nearest Ollama tier rather than rejecting the
// request, since the alternative is a 400 for an effort the client considers
// perfectly valid.
func thinkFromReasoningEffort(effort string) (*api.ThinkValue, error) {
switch effort {
case "":
return nil, nil
case "none":
return &api.ThinkValue{Value: false}, nil
case "minimal":
return &api.ThinkValue{Value: "low"}, nil
case "low", "medium", "high", "max":
return &api.ThinkValue{Value: effort}, nil
case "xhigh", "ultra":
return &api.ThinkValue{Value: "max"}, nil
default:
return nil, fmt.Errorf("invalid reasoning value: %q (must be \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", \"ultra\", \"max\", or \"none\")", effort)
}
}
// FromChatRequest converts a ChatCompletionRequest to api.ChatRequest
func FromChatRequest(r ChatCompletionRequest) (*api.ChatRequest, error) {
var messages []api.Message
@@ -670,7 +694,6 @@ func FromChatRequest(r ChatCompletionRequest) (*api.ChatRequest, error) {
}
}
var think *api.ThinkValue
var effort string
if r.Reasoning != nil {
@@ -679,16 +702,9 @@ func FromChatRequest(r ChatCompletionRequest) (*api.ChatRequest, error) {
effort = *r.ReasoningEffort
}
if effort != "" {
if !slices.Contains([]string{"high", "medium", "low", "max", "none"}, effort) {
return nil, fmt.Errorf("invalid reasoning value: '%s' (must be \"high\", \"medium\", \"low\", \"max\", or \"none\")", effort)
}
if effort == "none" {
think = &api.ThinkValue{Value: false}
} else {
think = &api.ThinkValue{Value: effort}
}
think, err := thinkFromReasoningEffort(effort)
if err != nil {
return nil, err
}
return &api.ChatRequest{
+3
View File
@@ -70,6 +70,9 @@ func TestFromChatRequest_ReasoningEffort(t *testing.T) {
{name: "medium", effort: effort("medium"), want: "medium"},
{name: "low", effort: effort("low"), want: "low"},
{name: "max", effort: effort("max"), want: "max"},
{name: "minimal clamps to low", effort: effort("minimal"), want: "low"},
{name: "xhigh clamps to max", effort: effort("xhigh"), want: "max"},
{name: "ultra clamps to max", effort: effort("ultra"), want: "max"},
{name: "none disables", effort: effort("none"), want: false},
{name: "invalid", effort: effort("extreme"), wantErr: true},
}
+3 -10
View File
@@ -572,16 +572,9 @@ func FromResponsesRequest(r ResponsesRequest) (*api.ChatRequest, error) {
options["num_predict"] = *r.MaxOutputTokens
}
var think *api.ThinkValue
if effort := r.Reasoning.Effort; effort != "" {
switch effort {
case "none":
think = &api.ThinkValue{Value: false}
case "low", "medium", "high", "max":
think = &api.ThinkValue{Value: effort}
default:
return nil, fmt.Errorf("invalid reasoning value: %q (must be \"high\", \"medium\", \"low\", \"max\", or \"none\")", effort)
}
think, err := thinkFromReasoningEffort(r.Reasoning.Effort)
if err != nil {
return nil, err
}
// Convert tools from Responses API format to api.Tool format
+15
View File
@@ -667,6 +667,21 @@ func TestFromResponsesRequest_ReasoningEffort(t *testing.T) {
effort: "max",
wantThink: "max",
},
{
name: "minimal clamps to low",
effort: "minimal",
wantThink: "low",
},
{
name: "xhigh clamps to max",
effort: "xhigh",
wantThink: "max",
},
{
name: "ultra clamps to max",
effort: "ultra",
wantThink: "max",
},
{
name: "none",
effort: "none",