Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 48 additions & 5 deletions desktop/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -4910,7 +4910,50 @@ func modelProviderAccessAllowed(access map[string]bool, name string) bool {
if len(access) == 0 {
return true
}
return access[strings.TrimSpace(name)]
return access[config.CanonicalDesktopOfficialProviderName(strings.TrimSpace(name))]
}

func resolveAccessibleModelWithFallback(cfg *config.Config, ref string) (resolvedRef string, fallback bool, ok bool) {
if cfg == nil {
return "", false, false
}
access := providerAccessSet(cfg.Desktop.ProviderAccess)
tryResolve := func(candidate string, fallback bool) (string, bool, bool) {
entry, found := cfg.ResolveModel(candidate)
if !found || !entry.Configured() || !modelProviderAccessAllowed(access, entry.Name) {
return "", false, false
}
return entry.Name + "/" + entry.Model, fallback, true
}

ref = strings.TrimSpace(ref)
if ref != "" {
if resolved, fallback, ok := tryResolve(ref, false); ok {
return resolved, fallback, true
}
}
defaultRef := strings.TrimSpace(cfg.DefaultModel)
if ref != defaultRef && defaultRef != "" {
if resolved, fallback, ok := tryResolve(defaultRef, true); ok {
return resolved, fallback, true
}
}
for i := range cfg.Providers {
p := &cfg.Providers[i]
if len(p.ModelList()) == 0 || !p.Configured() || !modelProviderAccessAllowed(access, p.Name) {
continue
}
return p.Name + "/" + p.DefaultModel(), true, true
}
return "", false, false
}

func noAccessibleModelError(ref string) error {
ref = strings.TrimSpace(ref)
if ref == "" {
ref = "<default>"
}
return fmt.Errorf("model %q is no longer available and no accessible fallback is configured", ref)
}

func controllerHasActiveRuntimeWork(ctrl *control.Controller) bool {
Expand Down Expand Up @@ -5652,9 +5695,9 @@ func (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, e
if strings.TrimSpace(ref) == "" {
ref = cfg.DefaultModel
}
resolved, _, ok := cfg.ResolveModelWithFallback(ref)
resolved, _, ok := resolveAccessibleModelWithFallback(cfg, ref)
if !ok {
return nil, fmt.Errorf("unknown model %q", ref)
return nil, noAccessibleModelError(ref)
}
entry, ok := cfg.ResolveModel(resolved)
if !ok {
Expand All @@ -5678,9 +5721,9 @@ func (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {
if ref == "" {
ref = cfg.DefaultModel
}
resolved, fallback, ok := cfg.ResolveModelWithFallback(ref)
resolved, fallback, ok := resolveAccessibleModelWithFallback(cfg, ref)
if !ok {
return "", false, fmt.Errorf("unknown model %q", ref)
return "", false, noAccessibleModelError(ref)
}
return resolved, fallback, nil
}
Expand Down
159 changes: 159 additions & 0 deletions desktop/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1176,6 +1176,165 @@ func TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {
}
}

func TestSetTokenModeFallsBackWhenTabProviderAccessWasRemoved(t *testing.T) {
isolateDesktopUserDirs(t)
t.Setenv("DEEPSEEK_API_KEY", "sk-test")
t.Setenv("PROV_A_KEY", "sk-test")

cfg := config.Default()
cfg.DefaultModel = "prov-a/model-a"
cfg.Desktop.ProviderAccess = []string{"prov-a"}
cfg.Providers = append(cfg.Providers, config.ProviderEntry{
Name: "prov-a",
Kind: "openai",
BaseURL: "https://a.example.com/v1",
Model: "model-a",
APIKeyEnv: "PROV_A_KEY",
})
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}

app := NewApp()
app.ctx = context.Background()
app.readyHook = func() {}
old := control.New(control.Options{Label: "old-controller"})
app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash")
defer func() {
if c := app.activeCtrl(); c != nil {
c.Close()
}
}()

if err := app.SetTokenMode("economy"); err != nil {
t.Fatalf("SetTokenMode(economy): %v", err)
}
tab := app.activeTab()
if tab == nil {
t.Fatal("active tab missing")
}
if tab.model != "prov-a/model-a" {
t.Fatalf("tab model = %q, want prov-a/model-a fallback", tab.model)
}
}

func TestBuildTabControllerFallsBackWhenSavedTabProviderAccessWasRemoved(t *testing.T) {
isolateDesktopUserDirs(t)
t.Setenv("DEEPSEEK_API_KEY", "sk-test")
t.Setenv("PROV_A_KEY", "sk-test")

cfg := config.Default()
cfg.DefaultModel = "prov-a/model-a"
cfg.Desktop.ProviderAccess = []string{"prov-a"}
cfg.Providers = append(cfg.Providers, config.ProviderEntry{
Name: "prov-a",
Kind: "openai",
BaseURL: "https://a.example.com/v1",
Model: "model-a",
APIKeyEnv: "PROV_A_KEY",
})
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}

project := t.TempDir()
app := NewApp()
tab := app.createTabEntryWithID("project", project, "", "tab_access_removed")
tab.model = "deepseek-flash/deepseek-v4-flash"
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID

app.buildTabController(tab)
if tab.Ctrl == nil {
t.Fatalf("tab controller was not built: %s", tab.StartupErr)
}
defer tab.Ctrl.Close()

if tab.model != "prov-a/model-a" {
t.Fatalf("tab model = %q, want prov-a/model-a fallback", tab.model)
}
saved := loadTabsFile()
if len(saved.Tabs) != 1 || saved.Tabs[0].Model != "prov-a/model-a" {
t.Fatalf("saved tabs = %+v, want prov-a/model-a", saved.Tabs)
}
}

func TestBuildTabControllerErrorsWhenNoAccessibleFallbackExists(t *testing.T) {
isolateDesktopUserDirs(t)
t.Setenv("PROV_A_KEY", "")

cfg := config.Default()
cfg.DefaultModel = "prov-a/model-a"
cfg.Desktop.ProviderAccess = []string{"prov-a"}
cfg.Providers = []config.ProviderEntry{{
Name: "prov-a",
Kind: "openai",
BaseURL: "https://a.example.com/v1",
Model: "model-a",
APIKeyEnv: "PROV_A_KEY",
}}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}

project := t.TempDir()
app := NewApp()
tab := app.createTabEntryWithID("project", project, "", "tab_no_accessible_fallback")
tab.model = "deepseek-flash/deepseek-v4-flash"
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID

app.buildTabController(tab)
if tab.Ctrl != nil {
t.Fatalf("tab controller should not be built when no accessible fallback exists")
}
if tab.StartupErr == "" || !strings.Contains(tab.StartupErr, "no accessible fallback") {
t.Fatalf("startup error = %q, want no accessible fallback message", tab.StartupErr)
}
}

func TestRebuildKeepsExistingControllerWhenNoAccessibleFallbackExists(t *testing.T) {
isolateDesktopUserDirs(t)
t.Setenv("PROV_A_KEY", "")

cfg := config.Default()
cfg.DefaultModel = "prov-a/model-a"
cfg.Desktop.ProviderAccess = []string{"prov-a"}
cfg.Providers = []config.ProviderEntry{{
Name: "prov-a",
Kind: "openai",
BaseURL: "https://a.example.com/v1",
Model: "model-a",
APIKeyEnv: "PROV_A_KEY",
}}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}

app := NewApp()
app.ctx = context.Background()
app.readyHook = func() {}
old := control.New(control.Options{Label: "old-controller"})
app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash")
defer func() {
if c := app.activeCtrl(); c != nil {
c.Close()
}
}()

err := app.rebuild()
if err == nil || !strings.Contains(err.Error(), "no accessible fallback") {
t.Fatalf("rebuild error = %v, want no accessible fallback", err)
}
if got := app.activeCtrl(); got != old {
t.Fatalf("active controller = %v, want existing controller preserved", got)
}
}

func TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {
isolateDesktopUserDirs(t)
t.Setenv("DEEPSEEK_API_KEY", "")
Expand Down
12 changes: 10 additions & 2 deletions desktop/frontend/src/lib/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1264,8 +1264,16 @@ function makeMockApp(): AppBindings {
},
];
const mockModelCatalog = [
{ ref: "deepseek/deepseek-v4-flash", provider: "deepseek", model: "deepseek-v4-flash" },
{ ref: "deepseek/deepseek-v4-pro", provider: "deepseek", model: "deepseek-v4-pro" },
{
ref: "deepseek/deepseek-v4-flash",
provider: "deepseek",
model: "deepseek-v4-flash",
},
{
ref: "deepseek/deepseek-v4-pro",
provider: "deepseek",
model: "deepseek-v4-pro",
},
];
const defaultMockModelRef = mockModelCatalog[0].ref;
const mockModelRef = (name: string): string => {
Expand Down
56 changes: 28 additions & 28 deletions desktop/settings_app.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ func providerAccessSet(names []string) map[string]bool {
for _, name := range names {
name = strings.TrimSpace(name)
if name != "" {
out[name] = true
out[config.CanonicalDesktopOfficialProviderName(name)] = true
}
}
return out
Expand All @@ -246,7 +246,7 @@ func providerAccessSet(names []string) map[string]bool {
func addProviderAccess(c *config.Config, names ...string) {
seen := providerAccessSet(c.Desktop.ProviderAccess)
for _, name := range names {
name = strings.TrimSpace(name)
name = config.CanonicalDesktopOfficialProviderName(strings.TrimSpace(name))
if name == "" || seen[name] {
continue
}
Expand All @@ -262,7 +262,7 @@ func removeProviderAccess(c *config.Config, names ...string) {
}
out := c.Desktop.ProviderAccess[:0]
for _, name := range c.Desktop.ProviderAccess {
if !remove[name] {
if !remove[config.CanonicalDesktopOfficialProviderName(strings.TrimSpace(name))] {
out = append(out, name)
}
}
Expand Down Expand Up @@ -305,7 +305,7 @@ func officialProviderAddedSet(cfg *config.Config) map[string]bool {
access := providerAccessSet(cfg.Desktop.ProviderAccess)
for i := range cfg.Providers {
p := cfg.Providers[i]
if !access[p.Name] {
if !access[config.CanonicalDesktopOfficialProviderName(strings.TrimSpace(p.Name))] {
continue
}
if kind := officialProviderKindFromEntry(p); kind != "" {
Expand Down Expand Up @@ -409,7 +409,7 @@ func (a *App) Settings() SettingsView {
v.OfficialProviders = officialProviderViews(officialProviderAddedSet(cfg))
for i := range cfg.Providers {
p := &cfg.Providers[i]
v.Providers = append(v.Providers, providerViewFromEntry(*p, isOfficialBuiltInProvider(*p), added[p.Name]))
v.Providers = append(v.Providers, providerViewFromEntry(*p, isOfficialBuiltInProvider(*p), added[config.CanonicalDesktopOfficialProviderName(strings.TrimSpace(p.Name))]))
}
return v
}
Expand Down Expand Up @@ -687,22 +687,24 @@ func (a *App) rebuild() error {
if controllerHasActiveRuntimeWork(tab.Ctrl) {
return rebuildControllerActiveWorkError("settings")
}
var carried []provider.Message
prevPath := ""
if tab.Ctrl != nil {
prevPath = tab.Ctrl.SessionPath()
_ = a.snapshotTab(tab)
carried = tab.Ctrl.History()
tab.Ctrl.Close()
}
model := tab.model
if cfg, err := config.LoadForRoot(tab.WorkspaceRoot); err == nil {
if resolved, fallback, ok := cfg.ResolveModelWithFallback(model); ok {
if fallback && strings.TrimSpace(model) != "" {
a.noticeForTab(tab.ID, fmt.Sprintf("model %q is no longer available; switched to %s", model, resolved))
}
model = resolved
resolved, fallback, ok := resolveAccessibleModelWithFallback(cfg, model)
if !ok {
return noAccessibleModelError(model)
}
if fallback && strings.TrimSpace(model) != "" {
a.noticeForTab(tab.ID, fmt.Sprintf("model %q is no longer available; switched to %s", model, resolved))
}
model = resolved
}
var carried []provider.Message
prevPath := ""
oldCtrl := tab.Ctrl
if oldCtrl != nil {
prevPath = oldCtrl.SessionPath()
_ = a.snapshotTab(tab)
carried = oldCtrl.History()
}
ctrl, err := boot.Build(a.bootContext(), boot.Options{
Model: model, RequireKey: false,
Expand All @@ -713,14 +715,12 @@ func (a *App) rebuild() error {
TokenMode: currentTabTokenMode(tab),
})
if err != nil {
a.mu.Lock()
tab.StartupErr = err.Error()
tab.Ready = true
a.mu.Unlock()
a.emitReady(a.ctx)
return err
}
a.bindControllerDisplayRecorder(ctrl)
if oldCtrl != nil {
oldCtrl.Close()
}
a.mu.Lock()
tab.Ctrl = ctrl
tab.model = model
Expand Down Expand Up @@ -1068,17 +1068,17 @@ type providerRemovalTab struct {
}

func providerAccessFallbackRef(c *config.Config, name string) string {
name = strings.TrimSpace(name)
name = config.CanonicalDesktopOfficialProviderName(strings.TrimSpace(name))
for _, candidate := range c.Desktop.ProviderAccess {
candidate = strings.TrimSpace(candidate)
candidate = config.CanonicalDesktopOfficialProviderName(strings.TrimSpace(candidate))
if candidate == "" || candidate == name {
continue
}
p, ok := c.Provider(candidate)
if !ok || len(p.ModelList()) == 0 {
p, ok := c.ResolveModel(candidate)
if !ok || !p.Configured() {
continue
}
return p.Name + "/" + p.DefaultModel()
return p.Name + "/" + p.Model
}
return ""
}
Expand Down
Loading