Skip to content

Commit c240e36

Browse files
authored
Fix model availability and simplify provider setup (#1789)
* fix(chat): close model pickers after confirming a model * docs: align model picker commentary with confirmation behavior * test(chat): confirm model selection returns to settings and draft * docs: record model picker confirmation behavior * fix(chat): list models only from usable provider connections * docs(chat): remove the unconnected-provider picker contract * docs(chat): pin the new picker behavior in the manual * test(chat): give picker interactions explicit available catalogs * fix(chat): choose defaults from available provider catalogs * fix(chat): honor the balance preference only when it is listed * fix(chat): reconcile defaults after connection changes settle * Choose a supported model provider before first-run connection * Omit a previous-model note on first provider connection * Scroll supported providers and show short browser sign-in links * Show all setup providers and confirm Codex browser connection * Put OpenRouter first in provider setup * Include every supported provider in later connection menus * Select the default provider in fresh-install terminal acceptance
1 parent b3d9d88 commit c240e36

68 files changed

Lines changed: 2112 additions & 366 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎cmd/codeaf/chatv3.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -507,7 +507,8 @@ func openChatV3(name string, args []string, pickSession bool) error {
507507
// resolved while the person was reading is a catalog the picker can
508508
// use, and one that has not resolved answers nil instead of waiting.
509509
// It reads the shelf, which ctrl+r in /model refills with today's list.
510-
Models: func() []tui3.Model { return v3Models(proc.Shelf) },
510+
Models: proc.Shelf.pickerModels,
511+
RequireListedModel: true,
511512
RefreshModels: proc.refreshDefaultModels,
512513
ModelsForService: proc.Shelf.modelsForService,
513514
RefreshModelsForService: proc.Shelf.refreshService,

‎cmd/codeaf/chatv3_host.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -717,7 +717,7 @@ func hostOptions(fleet *engineFleet, welcome remote.Welcome, pick bool) (tui3.Op
717717
// timer that is already armed around a far process.
718718
BashBackgroundAfterSeconds: welcome.BashBackgroundAfterSeconds,
719719
ContextWindow: v3Window(models, welcome.Model),
720-
Models: func() []tui3.Model { return v3Models(shelf) },
720+
Models: shelf.pickerModels,
721721
RefreshModels: shelf.refresh,
722722
ProviderFetchError: shelf.fetchErrorFor,
723723
// /export writes on THIS machine (host.go's honesty table), so its row

‎cmd/codeaf/chatv3_local.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,7 @@ func localDoors(options *tui3.Options, welcome remote.Welcome, settings config.C
400400
}
401401
}
402402
options.EngineRoad = true
403+
options.RequireListedModel = true
403404
options.ReadCredits = v3LocalCreditReader(settings)
404405
options.Connections = v3Connections(v3Connect(profileDir))
405406
options.Harnesses = subharness.Default()

‎cmd/codeaf/chatv3_local_test.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ func TestPlainLaunchReadsClosedTeamReportFromEngineProfile(t *testing.T) {
7171
t.Fatal("the engine did not hand teams to the plain launch")
7272
}
7373
localDoors(&options, welcome, settings)
74+
if !options.RequireListedModel {
75+
t.Fatal("the plain launch can still use an unlisted default model")
76+
}
7477
if options.Teams.History == nil {
7578
t.Fatal("the plain launch has no history door")
7679
}

‎cmd/codeaf/chatv3_modelshelf.go‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,20 @@ func (s *v3ModelShelf) setSources(sources modelsource.Set) {
9696
}
9797
}
9898

99+
// pickerModels excludes capability fallbacks from selectable rows. Those facts
100+
// help the engine describe a known model, but only a provider's listing proves
101+
// that a model belongs in this account's menu.
102+
func (s *v3ModelShelf) pickerModels() []tui3.Model {
103+
if s == nil {
104+
return nil
105+
}
106+
models := s.current.Load()
107+
if models == nil || models.FetchedAtNow().IsZero() {
108+
return nil
109+
}
110+
return v3Models(models)
111+
}
112+
99113
// modelsForService is the never-waiting half of the connected-service shelf
100114
// seam. The default keeps reading the atomic launch catalog; another service
101115
// reads only its own compartment.

‎cmd/codeaf/chatv3_modelshelf_test.go‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,3 +245,25 @@ func TestTheShelfTakesTodaysListAndKeepsYesterdaysOnFailure(t *testing.T) {
245245
t.Fatalf("~/.codeaf/v3/models.json is not today's list: %+v", cached)
246246
}
247247
}
248+
249+
func TestPickerModelsExcludeCapabilityFallbacksUntilAProviderListsThem(t *testing.T) {
250+
t.Setenv("CODEAF_HOME", t.TempDir())
251+
options := catalog.Options{BaseURL: catalog.DefaultBaseURL, Dir: t.TempDir(), HTTPClient: shelfRouter("", errors.New("offline"))}
252+
models := catalog.Load(t.Context(), options)
253+
shelf := newV3ModelShelf(models, options)
254+
if len(v3Models(shelf)) == 0 {
255+
t.Fatal("the fixture must carry engine capability fallbacks")
256+
}
257+
if offered := shelf.pickerModels(); len(offered) != 0 {
258+
t.Fatalf("unlisted capability fallbacks reached the picker: %+v", offered)
259+
}
260+
options.HTTPClient = shelfRouter(shelfNewRow, nil)
261+
listed, err := catalog.Refresh(t.Context(), options)
262+
if err != nil {
263+
t.Fatal(err)
264+
}
265+
shelf.current.Store(listed)
266+
if offered := shelf.pickerModels(); len(offered) != 1 || offered[0].ID != "vendor/shipped-this-morning" {
267+
t.Fatalf("listed provider rows did not reach the picker: %+v", offered)
268+
}
269+
}

‎cmd/codeaf/chatv3_process.go‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ func openV3ProcessWith(door string, askKey bool) (*v3Process, error) {
229229
}
230230
fmt.Fprintln(os.Stderr, "codeaf "+door+" needs a model to talk with.")
231231
if keyless, loadErr := config.LoadKeyless(); loadErr == nil && v3UsesDefaultOpenRouter(keyless) {
232-
fmt.Fprintln(os.Stderr, "run `codeaf` in a terminal to connect OpenRouter, or export "+config.APIKeyEnv+" and run it again.")
232+
fmt.Fprintln(os.Stderr, "run `codeaf` in a terminal to choose a model provider, or export "+config.APIKeyEnv+" and run it again.")
233233
} else {
234234
fmt.Fprintln(os.Stderr, "export "+config.APIKeyEnv+" and run it again.")
235235
}
@@ -462,6 +462,15 @@ func (p *v3Process) warmEmptyProviders(ctx context.Context) {
462462
return
463463
}
464464
p.Shelf.warmAll(ctx, true, p.noteServiceModels)
465+
// The default catalog warms independently. Deliver its answer on the same
466+
// subscription as direct providers so a cold opening can acquire a model
467+
// without a keystroke or a second fetch.
468+
service := p.Shelf.sourcesNow().Default()
469+
if service.HasCredentials() {
470+
if models := p.Shelf.current.Load(); models != nil && models.Warmed(ctx) {
471+
p.noteServiceModels(service)
472+
}
473+
}
465474
}
466475

467476
// registerServiceNotice subscribes one window and returns its removal function.

‎cmd/codeaf/chatv3_servicenotice_test.go‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"net/http"
77
"net/http/httptest"
88
"sync"
9+
"sync/atomic"
910
"testing"
1011

1112
"github.com/Agent-Field/codeaf/internal/catalog"
@@ -99,3 +100,33 @@ func TestProviderWarmReportsFailureBeforeTheNextProviderFinishes(t *testing.T) {
99100
t.Fatal("failed provider's reason missing")
100101
}
101102
}
103+
104+
func TestTheColdDefaultCatalogNotifiesTheSurfaceWithoutAnotherFetch(t *testing.T) {
105+
t.Setenv("CODEAF_HOME", t.TempDir())
106+
var requests atomic.Int32
107+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
108+
requests.Add(1)
109+
w.Header().Set("Content-Type", "application/json")
110+
_, _ = w.Write([]byte(`{"data":[{"id":"available-default"}]}`))
111+
}))
112+
defer server.Close()
113+
options := catalog.Options{Dir: t.TempDir(), BaseURL: server.URL, APIKey: "synthetic"}
114+
models := catalog.LoadLazy(t.Context(), options)
115+
defer models.Close()
116+
shelf := newV3ModelShelf(models, options)
117+
service := modelsource.Connected{Source: modelsource.DefaultSource(server.URL), Address: server.URL, Key: "synthetic"}
118+
shelf.setSources(modelsource.NewSet(service))
119+
p := &v3Process{Shelf: shelf}
120+
notices := 0
121+
remove := p.registerServiceNotice(func(source, address string) {
122+
if source != modelsource.DefaultID || address != server.URL {
123+
t.Errorf("notice = %q at %q", source, address)
124+
}
125+
notices++
126+
})
127+
defer remove()
128+
p.warmEmptyProviders(t.Context())
129+
if rows := shelf.pickerModels(); len(rows) != 1 || rows[0].ID != "available-default" || notices != 1 || requests.Load() != 1 {
130+
t.Fatalf("default warm: rows=%+v notices=%d requests=%d", rows, notices, requests.Load())
131+
}
132+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
kind: changed
3+
title: Choose a provider during setup and pick only available models
4+
pr: 1789
5+
surface: [chat, docs]
6+
invalidates:
7+
- "The later add-provider and /connect menus used to omit OpenRouter and treated every saved provider row as connected. Both now include all nine initial setup options and read actual credential availability; the add menu labels connected and not connected rows. OpenRouter can be connected after Ollama through its existing browser/key form without repeating onboarding, losing the draft or removing Ollama. Its profile key can also be disconnected with two-Enter confirmation and is revoked in the running session; shell keys remain connected until unset and restarted."
8+
- "Provider selection used to start on Ollama. OpenRouter is now first and initially selected, with Ollama second; the other seven options keep their order."
9+
- "Choosing Codex during setup used to start browser sign-in immediately. Codex now has the same enter connects in browser confirmation as OpenRouter; selecting the provider opens its connection screen and Enter starts sign-in."
10+
- "Provider setup used to divide supported providers between a first page and More providers, with a selectable Skip for now row. It now has one flat list showing all nine providers, with no scrolling viewport or page counter; Esc skips setup and no provider row skips it."
11+
- "Browser authorization URLs used to wrap across several visible rows. Codex, OpenRouter and the shared browser sign-in cards now show one short hyperlink retaining the complete URL. Setup adds Ctrl+Y to copy the complete sign-in URL; waiting cards retain their full-link copy action."
12+
- "A fresh profile used to ask for OpenRouter before offering any other model provider. Setup now begins with a choice of supported providers, retains the numbered connection and controls screens, and connects Ollama without asking for a key. Back cancels unfinished connections, and working connections bypass the chooser."
13+
- "Enter used to apply a model and leave the picker open until Esc. It now selects the model and closes the list immediately, returning to the conversation, task room, settings, Home draft or task composer."
14+
- "OpenRouter's public catalog used to appear even without an OpenRouter key. Every model picker now includes only provider connections with credentials or explicit anonymous access, including Ollama, and combines their own catalogs when several are connected."
15+
- "A cold picker used to offer five built-in guesses, and filtered or empty catalogs could fall through to older rows. Picker lists now use only the current provider's known catalog or its own cache while warming, with no built-in guesses."
16+
- "A local launch used to display its configured or shipped model even when the picker did not list it. Its default now comes from the available chat catalog; an absent choice is replaced by a listed model, and no known model leaves the draft held until discovery supplies one."
17+
---
18+
19+
The draft is preserved when the model is chosen. A list with no matching model
20+
stays open; Enter inside provider controls keeps their existing navigation.
21+
22+
The fresh-install terminal test confirms the initially selected OpenRouter row
23+
with Enter before checking its connection screen and setup count.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package config
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
8+
"github.com/Agent-Field/codeaf/internal/codexauth"
9+
"github.com/Agent-Field/codeaf/internal/modelsource"
10+
)
11+
12+
func TestCancelledConnectionsCannotSaveCredentials(t *testing.T) {
13+
ctx, cancel := context.WithCancel(t.Context())
14+
cancel()
15+
dir := t.TempDir()
16+
src := modelsource.Source{ID: "custom", Written: "local", KeyOptional: true}
17+
_, err := ConnectService(ctx, dir, PersistedSource{ID: "custom", Written: "local", Address: "http://localhost:1", KeyOptional: true}, src, nil)
18+
if !errors.Is(err, context.Canceled) || len(PersistedSources(dir)) != 0 {
19+
t.Fatalf("cancelled service persisted: %v", err)
20+
}
21+
_, err = ConnectCodex(ctx, dir, codexauth.Tokens{AccessToken: "cancelled-secret", RefreshToken: "cancelled-refresh"})
22+
if !errors.Is(err, context.Canceled) || codexauth.Connected(dir) || len(PersistedSources(dir)) != 0 {
23+
t.Fatalf("cancelled sign-in persisted: %v", err)
24+
}
25+
}

0 commit comments

Comments
 (0)