mirror of
https://github.com/ollama/ollama.git
synced 2026-09-21 13:38:14 -05:00
app: add Claude Desktop model mappings (#17979)
This commit is contained in:
+200
-70
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+15
-2
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}},
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user