app: add Claude Desktop model mappings (#17979)

This commit is contained in:
Eva H
2026-08-24 22:43:47 -04:00
committed by GitHub
parent 82ad9fa38b
commit f6c59d8703
15 changed files with 2103 additions and 991 deletions
+200 -70
View File
@@ -16,6 +16,7 @@ import (
"fmt"
"io"
"log/slog"
"maps"
"net"
"net/http"
"os"
@@ -65,9 +66,11 @@ const (
type claudeDesktopController interface {
AutodiscoveryConfiguredWithAutoMode(autoMode bool) bool
UsesOllamaGateway() bool
Running() bool
Open() error
ConfigureAutodiscoveryWithAutoMode(autoMode bool) error
SetInstalledFromDesktopWithAutoMode(installed, restart, autoMode bool) error
RestartWithProfileChange(change func() error) error
ApplyProfileChange(change func() error, restartConfirmed bool) error
RestoreForShutdown(ctx context.Context) error
}
@@ -386,10 +389,15 @@ func startClaudeAppProxy() error {
func resolveClaudeDesktopStartupCatalog(ctx context.Context) (available, selected []proxy.ClaudeDesktopModel, source string) {
selectedNames := launch.ClaudeDesktopModels()
savedMappings := launch.ClaudeDesktopModelMappings()
if len(selectedNames) > 0 {
localNames, err := claudeLocalModelsResolver(ctx)
if err == nil && allClaudeDesktopModelsLocal(selectedNames, localNames) {
selected = proxy.SelectClaudeDesktopModels(nil, selectedNames)
if len(savedMappings) > 0 {
selected = proxy.MapClaudeDesktopModels(nil, savedMappings)
} else {
selected = proxy.SelectClaudeDesktopModels(nil, selectedNames)
}
return selected, selected, "user"
}
}
@@ -409,12 +417,24 @@ func resolveClaudeDesktopStartupCatalog(ctx context.Context) (available, selecte
}
}
}
selected = proxy.SelectClaudeDesktopModels(selectable, selectedNames)
if len(selected) == 0 {
if len(savedMappings) > 0 {
selected = proxy.MapClaudeDesktopModels(selectable, savedMappings)
} else if len(selectedNames) == 0 {
selected = proxy.MapClaudeDesktopModels(
selectable,
proxy.DefaultClaudeDesktopMappingsForModels(
selectable,
err == nil && claudeDesktopHasFullDefaultAccess(state),
),
)
} else {
selected = proxy.SelectClaudeDesktopModels(selectable, selectedNames)
}
if len(selected) == 0 && len(selectedNames) > 0 {
selected = available
}
available = includeSelectedClaudeDesktopModels(available, selected)
if len(selectedNames) > 0 {
if len(selectedNames) > 0 || len(savedMappings) > 0 {
source = "user"
}
return available, selected, source
@@ -448,13 +468,12 @@ func allClaudeDesktopModelsLocal(selected, installed []string) bool {
func resolveClaudeDesktopCatalog(ctx context.Context) (available, selected []proxy.ClaudeDesktopModel, source string) {
available, source = claudeModelsLoader(ctx)
selectedNames := launch.ClaudeDesktopModels()
selected = proxy.SelectClaudeDesktopModels(available, selectedNames)
selected = configuredClaudeDesktopModels(available, nil)
if len(selected) == 0 {
selected = available
}
available = includeSelectedClaudeDesktopModels(available, selected)
if len(selectedNames) > 0 {
if len(launch.ClaudeDesktopModels()) > 0 {
source = "user"
}
return available, selected, source
@@ -511,14 +530,7 @@ func refreshClaudeDesktopCatalog(ctx context.Context, current []proxy.ClaudeDesk
}
}
}
selectedNames := launch.ClaudeDesktopModels()
if len(current) > 0 {
selectedNames = make([]string, len(current))
for i, model := range current {
selectedNames[i] = model.Name
}
}
selected = proxy.SelectClaudeDesktopModels(available, selectedNames)
selected = configuredClaudeDesktopModels(available, current)
if len(selected) == 0 {
selected = available
}
@@ -537,6 +549,18 @@ func refreshClaudeDesktopCatalog(ctx context.Context, current []proxy.ClaudeDesk
return available, selected, source
}
func configuredClaudeDesktopModels(available, current []proxy.ClaudeDesktopModel) []proxy.ClaudeDesktopModel {
if len(current) > 0 {
if mappings := proxy.ClaudeDesktopMappings(current); len(mappings) > 0 {
return proxy.MapClaudeDesktopModels(available, mappings)
}
}
if mappings := launch.ClaudeDesktopModelMappings(); len(mappings) > 0 {
return proxy.MapClaudeDesktopModels(available, mappings)
}
return proxy.SelectClaudeDesktopModels(available, launch.ClaudeDesktopModels())
}
func preserveClaudeDesktopEntitlements(fallback, previous []proxy.ClaudeDesktopModel) []proxy.ClaudeDesktopModel {
models := proxy.UnverifyClaudeDesktopCloudEntitlements(fallback)
known := make(map[string]proxy.ClaudeDesktopModel, len(previous)*2)
@@ -1136,18 +1160,47 @@ func getClaudeDesktopConnectionStatus() claudeDesktopStatus {
RequiredPlan: access.RequiredPlan,
})
}
mappedModels := proxy.ClaudeDesktopMappings(selectedModels)
defaultMappedModels := proxy.DefaultClaudeDesktopMappingsForModels(
availableModels,
claudeDesktopHasFullDefaultAccess(accessState),
)
if len(launch.ClaudeDesktopModels()) == 0 {
mappedModels = defaultMappedModels
}
mappingStatuses := make([]claudeDesktopMappingStatus, 0, proxy.MaxClaudeDesktopModels)
defaultMappingStatuses := make([]claudeDesktopMappingStatus, 0, proxy.MaxClaudeDesktopModels)
for _, route := range proxy.ClaudeDesktopRoutes() {
mappingStatuses = append(mappingStatuses, claudeDesktopMappingStatus{
RouteID: route.ID,
RouteName: route.DisplayName,
Model: mappedModels[route.ID],
})
defaultMappingStatuses = append(defaultMappingStatuses, claudeDesktopMappingStatus{
RouteID: route.ID,
RouteName: route.DisplayName,
Model: defaultMappedModels[route.ID],
})
}
status := claudeDesktopConnectionSummary(used)
autoMode, autoModeErr := launch.ClaudeDesktopAutoModeEnabled()
status.AutoMode = autoMode
status.ModelSource = modelSource
status.Models = modelStatuses
status.Mappings = mappingStatuses
status.DefaultMappings = defaultMappingStatuses
if status.Error == "" && autoModeErr != nil {
status.Error = autoModeErr.Error()
}
return status
}
func claudeDesktopHasFullDefaultAccess(state proxy.ClaudeDesktopAccessState) bool {
plan := strings.TrimSpace(state.Plan)
return state.Account == proxy.ClaudeDesktopAccountSignedIn && plan != "" && !strings.EqualFold(plan, "free")
}
func setClaudeDesktopConnection(enabled, restartConfirmed bool) error {
if !claudeDesktopInstalled() {
return errors.New("Claude Desktop is not installed")
@@ -1176,7 +1229,7 @@ func openClaudeDesktopApplication() error {
return launch.OpenClaudeDesktop()
}
func setClaudeDesktopAutoMode(enabled bool) error {
func setClaudeDesktopAutoMode(enabled, restartConfirmed bool) error {
models := activeClaudeDesktopModels()
if enabled && !claudeDesktopModelsSupportAutoMode(models) {
return errors.New("select at least one cloud model available to your Ollama.com account")
@@ -1188,30 +1241,41 @@ func setClaudeDesktopAutoMode(enabled bool) error {
if previous == enabled && (!claudeDesktop.UsesOllamaGateway() || claudeDesktop.AutodiscoveryConfiguredWithAutoMode(enabled)) {
return nil
}
if err := launch.SaveClaudeDesktopAutoMode(enabled); err != nil {
return fmt.Errorf("save Claude Desktop auto mode: %w", err)
}
if !claudeDesktop.UsesOllamaGateway() {
// The preference takes effect the next time the profile is written.
if err := launch.SaveClaudeDesktopAutoMode(enabled); err != nil {
return fmt.Errorf("save Claude Desktop auto mode: %w", err)
}
return nil
}
if claudeDesktopRunning() {
return claudeDesktop.RestartWithProfileChange(func() error {
return claudeDesktop.ConfigureAutodiscoveryWithAutoMode(enabled)
})
}
return claudeDesktop.ConfigureAutodiscoveryWithAutoMode(enabled)
return claudeDesktop.ApplyProfileChange(func() error {
// Persist only after the native layer has established that a running
// Claude process may be restarted. Canceling consent must be a no-op.
if err := launch.SaveClaudeDesktopAutoMode(enabled); err != nil {
return fmt.Errorf("save Claude Desktop auto mode: %w", err)
}
return claudeDesktop.ConfigureAutodiscoveryWithAutoMode(enabled)
}, restartConfirmed)
}
func restartClaudeDesktopWithModels(names []string) error {
func applyClaudeDesktopMappings(mappings map[string]string, restartConfirmed bool) (bool, error) {
if !claudeDesktopInstalled() {
return errors.New("Claude Desktop is not installed")
return false, errors.New("Claude Desktop is not installed")
}
if len(names) == 0 {
return errors.New("select at least one Claude Desktop model")
knownRoutes := make(map[string]struct{}, proxy.MaxClaudeDesktopModels)
for _, route := range proxy.ClaudeDesktopRoutes() {
knownRoutes[route.ID] = struct{}{}
}
if len(names) > proxy.MaxClaudeDesktopModels {
return fmt.Errorf("Claude Desktop supports at most %d models; deselect %d and try again", proxy.MaxClaudeDesktopModels, len(names)-proxy.MaxClaudeDesktopModels)
for routeID, model := range mappings {
if _, ok := knownRoutes[routeID]; !ok {
return false, fmt.Errorf("unknown Claude Desktop route %q", routeID)
}
if strings.TrimSpace(model) == "" {
continue
}
}
if len(mappings) == 0 {
return false, errors.New("map at least one Claude Desktop route")
}
claudeProxyMu.Lock()
gateway := claudeAppProxy
@@ -1237,7 +1301,13 @@ func restartClaudeDesktopWithModels(names []string) error {
slog.Debug("could not resolve Claude model access for selection", "error", accessErr)
}
selectable := available
if accessErr == nil && accessState.Cloud == proxy.ClaudeDesktopCloudOn && hasExplicitCloudClaudeDesktopModelName(names) {
mappedNames := make([]string, 0, len(mappings))
for _, name := range mappings {
if name = strings.TrimSpace(name); name != "" {
mappedNames = append(mappedNames, name)
}
}
if accessErr == nil && accessState.Cloud == proxy.ClaudeDesktopCloudOn && hasExplicitCloudClaudeDesktopModelName(mappedNames) {
cloudModels, err := claudeCloudModelsResolver(context.Background())
if err != nil {
slog.Debug("could not load account cloud models for Claude selection", "error", err)
@@ -1245,29 +1315,31 @@ func restartClaudeDesktopWithModels(names []string) error {
selectable = mergeClaudeDesktopCloudInventory(available, cloudModels, true)
}
}
selected, err := selectKnownClaudeDesktopModels(selectable, current, localNames, names)
selected, err := mapKnownClaudeDesktopModels(selectable, current, localNames, mappings)
if err != nil {
return err
return false, err
}
if err := validateClaudeDesktopModels(selected, accessState, localNames, localErr == nil); err != nil {
return err
return false, err
}
normalized := make([]string, len(selected))
for i, model := range selected {
normalized[i] = model.Name
if model.Cloud {
normalized[i] = model.OllamaModel
}
}
normalized := proxy.ClaudeDesktopMappings(selected)
previousSelection := launch.ClaudeDesktopModels()
previousMappings := launch.ClaudeDesktopModelMappings()
mappingsChanged := !maps.Equal(previousMappings, normalized)
// The Settings button must never restart a live Claude process when there
// is no mapping change to apply. A stopped app may still use the same button
// to repair its profile and launch Claude.
if !mappingsChanged && claudeDesktop.Running() {
return false, nil
}
restoreState := func() error {
var rollbackErr error
if gateway != nil && len(current) > 0 {
rollbackErr = gateway.SetModels(current)
}
if err := launch.RestoreClaudeDesktopModels(previousSelection); err != nil {
rollbackErr = errors.Join(rollbackErr, fmt.Errorf("restore Claude Desktop model selection: %w", err))
if err := launch.RestoreClaudeDesktopModelMappings(previousSelection, previousMappings); err != nil {
rollbackErr = errors.Join(rollbackErr, fmt.Errorf("restore Claude Desktop model mappings: %w", err))
}
claudeProxyMu.Lock()
claudeAvailableModels = previousAvailable
@@ -1276,45 +1348,103 @@ func restartClaudeDesktopWithModels(names []string) error {
claudeProxyMu.Unlock()
return rollbackErr
}
if gateway == nil || !claudeDesktop.UsesOllamaGateway() {
if err := launch.SaveClaudeDesktopModels(normalized); err != nil {
_ = restoreState()
return fmt.Errorf("save Claude Desktop models: %w", err)
}
if err := setClaudeGatewayInstalled(true, launch.ClaudeDesktopRunning()); err != nil {
// Profile installation can succeed even if opening Claude fails. Keep
// the live and persisted model state aligned with that committed profile.
if claudeDesktop.UsesOllamaGateway() {
if gateway == nil {
applyInitialChange := func() error {
if mappingsChanged {
if err := launch.SaveClaudeDesktopModelMappings(normalized); err != nil {
return fmt.Errorf("save Claude Desktop model mappings: %w", err)
}
}
if err := startClaudeAppProxy(); err != nil {
return err
}
return errors.Join(err, restoreState())
autoMode, err := effectiveClaudeDesktopAutoMode(selected)
if err != nil {
return err
}
return claudeDesktop.ConfigureAutodiscoveryWithAutoMode(autoMode)
}
return nil
if err := claudeDesktop.ApplyProfileChange(applyInitialChange, restartConfirmed); err != nil {
if errors.Is(err, launch.ErrClaudeDesktopRestartConfirmationRequired) {
return false, err
}
stopClaudeAppProxy()
return false, errors.Join(err, restoreState())
}
if !claudeDesktop.Running() {
if err := claudeDesktop.Open(); err != nil {
if mappingsChanged {
return true, fmt.Errorf("Claude model mappings were saved, but Claude Desktop could not open: %w", err)
}
return false, fmt.Errorf("open Claude Desktop: %w", err)
}
}
return mappingsChanged, nil
}
applyModelChange := func() error {
if err := launch.SaveClaudeDesktopModels(normalized); err != nil {
return errors.Join(fmt.Errorf("save Claude Desktop models: %w", err), restoreState())
if mappingsChanged {
if err := launch.SaveClaudeDesktopModelMappings(normalized); err != nil {
return fmt.Errorf("save Claude Desktop model mappings: %w", err)
}
if err := gateway.SetModels(selected); err != nil {
return err
}
claudeProxyMu.Lock()
claudeAvailableModels = includeSelectedClaudeDesktopModels(available, selected)
claudeModelSource = "user"
claudeProxyMu.Unlock()
}
if err := gateway.SetModels(selected); err != nil {
return errors.Join(err, restoreState())
}
claudeProxyMu.Lock()
claudeAvailableModels = includeSelectedClaudeDesktopModels(available, selected)
claudeModelSource = "user"
claudeProxyMu.Unlock()
autoMode, err := effectiveClaudeDesktopAutoMode(selected)
if err != nil {
return errors.Join(err, restoreState())
return err
}
if err := claudeDesktop.ConfigureAutodiscoveryWithAutoMode(autoMode); err != nil {
return errors.Join(err, restoreState())
return err
}
return nil
}
if err := claudeDesktop.RestartWithProfileChange(applyModelChange); err != nil {
return errors.Join(err, restoreState())
if err := claudeDesktop.ApplyProfileChange(applyModelChange, restartConfirmed); err != nil {
if errors.Is(err, launch.ErrClaudeDesktopRestartConfirmationRequired) {
return false, err
}
return false, errors.Join(err, restoreState())
}
return nil
if !claudeDesktop.Running() {
if err := claudeDesktop.Open(); err != nil {
if mappingsChanged {
return true, fmt.Errorf("Claude model mappings were saved, but Claude Desktop could not open: %w", err)
}
return false, fmt.Errorf("open Claude Desktop: %w", err)
}
}
return mappingsChanged, nil
}
func mapKnownClaudeDesktopModels(available, current []proxy.ClaudeDesktopModel, localNames []string, mappings map[string]string) ([]proxy.ClaudeDesktopModel, error) {
selectable := includeSelectedClaudeDesktopModels(available, current)
allowed := make(map[string]struct{}, len(selectable)+len(localNames))
for _, model := range selectable {
allowed[model.Name] = struct{}{}
allowed[model.OllamaModel] = struct{}{}
}
for _, name := range localNames {
allowed[strings.TrimSpace(name)] = struct{}{}
}
for routeID, rawName := range mappings {
name := strings.TrimSpace(rawName)
if name == "" {
continue
}
if _, ok := allowed[name]; !ok {
return nil, fmt.Errorf("model %q mapped from %s is not installed or recommended for Claude Desktop", name, routeID)
}
}
selected := proxy.MapClaudeDesktopModels(selectable, mappings)
if len(selected) == 0 {
return nil, errors.New("map at least one Claude Desktop route")
}
return selected, nil
}
func selectKnownClaudeDesktopModels(available, current []proxy.ClaudeDesktopModel, localNames, names []string) ([]proxy.ClaudeDesktopModel, error) {
+348 -38
View File
@@ -5,6 +5,7 @@ package main
import (
"context"
"errors"
"maps"
"net"
"net/http"
"net/http/httptest"
@@ -262,6 +263,71 @@ func TestResolveClaudeDesktopStartupCatalogVerifiesPersistedAccountCloudModel(t
}
}
func TestResolveClaudeDesktopStartupCatalogPreservesPersistedRouteMappings(t *testing.T) {
tests := []struct {
name string
mappings map[string]string
state proxy.ClaudeDesktopAccessState
local []string
cloud []string
}{
{
name: "free sparse mapping",
mappings: map[string]string{"claude-sonnet-5": "gemma4:31b-cloud"},
state: proxy.ClaudeDesktopAccessState{Cloud: proxy.ClaudeDesktopCloudOn, Account: proxy.ClaudeDesktopAccountSignedIn, Plan: "free"},
},
{
name: "one model shared by every route",
mappings: sharedClaudeDesktopMappings("gemma4:31b-cloud"),
state: proxy.ClaudeDesktopAccessState{Cloud: proxy.ClaudeDesktopCloudOn, Account: proxy.ClaudeDesktopAccountSignedIn, Plan: "free"},
},
{
name: "mixed local and cloud mapping",
mappings: map[string]string{
"claude-fable-5": "qwen3:8b",
"claude-opus-5": "glm-5.2:cloud",
},
state: proxy.ClaudeDesktopAccessState{Cloud: proxy.ClaudeDesktopCloudOn, Account: proxy.ClaudeDesktopAccountSignedIn, Plan: "pro"},
local: []string{"qwen3:8b"},
cloud: []string{"glm-5.2:cloud"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("HOME", t.TempDir())
if err := launch.SaveClaudeDesktopModelMappings(tt.mappings); err != nil {
t.Fatal(err)
}
previousAccess := claudeAccessStateResolver
previousLocal := claudeLocalModelsResolver
previousCloud := claudeCloudModelsResolver
claudeAccessStateResolver = func(context.Context) (proxy.ClaudeDesktopAccessState, error) {
return tt.state, nil
}
claudeLocalModelsResolver = func(context.Context) ([]string, error) {
return tt.local, nil
}
claudeCloudModelsResolver = func(context.Context) ([]proxy.ClaudeDesktopModel, error) {
return proxy.ClaudeDesktopModelsFromCloudInventory(tt.cloud), nil
}
t.Cleanup(func() {
claudeAccessStateResolver = previousAccess
claudeLocalModelsResolver = previousLocal
claudeCloudModelsResolver = previousCloud
})
_, selected, source := resolveClaudeDesktopStartupCatalog(context.Background())
if source != "user" {
t.Fatalf("source = %q, want user", source)
}
if got := proxy.ClaudeDesktopMappings(selected); !maps.Equal(got, tt.mappings) {
t.Fatalf("startup mappings = %v, want persisted routes %v", got, tt.mappings)
}
})
}
}
func TestResolveClaudeDesktopStartupCatalogMarksDefaultAccountModelsAutoEligible(t *testing.T) {
t.Setenv("HOME", t.TempDir())
previousLoader := claudeModelsLoader
@@ -286,6 +352,36 @@ func TestResolveClaudeDesktopStartupCatalogMarksDefaultAccountModelsAutoEligible
}
}
func TestResolveClaudeDesktopStartupCatalogUsesAccountDefaults(t *testing.T) {
states := []struct {
name string
state proxy.ClaudeDesktopAccessState
}{
{name: "signed out", state: proxy.ClaudeDesktopAccessState{Cloud: proxy.ClaudeDesktopCloudOn, Account: proxy.ClaudeDesktopAccountSignedOut}},
{name: "free", state: proxy.ClaudeDesktopAccessState{Cloud: proxy.ClaudeDesktopCloudOn, Account: proxy.ClaudeDesktopAccountSignedIn, Plan: "free"}},
{name: "pro", state: proxy.ClaudeDesktopAccessState{Cloud: proxy.ClaudeDesktopCloudOn, Account: proxy.ClaudeDesktopAccountSignedIn, Plan: "pro"}},
{name: "team", state: proxy.ClaudeDesktopAccessState{Cloud: proxy.ClaudeDesktopCloudOn, Account: proxy.ClaudeDesktopAccountSignedIn, Plan: "team"}},
}
for _, tt := range states {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("HOME", t.TempDir())
previousAccess := claudeAccessStateResolver
claudeAccessStateResolver = func(context.Context) (proxy.ClaudeDesktopAccessState, error) {
return tt.state, nil
}
t.Cleanup(func() { claudeAccessStateResolver = previousAccess })
_, selected, source := resolveClaudeDesktopStartupCatalog(context.Background())
want := proxy.DefaultClaudeDesktopMappings(
claudeDesktopHasFullDefaultAccess(tt.state),
)
if got := proxy.ClaudeDesktopMappings(selected); !maps.Equal(got, want) {
t.Fatalf("startup mappings = %v, want %v (source %q)", got, want, source)
}
})
}
}
func TestResolveClaudeDesktopStartupCatalogVerifiesFallbackFromAccountInventory(t *testing.T) {
t.Setenv("HOME", t.TempDir())
if err := launch.SaveClaudeDesktopModels([]string{"glm-5.2:cloud"}); err != nil {
@@ -566,14 +662,79 @@ func TestSelectKnownClaudeDesktopModelsAllowsInstalledModelsOnly(t *testing.T) {
}
}
func sharedClaudeDesktopMappings(model string) map[string]string {
mappings := make(map[string]string, proxy.MaxClaudeDesktopModels)
for _, route := range proxy.ClaudeDesktopRoutes() {
mappings[route.ID] = model
}
return mappings
}
func TestMapKnownClaudeDesktopModelsAllowsSharedModels(t *testing.T) {
selected, err := mapKnownClaudeDesktopModels(
proxy.DefaultClaudeDesktopModels(),
nil,
[]string{"qwen3:8b"},
sharedClaudeDesktopMappings("qwen3:8b"),
)
if err != nil {
t.Fatal(err)
}
if len(selected) != proxy.MaxClaudeDesktopModels {
t.Fatalf("selected models = %+v", selected)
}
for _, model := range selected {
if model.OllamaModel != "qwen3:8b" {
t.Fatalf("selected model = %+v", model)
}
}
sparse, err := mapKnownClaudeDesktopModels(proxy.DefaultClaudeDesktopModels(), nil, nil, map[string]string{"claude-fable-5": "glm-5.2:cloud"})
if err != nil {
t.Fatal(err)
}
if len(sparse) != 1 || sparse[0].GatewayID() != "claude-fable-5" {
t.Fatalf("sparse mapping = %+v", sparse)
}
}
func TestClaudeDesktopDefaultsFollowAccountPlan(t *testing.T) {
paidDefaults := proxy.DefaultClaudeDesktopMappings(true)
restrictedDefaults := proxy.DefaultClaudeDesktopMappings(false)
tests := []struct {
name string
state proxy.ClaudeDesktopAccessState
wantMappings map[string]string
}{
{name: "signed out", state: proxy.ClaudeDesktopAccessState{Account: proxy.ClaudeDesktopAccountSignedOut}, wantMappings: restrictedDefaults},
{name: "free", state: proxy.ClaudeDesktopAccessState{Account: proxy.ClaudeDesktopAccountSignedIn, Plan: "free"}, wantMappings: restrictedDefaults},
{name: "Pro", state: proxy.ClaudeDesktopAccessState{Account: proxy.ClaudeDesktopAccountSignedIn, Plan: "pro"}, wantMappings: paidDefaults},
{name: "Team", state: proxy.ClaudeDesktopAccessState{Account: proxy.ClaudeDesktopAccountSignedIn, Plan: "team"}, wantMappings: paidDefaults},
{name: "future paid plan", state: proxy.ClaudeDesktopAccessState{Account: proxy.ClaudeDesktopAccountSignedIn, Plan: "enterprise"}, wantMappings: paidDefaults},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := proxy.DefaultClaudeDesktopMappingsForModels(
proxy.DefaultClaudeDesktopModels(),
claudeDesktopHasFullDefaultAccess(tt.state),
)
if !maps.Equal(got, tt.wantMappings) {
t.Fatalf("default mappings = %v, want %v", got, tt.wantMappings)
}
})
}
}
type fakeClaudeDesktopController struct {
configured bool
profileCurrent bool
configureCalls int
configureErr error
running bool
opened bool
installed bool
restart bool
setErr error
openErr error
modelsAtSet []string
configureOnSet bool
requireRestart bool
@@ -581,6 +742,15 @@ type fakeClaudeDesktopController struct {
}
func (f *fakeClaudeDesktopController) UsesOllamaGateway() bool { return f.configured }
func (f *fakeClaudeDesktopController) Running() bool { return f.running }
func (f *fakeClaudeDesktopController) Open() error {
f.opened = true
f.running = true
if f.openErr != nil {
return f.openErr
}
return f.setErr
}
func (f *fakeClaudeDesktopController) AutodiscoveryConfiguredWithAutoMode(autoMode bool) bool {
return f.configured && f.profileCurrent && f.autoMode == autoMode
@@ -634,12 +804,15 @@ func TestSetClaudeDesktopConnectionForwardsRestartConfirmation(t *testing.T) {
}
}
func (f *fakeClaudeDesktopController) RestartWithProfileChange(change func() error) error {
func (f *fakeClaudeDesktopController) ApplyProfileChange(change func() error, restartConfirmed bool) error {
if f.running && !restartConfirmed {
return launch.ErrClaudeDesktopRestartConfirmationRequired
}
if err := change(); err != nil {
return err
}
f.installed = true
f.restart = true
f.restart = f.running
f.modelsAtSet = launch.ClaudeDesktopModels()
return f.setErr
}
@@ -706,7 +879,7 @@ func TestSetClaudeDesktopAutoModePersistsUntilConnection(t *testing.T) {
claudeDesktopRunning = previousRunning
})
if err := setClaudeDesktopAutoMode(false); err != nil {
if err := setClaudeDesktopAutoMode(false, true); err != nil {
t.Fatal(err)
}
enabled, err := launch.ClaudeDesktopAutoModeEnabled()
@@ -743,7 +916,7 @@ func TestSetClaudeDesktopAutoModeAvoidsUnnecessaryRestart(t *testing.T) {
claudeDesktopRunning = previousRunning
})
if err := setClaudeDesktopAutoMode(true); err != nil {
if err := setClaudeDesktopAutoMode(true, true); err != nil {
t.Fatal(err)
}
if fake.configureCalls != 0 || fake.restart {
@@ -755,7 +928,7 @@ func TestSetClaudeDesktopAutoModeRewritesProfileBeforeRestart(t *testing.T) {
t.Setenv("HOME", t.TempDir())
previousDesktop := claudeDesktop
previousRunning := claudeDesktopRunning
fake := &fakeClaudeDesktopController{configured: true, profileCurrent: true}
fake := &fakeClaudeDesktopController{configured: true, profileCurrent: true, running: true}
claudeDesktop = fake
claudeDesktopRunning = func() bool { return true }
t.Cleanup(func() {
@@ -763,7 +936,7 @@ func TestSetClaudeDesktopAutoModeRewritesProfileBeforeRestart(t *testing.T) {
claudeDesktopRunning = previousRunning
})
if err := setClaudeDesktopAutoMode(false); err != nil {
if err := setClaudeDesktopAutoMode(false, true); err != nil {
t.Fatal(err)
}
if fake.configureCalls != 1 || !fake.profileCurrent || !fake.restart {
@@ -771,6 +944,31 @@ func TestSetClaudeDesktopAutoModeRewritesProfileBeforeRestart(t *testing.T) {
}
}
func TestSetClaudeDesktopAutoModeCancelDoesNotSavePreference(t *testing.T) {
t.Setenv("HOME", t.TempDir())
if err := launch.SaveClaudeDesktopAutoMode(true); err != nil {
t.Fatal(err)
}
previousDesktop := claudeDesktop
fake := &fakeClaudeDesktopController{
configured: true, profileCurrent: true, running: true, autoMode: true,
}
claudeDesktop = fake
t.Cleanup(func() { claudeDesktop = previousDesktop })
err := setClaudeDesktopAutoMode(false, false)
if !errors.Is(err, launch.ErrClaudeDesktopRestartConfirmationRequired) {
t.Fatalf("error = %v, want restart confirmation", err)
}
enabled, loadErr := launch.ClaudeDesktopAutoModeEnabled()
if loadErr != nil {
t.Fatal(loadErr)
}
if !enabled || fake.configureCalls != 0 || fake.restart {
t.Fatalf("canceled Auto mode changed preference/profile: enabled=%v fake=%+v", enabled, fake)
}
}
func TestSetClaudeDesktopAutoModeKeepsDesiredPreferenceAfterProfileFailure(t *testing.T) {
t.Setenv("HOME", t.TempDir())
previousDesktop := claudeDesktop
@@ -783,7 +981,7 @@ func TestSetClaudeDesktopAutoModeKeepsDesiredPreferenceAfterProfileFailure(t *te
claudeDesktopRunning = previousRunning
})
err := setClaudeDesktopAutoMode(false)
err := setClaudeDesktopAutoMode(false, true)
if err == nil || !strings.Contains(err.Error(), "profile write failed") {
t.Fatalf("setClaudeDesktopAutoMode error = %v, want profile write failure", err)
}
@@ -847,7 +1045,7 @@ func TestSetClaudeDesktopAutoModeRejectsUnsupportedSelection(t *testing.T) {
claudeAvailableModels = proxy.SelectClaudeDesktopModels(nil, []string{"qwen3:8b"})
t.Cleanup(func() { claudeAvailableModels = previousAvailable })
err := setClaudeDesktopAutoMode(true)
err := setClaudeDesktopAutoMode(true, true)
if err == nil || !strings.Contains(err.Error(), "cloud model available to your Ollama.com account") {
t.Fatalf("setClaudeDesktopAutoMode() error = %v", err)
}
@@ -860,7 +1058,7 @@ func TestSetClaudeDesktopAutoModeRejectsUnsupportedSelection(t *testing.T) {
}
}
func TestRestartClaudeDesktopWithModelsPersistsSelection(t *testing.T) {
func TestApplyClaudeDesktopMappingsPersistsSelection(t *testing.T) {
t.Setenv("HOME", t.TempDir())
previousInstalled := claudeDesktopInstalled
@@ -882,7 +1080,7 @@ func TestRestartClaudeDesktopWithModelsPersistsSelection(t *testing.T) {
claudeCloudModelsResolver = previousCloudResolver
})
if err := restartClaudeDesktopWithModels([]string{"kimi-k3:cloud"}); err != nil {
if _, err := applyClaudeDesktopMappings(sharedClaudeDesktopMappings("kimi-k3:cloud"), true); err != nil {
t.Fatal(err)
}
if !fake.installed {
@@ -891,12 +1089,77 @@ func TestRestartClaudeDesktopWithModelsPersistsSelection(t *testing.T) {
if !fake.autoMode {
t.Fatal("expected the account cloud model to keep Auto mode enabled")
}
if got, want := launch.ClaudeDesktopModels(), []string{"kimi-k3:cloud"}; !slices.Equal(got, want) {
if got, want := launch.ClaudeDesktopModels(), []string{"kimi-k3:cloud", "kimi-k3:cloud", "kimi-k3:cloud", "kimi-k3:cloud", "kimi-k3:cloud"}; !slices.Equal(got, want) {
t.Fatalf("persisted models = %v, want Ollama routes %v", got, want)
}
}
func TestRestartClaudeDesktopWithModelsRollsBackWhenRestartFails(t *testing.T) {
func TestApplyClaudeDesktopMappingsRequiresLiveRestartConfirmation(t *testing.T) {
t.Setenv("HOME", t.TempDir())
previousInstalled := claudeDesktopInstalled
previousDesktop := claudeDesktop
previousCloudResolver := claudeCloudModelsResolver
claudeProxyMu.Lock()
previousGateway := claudeAppProxy
claudeAppProxy = nil
claudeProxyMu.Unlock()
claudeDesktopInstalled = func() bool { return true }
fake := &fakeClaudeDesktopController{configured: true, running: true}
claudeDesktop = fake
claudeCloudModelsResolver = func(context.Context) ([]proxy.ClaudeDesktopModel, error) {
return proxy.ClaudeDesktopModelsFromCloudInventory([]string{"kimi-k3:cloud"}), nil
}
t.Cleanup(func() {
claudeDesktopInstalled = previousInstalled
claudeDesktop = previousDesktop
claudeCloudModelsResolver = previousCloudResolver
claudeProxyMu.Lock()
claudeAppProxy = previousGateway
claudeProxyMu.Unlock()
})
applied, err := applyClaudeDesktopMappings(sharedClaudeDesktopMappings("kimi-k3:cloud"), false)
if applied || !errors.Is(err, launch.ErrClaudeDesktopRestartConfirmationRequired) {
t.Fatalf("applied/error = %v/%v, want live restart confirmation", applied, err)
}
if len(launch.ClaudeDesktopModelMappings()) != 0 || fake.restart || fake.opened {
t.Fatalf("unconfirmed apply changed state: mappings=%v fake=%+v", launch.ClaudeDesktopModelMappings(), fake)
}
}
func TestApplyClaudeDesktopMappingsDoesNotRestartWithoutChanges(t *testing.T) {
t.Setenv("HOME", t.TempDir())
want := sharedClaudeDesktopMappings("kimi-k3:cloud")
if err := launch.SaveClaudeDesktopModelMappings(want); err != nil {
t.Fatal(err)
}
previousInstalled := claudeDesktopInstalled
previousDesktop := claudeDesktop
previousCloudResolver := claudeCloudModelsResolver
claudeDesktopInstalled = func() bool { return true }
fake := &fakeClaudeDesktopController{configured: true, running: true}
claudeDesktop = fake
claudeCloudModelsResolver = func(context.Context) ([]proxy.ClaudeDesktopModel, error) {
return proxy.ClaudeDesktopModelsFromCloudInventory([]string{"kimi-k3:cloud"}), nil
}
t.Cleanup(func() {
claudeDesktopInstalled = previousInstalled
claudeDesktop = previousDesktop
claudeCloudModelsResolver = previousCloudResolver
})
applied, err := applyClaudeDesktopMappings(want, false)
if err != nil || applied {
t.Fatalf("unchanged apply = %v/%v, want no-op", applied, err)
}
if fake.restart || fake.opened || fake.configureCalls != 0 {
t.Fatalf("unchanged mappings affected Claude: %+v", fake)
}
}
func TestApplyClaudeDesktopMappingsRollsBackWhenRestartFails(t *testing.T) {
t.Setenv("HOME", t.TempDir())
if err := launch.SaveClaudeDesktopModels([]string{"glm-5.2:cloud"}); err != nil {
t.Fatal(err)
@@ -938,14 +1201,14 @@ func TestRestartClaudeDesktopWithModelsRollsBackWhenRestartFails(t *testing.T) {
claudeProxyMu.Unlock()
})
err = restartClaudeDesktopWithModels([]string{"kimi-k3:cloud"})
_, err = applyClaudeDesktopMappings(sharedClaudeDesktopMappings("kimi-k3:cloud"), true)
if err == nil || !strings.Contains(err.Error(), "restart failed") {
t.Fatalf("restart error = %v", err)
}
if got := launch.ClaudeDesktopModels(); !slices.Equal(got, []string{"glm-5.2:cloud"}) {
t.Fatalf("persisted models after failure = %v", got)
}
if !slices.Equal(fake.modelsAtSet, []string{"kimi-k3:cloud"}) {
if !slices.Equal(fake.modelsAtSet, []string{"kimi-k3:cloud", "kimi-k3:cloud", "kimi-k3:cloud", "kimi-k3:cloud", "kimi-k3:cloud"}) {
t.Fatalf("models visible before restart = %v, want new selection", fake.modelsAtSet)
}
gotModels := gateway.Models()
@@ -954,7 +1217,7 @@ func TestRestartClaudeDesktopWithModelsRollsBackWhenRestartFails(t *testing.T) {
}
}
func TestFirstConnectKeepsModelsWhenProfileCommitsButOpenFails(t *testing.T) {
func TestApplyClaudeDesktopMappingsStartsClaudeWhenStopped(t *testing.T) {
t.Setenv("HOME", t.TempDir())
if err := launch.SaveClaudeDesktopModels([]string{"glm-5.2:cloud"}); err != nil {
t.Fatal(err)
@@ -974,7 +1237,7 @@ func TestFirstConnectKeepsModelsWhenProfileCommitsButOpenFails(t *testing.T) {
claudeProxyMu.Unlock()
claudeDesktopInstalled = func() bool { return true }
claudeProxyListenAddr = "127.0.0.1:0"
fake := &fakeClaudeDesktopController{setErr: errors.New("open failed"), configureOnSet: true}
fake := &fakeClaudeDesktopController{}
claudeDesktop = fake
t.Cleanup(func() {
stopClaudeAppProxy()
@@ -989,29 +1252,62 @@ func TestFirstConnectKeepsModelsWhenProfileCommitsButOpenFails(t *testing.T) {
claudeProxyMu.Unlock()
})
err := restartClaudeDesktopWithModels([]string{"kimi-k3:cloud"})
if err == nil || !strings.Contains(err.Error(), "open failed") {
t.Fatalf("connect error = %v", err)
_, err := applyClaudeDesktopMappings(sharedClaudeDesktopMappings("kimi-k3:cloud"), true)
if err != nil {
t.Fatal(err)
}
if !fake.configured {
t.Fatal("profile did not remain configured after the open failure")
if !fake.configured || !fake.installed || !fake.opened || fake.restart {
t.Fatalf("stopped Claude action = %+v, want configured and opened without restart", fake)
}
if got := launch.ClaudeDesktopModels(); !slices.Equal(got, []string{"kimi-k3:cloud"}) {
if got := launch.ClaudeDesktopModels(); !slices.Equal(got, []string{"kimi-k3:cloud", "kimi-k3:cloud", "kimi-k3:cloud", "kimi-k3:cloud", "kimi-k3:cloud"}) {
t.Fatalf("persisted models after open failure = %v", got)
}
claudeProxyMu.Lock()
gateway := claudeAppProxy
claudeProxyMu.Unlock()
if gateway == nil {
t.Fatal("gateway stopped after the profile committed")
}
models := gateway.Models()
if len(models) != 1 || models[0].OllamaModel != "kimi-k3:cloud" {
t.Fatalf("live models after open failure = %+v", models)
t.Fatal("starting Claude must start the gateway")
}
}
func TestRestartClaudeDesktopWithModelsCapsSelectionAtLiteralSlots(t *testing.T) {
func TestApplyClaudeDesktopMappingsKeepsCommittedMappingsWhenOpenFails(t *testing.T) {
t.Setenv("HOME", t.TempDir())
previousInstalled := claudeDesktopInstalled
previousDesktop := claudeDesktop
previousAddr := claudeProxyListenAddr
previousCloudResolver := claudeCloudModelsResolver
claudeProxyMu.Lock()
previousGateway := claudeAppProxy
claudeAppProxy = nil
claudeProxyMu.Unlock()
claudeDesktopInstalled = func() bool { return true }
claudeProxyListenAddr = "127.0.0.1:0"
fake := &fakeClaudeDesktopController{openErr: errors.New("launch failed")}
claudeDesktop = fake
claudeCloudModelsResolver = func(context.Context) ([]proxy.ClaudeDesktopModel, error) {
return proxy.ClaudeDesktopModelsFromCloudInventory([]string{"kimi-k3:cloud"}), nil
}
t.Cleanup(func() {
stopClaudeAppProxy()
claudeDesktopInstalled = previousInstalled
claudeDesktop = previousDesktop
claudeProxyListenAddr = previousAddr
claudeCloudModelsResolver = previousCloudResolver
claudeProxyMu.Lock()
claudeAppProxy = previousGateway
claudeProxyMu.Unlock()
})
applied, err := applyClaudeDesktopMappings(sharedClaudeDesktopMappings("kimi-k3:cloud"), false)
if !applied || err == nil || !strings.Contains(err.Error(), "were saved") {
t.Fatalf("open failure = %v/%v, want committed mappings and launch error", applied, err)
}
if got := launch.ClaudeDesktopModelMappings(); !maps.Equal(got, sharedClaudeDesktopMappings("kimi-k3:cloud")) {
t.Fatalf("saved mappings after open failure = %v", got)
}
}
func TestApplyClaudeDesktopMappingsRejectsUnknownRoute(t *testing.T) {
t.Setenv("HOME", t.TempDir())
previousInstalled := claudeDesktopInstalled
@@ -1024,22 +1320,28 @@ func TestRestartClaudeDesktopWithModelsCapsSelectionAtLiteralSlots(t *testing.T)
claudeDesktop = previousDesktop
})
err := restartClaudeDesktopWithModels([]string{
"glm-5.2:cloud",
"kimi-k3:cloud",
"deepseek-v4-pro",
"deepseek-v4-flash",
"gemma4:26b:cloud",
"qwen3:8b",
})
if err == nil || !strings.Contains(err.Error(), "at most 5") {
t.Fatalf("error = %v, want a clear at most 5 message", err)
_, err := applyClaudeDesktopMappings(map[string]string{
"not-a-claude-route": "glm-5.2:cloud",
}, true)
if err == nil || !strings.Contains(err.Error(), "unknown Claude Desktop route") {
t.Fatalf("error = %v, want an unknown route message", err)
}
if fake.installed {
t.Fatal("the Claude profile must not change when the selection exceeds the model limit")
}
}
func TestApplyClaudeDesktopMappingsRejectsEmptyMapping(t *testing.T) {
previousInstalled := claudeDesktopInstalled
claudeDesktopInstalled = func() bool { return true }
t.Cleanup(func() { claudeDesktopInstalled = previousInstalled })
_, err := applyClaudeDesktopMappings(nil, true)
if err == nil || !strings.Contains(err.Error(), "at least one Claude Desktop route") {
t.Fatalf("error = %v, want an empty mapping message", err)
}
}
func TestClaudeDesktopIntegrationHistoryPersists(t *testing.T) {
previousStore := appStore
appStore = &store.Store{DBPath: filepath.Join(t.TempDir(), "db.sqlite")}
@@ -1573,6 +1875,14 @@ func TestClaudeGatewayStartupWithLocalSelectionSkipsCloudLookupsButSettingsLoads
if !foundCloud || !foundSelectedLocal {
t.Fatalf("Settings models = %+v, want cloud choices and selected qwen3:8b", status.Models)
}
if len(status.Mappings) != proxy.MaxClaudeDesktopModels || status.Mappings[0].RouteID != "claude-fable-5" || status.Mappings[0].Model != "qwen3:8b" {
t.Fatalf("local status mappings = %+v", status.Mappings)
}
for _, mapping := range status.Mappings[1:] {
if mapping.Model != "" {
t.Fatalf("unassigned route = %+v, want no model", mapping)
}
}
if loaderCalls != 1 || accessCalls != 2 {
t.Fatalf("Settings catalog calls = recommendations:%d access:%d, want recommendations:1 access:2", loaderCalls, accessCalls)
}
+16 -6
View File
@@ -2,7 +2,12 @@
package main
import "github.com/ollama/ollama/app/webview"
import (
"errors"
"github.com/ollama/ollama/app/webview"
"github.com/ollama/ollama/cmd/launch"
)
func bindClaudeDesktop(wv webview.WebView) {
wv.Bind("getClaudeDesktopStatus", func() claudeDesktopStatus {
@@ -48,20 +53,25 @@ func bindClaudeDesktop(wv webview.WebView) {
return requestClaudeDesktopInstall()
})
wv.Bind("restartClaudeDesktop", func(models []string) claudeDesktopActionResult {
err := restartClaudeDesktopWithModels(models)
result := claudeDesktopActionResult{Status: getClaudeDesktopConnectionStatus()}
wv.Bind("applyClaudeDesktopMappings", func(mappings map[string]string, restartConfirmed bool) claudeDesktopActionResult {
applied, err := applyClaudeDesktopMappings(mappings, restartConfirmed)
result := claudeDesktopActionResult{
Status: getClaudeDesktopConnectionStatus(),
MappingsApplied: applied,
}
if err != nil {
result.Error = err.Error()
result.RestartConfirmationRequired = errors.Is(err, launch.ErrClaudeDesktopRestartConfirmationRequired)
}
return result
})
wv.Bind("setClaudeDesktopAutoMode", func(enabled bool) claudeDesktopActionResult {
err := setClaudeDesktopAutoMode(enabled)
wv.Bind("setClaudeDesktopAutoMode", func(enabled, restartConfirmed bool) claudeDesktopActionResult {
err := setClaudeDesktopAutoMode(enabled, restartConfirmed)
result := claudeDesktopActionResult{Status: getClaudeDesktopConnectionStatus()}
if err != nil {
result.Error = err.Error()
result.RestartConfirmationRequired = errors.Is(err, launch.ErrClaudeDesktopRestartConfirmationRequired)
}
return result
})
+26 -16
View File
@@ -13,20 +13,28 @@ const (
)
type claudeDesktopStatus struct {
Supported bool `json:"supported"`
Used bool `json:"used"`
Installed bool `json:"installed"`
Configured bool `json:"configured"`
Connected bool `json:"connected"`
Running bool `json:"running"`
StartFailed bool `json:"startFailed"`
PortConflict bool `json:"portConflict"`
GatewayPort int `json:"gatewayPort,omitempty"`
RoutedRequests uint64 `json:"routedRequests"`
Error string `json:"error,omitempty"`
AutoMode bool `json:"autoMode"`
ModelSource string `json:"modelSource,omitempty"`
Models []claudeDesktopModelStatus `json:"models,omitempty"`
Supported bool `json:"supported"`
Used bool `json:"used"`
Installed bool `json:"installed"`
Configured bool `json:"configured"`
Connected bool `json:"connected"`
Running bool `json:"running"`
StartFailed bool `json:"startFailed"`
PortConflict bool `json:"portConflict"`
GatewayPort int `json:"gatewayPort,omitempty"`
RoutedRequests uint64 `json:"routedRequests"`
Error string `json:"error,omitempty"`
AutoMode bool `json:"autoMode"`
ModelSource string `json:"modelSource,omitempty"`
Models []claudeDesktopModelStatus `json:"models,omitempty"`
Mappings []claudeDesktopMappingStatus `json:"mappings,omitempty"`
DefaultMappings []claudeDesktopMappingStatus `json:"defaultMappings,omitempty"`
}
type claudeDesktopMappingStatus struct {
RouteID string `json:"routeId"`
RouteName string `json:"routeName"`
Model string `json:"model,omitempty"`
}
type claudeDesktopModelStatus struct {
@@ -42,6 +50,8 @@ type claudeDesktopModelStatus struct {
}
type claudeDesktopActionResult struct {
Status claudeDesktopStatus `json:"status"`
Error string `json:"error,omitempty"`
Status claudeDesktopStatus `json:"status"`
Error string `json:"error,omitempty"`
MappingsApplied bool `json:"mappingsApplied,omitempty"`
RestartConfirmationRequired bool `json:"restartConfirmationRequired,omitempty"`
}
@@ -1,7 +1,70 @@
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { describe, expect, it, vi } from "vitest";
import { Switch } from "./ui/switch";
import { ClaudeDesktopModelsSettings } from "./ClaudeDesktopModelsSettings";
const fableRoute = {
routeId: "claude-fable-5",
routeName: "Fable 5",
};
function testStatus(model = "glm-5.2:cloud", running = false) {
return {
supported: true,
used: true,
installed: true,
connected: true,
running,
startFailed: false,
portConflict: false,
autoMode: false,
modelSource: "user" as const,
mappings: [{ ...fableRoute, model }],
defaultMappings: [{ ...fableRoute, model: "glm-5.2:cloud" }],
models: [
{
name: "glm-5.2:cloud",
displayName: "glm-5.2:cloud",
cloud: true,
selected: model === "glm-5.2:cloud",
availability: "available" as const,
},
{
name: "kimi-k3:cloud",
displayName: "kimi-k3:cloud",
cloud: true,
selected: model === "kimi-k3:cloud",
availability: "available" as const,
},
],
};
}
async function selectKimi(renderer: ReactTestRenderer) {
await act(async () => {
renderer.root
.findByProps({ "aria-label": "Ollama model for Fable 5" })
.props.onClick();
await Promise.resolve();
});
await act(async () => {
renderer.root.findAllByProps({ role: "option" })[1].props.onClick();
await Promise.resolve();
});
}
function actionButton(renderer: ReactTestRenderer) {
const button = renderer.root
.findAllByType("button")
.find(
(candidate) =>
!candidate.props["aria-label"] &&
candidate.props.className?.includes("flex-shrink-0"),
);
if (!button) throw new Error("Claude action button not found");
return button;
}
describe("ClaudeDesktopModelsSettings interactions", () => {
it("disables auto mode while model changes are not applied", async () => {
class TestHTMLElement {
@@ -34,6 +97,13 @@ describe("ClaudeDesktopModelsSettings interactions", () => {
portConflict: false,
autoMode: true,
modelSource: "user",
mappings: [
{
routeId: "claude-fable-5",
routeName: "Fable 5",
model: "glm-5.2:cloud",
},
],
models: [
{
name: "glm-5.2:cloud",
@@ -61,9 +131,15 @@ describe("ClaudeDesktopModelsSettings interactions", () => {
expect(autoModeSwitch().props.disabled).not.toBe(true);
expect(autoModeSwitch().props["aria-checked"]).toBe(true);
const modelInputs = renderer!.root.findAllByType("input");
await act(async () => {
modelInputs[1].props.onChange();
renderer!.root
.findByProps({ "aria-label": "Ollama model for Fable 5" })
.props.onClick();
await Promise.resolve();
});
await act(async () => {
const options = renderer!.root.findAllByProps({ role: "option" });
options[1].props.onClick();
await Promise.resolve();
});
@@ -76,7 +152,7 @@ describe("ClaudeDesktopModelsSettings interactions", () => {
node.children
.join("")
.includes(
"Restart Claude to apply model changes before changing auto mode.",
"Start or restart Claude to apply model changes before changing auto mode.",
),
),
).toBe(true);
@@ -88,4 +164,364 @@ describe("ClaudeDesktopModelsSettings interactions", () => {
vi.unstubAllGlobals();
}
});
it("asks for confirmation from live native state before restarting", async () => {
class TestHTMLElement {
focus() {}
}
const apply = vi
.fn()
.mockResolvedValueOnce({
status: testStatus("glm-5.2:cloud", true),
error:
"Claude Desktop restart confirmation is required before changing its profile",
restartConfirmationRequired: true,
})
.mockResolvedValueOnce({
status: testStatus("kimi-k3:cloud", true),
mappingsApplied: true,
});
const confirm = vi.fn(() => true);
vi.stubGlobal("window", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
HTMLElement: TestHTMLElement,
applyClaudeDesktopMappings: apply,
confirm,
});
vi.stubGlobal("document", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => {
renderer = create(
<ClaudeDesktopModelsSettings
initialLocalModels={[]}
initialStatus={testStatus()}
/>,
);
await Promise.resolve();
});
await selectKimi(renderer!);
await act(async () => {
actionButton(renderer!).props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect(confirm).toHaveBeenCalledWith(
"Restart Claude Desktop? Any running task will stop.",
);
expect(apply).toHaveBeenNthCalledWith(
1,
{ "claude-fable-5": "kimi-k3:cloud" },
false,
);
expect(apply).toHaveBeenNthCalledWith(
2,
{ "claude-fable-5": "kimi-k3:cloud" },
true,
);
} finally {
await act(async () => {
renderer?.unmount();
await Promise.resolve();
});
vi.unstubAllGlobals();
}
});
it("restores Auto mode when restart confirmation is canceled", async () => {
class TestHTMLElement {
focus() {}
}
const runningStatus = {
...testStatus("glm-5.2:cloud", true),
autoMode: true,
models: testStatus().models.map((model) => ({
...model,
autoMode: true,
})),
};
const setAutoMode = vi.fn().mockResolvedValue({
status: runningStatus,
error:
"Claude Desktop restart confirmation is required before changing its profile",
restartConfirmationRequired: true,
});
const confirm = vi.fn(() => false);
vi.stubGlobal("window", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
HTMLElement: TestHTMLElement,
setClaudeDesktopAutoMode: setAutoMode,
confirm,
});
vi.stubGlobal("document", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => {
renderer = create(
<ClaudeDesktopModelsSettings
initialLocalModels={[]}
initialStatus={runningStatus}
/>,
);
await Promise.resolve();
});
await act(async () => {
renderer!.root.findByType(Switch).props.onChange(false);
await Promise.resolve();
await Promise.resolve();
});
expect(setAutoMode).toHaveBeenCalledTimes(1);
expect(setAutoMode).toHaveBeenCalledWith(false, false);
expect(confirm).toHaveBeenCalledWith(
"Restart Claude to change auto mode? Any running task will stop.",
);
expect(
renderer!.root.findByProps({ role: "switch" }).props["aria-checked"],
).toBe(true);
} finally {
await act(async () => {
renderer?.unmount();
await Promise.resolve();
});
vi.unstubAllGlobals();
}
});
it("ignores a stale focus refresh that finishes after apply", async () => {
class TestHTMLElement {
focus() {}
}
let focusHandler: (() => void) | undefined;
let resolveRefresh:
| ((status: ReturnType<typeof testStatus>) => void)
| undefined;
const staleRefresh = new Promise<ReturnType<typeof testStatus>>(
(resolve) => {
resolveRefresh = resolve;
},
);
vi.stubGlobal("window", {
addEventListener: vi.fn((event: string, handler: () => void) => {
if (event === "focus") focusHandler = handler;
}),
removeEventListener: vi.fn(),
HTMLElement: TestHTMLElement,
getClaudeDesktopStatus: vi.fn(() => staleRefresh),
applyClaudeDesktopMappings: vi.fn().mockResolvedValue({
status: testStatus("kimi-k3:cloud"),
mappingsApplied: true,
}),
confirm: vi.fn(() => true),
});
vi.stubGlobal("document", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => {
renderer = create(
<ClaudeDesktopModelsSettings
initialLocalModels={[]}
initialStatus={testStatus()}
/>,
);
await Promise.resolve();
});
await selectKimi(renderer!);
await act(async () => {
focusHandler?.();
actionButton(renderer!).props.onClick();
await Promise.resolve();
await Promise.resolve();
});
await act(async () => {
resolveRefresh?.(testStatus("glm-5.2:cloud"));
await staleRefresh;
await Promise.resolve();
});
const picker = renderer!.root.findByProps({
"aria-label": "Ollama model for Fable 5",
});
expect(picker.findAllByType("span")[0].children.join("")).toBe(
"kimi-k3:cloud",
);
} finally {
await act(async () => {
renderer?.unmount();
await Promise.resolve();
});
vi.unstubAllGlobals();
}
});
it("accepts committed mappings when launching Claude fails", async () => {
class TestHTMLElement {
focus() {}
}
const onDraftChange = vi.fn();
vi.stubGlobal("window", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
HTMLElement: TestHTMLElement,
applyClaudeDesktopMappings: vi.fn().mockResolvedValue({
status: testStatus("kimi-k3:cloud"),
error:
"Claude model mappings were saved, but Claude Desktop could not open",
mappingsApplied: true,
}),
confirm: vi.fn(() => true),
});
vi.stubGlobal("document", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => {
renderer = create(
<ClaudeDesktopModelsSettings
initialLocalModels={[]}
initialStatus={testStatus()}
onDraftChange={onDraftChange}
/>,
);
await Promise.resolve();
});
await selectKimi(renderer!);
await act(async () => {
actionButton(renderer!).props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect(onDraftChange).toHaveBeenLastCalledWith(false);
const picker = renderer!.root.findByProps({
"aria-label": "Ollama model for Fable 5",
});
expect(picker.findAllByType("span")[0].children.join("")).toBe(
"kimi-k3:cloud",
);
} finally {
await act(async () => {
renderer?.unmount();
await Promise.resolve();
});
vi.unstubAllGlobals();
}
});
it("resets sparse mappings to the defaults for the current account", async () => {
class TestHTMLElement {
focus() {}
}
const initialStatus = {
...testStatus("glm-5.2:cloud"),
mappings: [
{ ...fableRoute, model: "glm-5.2:cloud" },
{
routeId: "claude-sonnet-5",
routeName: "Sonnet 5",
model: "kimi-k3:cloud",
},
],
defaultMappings: [
{ ...fableRoute, model: "kimi-k3:cloud" },
{
routeId: "claude-sonnet-5",
routeName: "Sonnet 5",
},
],
};
const refreshedStatus = {
...initialStatus,
defaultMappings: [
{ ...fableRoute },
{
routeId: "claude-sonnet-5",
routeName: "Sonnet 5",
model: "glm-5.2:cloud",
},
],
};
const getStatus = vi.fn().mockResolvedValue(refreshedStatus);
vi.stubGlobal("window", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
HTMLElement: TestHTMLElement,
getClaudeDesktopStatus: getStatus,
});
vi.stubGlobal("document", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => {
renderer = create(
<ClaudeDesktopModelsSettings
initialLocalModels={[]}
initialStatus={initialStatus}
resetVersion={0}
/>,
);
await Promise.resolve();
await Promise.resolve();
});
await act(async () => {
renderer!.update(
<ClaudeDesktopModelsSettings
initialLocalModels={[]}
initialStatus={initialStatus}
resetVersion={1}
/>,
);
await Promise.resolve();
await Promise.resolve();
});
expect(getStatus).toHaveBeenCalledTimes(1);
const fable = renderer!.root.findByProps({
"aria-label": "Ollama model for Fable 5",
});
const sonnet = renderer!.root.findByProps({
"aria-label": "Ollama model for Sonnet 5",
});
expect(fable.findAllByType("span")[0].children.join("")).toBe(
"Select a model",
);
expect(sonnet.findAllByType("span")[0].children.join("")).toBe(
"glm-5.2:cloud",
);
} finally {
await act(async () => {
renderer?.unmount();
await Promise.resolve();
});
vi.unstubAllGlobals();
}
});
});
@@ -1,581 +1,175 @@
import type { ClaudeDesktopStatus } from "@/types/webview";
import { claudeDesktopModelStatusLabel } from "@/lib/claudeDesktopModelStatus";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { ClaudeDesktopModelsSettings } from "./ClaudeDesktopModelsSettings";
const routes = [
{ routeId: "claude-fable-5", routeName: "Fable 5" },
{ routeId: "claude-opus-5", routeName: "Opus 5" },
{ routeId: "claude-sonnet-5", routeName: "Sonnet 5" },
{
routeId: "claude-haiku-4-5-20251001",
routeName: "Haiku 4.5",
},
{ routeId: "claude-sonnet-4-6", routeName: "Sonnet 4.6" },
];
function status(
overrides: Partial<ClaudeDesktopStatus> = {},
): ClaudeDesktopStatus {
return {
supported: true,
used: true,
installed: true,
configured: true,
connected: true,
running: false,
startFailed: false,
portConflict: false,
modelSource: "endpoint",
models: [
{
name: "glm-5.2:cloud",
displayName: "glm-5.2:cloud",
cloud: true,
selected: true,
availability: "available",
},
{
name: "qwen3:8b",
displayName: "qwen3:8b",
selected: true,
availability: "available",
},
],
mappings: routes.map((route, index) => ({
...route,
model: index === 0 ? "glm-5.2:cloud" : undefined,
})),
...overrides,
};
}
describe("ClaudeDesktopModelsSettings", () => {
it("shows Claude recommendations and an installed-model search in Settings", () => {
const html = renderToStaticMarkup(
<ClaudeDesktopModelsSettings
initialLocalModels={["llama3.2", "qwen3:8b"]}
initialStatus={{
supported: true,
used: true,
installed: true,
connected: true,
running: false,
startFailed: false,
portConflict: false,
modelSource: "endpoint",
models: [
{
name: "glm-5.2:cloud",
displayName: "glm-5.2:cloud",
cloud: true,
selected: true,
},
{
name: "deepseek-v4-flash:cloud",
displayName: "deepseek-v4-flash:cloud",
cloud: true,
selected: false,
},
],
}}
/>,
);
expect(html).toContain(">Claude<");
expect(html).toContain(">Apps<");
expect(html).toContain('id="apps-settings-heading"');
expect(html).toContain('src="/launch-icons/claude.svg"');
expect(html).not.toContain("Models in Claude");
expect(html).toContain("glm-5.2:cloud");
expect(html).toContain("deepseek-v4-flash:cloud");
expect(html).toContain("Search Ollama models");
expect(html).not.toContain("Add any Ollama model");
expect(html).toContain("Restart Claude");
expect((html.match(/checked=""/g) ?? []).length).toBe(2);
it("labels model plan and account requirements in the picker", () => {
expect(
claudeDesktopModelStatusLabel({
name: "gemma4:31b-cloud",
displayName: "gemma4:31b-cloud",
cloud: true,
selected: false,
requiredPlan: "free",
}),
).toBeNull();
expect(
claudeDesktopModelStatusLabel({
name: "glm-5.2:cloud",
displayName: "glm-5.2:cloud",
cloud: true,
selected: false,
availability: "unavailable",
reason: "upgrade_required",
requiredPlan: "pro",
}),
).toBe("Pro plan required");
expect(
claudeDesktopModelStatusLabel({
name: "gemma4:31b-cloud",
displayName: "gemma4:31b-cloud",
cloud: true,
selected: false,
availability: "unavailable",
reason: "sign_in_required",
requiredPlan: "free",
}),
).toBe("Sign in required");
});
it("does not show the invalid Ollama Cloud sentinel", () => {
it("renders the five explicit Claude routes and an Ollama model picker", () => {
const html = renderToStaticMarkup(
<ClaudeDesktopModelsSettings
initialStatus={{
supported: true,
used: true,
installed: true,
connected: true,
running: false,
startFailed: false,
portConflict: false,
modelSource: "user",
models: [
{
name: "Ollama Cloud",
displayName: "Ollama Cloud",
selected: true,
},
{
name: "qwen3:8b",
displayName: "qwen3:8b",
selected: true,
},
],
}}
/>,
<ClaudeDesktopModelsSettings initialStatus={status()} />,
);
expect(html).not.toContain("Ollama Cloud");
expect(html).toContain("qwen3:8b");
});
it("shows the auto mode switch checked when auto mode is enabled", () => {
const base = {
supported: true,
used: true,
installed: true,
connected: true,
running: false,
startFailed: false,
portConflict: false,
modelSource: "user" as const,
models: [
{
name: "qwen3:8b",
displayName: "qwen3:8b",
selected: true,
autoMode: true,
},
],
};
const enabled = renderToStaticMarkup(
<ClaudeDesktopModelsSettings
initialStatus={{ ...base, autoMode: true }}
/>,
);
expect(enabled).toContain("Enable auto mode");
expect(enabled).toContain("Let Claude decide when to ask");
expect(enabled).toContain('role="switch"');
expect(enabled).toContain('aria-checked="true"');
const disabled = renderToStaticMarkup(
<ClaudeDesktopModelsSettings initialStatus={base} />,
);
expect(disabled).toContain('aria-checked="false"');
});
it("labels the built-in fallback without exposing MLX", () => {
const html = renderToStaticMarkup(
<ClaudeDesktopModelsSettings
initialStatus={{
supported: true,
used: true,
installed: true,
connected: true,
running: false,
startFailed: false,
portConflict: false,
modelSource: "fallback",
models: [
{
name: "deepseek-v4-flash:0731:cloud",
displayName: "deepseek-v4-flash:0731:cloud",
cloud: true,
selected: true,
},
],
}}
/>,
);
expect(html).toContain("Built-in defaults");
expect(html).toContain("deepseek-v4-flash:0731:cloud");
expect(html).not.toContain("MLX");
});
it("prevents a sixth selection when the literal Claude slots are full", () => {
const html = renderToStaticMarkup(
<ClaudeDesktopModelsSettings
initialStatus={{
supported: true,
used: true,
installed: true,
connected: true,
running: false,
startFailed: false,
portConflict: false,
modelSource: "endpoint",
maxModels: 5,
models: [
{
name: "glm-5.2:cloud",
displayName: "glm-5.2:cloud",
cloud: true,
selected: true,
},
{
name: "kimi-k3:cloud",
displayName: "kimi-k3:cloud",
cloud: true,
selected: true,
},
{
name: "deepseek-v4-pro:cloud",
displayName: "deepseek-v4-pro:cloud",
cloud: true,
selected: true,
},
{
name: "deepseek-v4-flash:cloud",
displayName: "deepseek-v4-flash:cloud",
cloud: true,
selected: true,
},
{
name: "gemma4:26b:cloud",
displayName: "gemma4:26b:cloud",
cloud: true,
selected: true,
},
{
name: "qwen3:8b",
displayName: "qwen3:8b",
selected: false,
},
],
}}
/>,
);
const index = html.indexOf(">qwen3:8b</span>");
expect(index).toBeGreaterThan(-1);
const label = html.slice(html.lastIndexOf("<label", index), index);
expect(label).toContain("disabled");
expect(html).toContain(">Claude</h2>");
for (const route of routes) {
expect(html).toContain(route.routeName);
expect(html).not.toContain(`>${route.routeId}<`);
}
expect((html.match(/aria-haspopup="listbox"/g) ?? []).length).toBe(5);
expect(html).not.toContain('for="claude-route-');
expect(html).toContain(
"Claude supports up to 5 models. Deselect one to add another.",
"Choose which Ollama model Claude uses for each model option.",
);
expect(html).not.toContain("routing");
expect(html).not.toContain("Built-in defaults");
expect(html).not.toContain("Unassigned");
expect(html).toContain("Select a model");
expect(html).toContain("Start Claude");
});
it("enables auto mode when a selected model is in the account cloud list", () => {
it("allows the same Ollama model to be assigned to multiple routes", () => {
const shared = routes.map((route) => ({
...route,
model: "qwen3:8b",
}));
const html = renderToStaticMarkup(
<ClaudeDesktopModelsSettings
initialStatus={{
supported: true,
used: true,
installed: true,
connected: true,
running: false,
startFailed: false,
portConflict: false,
autoMode: true,
modelSource: "user",
initialStatus={status({ mappings: shared })}
/>,
);
expect((html.match(/>qwen3:8b<\/span>/g) ?? []).length).toBe(5);
});
it("keeps an unavailable default visible with its access status", () => {
const html = renderToStaticMarkup(
<ClaudeDesktopModelsSettings
initialStatus={status({
models: [
{
name: "glm-5.2:cloud",
displayName: "glm-5.2:cloud",
cloud: true,
selected: true,
autoMode: true,
},
{
name: "gemma4:31b-cloud",
displayName: "gemma4:31b-cloud",
cloud: true,
selected: true,
autoMode: false,
},
],
}}
/>,
);
expect(html).toContain(
"Let Claude decide when to ask before making changes.",
);
expect(html).toContain('aria-checked="true"');
expect(html).not.toContain('aria-checked="true" disabled=""');
});
it("disables auto mode when no selected model is in the account cloud list", () => {
const html = renderToStaticMarkup(
<ClaudeDesktopModelsSettings
initialStatus={{
supported: true,
used: true,
installed: true,
connected: true,
running: false,
startFailed: false,
portConflict: false,
autoMode: true,
modelSource: "user",
models: [
{
name: "glm-5.2:cloud",
displayName: "glm-5.2:cloud",
cloud: true,
selected: false,
autoMode: true,
},
{
name: "kimi-k3:cloud",
displayName: "kimi-k3:cloud",
cloud: true,
selected: false,
autoMode: true,
},
{
name: "gemma4:31b-cloud",
displayName: "gemma4:31b-cloud",
cloud: true,
selected: true,
autoMode: false,
},
],
}}
/>,
);
expect(html).toContain(
"Select one of glm-5.2:cloud or kimi-k3:cloud to use auto mode.",
);
expect(html).toContain('role="switch"');
expect(html).toContain('aria-checked="false" disabled=""');
});
it("does not infer Auto eligibility from a cloud suffix", () => {
const status = {
supported: true,
used: true,
installed: true,
connected: true,
running: false,
startFailed: false,
portConflict: false,
autoMode: true,
modelSource: "user",
models: [
{
name: "custom:cloud",
displayName: "custom:cloud",
cloud: true,
selected: true,
autoMode: false,
},
],
} as const;
const unavailable = renderToStaticMarkup(
<ClaudeDesktopModelsSettings initialStatus={status} />,
);
expect(unavailable).toContain('aria-checked="false" disabled=""');
const available = renderToStaticMarkup(
<ClaudeDesktopModelsSettings
initialStatus={status}
initialCloudModels={["custom:cloud"]}
/>,
);
expect(available).toContain('aria-checked="true"');
expect(available).not.toContain('aria-checked="true" disabled=""');
});
it("honors a smaller maxModels limit from the status", () => {
const html = renderToStaticMarkup(
<ClaudeDesktopModelsSettings
initialStatus={{
supported: true,
used: true,
installed: true,
connected: true,
running: false,
startFailed: false,
portConflict: false,
modelSource: "endpoint",
maxModels: 1,
models: [
{
name: "kimi-k3:cloud",
displayName: "kimi-k3:cloud",
cloud: true,
selected: true,
},
{
name: "qwen3:8b",
displayName: "qwen3:8b",
selected: false,
},
],
}}
/>,
);
const index = html.indexOf(">qwen3:8b</span>");
expect(index).toBeGreaterThan(-1);
const label = html.slice(html.lastIndexOf("<label", index), index);
expect(label).toContain("disabled");
});
it("hides recommendation and selected cloud models when cloud is disabled", () => {
const html = renderToStaticMarkup(
<ClaudeDesktopModelsSettings
initialLocalModels={["qwen3:8b"]}
initialStatus={{
supported: true,
used: true,
installed: true,
connected: false,
running: false,
startFailed: true,
portConflict: false,
error: "Cloud models are off. Select an installed model in Settings.",
modelSource: "endpoint",
models: [
{
name: "glm-5.2:cloud",
displayName: "glm-5.2:cloud",
cloud: true,
selected: true,
availability: "unavailable",
reason: "cloud_off",
},
],
}}
/>,
);
expect(html).not.toContain("glm-5.2:cloud");
expect(html).toContain("Search Ollama models");
expect(html).toContain(
"Cloud models are off. Select an installed model in Settings.",
);
expect(html).not.toContain(
"These models will be available when Claude starts.",
);
expect(html).not.toContain("text-red");
});
it("shows account requirements and prevents selecting unavailable models", () => {
const html = renderToStaticMarkup(
<ClaudeDesktopModelsSettings
initialStatus={{
supported: true,
used: true,
installed: true,
connected: true,
running: false,
startFailed: false,
portConflict: false,
modelSource: "endpoint",
models: [
{
name: "glm-5.2:cloud",
displayName: "glm-5.2:cloud",
cloud: true,
selected: false,
availability: "unavailable",
reason: "upgrade_required",
requiredPlan: "pro",
},
{
name: "gemma4:31b-cloud",
displayName: "gemma4:31b-cloud",
cloud: true,
selected: true,
name: "qwen3:8b",
displayName: "qwen3:8b",
selected: false,
availability: "available",
requiredPlan: "free",
},
],
}}
})}
/>,
);
expect(html).toContain("pro plan required");
const index = html.indexOf(">glm-5.2:cloud</span>");
const label = html.slice(html.lastIndexOf("<label", index), index);
expect(label).toContain("disabled");
expect(html).toContain("gemma4:31b-cloud");
expect(html).toContain(">glm-5.2:cloud</span>");
});
it("replaces paid defaults with the available free recommendation", () => {
it("presents Start or Restart based on whether Claude is running", () => {
const html = renderToStaticMarkup(
<ClaudeDesktopModelsSettings
initialStatus={{
supported: true,
used: true,
installed: true,
connected: false,
running: false,
startFailed: false,
portConflict: false,
modelSource: "endpoint",
models: [
"glm-5.2:cloud",
"kimi-k3:cloud",
"deepseek-v4-pro:cloud",
"deepseek-v4-flash:cloud",
]
.map((name) => ({
name,
displayName: name,
cloud: true,
selected: true,
availability: "unavailable" as const,
reason: "upgrade_required" as const,
requiredPlan: "pro",
}))
.concat([
{
name: "gemma4:31b-cloud",
displayName: "gemma4:31b-cloud",
cloud: true,
selected: false,
availability: "available" as const,
requiredPlan: "free",
},
]),
}}
initialStatus={status({ configured: false, connected: false })}
/>,
);
expect((html.match(/>pro plan required<\/span>/g) ?? []).length).toBe(4);
expect((html.match(/checked=""/g) ?? []).length).toBe(1);
const gemmaIndex = html.indexOf(">gemma4:31b-cloud</span>");
const gemmaInput = html.slice(
html.lastIndexOf("<input", gemmaIndex),
gemmaIndex,
);
expect(gemmaInput).toContain('checked=""');
expect(html).toContain("Start Claude");
});
expect(html).not.toContain("Apply changes");
it("does not restart Claude when every selected model is unavailable", () => {
const html = renderToStaticMarkup(
<ClaudeDesktopModelsSettings
initialStatus={{
supported: true,
used: true,
installed: true,
connected: true,
running: false,
startFailed: false,
portConflict: false,
modelSource: "endpoint",
models: [
{
name: "glm-5.2:cloud",
displayName: "glm-5.2:cloud",
cloud: true,
selected: true,
availability: "unavailable",
reason: "upgrade_required",
requiredPlan: "pro",
},
],
}}
/>,
const runningHTML = renderToStaticMarkup(
<ClaudeDesktopModelsSettings initialStatus={status({ running: true })} />,
);
expect(html).toContain("Select a model available to your account.");
const button = html.slice(html.lastIndexOf("<button"));
expect(button).toContain("disabled");
});
it("remains visible after Claude has been disconnected", () => {
const html = renderToStaticMarkup(
<ClaudeDesktopModelsSettings
initialStatus={{
supported: true,
used: true,
installed: true,
connected: false,
running: false,
startFailed: false,
portConflict: false,
modelSource: "user",
models: [
{
name: "qwen3:8b",
displayName: "qwen3:8b",
selected: true,
},
],
}}
/>,
);
expect(html).toContain(">Claude<");
expect(html).toContain("qwen3:8b");
expect(html).toContain(
"These models will be available when Claude starts.",
);
expect(html).toContain("Start Claude");
expect(runningHTML).toContain("Restart Claude");
expect(runningHTML).toContain("disabled");
});
it("stays hidden until Claude has been enabled once", () => {
const html = renderToStaticMarkup(
<ClaudeDesktopModelsSettings
initialStatus={{
supported: true,
used: false,
installed: true,
connected: false,
running: false,
startFailed: false,
portConflict: false,
}}
/>,
<ClaudeDesktopModelsSettings initialStatus={status({ used: false })} />,
);
expect(html).toBe("");
@@ -1,20 +1,21 @@
import { getClaudeDesktopAvailableModels } from "@/api";
import { Button } from "@/components/ui/button";
import { Description, Field, Label } from "@/components/ui/fieldset";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
addClaudeModelSelection,
claudeDesktopRecoveryMessage,
claudeDesktopMaxModels,
claudeDesktopMaxModelsMessage,
claudeDesktopUsableSelection,
} from "@/lib/claudeDesktop";
import { claudeDesktopRecoveryMessage } from "@/lib/claudeDesktop";
import { claudeDesktopModelStatusLabel } from "@/lib/claudeDesktopModelStatus";
import type {
ClaudeDesktopMappingStatus,
ClaudeDesktopModelStatus,
ClaudeDesktopStatus,
} from "@/types/webview";
import { ArrowPathIcon } from "@heroicons/react/20/solid";
import {
ArrowPathIcon,
ArrowRightIcon,
CheckIcon,
ChevronUpDownIcon,
MagnifyingGlassIcon,
} from "@heroicons/react/20/solid";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
interface ClaudeDesktopModelsSettingsProps {
@@ -22,8 +23,21 @@ interface ClaudeDesktopModelsSettingsProps {
initialLocalModels?: string[];
initialCloudModels?: string[];
includeCloudModels?: boolean;
onDraftChange?: (hasChanges: boolean) => void;
resetVersion?: number;
}
const fallbackRoutes: ClaudeDesktopMappingStatus[] = [
{ routeId: "claude-fable-5", routeName: "Fable 5" },
{ routeId: "claude-opus-5", routeName: "Opus 5" },
{ routeId: "claude-sonnet-5", routeName: "Sonnet 5" },
{
routeId: "claude-haiku-4-5-20251001",
routeName: "Haiku 4.5",
},
{ routeId: "claude-sonnet-4-6", routeName: "Sonnet 4.6" },
];
function isInvalidModelName(name: string): boolean {
const normalized = name.trim().toLowerCase().replace(/[-:]+/g, " ");
return normalized === "ollama cloud";
@@ -37,29 +51,63 @@ function visibleModels(
);
}
function selectedModelNames(status: ClaudeDesktopStatus): string[] {
return claudeDesktopUsableSelection(
visibleModels(status),
status.modelSource !== "user",
claudeDesktopMaxModels(status),
function modelIsAvailable(model: ClaudeDesktopModelStatus): boolean {
return !model.availability || model.availability === "available";
}
function initialMappings(
status: ClaudeDesktopStatus,
): ClaudeDesktopMappingStatus[] {
const models = visibleModels(status);
const known = new Set(models.map((model) => model.name));
const available = new Set(
models.filter(modelIsAvailable).map((model) => model.name),
);
const routes = (
status.mappings?.length ? status.mappings : fallbackRoutes
).map((route) => ({ ...route }));
if (!status.mappings?.length) {
const selected = models.filter(
(model) => model.selected && available.has(model.name),
);
selected.slice(0, routes.length).forEach((model, index) => {
routes[index].model = model.name;
});
}
for (const route of routes) {
if (route.model && !known.has(route.model)) route.model = undefined;
}
if (!routes.some((route) => route.model)) {
const first = models.find(modelIsAvailable);
if (first && routes.length > 0) routes[0].model = first.name;
}
return routes;
}
function mappingsEqual(
left: ClaudeDesktopMappingStatus[],
right: ClaudeDesktopMappingStatus[],
): boolean {
return (
left.length === right.length &&
left.every(
(route, index) =>
route.routeId === right[index]?.routeId &&
(route.model ?? "") === (right[index]?.model ?? ""),
)
);
}
function modelAccessLabel(model: ClaudeDesktopModelStatus): string | null {
switch (model.reason) {
case "sign_in_required":
return "Sign in required";
case "upgrade_required":
return model.requiredPlan
? `${model.requiredPlan} plan required`
: "Upgrade required";
case "verification_unavailable":
return "Access unavailable";
case "model_not_installed":
return "Not installed";
default:
return null;
}
function mappingRecord(
mappings: ClaudeDesktopMappingStatus[],
): Record<string, string> {
return Object.fromEntries(
mappings
.filter((route) => route.model)
.map((route) => [route.routeId, route.model ?? ""]),
);
}
function formatModelList(names: string[]): string {
@@ -68,10 +116,134 @@ function formatModelList(names: string[]): string {
return `${names.slice(0, -1).join(", ")}, or ${names[names.length - 1]}`;
}
function sameModelSelection(left: string[], right: string[]): boolean {
if (left.length !== right.length) return false;
const names = new Set(left);
return right.every((name) => names.has(name));
interface ClaudeModelPickerProps {
id: string;
routeName: string;
value?: string;
models: ClaudeDesktopModelStatus[];
disabled: boolean;
onChange: (model: string) => void;
}
function ClaudeModelPicker({
id,
routeName,
value,
models,
disabled,
onChange,
}: ClaudeModelPickerProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const pickerRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const normalizedQuery = query.trim().toLowerCase();
const filteredModels = models.filter((model) =>
model.displayName.toLowerCase().includes(normalizedQuery),
);
useEffect(() => {
if (!open) {
setQuery("");
return;
}
searchRef.current?.focus();
const handlePointerDown = (event: MouseEvent) => {
if (!pickerRef.current?.contains(event.target as Node)) setOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
}
};
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
const choose = (model: string) => {
onChange(model);
setOpen(false);
};
return (
<div ref={pickerRef} className="relative min-w-0">
<button
id={id}
type="button"
aria-label={`Ollama model for ${routeName}`}
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabled}
onClick={() => setOpen((current) => !current)}
className="flex min-h-9 w-full items-center gap-2 rounded-lg bg-neutral-50 px-3 py-1.5 text-left text-sm text-neutral-800 outline-none ring-1 ring-inset ring-neutral-200 hover:bg-neutral-100 focus:ring-2 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-neutral-700 dark:text-neutral-100 dark:ring-neutral-600 dark:hover:bg-neutral-600"
>
<span
className={`min-w-0 flex-1 truncate ${value ? "" : "text-neutral-400"}`}
>
{value || "Select a model"}
</span>
<ChevronUpDownIcon className="h-4 w-4 flex-shrink-0 text-neutral-400" />
</button>
{open && (
<div className="absolute bottom-full right-0 z-50 mb-2 w-full min-w-64 overflow-hidden rounded-2xl border border-neutral-100 bg-white text-[15px] text-neutral-800 shadow-xl shadow-black/5 dark:border-neutral-600/40 dark:bg-neutral-800 dark:text-white">
<div className="flex items-center gap-2 border-b border-neutral-100 px-3 py-2 dark:border-neutral-700">
<MagnifyingGlassIcon className="h-4 w-4 flex-shrink-0 text-neutral-400" />
<input
ref={searchRef}
type="text"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Find model..."
aria-label={`Find model for ${routeName}`}
autoCorrect="off"
autoComplete="off"
className="min-w-0 flex-1 border-none bg-transparent py-0.5 outline-none"
/>
</div>
<div role="listbox" className="max-h-64 overflow-y-auto py-1">
{filteredModels.map((model) => {
const available = modelIsAvailable(model);
const statusLabel = claudeDesktopModelStatusLabel(model);
const selected = value === model.name;
return (
<button
key={model.name}
type="button"
role="option"
aria-selected={selected}
disabled={!available}
onClick={() => choose(model.name)}
className="flex w-full cursor-pointer items-start gap-2 px-3 py-2 text-left hover:bg-neutral-100 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-45 dark:hover:bg-neutral-700/60 dark:focus:bg-neutral-700/60"
>
<span className="mt-0.5 h-4 w-4 flex-shrink-0">
{selected && <CheckIcon className="h-4 w-4" />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate">{model.displayName}</span>
{statusLabel && (
<span className="mt-0.5 block truncate text-xs text-neutral-400">
{statusLabel}
</span>
)}
</span>
</button>
);
})}
{filteredModels.length === 0 && (
<p className="px-3 py-2 text-neutral-400">No models found</p>
)}
</div>
</div>
)}
</div>
);
}
export function ClaudeDesktopModelsSettings({
@@ -79,6 +251,8 @@ export function ClaudeDesktopModelsSettings({
initialLocalModels,
initialCloudModels,
includeCloudModels = false,
onDraftChange,
resetVersion = 0,
}: ClaudeDesktopModelsSettingsProps) {
const [status, setStatus] = useState<ClaudeDesktopStatus | null>(
initialStatus ?? null,
@@ -86,38 +260,68 @@ export function ClaudeDesktopModelsSettings({
const [models, setModels] = useState<ClaudeDesktopModelStatus[]>(() =>
initialStatus ? visibleModels(initialStatus) : [],
);
const [selection, setSelection] = useState<string[]>(() =>
initialStatus ? selectedModelNames(initialStatus) : [],
const [mappings, setMappings] = useState<ClaudeDesktopMappingStatus[]>(() =>
initialStatus ? initialMappings(initialStatus) : [],
);
const [savedMappings, setSavedMappings] = useState<
ClaudeDesktopMappingStatus[]
>(() => (initialStatus ? initialMappings(initialStatus) : []));
const [localModels, setLocalModels] = useState<string[]>(
initialLocalModels ?? [],
);
const [accountCloudModels, setAccountCloudModels] = useState<string[]>(
initialCloudModels ?? [],
);
const [searchQuery, setSearchQuery] = useState("");
const [pickerOpen, setPickerOpen] = useState(false);
const [modelsLoading, setModelsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [restarting, setRestarting] = useState(false);
const [applying, setApplying] = useState(false);
const [autoModeApplying, setAutoModeApplying] = useState(false);
const [autoModeOverride, setAutoModeOverride] = useState<boolean | null>(
null,
);
const pickerRef = useRef<HTMLDivElement>(null);
const draftRef = useRef({ mappings, savedMappings });
const statusRequestRef = useRef(0);
const operationInFlightRef = useRef(false);
const lastResetVersionRef = useRef(resetVersion);
const statusRef = useRef(status);
draftRef.current = { mappings, savedMappings };
statusRef.current = status;
const applyStatus = useCallback((next: ClaudeDesktopStatus) => {
setStatus(next);
setModels(visibleModels(next));
setSelection(selectedModelNames(next));
setError(null);
}, []);
const applyStatus = useCallback(
(next: ClaudeDesktopStatus, preserveDraft = false) => {
const nextMappings = initialMappings(next);
const draft = draftRef.current;
const keepDraft =
preserveDraft && !mappingsEqual(draft.mappings, draft.savedMappings);
setStatus(next);
setModels(visibleModels(next));
if (!keepDraft) {
setMappings(nextMappings);
setSavedMappings(nextMappings);
}
setError(null);
},
[],
);
const refreshStatus = useCallback(async () => {
if (!window.getClaudeDesktopStatus) return;
const request = ++statusRequestRef.current;
try {
applyStatus(await window.getClaudeDesktopStatus());
const next = await window.getClaudeDesktopStatus();
if (
request === statusRequestRef.current &&
!operationInFlightRef.current
) {
applyStatus(next, true);
}
} catch {
setError("Ollama could not read the Claude connection status.");
if (
request === statusRequestRef.current &&
!operationInFlightRef.current
) {
setError("Ollama could not read the Claude connection status.");
}
}
}, [applyStatus]);
@@ -128,12 +332,6 @@ export function ClaudeDesktopModelsSettings({
return () => window.removeEventListener("focus", handleFocus);
}, [initialStatus, refreshStatus]);
useEffect(() => {
if (!status) return;
setModels(visibleModels(status));
setSelection(selectedModelNames(status));
}, [status]);
useEffect(() => {
if (initialLocalModels || !status?.used) return;
let cancelled = false;
@@ -160,116 +358,126 @@ export function ClaudeDesktopModelsSettings({
};
}, [includeCloudModels, initialLocalModels, status?.used]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
pickerRef.current &&
!pickerRef.current.contains(event.target as Node)
) {
setPickerOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const matchingLocalModels = useMemo(() => {
const current = new Set(selection);
const query = searchQuery.trim().toLowerCase();
return localModels
.filter(
(name) =>
!current.has(name) &&
!isInvalidModelName(name) &&
(!query || name.toLowerCase().includes(query)),
)
.sort((left, right) => left.localeCompare(right));
}, [localModels, searchQuery, selection]);
const toggleModel = (name: string) => {
setError(null);
setSelection((current) => {
if (!current.includes(name)) {
const result = addClaudeModelSelection(
current,
name,
claudeDesktopMaxModels(status),
);
if (result.error) {
setError(result.error);
return current;
}
return result.selection;
}
if (current.length === 1) {
setError("Select at least one model for Claude.");
return current;
}
return current.filter((model) => model !== name);
});
};
const addLocalModel = (name: string) => {
const maxModels = claudeDesktopMaxModels(status);
const result = addClaudeModelSelection(selection, name, maxModels);
if (result.error) {
setError(result.error);
return;
}
const cloud = name.endsWith(":cloud");
setModels((current) => [
...current,
{
const catalogModels = useMemo(() => {
const current = new Set(models.map((model) => model.name));
const installed: ClaudeDesktopModelStatus[] = localModels
.filter((name) => !current.has(name) && !isInvalidModelName(name))
.sort((left, right) => left.localeCompare(right))
.map((name) => ({
name,
displayName: name,
cloud,
selected: true,
selected: false,
availability: "available",
},
]);
setSelection(result.selection);
setSearchQuery("");
setPickerOpen(false);
}));
return [...models, ...installed];
}, [localModels, models]);
const hasDraftChanges = !mappingsEqual(mappings, savedMappings);
const assignedModels = mappings
.map((route) => route.model)
.filter((model): model is string => Boolean(model));
const hasInvalidMapping = assignedModels.some((name) => {
const model = catalogModels.find((candidate) => candidate.name === name);
return !model || !modelIsAvailable(model);
});
const busy = applying || autoModeApplying;
useEffect(() => {
onDraftChange?.(hasDraftChanges);
}, [hasDraftChanges, onDraftChange]);
useEffect(() => {
if (resetVersion === lastResetVersionRef.current) return;
let cancelled = false;
const resetToFreshDefaults = async () => {
try {
const next = window.getClaudeDesktopStatus
? await window.getClaudeDesktopStatus()
: statusRef.current;
if (cancelled) return;
if (!next?.defaultMappings?.length) {
setError("Ollama could not refresh the Claude mapping defaults.");
return;
}
// Reset is an explicit action and wins over older focus refreshes.
++statusRequestRef.current;
applyStatus(next, true);
setMappings(next.defaultMappings.map((mapping) => ({ ...mapping })));
lastResetVersionRef.current = resetVersion;
} catch {
if (!cancelled) {
setError("Ollama could not refresh the Claude mapping defaults.");
}
}
};
void resetToFreshDefaults();
return () => {
cancelled = true;
};
}, [applyStatus, resetVersion]);
const updateMapping = (routeId: string, model: string) => {
setError(null);
setMappings((current) =>
current.map((route) =>
route.routeId === routeId
? { ...route, model: model || undefined }
: route,
),
);
};
const hasAvailableSelection = selection.some((name) => {
const model = models.find((candidate) => candidate.name === name);
return !model?.availability || model.availability === "available";
});
const confirmRestartIfRunning = (message: string) =>
!status?.running ||
window.confirm(`${message} Any running task will stop.`);
const restartClaude = async () => {
if (!window.restartClaudeDesktop) {
setError("Claude restart is available in the Ollama macOS app.");
const applyChanges = async () => {
if (!window.applyClaudeDesktopMappings) {
setError(
"Claude routing settings are available in the Ollama macOS app.",
);
return;
}
if (selection.length === 0) {
setError("Select at least one model for Claude.");
if (assignedModels.length === 0) {
setError("Choose at least one Ollama model for Claude.");
return;
}
if (!hasAvailableSelection) {
setError("Select a model available to your account.");
if (hasInvalidMapping) {
setError("Choose models available to your account and device.");
return;
}
if (
!confirmRestartIfRunning("Restart Claude Desktop to update its models?")
)
return;
setApplying(true);
setError(null);
setRestarting(true);
operationInFlightRef.current = true;
++statusRequestRef.current;
try {
const result = await window.restartClaudeDesktop(selection);
applyStatus(result.status);
if (result.error) setError(result.error);
let result = await window.applyClaudeDesktopMappings(
mappingRecord(mappings),
false,
);
if (result.restartConfirmationRequired) {
applyStatus(result.status, true);
if (
!window.confirm("Restart Claude Desktop? Any running task will stop.")
) {
return;
}
result = await window.applyClaudeDesktopMappings(
mappingRecord(mappings),
true,
);
}
++statusRequestRef.current;
if (result.error) {
applyStatus(result.status, !result.mappingsApplied);
setError(result.error);
} else {
applyStatus(result.status);
}
} catch {
setError("Ollama could not restart Claude.");
setError("Ollama could not apply the Claude model mappings.");
} finally {
setRestarting(false);
++statusRequestRef.current;
operationInFlightRef.current = false;
setApplying(false);
}
};
@@ -278,29 +486,39 @@ export function ClaudeDesktopModelsSettings({
setError("Auto mode is available in the Ollama macOS app.");
return;
}
if (!confirmRestartIfRunning("Restart Claude to change auto mode?")) return;
setError(null);
setAutoModeOverride(checked);
setRestarting(true);
setAutoModeApplying(true);
operationInFlightRef.current = true;
++statusRequestRef.current;
try {
const result = await window.setClaudeDesktopAutoMode(checked);
let result = await window.setClaudeDesktopAutoMode(checked, false);
if (result.restartConfirmationRequired) {
applyStatus(result.status, true);
if (
!window.confirm(
"Restart Claude to change auto mode? Any running task will stop.",
)
) {
return;
}
result = await window.setClaudeDesktopAutoMode(checked, true);
}
++statusRequestRef.current;
applyStatus(result.status);
if (result.error) setError(result.error);
} catch {
setError("Ollama could not update Claude auto mode.");
} finally {
++statusRequestRef.current;
operationInFlightRef.current = false;
setAutoModeOverride(null);
setRestarting(false);
setAutoModeApplying(false);
}
};
if (!status?.supported || !status.used) {
return null;
}
if (!status?.supported || !status.used) return null;
const maxModels = claudeDesktopMaxModels(status);
const selectionFull = selection.length >= maxModels;
const autoModeModelNames = Array.from(
new Set([
...models.filter((model) => model.autoMode).map((model) => model.name),
@@ -308,19 +526,15 @@ export function ClaudeDesktopModelsSettings({
]),
);
const autoModeModelSet = new Set(autoModeModelNames);
const modelSelectionApplied = sameModelSelection(
selection,
selectedModelNames(status),
);
const autoModeAvailable =
modelSelectionApplied &&
selection.length > 0 &&
selection.some((name) => autoModeModelSet.has(name));
const autoMode = modelSelectionApplied
? autoModeAvailable && (autoModeOverride ?? status.autoMode ?? false)
!hasDraftChanges &&
assignedModels.length > 0 &&
assignedModels.some((name) => autoModeModelSet.has(name));
const autoMode = autoModeAvailable
? (autoModeOverride ?? status.autoMode ?? false)
: (status.autoMode ?? false);
const autoModeDescription = !modelSelectionApplied
? "Restart Claude to apply model changes before changing auto mode."
const autoModeDescription = hasDraftChanges
? "Start or restart Claude to apply model changes before changing auto mode."
: autoModeAvailable
? "Let Claude decide when to ask before making changes."
: accountCloudModels.length > 0
@@ -328,15 +542,12 @@ export function ClaudeDesktopModelsSettings({
: autoModeModelNames.length > 0
? `Select one of ${formatModelList(autoModeModelNames)} to use auto mode.`
: "Auto mode needs a cloud model available to your Ollama.com account.";
const guidance =
claudeDesktopRecoveryMessage(status.error, error) ??
(!hasAvailableSelection && models.length > 0
? "Select a model available to your account."
: selectionFull
? claudeDesktopMaxModelsMessage(maxModels)
: status.connected
? "Restart Claude to refresh its model list."
: "These models will be available when Claude starts.");
(hasDraftChanges && status.running
? "Restarting Claude will stop any running task."
: null);
return (
<section aria-labelledby="apps-settings-heading" className="space-y-2">
@@ -347,7 +558,7 @@ export function ClaudeDesktopModelsSettings({
Apps
</h2>
<div
aria-labelledby="claude-models-settings-heading"
aria-labelledby="claude-settings-heading"
className="overflow-visible rounded-xl bg-white p-4 dark:bg-neutral-800"
>
<div className="flex items-start space-x-3">
@@ -357,104 +568,75 @@ export function ClaudeDesktopModelsSettings({
className="mt-0.5 h-5 w-5 flex-shrink-0"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<h2
id="claude-models-settings-heading"
className="text-sm font-medium text-neutral-900 dark:text-white"
>
Claude
</h2>
{status.modelSource === "fallback" && models.length > 0 && (
<span className="text-xs text-neutral-400">
Built-in defaults
</span>
)}
</div>
<div className="mt-3 grid grid-cols-2 gap-x-5 gap-y-2 max-[850px]:grid-cols-1">
{models.map((model) => {
const selected = selection.includes(model.name);
const accessLabel = modelAccessLabel(model);
const unavailable =
model.availability !== undefined &&
model.availability !== "available";
return (
<label
key={model.name}
className="flex min-w-0 cursor-pointer items-center gap-2 rounded-md py-1 text-sm text-neutral-600 dark:text-neutral-300"
title={accessLabel ?? model.description}
>
<input
type="checkbox"
checked={selected}
disabled={
restarting ||
(!selected && (selectionFull || unavailable))
}
onChange={() => toggleModel(model.name)}
className="h-4 w-4 rounded border-neutral-300 accent-neutral-900 dark:border-neutral-600 dark:accent-white"
/>
<span className="truncate">{model.displayName}</span>
{accessLabel && (
<span className="flex-shrink-0 text-xs text-neutral-400">
{accessLabel}
</span>
)}
</label>
);
})}
</div>
<div ref={pickerRef} className="relative mt-3">
<Input
type="search"
value={searchQuery}
onFocus={() => setPickerOpen(true)}
onChange={(event) => {
setSearchQuery(event.target.value);
setPickerOpen(true);
}}
placeholder="Search Ollama models"
aria-label="Search Ollama models"
aria-expanded={pickerOpen}
aria-controls="claude-local-models"
disabled={restarting}
autoComplete="off"
/>
{pickerOpen && (
<div
id="claude-local-models"
className="absolute z-20 mt-1 max-h-52 w-full overflow-y-auto rounded-lg border border-neutral-200 bg-white p-1 shadow-lg dark:border-neutral-600 dark:bg-neutral-800"
<div className="flex items-start justify-between gap-4">
<div>
<h2
id="claude-settings-heading"
className="text-sm font-medium text-neutral-900 dark:text-white"
>
{modelsLoading ? (
<p className="px-3 py-2 text-sm text-neutral-400">
Loading models
</p>
) : selectionFull ? (
<p className="px-3 py-2 text-sm text-neutral-400">
{claudeDesktopMaxModelsMessage(maxModels)}
</p>
) : matchingLocalModels.length > 0 ? (
matchingLocalModels.map((name) => (
<button
key={name}
type="button"
onClick={() => addLocalModel(name)}
className="block w-full rounded-md px-3 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:text-neutral-200 dark:hover:bg-neutral-700"
>
{name}
</button>
))
) : (
<p className="px-3 py-2 text-sm text-neutral-400">
No models found.
</p>
)}
</div>
)}
Claude
</h2>
<p className="mt-1 text-base/6 text-zinc-500 sm:text-sm/6 dark:text-zinc-400">
Choose which Ollama model Claude uses for each model option.
</p>
</div>
<Button
type="button"
color="white"
onClick={applyChanges}
disabled={
busy ||
assignedModels.length === 0 ||
hasInvalidMapping ||
(status.running && !hasDraftChanges)
}
className="flex-shrink-0"
>
{applying && (
<ArrowPathIcon data-slot="icon" className="animate-spin" />
)}
{applying
? status.running
? "Restarting…"
: "Starting…"
: status.running
? "Restart Claude"
: "Start Claude"}
</Button>
</div>
<Field className="mt-3 border-t border-neutral-200 pt-3 dark:border-neutral-700">
<div className="mt-4 w-full max-w-xl space-y-1">
{mappings.map((mapping) => (
<div
key={mapping.routeId}
className="relative grid min-h-12 grid-cols-[5.5rem_3.75rem_minmax(0,1fr)] items-center gap-2 py-1 max-sm:grid-cols-1 max-sm:gap-2"
>
<div className="min-w-0">
<span className="block text-sm font-medium text-neutral-800 dark:text-neutral-200">
{mapping.routeName}
</span>
</div>
<ArrowRightIcon
aria-hidden="true"
className="absolute left-[6.6625rem] h-4 w-4 -translate-x-1/2 text-neutral-300 dark:text-neutral-500 max-sm:hidden"
/>
<div className="col-start-3 w-2/3 min-w-0 max-sm:col-start-auto max-sm:w-full">
<ClaudeModelPicker
id={`claude-route-${mapping.routeId}`}
routeName={mapping.routeName}
value={mapping.model ?? ""}
disabled={busy || modelsLoading}
models={catalogModels}
onChange={(model) =>
updateMapping(mapping.routeId, model)
}
/>
</div>
</div>
))}
</div>
<Field className="mt-3 w-full max-w-xl border-t border-neutral-200 pt-3 dark:border-neutral-700">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<Label>Enable auto mode</Label>
@@ -462,41 +644,21 @@ export function ClaudeDesktopModelsSettings({
</div>
<Switch
checked={autoMode}
disabled={restarting || !autoModeAvailable}
disabled={busy || !autoModeAvailable}
onChange={(checked) => void toggleAutoMode(checked)}
className="flex-shrink-0"
/>
</div>
</Field>
<div className="mt-3 flex items-center justify-between gap-4 max-sm:flex-col max-sm:items-stretch">
{guidance && (
<p
role={error || status.error ? "alert" : undefined}
className="text-xs leading-5 text-neutral-500 dark:text-neutral-400"
role={error || status.error ? "alert" : "status"}
className="mt-3 w-full max-w-xl text-xs leading-5 text-neutral-500 dark:text-neutral-400"
>
{guidance}
</p>
<Button
type="button"
color="white"
onClick={restartClaude}
disabled={
restarting || selection.length === 0 || !hasAvailableSelection
}
className="flex-shrink-0 max-sm:w-full"
>
{restarting && (
<ArrowPathIcon data-slot="icon" className="animate-spin" />
)}
{restarting
? status.connected
? "Restarting…"
: "Starting…"
: status.connected
? "Restart Claude"
: "Start Claude"}
</Button>
</div>
)}
</div>
</div>
</div>
+26 -13
View File
@@ -23,6 +23,7 @@ import { settingsMutationScope } from "@/lib/settingsMutationScope";
import { useUser } from "@/hooks/useUser";
import { useCloudStatus } from "@/hooks/useCloudStatus";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useBlocker } from "@tanstack/react-router";
import {
getSettings,
type CloudStatusSource,
@@ -95,6 +96,15 @@ export default function Settings() {
const [showAppsInMenu, setShowAppsInMenuState] = useState(true);
const [showAppsInMenuPending, setShowAppsInMenuPending] = useState(false);
const [resettingToDefaults, setResettingToDefaults] = useState(false);
const [hasClaudeDraftChanges, setHasClaudeDraftChanges] = useState(false);
const [claudeMappingsResetVersion, setClaudeMappingsResetVersion] =
useState(0);
useBlocker({
shouldBlockFn: () =>
!window.confirm("Discard unapplied Claude routing changes?"),
enableBeforeUnload: hasClaudeDraftChanges,
disabled: !hasClaudeDraftChanges,
});
const {
user,
isAuthenticated,
@@ -325,6 +335,7 @@ export default function Settings() {
cloudSource: cloudStatus?.source,
onSaved: showSavedConfirmation,
});
setClaudeMappingsResetVersion((version) => version + 1);
} catch (error) {
console.error("Failed to reset settings:", error);
} finally {
@@ -669,10 +680,25 @@ export default function Settings() {
</div>
</div>
{/* Reset button */}
<div className="flex justify-end px-4">
<Button
type="button"
color="white"
className="px-3"
disabled={resettingToDefaults}
onClick={() => void handleResetToDefaults()}
>
Reset to defaults
</Button>
</div>
<ClaudeDesktopModelsSettings
includeCloudModels={
isAuthenticated && cloudStatusKnown && !cloudDisabled
}
onDraftChange={setHasClaudeDraftChanges}
resetVersion={claudeMappingsResetVersion}
/>
{/* Agent Mode */}
@@ -718,19 +744,6 @@ export default function Settings() {
</div>
</div>
)}
{/* Reset button */}
<div className="mt-6 flex justify-end px-4">
<Button
type="button"
color="white"
className="px-3"
disabled={resettingToDefaults}
onClick={() => void handleResetToDefaults()}
>
Reset to defaults
</Button>
</div>
</div>
{/* Saved indicator */}
@@ -0,0 +1,20 @@
import type { ClaudeDesktopModelStatus } from "@/types/webview";
export function claudeDesktopModelStatusLabel(
model: ClaudeDesktopModelStatus,
): string | null {
switch (model.reason) {
case "sign_in_required":
return "Sign in required";
case "upgrade_required":
return model.requiredPlan
? `${model.requiredPlan[0]?.toUpperCase()}${model.requiredPlan.slice(1)} plan required`
: "Upgrade required";
case "verification_unavailable":
return "Access unavailable";
case "model_not_installed":
return "Not installed";
}
return null;
}
+15 -2
View File
@@ -28,6 +28,14 @@ interface ClaudeDesktopStatus {
modelSource?: "user" | "endpoint" | "fallback";
maxModels?: number;
models?: ClaudeDesktopModelStatus[];
mappings?: ClaudeDesktopMappingStatus[];
defaultMappings?: ClaudeDesktopMappingStatus[];
}
interface ClaudeDesktopMappingStatus {
routeId: string;
routeName: string;
model?: string;
}
interface ClaudeDesktopModelStatus {
@@ -50,6 +58,8 @@ interface ClaudeDesktopModelStatus {
interface ClaudeDesktopActionResult {
status: ClaudeDesktopStatus;
error?: string;
mappingsApplied?: boolean;
restartConfirmationRequired?: boolean;
}
type ClaudeDesktopInstallResult = "opened" | "cancelled" | "failed";
@@ -79,11 +89,13 @@ declare global {
installClaudeDesktop?: () => Promise<ClaudeDesktopInstallResult>;
getShowAppsInMenu?: () => Promise<boolean>;
setShowAppsInMenu?: (visible: boolean) => Promise<void>;
restartClaudeDesktop?: (
models: string[],
applyClaudeDesktopMappings?: (
mappings: Record<string, string>,
restartConfirmed: boolean,
) => Promise<ClaudeDesktopActionResult>;
setClaudeDesktopAutoMode?: (
enabled: boolean,
restartConfirmed: boolean,
) => Promise<ClaudeDesktopActionResult>;
setOnboardingWindow?: (enabled: boolean) => void;
menu: (items: MenuItem[]) => Promise<string | null>;
@@ -112,6 +124,7 @@ declare global {
export type {
ClaudeDesktopActionResult,
ClaudeDesktopInstallResult,
ClaudeDesktopMappingStatus,
ClaudeDesktopModelStatus,
ClaudeDesktopStatus,
ContextMenuItem,
+90 -11
View File
@@ -51,6 +51,10 @@ var (
// inference mode using the Ollama app's local gateway.
type ClaudeDesktop struct{}
// ErrClaudeDesktopRestartConfirmationRequired reports that applying a profile
// change would interrupt a running Claude Desktop process.
var ErrClaudeDesktopRestartConfirmationRequired = errors.New("Claude Desktop restart confirmation is required before changing its profile")
func (c *ClaudeDesktop) String() string { return "Claude Desktop" }
func (c *ClaudeDesktop) Supported() error { return claudeDesktopSupported() }
@@ -170,14 +174,15 @@ func (c *ClaudeDesktop) SetInstalledFromDesktopWithAutoMode(installed, restart,
return nil
}
if !restart {
return errors.New("Claude Desktop restart confirmation is required before changing its profile")
return ErrClaudeDesktopRestartConfirmationRequired
}
return restartClaudeDesktop(applyProfile)
}
// RestartWithProfileChange stops Claude before applying a profile-dependent
// change, then reopens it after the change is complete.
func (c *ClaudeDesktop) RestartWithProfileChange(change func() error) error {
// ApplyProfileChange applies a profile-dependent change immediately. Claude is
// restarted only when it is already running; otherwise the change takes effect
// the next time the user opens it.
func (c *ClaudeDesktop) ApplyProfileChange(change func() error, restartConfirmed bool) error {
if err := claudeDesktopSupported(); err != nil {
return err
}
@@ -186,10 +191,10 @@ func (c *ClaudeDesktop) RestartWithProfileChange(change func() error) error {
return fmt.Errorf("check whether Claude Desktop is running: %w", err)
}
if !running {
if err := change(); err != nil {
return err
}
return claudeDesktopOpenApp()
return change()
}
if !restartConfirmed {
return ErrClaudeDesktopRestartConfirmationRequired
}
return restartClaudeDesktop(change)
}
@@ -230,7 +235,36 @@ func (c *ClaudeDesktop) Onboard() error {
// ClaudeDesktopModels returns the user's explicitly saved Claude Desktop
// model subset. A nil result means the recommendation source should decide.
func ClaudeDesktopModels() []string {
return config.IntegrationModels(claudeDesktopIntegrationName)
configured, err := config.LoadIntegration(claudeDesktopIntegrationName)
if err != nil {
return nil
}
if len(configured.Aliases) > 0 {
models := make([]string, 0, len(configured.Aliases))
for _, route := range proxy.ClaudeDesktopRoutes() {
if model := strings.TrimSpace(configured.Aliases[route.ID]); model != "" {
models = append(models, model)
}
}
return models
}
return configured.Models
}
// ClaudeDesktopModelMappings returns explicit Claude route assignments. An
// empty map means the legacy ordered model selection or recommendations apply.
func ClaudeDesktopModelMappings() map[string]string {
configured, err := config.LoadIntegration(claudeDesktopIntegrationName)
if err != nil || len(configured.Aliases) == 0 {
return nil
}
mappings := make(map[string]string, len(configured.Aliases))
for _, route := range proxy.ClaudeDesktopRoutes() {
if model := strings.TrimSpace(configured.Aliases[route.ID]); model != "" {
mappings[route.ID] = model
}
}
return mappings
}
// SaveClaudeDesktopModels persists the user's explicit Claude Desktop model
@@ -239,13 +273,48 @@ func SaveClaudeDesktopModels(models []string) error {
if len(models) == 0 {
return errors.New("select at least one Claude Desktop model")
}
return config.SaveIntegration(claudeDesktopIntegrationName, models)
if err := config.SaveIntegration(claudeDesktopIntegrationName, models); err != nil {
return err
}
return config.SaveAliases(claudeDesktopIntegrationName, nil)
}
// SaveClaudeDesktopModelMappings persists explicit route assignments. Empty
// routes are omitted; sparse mappings and duplicate model values are valid.
func SaveClaudeDesktopModelMappings(mappings map[string]string) error {
normalized := make(map[string]string)
models := make([]string, 0, len(mappings))
for _, route := range proxy.ClaudeDesktopRoutes() {
if model := strings.TrimSpace(mappings[route.ID]); model != "" {
normalized[route.ID] = model
models = append(models, model)
}
}
if len(models) == 0 {
return errors.New("map at least one Claude Desktop route")
}
if err := config.SaveIntegration(claudeDesktopIntegrationName, models); err != nil {
return err
}
return config.SaveAliases(claudeDesktopIntegrationName, normalized)
}
// RestoreClaudeDesktopModels restores a previously captured selection. A nil
// selection restores the implicit recommendation defaults.
func RestoreClaudeDesktopModels(models []string) error {
return config.SaveIntegration(claudeDesktopIntegrationName, models)
if err := config.SaveIntegration(claudeDesktopIntegrationName, models); err != nil {
return err
}
return config.SaveAliases(claudeDesktopIntegrationName, nil)
}
// RestoreClaudeDesktopModelMappings restores a previously captured explicit
// mapping, including the legacy no-mapping state.
func RestoreClaudeDesktopModelMappings(models []string, mappings map[string]string) error {
if err := config.SaveIntegration(claudeDesktopIntegrationName, models); err != nil {
return err
}
return config.SaveAliases(claudeDesktopIntegrationName, mappings)
}
// ClaudeDesktopAutoModeEnabled reports the user's Claude Desktop auto mode
@@ -980,6 +1049,11 @@ func ClaudeDesktopRunning() bool {
return running
}
// Running reports whether Claude Desktop is currently open.
func (c *ClaudeDesktop) Running() bool {
return ClaudeDesktopRunning()
}
// OpenClaudeDesktop brings the installed Claude Desktop app to the foreground.
func OpenClaudeDesktop() error {
if err := claudeDesktopSupported(); err != nil {
@@ -988,6 +1062,11 @@ func OpenClaudeDesktop() error {
return claudeDesktopOpenApp()
}
// Open launches or foregrounds Claude Desktop.
func (c *ClaudeDesktop) Open() error {
return OpenClaudeDesktop()
}
func defaultClaudeDesktopRunning(ctx context.Context) (bool, error) {
var (
out []byte
+100
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"maps"
"net/http"
"net/http/httptest"
"os"
@@ -1172,6 +1173,105 @@ func TestClaudeDesktopModelsPersistInLauncherConfig(t *testing.T) {
}
}
func TestClaudeDesktopModelMappingsPersistSharedRoutes(t *testing.T) {
setTestHome(t, t.TempDir())
want := map[string]string{
"claude-fable-5": "qwen3.8:27b",
"claude-opus-5": "qwen3.8:27b",
"claude-sonnet-5": "qwen3.8:27b",
"claude-haiku-4-5-20251001": "qwen3.8:27b",
"claude-sonnet-4-6": "qwen3.8:27b",
}
if err := SaveClaudeDesktopModelMappings(want); err != nil {
t.Fatal(err)
}
if got := ClaudeDesktopModelMappings(); !maps.Equal(got, want) {
t.Fatalf("ClaudeDesktopModelMappings() = %v, want %v", got, want)
}
if got, wantModels := ClaudeDesktopModels(), []string{"qwen3.8:27b", "qwen3.8:27b", "qwen3.8:27b", "qwen3.8:27b", "qwen3.8:27b"}; !slices.Equal(got, wantModels) {
t.Fatalf("ClaudeDesktopModels() = %v, want %v", got, wantModels)
}
if err := SaveClaudeDesktopModels([]string{"glm-5.2:cloud"}); err != nil {
t.Fatal(err)
}
if got := ClaudeDesktopModelMappings(); len(got) != 0 {
t.Fatalf("legacy model selection left mappings behind: %v", got)
}
}
func TestClaudeDesktopModelMappingsAllowSparseRoutes(t *testing.T) {
setTestHome(t, t.TempDir())
want := map[string]string{"claude-fable-5": "qwen3.8:27b"}
if err := SaveClaudeDesktopModelMappings(want); err != nil {
t.Fatal(err)
}
if got := ClaudeDesktopModelMappings(); !maps.Equal(got, want) {
t.Fatalf("ClaudeDesktopModelMappings() = %v, want %v", got, want)
}
}
func TestClaudeDesktopApplyProfileChangeDoesNotOpenStoppedClaude(t *testing.T) {
setTestHome(t, t.TempDir())
withClaudeDesktopPlatform(t, "darwin")
withClaudeDesktopProcessHooks(t,
func() bool { return false },
func() error { t.Fatal("stopped Claude should not quit"); return nil },
func() error { t.Fatal("applying settings should not open Claude"); return nil },
)
changed := false
if err := (&ClaudeDesktop{}).ApplyProfileChange(func() error {
changed = true
return nil
}, true); err != nil {
t.Fatal(err)
}
if !changed {
t.Fatal("profile change was not applied")
}
}
func TestClaudeDesktopApplyProfileChangeRestartsRunningClaude(t *testing.T) {
setTestHome(t, t.TempDir())
withClaudeDesktopPlatform(t, "darwin")
running := true
quitCalls, openCalls := 0, 0
withClaudeDesktopProcessHooks(t,
func() bool { return running },
func() error { quitCalls++; running = false; return nil },
func() error { openCalls++; return nil },
)
if err := (&ClaudeDesktop{}).ApplyProfileChange(func() error { return nil }, true); err != nil {
t.Fatal(err)
}
if quitCalls != 1 || openCalls != 1 {
t.Fatalf("quit/open calls = %d/%d, want 1/1", quitCalls, openCalls)
}
}
func TestClaudeDesktopApplyProfileChangeRequiresRestartConfirmation(t *testing.T) {
setTestHome(t, t.TempDir())
withClaudeDesktopPlatform(t, "darwin")
changed := false
withClaudeDesktopProcessHooks(t,
func() bool { return true },
func() error { t.Fatal("unconfirmed change should not quit Claude"); return nil },
func() error { t.Fatal("unconfirmed change should not reopen Claude"); return nil },
)
err := (&ClaudeDesktop{}).ApplyProfileChange(func() error {
changed = true
return nil
}, false)
if !errors.Is(err, ErrClaudeDesktopRestartConfirmationRequired) {
t.Fatalf("error = %v, want restart confirmation", err)
}
if changed {
t.Fatal("unconfirmed profile change ran")
}
}
func TestClaudeDesktopConfigureOmitsInferenceModelsWithoutMappedIDs(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
@@ -23,6 +23,7 @@ func TestEvaluateClaudeDesktopModelAccess(t *testing.T) {
{name: "free account free model", model: free, state: ClaudeDesktopAccessState{Cloud: ClaudeDesktopCloudOn, Account: ClaudeDesktopAccountSignedIn, Plan: "free"}, want: ClaudeDesktopModelAccess{Availability: ClaudeDesktopAvailabilityAvailable, RequiredPlan: "free"}},
{name: "free account pro model", model: pro, state: ClaudeDesktopAccessState{Cloud: ClaudeDesktopCloudOn, Account: ClaudeDesktopAccountSignedIn, Plan: "free"}, want: ClaudeDesktopModelAccess{Availability: ClaudeDesktopAvailabilityUnavailable, Reason: ClaudeDesktopAccessUpgradeRequired, RequiredPlan: "pro"}},
{name: "pro account pro model", model: pro, state: ClaudeDesktopAccessState{Cloud: ClaudeDesktopCloudOn, Account: ClaudeDesktopAccountSignedIn, Plan: "pro"}, want: ClaudeDesktopModelAccess{Availability: ClaudeDesktopAvailabilityAvailable, RequiredPlan: "pro"}},
{name: "team account pro model", model: pro, state: ClaudeDesktopAccessState{Cloud: ClaudeDesktopCloudOn, Account: ClaudeDesktopAccountSignedIn, Plan: "team"}, want: ClaudeDesktopModelAccess{Availability: ClaudeDesktopAvailabilityAvailable, RequiredPlan: "pro"}},
{name: "future paid plan", model: pro, state: ClaudeDesktopAccessState{Cloud: ClaudeDesktopCloudOn, Account: ClaudeDesktopAccountSignedIn, Plan: "enterprise"}, want: ClaudeDesktopModelAccess{Availability: ClaudeDesktopAvailabilityAvailable, RequiredPlan: "pro"}},
}
+134 -5
View File
@@ -19,17 +19,79 @@ const (
type claudeDesktopModelSlot struct {
id string
displayName string
family string
createdAt string
familyDefault bool
}
var claudeDesktopModelSlots = [MaxClaudeDesktopModels]claudeDesktopModelSlot{
{id: "claude-fable-5", family: "fable", createdAt: "2026-06-09T00:00:00Z", familyDefault: true},
{id: "claude-opus-5", family: "opus", createdAt: "2026-07-24T00:00:00Z", familyDefault: true},
{id: "claude-sonnet-5", family: "sonnet", createdAt: "2026-06-30T00:00:00Z", familyDefault: true},
{id: "claude-haiku-4-5-20251001", family: "haiku", createdAt: "2025-10-01T00:00:00Z", familyDefault: true},
{id: "claude-sonnet-4-6", family: "sonnet", createdAt: "2025-11-18T00:00:00Z"},
{id: "claude-fable-5", displayName: "Fable 5", family: "fable", createdAt: "2026-06-09T00:00:00Z", familyDefault: true},
{id: "claude-opus-5", displayName: "Opus 5", family: "opus", createdAt: "2026-07-24T00:00:00Z", familyDefault: true},
{id: "claude-sonnet-5", displayName: "Sonnet 5", family: "sonnet", createdAt: "2026-06-30T00:00:00Z", familyDefault: true},
{id: "claude-haiku-4-5-20251001", displayName: "Haiku 4.5", family: "haiku", createdAt: "2025-10-01T00:00:00Z", familyDefault: true},
{id: "claude-sonnet-4-6", displayName: "Sonnet 4.6", family: "sonnet", createdAt: "2025-11-18T00:00:00Z"},
}
// ClaudeDesktopRoute is one fixed model ID accepted by Claude Desktop. The
// gateway advertises the mapped Ollama model as its display name.
type ClaudeDesktopRoute struct {
ID string
DisplayName string
}
// ClaudeDesktopRoutes returns the fixed routes in Claude's preferred order.
func ClaudeDesktopRoutes() []ClaudeDesktopRoute {
routes := make([]ClaudeDesktopRoute, len(claudeDesktopModelSlots))
for i, slot := range claudeDesktopModelSlots {
routes[i] = ClaudeDesktopRoute{ID: slot.id, DisplayName: slot.displayName}
}
return routes
}
// DefaultClaudeDesktopMappings returns the initial Claude-to-Ollama mapping
// for the current account tier. Accounts without Pro access expose only the
// free Sonnet route; the remaining routes intentionally stay unassigned.
func DefaultClaudeDesktopMappings(fullAccess bool) map[string]string {
if !fullAccess {
return map[string]string{
"claude-sonnet-5": "gemma4:31b-cloud",
}
}
return map[string]string{
"claude-fable-5": "kimi-k3:cloud",
"claude-opus-5": "glm-5.2:cloud",
"claude-sonnet-5": "deepseek-v4-flash:0731:cloud",
"claude-haiku-4-5-20251001": "gemma4:31b-cloud",
"claude-sonnet-4-6": "deepseek-v4-pro:cloud",
}
}
// DefaultClaudeDesktopMappingsForModels resolves the default recommendation
// names to the exact Ollama routes in the current catalog. This preserves
// server-owned aliases such as the current DeepSeek Flash revision.
func DefaultClaudeDesktopMappingsForModels(available []ClaudeDesktopModel, fullAccess bool) map[string]string {
mappings := make(map[string]string, MaxClaudeDesktopModels)
wanted := map[string]string{
"claude-sonnet-5": "gemma4:31b-cloud",
}
if fullAccess {
wanted = map[string]string{
"claude-fable-5": "kimi-k3:cloud",
"claude-opus-5": "glm-5.2:cloud",
"claude-sonnet-5": "deepseek-v4-flash",
"claude-haiku-4-5-20251001": "gemma4:31b-cloud",
"claude-sonnet-4-6": "deepseek-v4-pro",
}
}
for _, model := range available {
for route, name := range wanted {
if model.Name == name {
mappings[route] = model.OllamaModel
}
}
}
return mappings
}
// ClaudeDesktopModel is one Ollama model adapted for Claude Desktop's model
@@ -212,6 +274,9 @@ func SelectClaudeDesktopModels(available []ClaudeDesktopModel, selected []string
var models []ClaudeDesktopModel
if len(selected) == 0 {
models = cloneClaudeDesktopModels(available)
if validClaudeDesktopRouteAssignments(models) {
return models
}
} else {
byName := make(map[string]ClaudeDesktopModel, len(available))
for _, model := range available {
@@ -248,6 +313,70 @@ func SelectClaudeDesktopModels(available []ClaudeDesktopModel, selected []string
return models
}
// MapClaudeDesktopModels assigns explicit Claude route IDs to Ollama models.
// Empty routes are omitted and the same Ollama model may serve multiple routes.
func MapClaudeDesktopModels(available []ClaudeDesktopModel, mappings map[string]string) []ClaudeDesktopModel {
byName := make(map[string]ClaudeDesktopModel, len(available)*2)
for _, model := range available {
byName[model.Name] = model
byName[model.OllamaModel] = model
}
models := make([]ClaudeDesktopModel, 0, len(claudeDesktopModelSlots))
for _, slot := range claudeDesktopModelSlots {
name := strings.TrimSpace(mappings[slot.id])
if !validClaudeDesktopModelName(name) {
continue
}
model, ok := byName[name]
if !ok {
model = newClaudeDesktopModel(name, "User-selected model", "", 64_000)
}
model.assignClaudeDesktopSlot(slot)
models = append(models, model)
}
return models
}
// ClaudeDesktopMappings returns the explicit route-to-model mapping represented
// by an assigned model catalog.
func ClaudeDesktopMappings(models []ClaudeDesktopModel) map[string]string {
mappings := make(map[string]string, len(models))
for _, model := range models {
if !isClaudeDesktopRouteID(model.gateway.ID) {
continue
}
mappings[model.gateway.ID] = model.OllamaModel
}
return mappings
}
func validClaudeDesktopRouteAssignments(models []ClaudeDesktopModel) bool {
if len(models) == 0 {
return false
}
seen := make(map[string]struct{}, len(models))
for _, model := range models {
if !isClaudeDesktopRouteID(model.gateway.ID) {
return false
}
if _, ok := seen[model.gateway.ID]; ok {
return false
}
seen[model.gateway.ID] = struct{}{}
}
return true
}
func isClaudeDesktopRouteID(id string) bool {
for _, slot := range claudeDesktopModelSlots {
if id == slot.id {
return true
}
}
return false
}
func validClaudeDesktopModelName(name string) bool {
name = strings.TrimSpace(name)
if name == "" {
@@ -2,6 +2,7 @@ package proxy
import (
"encoding/json"
"maps"
"net/http"
"net/http/httptest"
"slices"
@@ -327,3 +328,107 @@ func TestSelectClaudeDesktopModelsCapsAtLiteralSlots(t *testing.T) {
seen[id] = struct{}{}
}
}
func TestMapClaudeDesktopModelsSupportsUnassignedAndSharedModels(t *testing.T) {
available := DefaultClaudeDesktopModels()
mapped := MapClaudeDesktopModels(available, map[string]string{
"claude-fable-5": "glm-5.2:cloud",
"claude-opus-5": "glm-5.2:cloud",
"claude-sonnet-5": "",
})
if len(mapped) != 2 {
t.Fatalf("mapped models = %d, want 2 assigned routes", len(mapped))
}
if mapped[0].GatewayID() != "claude-fable-5" || mapped[1].GatewayID() != "claude-opus-5" {
t.Fatalf("gateway IDs = %q/%q", mapped[0].GatewayID(), mapped[1].GatewayID())
}
if mapped[0].OllamaModel != "glm-5.2:cloud" || mapped[1].OllamaModel != "glm-5.2:cloud" {
t.Fatalf("shared mappings = %q/%q", mapped[0].OllamaModel, mapped[1].OllamaModel)
}
if got := ClaudeDesktopMappings(mapped); !maps.Equal(got, map[string]string{
"claude-fable-5": "glm-5.2:cloud",
"claude-opus-5": "glm-5.2:cloud",
}) {
t.Fatalf("mappings = %v", got)
}
}
func TestSelectClaudeDesktopModelsPreservesExplicitRouteAssignments(t *testing.T) {
mapped := MapClaudeDesktopModels(DefaultClaudeDesktopModels(), map[string]string{
"claude-sonnet-5": "kimi-k3:cloud",
})
selected := SelectClaudeDesktopModels(mapped, nil)
if len(selected) != 1 || selected[0].GatewayID() != "claude-sonnet-5" {
t.Fatalf("selected models = %+v", selected)
}
}
func TestClaudeDesktopRoutesExposeStableOrder(t *testing.T) {
routes := ClaudeDesktopRoutes()
if len(routes) != MaxClaudeDesktopModels {
t.Fatalf("routes = %d, want %d", len(routes), MaxClaudeDesktopModels)
}
if routes[0].ID != "claude-fable-5" || routes[0].DisplayName != "Fable 5" || routes[4].ID != "claude-sonnet-4-6" {
t.Fatalf("routes = %+v", routes)
}
}
func TestDefaultClaudeDesktopMappingsFollowAccountTier(t *testing.T) {
pro := DefaultClaudeDesktopMappings(true)
wantPro := map[string]string{
"claude-fable-5": "kimi-k3:cloud",
"claude-opus-5": "glm-5.2:cloud",
"claude-sonnet-5": "deepseek-v4-flash:0731:cloud",
"claude-haiku-4-5-20251001": "gemma4:31b-cloud",
"claude-sonnet-4-6": "deepseek-v4-pro:cloud",
}
if !maps.Equal(pro, wantPro) {
t.Fatalf("Pro defaults = %v, want %v", pro, wantPro)
}
free := DefaultClaudeDesktopMappings(false)
wantFree := map[string]string{"claude-sonnet-5": "gemma4:31b-cloud"}
if !maps.Equal(free, wantFree) {
t.Fatalf("free defaults = %v, want %v", free, wantFree)
}
}
func TestDefaultClaudeDesktopMappingsUseCurrentCatalogRoutes(t *testing.T) {
models := ClaudeDesktopModelsFromRecommendations([]api.ModelRecommendation{
{Model: "glm-5.2:cloud", RequiredPlan: "pro"},
{Model: "kimi-k3:cloud", RequiredPlan: "pro"},
{Model: "deepseek-v4-pro", RequiredPlan: "pro"},
{Model: "deepseek-v4-flash", RequiredPlan: "pro"},
{Model: "gemma4:31b-cloud", RequiredPlan: "free"},
})
paid := DefaultClaudeDesktopMappingsForModels(models, true)
wantPaid := map[string]string{
"claude-fable-5": "kimi-k3:cloud",
"claude-opus-5": "glm-5.2:cloud",
"claude-sonnet-5": "deepseek-v4-flash:cloud",
"claude-haiku-4-5-20251001": "gemma4:31b-cloud",
"claude-sonnet-4-6": "deepseek-v4-pro:cloud",
}
if !maps.Equal(paid, wantPaid) {
t.Fatalf("paid catalog defaults = %v, want %v", paid, wantPaid)
}
restricted := DefaultClaudeDesktopMappingsForModels(models, false)
wantRestricted := map[string]string{
"claude-sonnet-5": "gemma4:31b-cloud",
}
if !maps.Equal(restricted, wantRestricted) {
t.Fatalf("restricted catalog defaults = %v, want %v", restricted, wantRestricted)
}
}
func TestDefaultClaudeDesktopMappingsOnlyUseAvailableModels(t *testing.T) {
models := ClaudeDesktopModelsFromRecommendations([]api.ModelRecommendation{
{Model: "glm-5.2:cloud", RequiredPlan: "pro"},
})
want := map[string]string{"claude-opus-5": "glm-5.2:cloud"}
if got := DefaultClaudeDesktopMappingsForModels(models, true); !maps.Equal(got, want) {
t.Fatalf("partial catalog defaults = %v, want %v", got, want)
}
}