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
4 changes: 4 additions & 0 deletions cmd/codeaf/chatv3.go
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,10 @@ func openChatV3(name string, args []string, pickSession bool) error {
RefreshModels: proc.Shelf.refresh,
ModelsForService: proc.Shelf.modelsForService,
RefreshModelsForService: proc.Shelf.refreshService,
RefreshAllModels: proc.refreshAllModels,
WarmEmptyProviders: proc.warmEmptyProviders,
OnServiceModels: proc.onServiceModelsLanded,
ProviderFetchError: proc.Shelf.fetchErrorFor,
Sources: settings.Sources,
// The same deliverables index the session's config carries, so the
// surface's /export rows and the session's own land in one file.
Expand Down
4 changes: 2 additions & 2 deletions cmd/codeaf/chatv3_credits.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func v3CreditReader(proc *v3Process) func(context.Context) (credits.Reading, err
return func(ctx context.Context) (credits.Reading, error) {
key, sources := proc.currentAccount()
if strings.TrimSpace(key) == "" {
return credits.Reading{}, errors.New("no default-service key")
return credits.Reading{}, errors.New("no default provider key")
}
base := sources.Default().Address
if base == "" {
Expand All @@ -52,7 +52,7 @@ func v3LocalCreditReader(settings config.Config) func(context.Context) (credits.
return func(ctx context.Context) (credits.Reading, error) {
key := config.APIKeyAt(settings.ProfileDir)
if key == "" {
return credits.Reading{}, errors.New("no default-service key")
return credits.Reading{}, errors.New("no default provider key")
}
base := settings.Sources.Default().Address
if base == "" {
Expand Down
1 change: 1 addition & 0 deletions cmd/codeaf/chatv3_host.go
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,7 @@ func hostOptions(fleet *engineFleet, welcome remote.Welcome, pick bool) (tui3.Op
ContextWindow: v3Window(models, welcome.Model),
Models: func() []tui3.Model { return v3Models(shelf) },
RefreshModels: shelf.refresh,
ProviderFetchError: shelf.fetchErrorFor,
// /export writes on THIS machine (host.go's honesty table), so its row
// goes in this machine's index — the same one the local launch spells.
ArtifactsIndex: artifactsIndexPath(),
Expand Down
104 changes: 103 additions & 1 deletion cmd/codeaf/chatv3_modelshelf.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ type v3ModelShelf struct {
// sources is the service set the compartments were last aligned with, kept
// so a model id can be taken to ITS service's rows ([v3ModelShelf.contextWindow]).
sources modelsource.Set
// fetchErrors holds, per provider id, why its last listing attempt failed —
// the sentence the provider's group shows until a fetch lands. Written only
// from commands off the event loop, read on the draw path.
fetchErrors map[string]string
}

type serviceCompartment struct {
Expand Down Expand Up @@ -243,16 +247,114 @@ func (s *v3ModelShelf) refreshService(ctx context.Context, service modelsource.C
if len(seed) > 0 {
return append([]tui3.Model(nil), seed...), nil
}
return nil, v3FetchReason(err)
reason := v3FetchReason(err)
s.fetchError(strings.ToLower(strings.TrimSpace(service.Source.ID)), reason)
return nil, reason
}
rows := v3Models(fresh)
id := strings.ToLower(strings.TrimSpace(service.Source.ID))
address, door := serviceCompartmentIdentity(service)
s.stock(id, address, door, append([]tui3.Model(nil), rows...))
s.fetchError(id, nil)
_ = tui3.WriteModelCacheFor(service.Source.ID, service.Address, rows)
return rows, nil
}

// v3ServiceFetch is one connected provider's answer to a warm or a refresh:
// the rows when the door answered, and the reason when it did not. A provider
// that lists no models reports neither rows nor error — its group says so
// through the surface's own empty-group sentence.
type v3ServiceFetch struct {
service modelsource.Connected
rows []tui3.Model
err error
}

// warmAll fetches every connected provider whose compartment is cold, through
// the same door the connect path uses ([v3ModelShelf.refreshService]). It is
// issue #1508's launch half: a profile whose model_sources rows survive but
// whose per-provider caches do not (a new machine, a cleaned profile, a
// hand-written row) used to open /model with nothing to offer and nothing
// coming — the only fetch was the one at connect time.
//
// IT IS THE CALLER'S GOROUTINE: this walks the network and must never run on
// the event loop (the same law the connect command keeps). The draw path keeps
// its lock discipline — the shelf takes its own lock and no other — and the
// reader sees each provider's group fill the moment its fetch stocks the
// compartment, without a reopen.
//
// A PROVIDER THAT CANNOT LIST IS NOT SKIPPED SILENTLY: the reason is recorded
// per provider ([v3ModelShelf.fetchErrors]) so the group can say why, and the
// next provider is still tried. ctrl+r shares this walk.
func (s *v3ModelShelf) warmAll(ctx context.Context, onlyCold bool) []v3ServiceFetch {
if s == nil {
return nil
}
var fetches []v3ServiceFetch
for _, service := range s.sourcesNow().All()[1:] {
if strings.EqualFold(service.Source.ID, "codex") {
// A CODEX COMPARTMENT IS RE-READ FROM ITS REMEMBERED CATALOG, never
// from the wire; its rows arrive at setSources. Skipped here.
continue
}
id := strings.ToLower(strings.TrimSpace(service.Source.ID))
if onlyCold {
held, rows, ok := s.compartment(id)
if ok && (len(rows) > 0 || held.address == "" && held.door == "") {
// Warm, or a compartment that says it cannot be listed at all.
continue
}
if len(rows) == 0 && !ok {
// Not kept by setSources: not a listing provider this run.
if service.Source.Listing != modelsource.ListingModels || len(service.Door.Models) > 0 {
continue
}
}
}
if service.Source.Listing != modelsource.ListingModels || len(service.Door.Models) > 0 {
// A provider that declares no listing (or vendors its catalog in
// the door) has nothing to fetch; its group is drawn from what the
// compartment or the vendored rows hold.
continue
}
rows, err := s.refreshService(ctx, service, nil)
if err == nil && len(rows) == 0 {
continue
}
fetches = append(fetches, v3ServiceFetch{service: service, rows: rows, err: err})
}
return fetches
}

// fetchError records one provider's listing refusal, and fetchErrorFor reads it
// back for the group's status line. The maps are only ever touched under the
// shelf lock, from commands off the loop.
func (s *v3ModelShelf) fetchError(id string, err error) {
if s == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
if s.fetchErrors == nil {
s.fetchErrors = make(map[string]string)
}
if err == nil {
delete(s.fetchErrors, id)
return
}
s.fetchErrors[id] = err.Error()
}

// fetchErrorFor is the recorded reason one provider last failed to list.
func (s *v3ModelShelf) fetchErrorFor(id string) string {
if s == nil {
return ""
}
s.mu.RLock()
defer s.mu.RUnlock()
return s.fetchErrors[id]
}

// v3Rows is a list of catalog rows already in hand, asked the one question
// [v3Models] asks of a catalog.
type v3Rows []catalog.Model
Expand Down
96 changes: 96 additions & 0 deletions cmd/codeaf/chatv3_process.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ type v3Process struct {
// session readers that answer about a model somebody may have just picked
// out of that list — can it see, may a task be handed to it — read here.
Shelf *v3ModelShelf
// serviceNotices are the surfaces to tell when a provider's listing lands.
// Appended by each launch that opens the surface, never read on the draw
// path.
serviceNotices []func(string, string)
// Harnesses is the registry under the state root. The law is already written
// at [openV3Launch]: two stores at one directory is how /harness and the
// offer card come to name different harnesses.
Expand Down Expand Up @@ -390,6 +394,98 @@ func (p *v3Process) setModelSources(sources modelsource.Set) {
}
}

// refreshAllModels is [tui3.Options.RefreshAllModels]: ctrl+r in /model walks
// the router's catalog AND every connected provider's listing (issue #1508).
// One provider's refusal never stops the walk: each fetch is its own call and
// its own error, and the group that could not list names its own reason
// ([v3ModelShelf.fetchErrors]). Runs as a command off the event loop.
func (p *v3Process) refreshAllModels(ctx context.Context) {
if p == nil || p.Shelf == nil {
return
}
if _, _, err := p.Shelf.refresh(ctx); err != nil {
// The default provider's own refusal is recorded on the shelf like any
// other: nothing here writes to a terminal that is not ours to write.
p.Shelf.fetchError(modelsource.DefaultID, err)
} else {
p.Shelf.fetchError(modelsource.DefaultID, nil)
}
for _, fetch := range p.Shelf.warmAll(ctx, false) {
if fetch.err == nil {
p.noteServiceModels(fetch.service)
}
}
}

// warmEmptyProviders is [tui3.Options.WarmEmptyProviders]: the launch half of
// issue #1508. Every connected provider that lists models and whose cache file
// is missing or empty is fetched once, off the loop, through the connect path's
// own door. It is called after setSources has filled what the caches could,
// so a warm provider costs nothing and a cold one fills its group without a
// reopen.
func (p *v3Process) warmEmptyProviders(ctx context.Context) {
if p == nil || p.Shelf == nil {
return
}
for _, fetch := range p.Shelf.warmAll(ctx, true) {
if fetch.err == nil {
p.noteServiceModels(fetch.service)
}
}
}

// onServiceModelsLanded is the closure a launch hands its surface as
// [tui3.Options.OnServiceModels]: the process fans the news out and nothing
// here needs the surface's own app. The fan-out registers nothing per launch —
// [v3Process.noteServiceModels] walks the doors the process retained — so a
// second window in this process hears its own provider news the same way.
func (p *v3Process) onServiceModelsLanded(source, address string) {
p.noteServiceModelsTo(source, address)
}

// registerServiceNotice adds one surface's door to the fan-out. The launch
// calls it with the closure its surface answered [tui3.Options.OnServiceModels]
// with, so a fetch that lands in THIS process — a launch warm or a ctrl+r walk
// in another window's conversation — reaches every open picker without a
// reopen. A closed surface takes itself off the list.
func (p *v3Process) registerServiceNotice(tell func(source, address string)) func() {
p.mu.Lock()
defer p.mu.Unlock()
p.serviceNotices = append(p.serviceNotices, tell)
at := len(p.serviceNotices) - 1
return func() {
p.mu.Lock()
defer p.mu.Unlock()
p.serviceNotices[at] = nil
}
}

// noteServiceModelsTo is one surface's slice of the news: the drop of its memo
// and the restock of its open picker happen on ITS loop, through the callback
// the surface itself supplied — which is the only side allowed to touch the
// app's memos.
func (p *v3Process) serviceNoticesSnapshot() []func(string, string) {
p.mu.Lock()
defer p.mu.Unlock()
return append([]func(string, string){nil}, p.serviceNotices...)
}

func (p *v3Process) noteServiceModelsTo(source, address string) {
notify := p.serviceNoticesSnapshot()
for _, tell := range notify {
if tell != nil {
tell(source, address)
}
}
}

// noteServiceModels tells every live surface one provider's listing changed,
// so its memo is dropped and an open picker restocks. The process holds the
// launch doors; each registers itself here when it opens the surface.
func (p *v3Process) noteServiceModels(service modelsource.Connected) {
p.noteServiceModelsTo(service.Source.ID, service.Address)
}

// refreshModelSources re-reads this process's own profile and makes that
// answer live in every conversation it retains.
//
Expand Down
8 changes: 4 additions & 4 deletions cmd/codeaf/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func runConnect(args []string) error {
return listConnections(config.ProfileDir())
}
if flags.NArg() != 1 {
return wrongCall("codeaf connect takes one service name")
return wrongCall("codeaf connect takes one provider name")
}
service := strings.ToLower(strings.TrimSpace(flags.Arg(0)))
switch service {
Expand Down Expand Up @@ -175,15 +175,15 @@ func listConnections(profileDir string) error {
}
}
if !any {
fmt.Fprintln(usageOut, "no model service is connected")
fmt.Fprintln(usageOut, "no provider is connected")
}
return nil
}

func connectKeyService(ctx context.Context, profileDir, name, region string) error {
source, row, found := connectionSource(profileDir, name)
if !found || source.ID == "codex" || source.ID == modelsource.DefaultID {
fmt.Fprintln(usageOut, name+" is not a model service this profile knows")
fmt.Fprintln(usageOut, name+" is not a provider this profile knows")
return exitStatus(1)
}
if len(source.Regions) > 0 && region == "" {
Expand Down Expand Up @@ -270,7 +270,7 @@ func runDisconnect(args []string) error {
return err
}
if flags.NArg() != 1 {
return wrongCall("codeaf disconnect needs one service name")
return wrongCall("codeaf disconnect needs one provider name")
}
profileDir := config.ProfileDir()
name := strings.ToLower(strings.TrimSpace(flags.Arg(0)))
Expand Down
6 changes: 3 additions & 3 deletions cmd/codeaf/connect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ func TestC8ConnectWithoutAServiceListsMethodsAndNeverDrawsNothing(t *testing.T)
if err := runConnect(nil); err != nil {
t.Fatal(err)
}
for _, want := range []string{modelsource.DefaultID + " · not connected · browser or key", "codex · not connected · browser", "deepseek · not connected · key", "no model service is connected"} {
for _, want := range []string{modelsource.DefaultID + " · not connected · browser or key", "codex · not connected · browser", "deepseek · not connected · key", "no provider is connected"} {
if !strings.Contains(output.String(), want) {
t.Errorf("listing missing %q: %q", want, output.String())
}
Expand Down Expand Up @@ -267,12 +267,12 @@ func TestC10DisconnectForgetsCodexAndRejectsAnUnknownService(t *testing.T) {

func TestC11ConnectHelpIsLiftedFromTheEightyColumnTable(t *testing.T) {
// C11: both terminal doors are present in the shared usage source.
for _, want := range []string{"codeaf connect", "codeaf connect <service> [--no-browser] [--region intl|cn]", "codeaf disconnect <service>"} {
for _, want := range []string{"codeaf connect", "codeaf connect <provider> [--no-browser] [--region intl|cn]", "codeaf disconnect <provider>"} {
if !strings.Contains(usageText, want) {
t.Errorf("usage is missing %q", want)
}
}
if page := usageForCommand("connect"); !strings.Contains(page, "list the model services") || !strings.Contains(page, "--no-browser") {
if page := usageForCommand("connect"); !strings.Contains(page, "list the providers") || !strings.Contains(page, "--no-browser") {
t.Fatalf("connect help = %q", page)
}
}
Expand Down
6 changes: 3 additions & 3 deletions cmd/codeaf/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -515,11 +515,11 @@ Look at what happened — read-only, no key, nothing spent
print the build this binary was cut from (--version and -v say the same)
Housekeeping — changes state on disk or on the network
codeaf connect
list the model services this profile knows and which are connected
codeaf connect <service> [--no-browser] [--region intl|cn]
list the providers this profile knows and which are connected
codeaf connect <provider> [--no-browser] [--region intl|cn]
connect one: openrouter and codex sign in in your browser; the others
take a key on stdin, or ask for one without echo
codeaf disconnect <service>
codeaf disconnect <provider>
forget a service and the key or sign-in behind it
codeaf update [--check] [--stable|--rc|--dev|--staging] [--version tag]
check or install a release; this build's own channel is the default
Expand Down
Loading
Loading