diff --git a/cmd/codeaf/chatv3.go b/cmd/codeaf/chatv3.go index 046dbf1e5..6e170a2f8 100644 --- a/cmd/codeaf/chatv3.go +++ b/cmd/codeaf/chatv3.go @@ -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. diff --git a/cmd/codeaf/chatv3_credits.go b/cmd/codeaf/chatv3_credits.go index fd85aaf70..81aa990ed 100644 --- a/cmd/codeaf/chatv3_credits.go +++ b/cmd/codeaf/chatv3_credits.go @@ -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 == "" { @@ -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 == "" { diff --git a/cmd/codeaf/chatv3_host.go b/cmd/codeaf/chatv3_host.go index 194255e9c..c731022c5 100644 --- a/cmd/codeaf/chatv3_host.go +++ b/cmd/codeaf/chatv3_host.go @@ -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(), diff --git a/cmd/codeaf/chatv3_modelshelf.go b/cmd/codeaf/chatv3_modelshelf.go index bc75c8c34..3f09b1ef3 100644 --- a/cmd/codeaf/chatv3_modelshelf.go +++ b/cmd/codeaf/chatv3_modelshelf.go @@ -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 { @@ -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 diff --git a/cmd/codeaf/chatv3_process.go b/cmd/codeaf/chatv3_process.go index 93c78d8d3..5c19fd14b 100644 --- a/cmd/codeaf/chatv3_process.go +++ b/cmd/codeaf/chatv3_process.go @@ -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. @@ -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. // diff --git a/cmd/codeaf/connect.go b/cmd/codeaf/connect.go index b468b91d3..da32231aa 100644 --- a/cmd/codeaf/connect.go +++ b/cmd/codeaf/connect.go @@ -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 { @@ -175,7 +175,7 @@ func listConnections(profileDir string) error { } } if !any { - fmt.Fprintln(usageOut, "no model service is connected") + fmt.Fprintln(usageOut, "no provider is connected") } return nil } @@ -183,7 +183,7 @@ func listConnections(profileDir string) error { 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 == "" { @@ -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))) diff --git a/cmd/codeaf/connect_test.go b/cmd/codeaf/connect_test.go index f1de93284..7fad0afa3 100644 --- a/cmd/codeaf/connect_test.go +++ b/cmd/codeaf/connect_test.go @@ -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()) } @@ -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 [--no-browser] [--region intl|cn]", "codeaf disconnect "} { + for _, want := range []string{"codeaf connect", "codeaf connect [--no-browser] [--region intl|cn]", "codeaf disconnect "} { 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) } } diff --git a/cmd/codeaf/main.go b/cmd/codeaf/main.go index a1554d49d..febacf05b 100644 --- a/cmd/codeaf/main.go +++ b/cmd/codeaf/main.go @@ -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 [--no-browser] [--region intl|cn] + list the providers this profile knows and which are connected + codeaf connect [--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 + codeaf disconnect 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 diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 77f17cbc8..3492d226e 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -263,7 +263,7 @@ capability that cannot work is left off it rather than offered and failing. Furrow watch. The five `gmail_*` and `calendar_*` tools arrive only with a connected Google account and the four `slack_*` only with Slack; `/connect` — `your connected accounts · connect another` — is the door, and any other keyed account brings one -`_request` instead, plus whatever the service names for itself. +`_request` instead, plus whatever the account names for itself. `view_image` needs a vision model. `edit_video` needs its local video binaries. Each media-generation tool needs both a media client and a resolved model for its modality. @@ -286,12 +286,12 @@ after the conversation. Memory keeps person-, project-, or machine-scoped record ## Models, keys, and spending -Key resolution for the default service is `OPENROUTER_API_KEY`, then +Key resolution for the default provider is `OPENROUTER_API_KEY`, then `OPENAI_API_KEY`, then `api_key` in the profile's `config.json`. With no credential, an interactive local launch opens a two-page setup that offers to connect OpenRouter in a browser or take a pasted key. First run is unchanged and does not offer Codex. -A non-interactive chat starts when the default service has a key or any connected -service holds its credential; a call to a service without one still fails when it is +A non-interactive chat starts when the default provider has a key or any connected +provider holds its credential; a call to an account without one still fails when it is made. With no credential anywhere it stops with `codeaf chat needs a model to talk with.`
@@ -300,11 +300,11 @@ made. With no credential anywhere it stops with `codeaf chat needs a model to ta Where a browser is reachable the first page is headed `connect openrouter`: ```text -sign in once in your browser. openrouter makes the default service's key for this profile; codeaf stores it on this machine. no prompt is sent and no model is called. +sign in once in your browser. openrouter makes the default provider's key for this profile; codeaf stores it on this machine. no prompt is sent and no model is called. ``` Where it is not, the same page is headed `your openrouter key` and reads `codeaf talks -to models on its default service through openrouter, on your key and your card. nothing +to models on its default provider through openrouter, on your key and your card. nothing is sent until you do.` Either way the foot takes a pasted key and `esc` skips setup. The second page is `Daily limit` and `Chat model`. @@ -313,13 +313,13 @@ The second page is `Daily limit` and `Chat model`. The chat model resolves from `--model`, then saved `model.talk`, then `CODEAF_MODEL`, then `~deepseek/deepseek-v4-flash-latest`. The last value is a floating alias. Besides OpenRouter, the connection screen supports DeepSeek, Z.ai, Moonshot, MiniMax, Alibaba -Qwen, Codex through a ChatGPT plan, Ollama, and a custom OpenAI-compatible service. -The same supported services can be managed without opening the chat with `codeaf +Qwen, Codex through a ChatGPT plan, Ollama, and a custom OpenAI-compatible provider. +The same supported providers can be managed without opening the chat with `codeaf connect` and `codeaf disconnect`; a qualified slug such as -`qwen/` selects its service. +`qwen/` selects its provider. -Provider routing defaults to `simple`: an unpinned OpenRouter call carries no provider -object, while a pinned call asks for exactly that lane. `latency` and `price` remain +Host routing defaults to `simple`: an unpinned OpenRouter call carries no host +object, while a pinned call asks for exactly that host. `latency` and `price` remain opt-in settings. The default daily rail is `$500`; setting that row to `0` removes it. First run asks for diff --git a/docs/changes/unreleased/1513-providers-unification.md b/docs/changes/unreleased/1513-providers-unification.md new file mode 100644 index 000000000..b6511a83a --- /dev/null +++ b/docs/changes/unreleased/1513-providers-unification.md @@ -0,0 +1,18 @@ +--- +kind: changed +title: unify provider vocabulary, background-list connected provider models, and ease adding providers +pr: 1513 +surface: + - chat + - docs +invalidates: + - "the entity serving models was called service or connection; it is now called provider everywhere." + - "the openrouter routing target was called provider; it is now called host." + - "external tools were called connections; they are now accounts." + - "/model only listed models from OpenRouter on launch; now every connected provider lists models in the background." +--- + +Unifies model-serving vocabulary across UI, settings, manual, and CLI on 'provider', +uses 'host' for OpenRouter routing destinations, warms cold provider caches at launch +off the event loop, enables ctrl+r multi-provider refresh, and adds loopback port +probing for local model servers. diff --git a/internal/config/settings.go b/internal/config/settings.go index 275306095..54378f4bd 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -2103,16 +2103,16 @@ func (s *Settings) build() []Setting { Setting{ Key: KeyRouting, Category: CategoryModels, Kind: SettingChoice, Label: "routing", Choices: RoutingModes, - Hint: "one model id is served by many providers, and they answer at very " + + Hint: "one model id is served by many hosts, and they answer at very " + "different speeds AND very different prices. Left alone — simple — codeaf " + - "sends no preference of its own at all: with no provider pinned the router's own " + - "default routing answers, and a provider you pinned is the whole request, that " + - "provider and no fallbacks. Choosing another word here changes that " + + "sends no preference of its own at all: with no host pinned the router's own " + + "default routing answers, and a host you pinned is the whole request, that " + + "host and no fallbacks. Choosing another word here changes that " + "everywhere: latency asks " + - "for the fastest provider for every call, capped at a quarter over the " + + "for the fastest host for every call, capped at a quarter over the " + "model's list price, and times every answer, demoting one that keeps being " + "slow; price asks for the cheapest for every call; off asks for nothing and " + - "measures nothing — and with nothing measured there is no provider to choose, " + + "measures nothing — and with nothing measured there is no host to choose, " + "no sheet of them to open and no speed guard. A change lands on " + "the next session.", read: func() string { return RoutingAt(dir) }, @@ -2131,7 +2131,7 @@ func (s *Settings) build() []Setting { "goes lean. lean takes one section off the page, leaves seven verbs one " + "load_capability call away, puts ask straight in the list, turns saved " + "memories off and cuts the project's own instructions to 2KiB. full sends " + - "everything. Choose one of those two when the provider reports a window its " + + "everything. Choose one of those two when the host reports a window its " + "model does not really have. A change lands the next time codeaf starts.", read: func() string { return PromptProfileAt(dir) }, write: func(raw string) error { return writeChoice(dir, KeyPromptProfile, raw, PromptProfileModes) }, @@ -2141,15 +2141,15 @@ func (s *Settings) build() []Setting { // to, for the person who has watched the numbers and knows. Setting{ Key: LaneSettingKey(LaneSlotTalk), Category: CategoryModels, Kind: SettingText, - Label: "provider", EmptyLabel: LaneAuto, - Hint: "which provider answers your model, for requests from this home. One model id is served by " + - "a dozen providers that differ by seven times on the wait before the first " + + Label: "host", EmptyLabel: LaneAuto, + Hint: "which host answers your model, for requests from this home. One model id is served by " + + "a dozen hosts that differ by seven times on the wait before the first " + "word, so this is often a bigger change than switching model. auto lets the router " + - "route — and codeaf takes over choosing the provider when its answers start coming " + + "route — and codeaf takes over choosing the host when its answers start coming " + "back refused or unusable, handing it back once it has been well for a while; " + "a name — `cloudflare` — pins it and nothing else is asked; " + "`pinned: cloudflare, borrow when slow` keeps the pin but lets " + - "a slow answer be rescued elsewhere; openrouter asks for no provider at all and " + + "a slow answer be rescued elsewhere; openrouter asks for no host at all and " + "lets the router balance on price, with no takeover. enter on this row opens them with what " + "has been measured of each, and so does → on a model row in the picker — " + "under /model and under `your model` in the settings panel alike.", @@ -2159,7 +2159,7 @@ func (s *Settings) build() []Setting { Setting{ Key: KeyLaneGuard, Category: CategoryModels, Kind: SettingBool, Label: "speed guard", - Hint: "when an answer takes much longer to start than that provider normally " + + Hint: "when an answer takes much longer to start than that host normally " + "does, the same question is asked of the next-best one and whichever replies " + "first is the one you read. It hedges at most one extra call, under a tenth of " + "spend; off under price routing.", @@ -2287,7 +2287,7 @@ func (s *Settings) build() []Setting { Label: "tasks at once", EmptyLabel: "no limit", Unit: UnitInLabel, Hint: "how many tasks may run at the same time. Blank is no limit, which is the " + "default: what actually runs out is this machine — the two rows below hold new " + - "tasks back when it is loaded — and the model provider's own rate limit, which " + + "tasks back when it is loaded — and the model host's own rate limit, which " + "codeaf already paces itself against. A cap is a queue, never a refusal.", read: func() string { if value := TaskParallelAt(dir); value > 0 { diff --git a/internal/e2e/tuiwords_test.go b/internal/e2e/tuiwords_test.go index 37cc9ce95..13cc18bfc 100644 --- a/internal/e2e/tuiwords_test.go +++ b/internal/e2e/tuiwords_test.go @@ -408,7 +408,7 @@ var tuiWords = map[string]tuiWord{ "an offer whose key was cut is a question nobody can answer", }, "phaseAllSlowWord": { - screen: "all providers slow", + screen: "all hosts slow", why: "every reachable provider is believed slow, so there is nowhere better to be", }, "phaseWaitingWord": { diff --git a/internal/manual/chat/accounts.md b/internal/manual/chat/accounts.md index 9216d6fdf..2b7dff07a 100644 --- a/internal/manual/chat/accounts.md +++ b/internal/manual/chat/accounts.md @@ -1,15 +1,15 @@ # Connected accounts codeaf can act on accounts you already hold — your mail, your calendar, your Notion -pages, a billing service you have a key for. This page covers what connecting one +pages, a billing account you have a key for. This page covers what connecting one gives codeaf, how to connect, what you can turn on and off per account, and where the keys are kept. A connected account gives codeaf tools it may use in your name; a -connected model service is a place models come from and is covered by the -[services page](services.md). +connected model provider is a place models come from and is covered by the +[providers page](accounts.md). ## What a connected account is -A connected account is a service codeaf holds a credential for. Connecting one does +A connected account is an account codeaf holds a credential for. Connecting one does two things: it stores the credential in your profile directory, and it puts that account's tools on codeaf's toolbelt so the model can call them. @@ -21,8 +21,8 @@ What arrives depends on the account: `slack_list_channels`, `slack_send`. - **A key account** brings exactly one tool, `_request` — `stripe_request`, for example — taking `method` (get, post, put, patch, delete; default get), `path`, - `query` and `body`. The path is always relative to the service's own address. An - absolute address is refused with `the path is relative to the service's own + `query` and `body`. The path is always relative to the account's own address. An + absolute address is refused with `the path is relative to the account's own address, not a whole address of its own`, because an absolute one would send your key to a host nobody vouched for. - **A tool server** brings whatever tools it serves. @@ -34,9 +34,9 @@ response bodies are read up to 8 MiB. An account with no tools in this build answers ` is connected, and this build has no tools for it. Do the work without it and say so plainly.` -## How many services can be connected +## How many accounts can be connected -**129 services register in this build: 99 are connected with a pasted key, and 30 are +**129 accounts register in this build: 99 are connected with a pasted key, and 30 are connected in a browser.** The 30 browser ones are **Google** (Gmail and Calendar), **Slack**, and the 28 tool servers @@ -44,18 +44,18 @@ The 30 browser ones are **Google** (Gmail and Calendar), **Slack**, and the 28 t Datadog, GitLab, Grafana, Heroku, Hugging Face, Klaviyo, LaunchDarkly, Linear, Miro, Neon, Netlify, Notion, PayPal, PostHog, Postman, Railway, Sanity, Sentry, Supabase** and **Todoist**. -The 99 key services come from the bundled connectors catalog and are filed under +The 99 key accounts come from the bundled connectors catalog and are filed under eleven categories: `crm`, `support`, `billing`, `marketing`, `sales & outreach`, `calls & meetings`, `analytics`, `hr & recruiting`, `developer`, `productivity`, -`communication`. A service with none of these is shown under `other`. +`communication`. An account with none of these is shown under `other`. Every menu is ordered by name, case-insensitive — never registration order. Google and Slack both ship with the application their browser sign-in needs, so both are listed on a fresh install. The `google_oauth_client` and `slack_oauth_client` settings replace those shipped applications for somebody who wants their own. A -browser service with no application configured is not listed at all: no greyed row, -no explanation. Key services and tool servers need nothing configured and are always +browser account with no application configured is not listed at all: no greyed row, +no explanation. Key accounts and tool servers need nothing configured and are always listed. ## The two ways to sign in: Google and Slack in a browser, or a pasted key @@ -67,7 +67,7 @@ connected, when, and that it was you — it does not carry the key, and neither does anything the model is sent, anything another window is told, or anything written to a log. -Browser accounts open the service's sign-in page; key accounts collect a key in the +Browser accounts open the account's sign-in page; key accounts collect a key in the message box without starting a browser trip. Neither route writes a credential into the conversation. @@ -115,7 +115,7 @@ read; searching, listing channels and posting are not under that limit. ## Tool-server and Datadog browser questions **The 28 tool servers listed below need nothing registered first.** codeaf introduces -itself to the service at connect time and is issued an identity on the spot, then +itself to the account at connect time and is issued an identity on the spot, then makes the same browser trip. Some accounts ask one thing before they open. **Datadog asks which Datadog site your @@ -127,12 +127,12 @@ and renewals return to the same site. ## Signing in with a pasted key Nothing opens and nothing renews; the key is as good as the day it was made. Most -services want one key and nothing else. A few whose address contains your own -workspace want the workspace, one space, then the key — the service's own line says +accounts want one key and nothing else. A few whose address contains your own +workspace want the workspace, one space, then the key — the account's own line says so. Where the catalog names a cheap health check, the key is proved before anything is stored, and a refusal fails with the far end's own words and writes nothing. -Trying a browser sign-in on a key service errors with +Trying a browser sign-in on a key account errors with ` is connected with a key, not in a browser`. ## Naming an environment variable instead of pasting a key @@ -164,9 +164,9 @@ Connected accounts come first, flat and with no heading; everything else is grou under one dim lowercase category word, alphabetical, with `other` last. A tick marks an account you hold, a dim dot marks one you do not. The right-hand tail carries one fact: the account address when held, otherwise `key` or `sign in` on a long list, or -the service's blurb on a short one. +the account's blurb on a short one. -Past 10 available services the box under the list becomes a filter and the list +Past 10 available accounts the box under the list becomes a filter and the list narrows as you type; the placeholder reads `filter · ↑↓ · enter connect · esc close`. Under ten there is no filter box. The filter matches name and category, so typing `billing` reaches Stripe, Chargebee and Recurly. Accounts you already hold stay @@ -196,10 +196,10 @@ so the command instead gives the longer `--host` sentence and leaves the panel o Until 2026-09-11, a plain launch wrongly inherited that remote absence from the engine road and said `connections are unavailable here`. It now keeps this machine's -store, connected rows, model-service group and browser door. +store, connected rows, model-provider group and browser door. If `credentials.json` is damaged, accounts are absent but the `models` group still -draws; model-service settings live separately in the profile's `config.json`. +draws; model-provider settings live separately in the profile's `config.json`. ## What each account may be used for: yes, ask first, off @@ -229,7 +229,7 @@ change mid-conversation takes effect on the next call. default. **Where it is stored:** `connections.json` in your profile directory, as -`{service: {capability: "yes"|"ask"|"off"}}`. Only what you actually said is written — +`{account: {capability: "yes"|"ask"|"off"}}`. Only what you actually said is written — setting a control back to its default forgets the row rather than writing the default down, and forgetting the last answer deletes the file. A damaged or absent file reads as all defaults, never as an error. @@ -237,7 +237,7 @@ as all defaults, never as an error. ## What "off" does — the tool is not there at all Off is not a refusal at the gate. It is absence. The tool is left **off the belt -entirely**, the account's line in the `services` listing stops advertising it, and the +entirely**, the account's line in the `accounts` listing stops advertising it, and the conversation never learns the capability exists. The reason is plain: a refused call costs a turn, teaches the model to try again in different words, and puts a question in front of somebody who already answered it. @@ -262,20 +262,20 @@ safeguard in the gate catches that, checked first. The model then reads: the work without it and say so plainly; calling again, or calling it another way, will not change their answer.` -## The services and use_service tools — does it ask permission to run services +## The accounts and use_service tools — does it ask permission to run accounts Two tools are always on the belt when an accounts layer exists. -- **`services`** — lists what can be connected and what is connected already, with the +- **`accounts`** — lists what can be connected and what is connected already, with the address each is held as. Connected ones are written out in full; the rest are a block of ids only. It takes an optional `filter` argument. - **`use_service`** — picks up one account's tools. An optional `tools` argument names a subset. -On the shipped default, neither `services` nor `use_service` raises a tool-approval -question. `services` only lists accounts. `use_service` owns the connect card below, +On the shipped default, neither `accounts` nor `use_service` raises a tool-approval +question. `accounts` only lists accounts. `use_service` owns the connect card below, which is the one question about connecting; every tool it brings is still judged when -it is called. An explicit `services:prompt` or `use_service:prompt` rule still asks, +it is called. An explicit `accounts:prompt` or `use_service:prompt` rule still asks, and the `deny` default still refuses. **The account's tools are in your tool list from your very next request, which is still @@ -316,7 +316,7 @@ writes an account credential. ## Answering a use_service connect question with a key -**A key service asks for the key in that same message box.** There is no yes step — +**A key account asks for the key in that same message box.** There is no yes step — a bare yes to one of these is read as a decline anyway — so the question arrives with what to type written under it and the box below it collecting the answer: @@ -334,7 +334,7 @@ whole paste arrived. `enter` sends it. `2` is the way out. `esc` is later, and w typed stays in the box. An empty box and `enter` answers nothing at all — it used to be a decline, and now the way out is the answer that says so. -Where a service names its own instruction — Chargebee's `Give the site name and then +Where a account names its own instruction — Chargebee's `Give the site name and then the key, one space between them.` — that sentence is what the card says over the box, in place of the generic paste hint. @@ -364,7 +364,7 @@ decided. An empty box and `enter` answers nothing either — `enter` sends what box, and there is nothing in it. **And silence leaves the account unconnected after 5 minutes.** The model is told that -you did not answer, never that you refused. See *The services and use_service tools* +you did not answer, never that you refused. See *The accounts and use_service tools* above for the exact distinction. ## Connecting while the conversation is idle @@ -382,7 +382,7 @@ can be used for in this conversation.` ## MCP: accounts that bring their own tools -Some services run a server whose whole job is to hand a program a list of tools and +Some accounts run a server whose whole job is to hand a program a list of tools and run one when asked. codeaf fetches that list per account at the moment the account is picked up. You connect "Notion" — no protocol, server or grant is ever named in front of you. A tool server appears in `/connect` as a browser connection like any other, is @@ -416,7 +416,7 @@ Twenty-eight ship, each at the address on the vendor's own page: | PayPal | `https://mcp.paypal.com/http` | your payments, invoices and payouts | | PostHog | `https://mcp.posthog.com/mcp` | your events, insights and feature flags | | Postman | `https://mcp.postman.com/minimal` | your collections, specs and environments | -| Railway | `https://mcp.railway.com/` | your projects, services and deployments | +| Railway | `https://mcp.railway.com/` | your projects, accounts and deployments | | Sanity | `https://mcp.sanity.io` | your content, datasets and schemas | | Sentry | `https://mcp.sentry.dev/mcp` | your issues, events and releases | | Supabase | `https://mcp.supabase.com/mcp` | your projects, tables and queries | @@ -427,30 +427,30 @@ Airtable's adds: "An enterprise admin may have to allow it first." Postman's adds: "Postman's EU workspaces cannot be reached this way." — Postman's EU address signs in with a key and nothing else, so it is deliberately not shipped. -**All 28 work with zero registration.** codeaf introduces itself to the service at +**All 28 work with zero registration.** codeaf introduces itself to the account at connect time and is issued an identity on the spot, kept in `toolservers.json`. Keys -minted for one service cannot be spent at another. +minted for one account cannot be spent at another. **GitHub is deliberately not shipped** — its sign-in does not let a program introduce itself, and its maintainers say that will not change, so it can return only with an application registered by hand in a later wave. Slack now signs in through a browser with the application codeaf ships; the Slack paragraph above describes that trip. Any -service whose sign-in refuses an introduction cannot be connected this way at all, and +account whose sign-in refuses an introduction cannot be connected this way at all, and codeaf says so in one sentence the moment you ask. -An identity is reused only when the service address, the issuer, the resource and the +An identity is reused only when the account address, the issuer, the resource and the loopback port all still match. The registration file survives a disconnect, so reconnecting is one browser trip and not a second registration. ## How MCP tool names are built, and how many can be armed -A served tool is named **`_`**. Notion's +A served tool is named **`_`**. Notion's `Create Page` becomes `notion_create_page`, and two accounts both serving `search` become `notion_search` and `linear_search`. Folding is lower case, letters, digits and single underscores; everything else reads as a word break, so `Create Page`, `create-page` and `create.page` all fold to `create_page`. The fold loses information -on purpose, so the service's own spelling is kept beside the belt name and the call is -always made with the service's spelling. Names are cut at **64 characters**. +on purpose, so the account's own spelling is kept beside the belt name and the call is +always made with the account's spelling. Names are cut at **64 characters**. Two names that fold to one are one name here: the first stands, the second is left off and named in the reply. A tool whose argument schema cannot be read is left off and @@ -461,11 +461,11 @@ arrives. Over it, **nothing does** — the reply names them all and tells the mo call `use_service` again with `tools` naming the few the work needs. Silent trimming was rejected outright: the model would plan around a list it was never told was cut. -The list a service gives is fetched once per run and remembered for the life of the -process, so a tool newly added at the service needs codeaf restarted. +The list a account gives is fetched once per run and remembered for the life of the +process, so a tool newly added at the account needs codeaf restarted. Each call opens a connection, does its one thing and closes it. The outer backstop is -2 minutes. A tool that refuses comes back as an error carrying the service's own +2 minutes. A tool that refuses comes back as an error carrying the account's own sentence. Images, sounds and resources come back named — `[an image]`, `[a sound]` — rather than as bytes. @@ -479,8 +479,8 @@ allow. That covers: verb: a `GET`, or no arguments at all, is a read; every other method acts. **An argument payload that cannot be read counts as one that acts**, because the safe reading of "I do not know" is the one that asks; -- any tool an MCP server serves that the service did not mark read-only. Absent means - false, and false is the stricter reading: a service that says nothing has not +- any tool an MCP server serves that the account did not mark read-only. Absent means + false, and false is the stricter reading: a account that says nothing has not promised its tool only looks. **No blanket setting turns this off.** Setting the approval default to `allow`, or @@ -517,10 +517,10 @@ again, and you decide what to do next. ## Where your keys are kept on disk -Account keys and model-service keys are different stores. An account adds tools codeaf -may use in your name and keeps its credential in `credentials.json`; a model service is a +Account keys and model-provider keys are different stores. An account adds tools codeaf +may use in your name and keeps its credential in `credentials.json`; a model account is a place models come from and keeps its key in the profile `config.json`. The -[services page](services.md) covers those model keys. +[accounts page](accounts.md) covers those model keys. Everything the accounts layer writes lives in your profile directory — `$CODEAF_PROFILE_DIR` when set, otherwise codeaf's state root `$CODEAF_HOME` or @@ -568,7 +568,7 @@ literally: connecting an account is not available over --host yet — the sign-in opens a browser here and the account belongs to the machine over there. accounts already connected on that machine keep working. ``` -When the model raises the connect question over `--host`, a browser service says +When the model raises the connect question over `--host`, a browser account says `connecting an account is not available over --host yet` as its reason and offers only `2 not now`. The `1 connect` answer is not drawn at all, rather than drawn as an affordance that answers as a failure. diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 4bbd96162..3ff54c285 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -157,7 +157,7 @@ Canonical word, the other words it answers to, its argument form, and what it do | `/model` | — | — | opens the model picker | | `/model` | — | `` | switches the model to that slug | | `/settings` | `/set`, `/config` | — | opens the fullscreen settings panel (also ctrl+,) | -| `/connect` | `/connections` | — | opens the connection panel; its `models` group holds model services, followed by connected accounts | +| `/connect` | `/connections` | — | opens the connect panel; its `providers` group holds model providers, followed by connected accounts | | `/new` | `/clear`, `/clean`, `/reset` | — | closes this session and starts a fresh one | | `/resume` | `/sessions` | — | opens the earlier-conversations picker | | `/compact` | — | — | summarizes the conversation now | @@ -893,8 +893,8 @@ place with a short list of models under it. It is bottom-anchored, so the conver shrinks above it and nothing pops up over what you were reading. Pressing the model's name on the legend line above the box opens the same picker. -Models from connected services sit under their service's name as a dim heading, default -service first; a custom connection's heading is the name you gave it. +Models from connected providers sit under their provider's name as a dim heading, default +provider first; a custom provider's heading is the name you gave it. `/model ` switches straight to that slug: no list, no confirmation, and no check that the slug exists in any list. If the slug is in no known list, the context window is @@ -944,7 +944,7 @@ falls back to `filter`. moment you type — which is exactly when you have found your model and want its providers. The foot follows the cursor and always reads in one order — the keys that move the **cursor**, then the ones that change the **list**, then `enter`, then the one key that is about neither: -`→ providers · alt+s sort · enter switch · ctrl+t effort · esc` on a model, +`→ hosts · alt+s sort · enter switch · ctrl+t effort · esc` on a model, `← back · alt+s sort · enter choose · esc` inside its providers — and `← back · alt+s sort · enter unpin · esc` on the provider you are already pinned to, where the same key takes the pin off again. While you are @@ -1543,15 +1543,15 @@ status sheet. Change that machine's profile there. ## /connect — your connected accounts -`/connect` (or `/connections`) opens the connection panel. Its pinned `models` group -holds the six built-in model services plus every one already connected; the account +`/connect` (or `/connections`) opens the connect panel. Its pinned `providers` group +holds the six built-in model providers plus every one already connected; the account catalog groups follow it. The Codex row says `browser`; enter opens the sign-in road and -the waiting card keeps the address available to copy. The other listed services say what -they need. Pick a row and connect it. There is no argument form. **Custom OpenAI-compatible API** connects a custom service: it asks for a +the waiting card keeps the address available to copy. The other listed providers say what +they need. Pick a row and connect it. There is no argument form. **Custom OpenAI-compatible API** connects a custom provider: it asks for a base URL, then a name of your own with the host's own spelling pre-filled (`127.0.0.1` -becomes `127-0-0-1`), then a key. Several custom connections sit beside each other, -each under its name; once one is connected an `add custom connection` row appears and -the **Custom OpenAI-compatible API** row becomes that connection's edit door. The +becomes `127-0-0-1`), then a key. Several custom providers sit beside each other, +each under its name; once one is connected a `+ add a provider` row appears and +the **Custom OpenAI-compatible API** row becomes that provider's edit door. The [services page](services.md) covers model keys, and the accounts page covers what each account can do once it is connected. @@ -1687,7 +1687,7 @@ Refusals inside the panel, exactly as written: ## config.json keys are not read — why codeaf says a setting I wrote is ignored codeaf reads the top-level keys of your profile's `config.json` that a settings row or -the model-service setup owns. A key nothing reads — a hand-written `models` object, a +the model-provider setup owns. A key nothing reads — a hand-written `models` object, a spelling from another tool — does nothing, and the defaults apply in its place. (A key codeaf itself retired is passed over quietly rather than named.) So the conversation says so once, as a note: @@ -1756,7 +1756,7 @@ moved to Spending. The ssh rows are here because "what may codeaf reach on your behalf" is this tab's own question, and a link to another machine is that question asked about a machine rather -than about a service. They are not on the tab named **Connections**: that one is the +than about an account. They are not on the tab named **Connections**: that one is the catalog of third-party accounts you sign in to, and it is built from the account list rather than from the settings registry. @@ -1872,12 +1872,12 @@ worker, checker and planner have no rows of their own here: the one **seats** ro `/crew` panel, and a seat is pinned there or with `/crew pin` — a pin may carry a thinking level, `/crew pin planner moonshotai/kimi-k3:high`, and the seat is then asked at that level. -The connected model services have their own section on the tab, each with its billing -door, the safe spelling of its key, its region and its order. The section ends with an -`add custom connection` row, and once a custom connection is connected an `active -connection` row follows it: it reads +The connected model providers have their own section on the tab, each with its billing +door, the safe spelling of its key, its region and its order. The section ends with a +`+ add a provider` row, and once a custom provider is connected an `active +provider` row follows it: it reads `answering on localhost · enter moves it to homelab`, and enter moves this conversation -onto the next connection, wrapping past the last back to the first. The +onto the next provider, wrapping past the last back to the first. The [services page](services.md) has the whole of it. **Connections** — the accounts this profile has connected and what each may do. Its rows diff --git a/internal/manual/chat/getting-started.md b/internal/manual/chat/getting-started.md index 1aa2256d9..f10feede2 100644 --- a/internal/manual/chat/getting-started.md +++ b/internal/manual/chat/getting-started.md @@ -16,7 +16,7 @@ The first time `codeaf` opens on a profile with nothing in it, the chat does not an empty prompt and a provider error. It opens in the chat itself, on **two screens** — under a minute, nothing else on the frame: -1. **connect openrouter** — the default service; `enter` signs in in your browser, and pasting an existing key also works +1. **connect openrouter** — the default provider; `enter` signs in in your browser, and pasting an existing key also works 2. **Models and spending** — one screen with two controls on it, **Daily limit** and **Chat model**, each already showing the value that is in force @@ -29,10 +29,10 @@ screen. Its heading is `Models and spending` and the line under it is again. `esc` on the controls screen goes **back** to the connection when there is one behind it, and skips when the controls are the whole of the setup. A skip leaves one dim line naming the doors onto what it walked past: `still yours to set · /budget sets what -codeaf may spend · /model and /crew pick the models`. If the default OpenRouter service is +codeaf may spend · /model and /crew pick the models`. If the default OpenRouter provider is still not connected and the conversation is using one of its models, its one-step screen returns on the next local interactive launch because that model cannot work without it. A -conversation on a connected direct service's model does not owe OpenRouter a key, so that +conversation on a connected direct provider's model does not owe OpenRouter a key, so that step stays away. Codex is deliberately not another first-run step. After setup, its browser sign-in is @@ -49,19 +49,19 @@ sentence at a time and never half of one. The two values, `Start a conversation` the keyboard line are never given up, so a sixteen-row window still shows a screen you can answer and leave. -## Set up my api key — the default service's openrouter key step, and what happens with no key +## Set up my api key — the default provider's openrouter key step, and what happens with no key -On a local interactive launch using codeaf's built-in default model service, the first step reads +On a local interactive launch using codeaf's built-in default model provider, the first step reads *connect openrouter*. Press `enter`: codeaf opens OpenRouter in your browser, waits on a random return address bound only to `127.0.0.1`, and uses an S256 proof key for the trip. -After you sign in and approve it, OpenRouter makes a user-controlled API key for the default service in this +After you sign in and approve it, OpenRouter makes a user-controlled API key for the default provider in this profile and sends the browser back to codeaf. The browser says it is connected, the screen continues, and the running conversation can use the key immediately. No prompt is sent and no model is called during the connection. The address is also written on the waiting screen. If the browser cannot be opened, select or click that address yourself. `esc` while waiting cancels the return listener and leaves -you on the default service's OpenRouter step; another `enter` tries again. +you on the default provider's OpenRouter step; another `enter` tries again. ## What the setup screen says when something goes wrong @@ -83,7 +83,7 @@ those are written for you to read. What is never shown is the operating system's a failure: a path inside codeaf's own storage with an errno after it tells you nothing you can act on. -## Paste an existing OpenRouter API key for the default service instead of connecting in the browser +## Paste an existing OpenRouter API key for the default provider instead of connecting in the browser Already have a key? Paste it on the same first screen instead of pressing `enter` on an empty box. The key is masked while it is typed, and the manual-key address remains on the @@ -94,34 +94,34 @@ not accept is discovered by the first message you send. One that fails the shape leaves this line under the box and stays on the step: `not the shape of an openrouter key — they start with sk-or-`. -What it writes for the default service: the `api_key` field of your profile's `config.json` (under `~/.codeaf`), +What it writes for the default provider: the `api_key` field of your profile's `config.json` (under `~/.codeaf`), owner-readable only. That is the same field the **openrouter key** row on the settings panel's Providers tab writes, and the one every later launch reads. The running conversation takes it at once — the next message rides it, no restart. -## Skip the default OpenRouter service, retry later, and keep the message I typed +## Skip the default OpenRouter provider, retry later, and keep the message I typed -`esc` on the idle step skips setup. When the conversation is using the default service, it +`esc` on the idle step skips setup. When the conversation is using the default provider, it then says one dim line: `openrouter is not connected · enter on your message connects in a browser, or export OPENROUTER_API_KEY`. Your draft is not sacrificed to a provider error: type it normally and press `enter`, and the one-step connection opens over the conversation before the draft is cleared. Connect, then press `enter` again to send those same words. -When the conversation is on a connected direct service's model, pressing `enter` sends +When the conversation is on a connected direct provider's model, pressing `enter` sends those words instead. The OpenRouter step does not open and the missing-OpenRouter line is -absent, because that turn already has a service that can answer. +absent, because that turn already has a provider that can answer. -This default-service step also opens over an existing or resumed conversation and over a profile +This default-provider step also opens over an existing or resumed conversation and over a profile whose first-run setup was already shown. It appears whenever all of these are true: the launch is local and interactive, the built-in OpenRouter endpoint is still the model provider for the conversation's model, and neither the shell nor the profile holds a key. -A connected direct service carrying the conversation, a custom `CODEAF_BASE_URL`, a +A connected direct provider carrying the conversation, a custom `CODEAF_BASE_URL`, a `--host` session, and a headless `--once` run are not offered an OpenRouter browser trip. -For a headless run using the default service, start bare `codeaf` once to connect in a terminal, or export +For a headless run using the default provider, start bare `codeaf` once to connect in a terminal, or export `OPENROUTER_API_KEY` (or `OPENAI_API_KEY`) before running it. -**If the default service's `OPENROUTER_API_KEY` is already set in your shell, this step is not shown at all.** +**If the default provider's `OPENROUTER_API_KEY` is already set in your shell, this step is not shown at all.** The environment outranks the file, always; the setup only asks for what nothing else has answered. @@ -193,7 +193,7 @@ Under 112 columns it is not drawn and the form is unchanged. The controls screen shows **once, ever**. The default OpenRouter prerequisite above is the only step that may return. -## What appears once — and why the default service's OpenRouter step can return +## What appears once — and why the default provider's OpenRouter step can return The **Models and spending screen** is shown once per profile. When the first-run screen closes — finished or skipped — `setup_seen_at` is written into `config.json` with the time, @@ -205,7 +205,7 @@ that marker. It returns as a one-step screen on a later eligible launch while th still missing. It can also return in the same launch when an unsent model message reaches `enter`; the draft stays in the box. -That prerequisite is only for the default service during first run. A second service is +That prerequisite is only for the default provider during first run. A second provider is not required; add one later through `/connect`, as described on the [services page](services.md). @@ -213,7 +213,7 @@ The once-only controls screen stays away from `--session `, `codeaf resume`, `--once`, `--host`, pipes, existing conversations, and profiles that have already seen them. If every answer already exists, the marker is written silently. -The default service's OpenRouter prerequisite follows a narrower rule of its own. A missing connection is +The default provider's OpenRouter prerequisite follows a narrower rule of its own. A missing connection is shown for local interactive `--session ` and `codeaf resume` launches too, because those conversations still need a model. It stays away from `--once`, `--host`, pipes, custom endpoints, and profiles whose shell or profile already supplies a key. @@ -232,7 +232,7 @@ Every answer went through a settings row, so every answer has a door: | What you answered | Where to change it later | | --- | --- | -| the default service's openrouter key | clear or remove it and the next local interactive launch offers **connect openrouter** again; `/settings`, Providers tab, the **openrouter key** row still accepts a pasted replacement | +| the default provider's openrouter key | clear or remove it and the next local interactive launch offers **connect openrouter** again; `/settings`, Providers tab, the **openrouter key** row still accepts a pasted replacement | | the crew | nothing was asked — it is auto. `/crew` shows it, and `/crew pin ` pins a seat | | the daily limit | `/budget` (also `/limits`), or `/settings` → **Spending**. `CODEAF_DAILY_BUDGET` in your shell outranks the row | | the model you talk to | `/model`, or the **Chat model** row on the setup screen — the same settings row either way | @@ -261,7 +261,7 @@ already written any of them down — it never claims your own settings are defau ``` Per-plan approval, the per-conversation ceiling, individual crew seats, reasoning, -routing, extra service keys, concurrency and appearance are all deliberately absent from +routing, extra provider keys, concurrency and appearance are all deliberately absent from the setup. They have doors — `/budget`, `/settings`, `/crew`, `/model` — and they are asked about at the moment they matter rather than before you have started. diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index 986bb223f..e3b169c47 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -192,7 +192,7 @@ build if the two disagree), so a tip you saw is on it word for word. It is the only row that names two commands as a pair, because the two rows about keeping something used to be told apart by nothing: a standing order is a condition the work has to honour and a memory is a fact carried forward. -- `/connect links Notion, Slack and other services` — retired when the connect panel +- `/connect links Notion, Slack and other accounts` — retired when the connect panel is reached for. - `/autonomy sets how questions are handled while you are away` — after the first exchange. Retired when `/autonomy` is typed, bare or with a rule. (It took the seat diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 52f0b16a3..ed697923e 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -1389,7 +1389,7 @@ same door: - **`/model`**, or **pressing the model's name on that rule**, opens the model list in home's own body — the same filterable list `/model` opens in a conversation. Type to narrow it, `↑↓` to walk it, `enter` to take the row, `esc` to leave it alone. The foot while it is up - follows the cursor and reads `↑↓ pick · → providers · alt+s sort · enter choose · ctrl+t + follows the cursor and reads `↑↓ pick · → hosts · alt+s sort · enter choose · ctrl+t effort · esc back` on a model, and `↑↓ pick · ← back · alt+s sort · enter choose · esc back` inside an open provider fold. `← back` stands beside `↑↓ pick` because both move the cursor. - **`/model `** typed into the box pins it straight away, with no list. diff --git a/internal/manual/chat/how-tasks-run.md b/internal/manual/chat/how-tasks-run.md index 745a7d702..0d8eb482f 100644 --- a/internal/manual/chat/how-tasks-run.md +++ b/internal/manual/chat/how-tasks-run.md @@ -2606,13 +2606,13 @@ its own and does not spend any of those three (see *Models, context, and what it **Routing around a full pool.** Some *too many requests* answers name which upstream provider's pool is full — one machine room out of the several that can serve the same model. When that happens, codeaf remembers the name and asks the router to route new -calls around that provider for the next five minutes (or for the comeback time it named, +calls around that host for the next five minutes (or for the comeback time it named, if shorter), so fresh work lands on machines with room instead of queueing behind the full one. The call that drew the answer still waits its own wait — only calls sent after -it steer around. A model served by a single provider has nowhere else to go, and simply +it steer around. A model served by a single host has nowhere else to go, and simply waits as described above. -**Why things can stay slow afterwards.** codeaf watches how many calls the provider will +**Why things can stay slow afterwards.** codeaf watches how many calls the host will take at once and pulls that number in half when it is told *too many requests* — once per burst, not once per answer. It gives it back on the clock: after **20 seconds** with no further pacing, one call's worth returns every **5 seconds** until it is back where it diff --git a/internal/manual/chat/keeping-an-eye.md b/internal/manual/chat/keeping-an-eye.md index faf713c29..900f96ce9 100644 --- a/internal/manual/chat/keeping-an-eye.md +++ b/internal/manual/chat/keeping-an-eye.md @@ -602,7 +602,7 @@ needs a yes and nobody was able to say one. The item still records that it looke the count of what was examined is honest and the record of the pass carries one error. Set the default key — `/settings` → **openrouter key**, say "set up my api key", or use -the `models` group in `/connect` to add a service — and the +the `providers` group in `/connect` to add a provider — and the next pass judges normally. Nothing has to be re-made and nothing was lost while there was no key. diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 177f50b36..a2e8beedf 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -1647,7 +1647,7 @@ With the providers open, `enter` on one of them pins it instead of switching mod Its placeholder reads exactly `filter by name · ctrl+r refresh` — the keys are on the FOOT, because a placeholder vanishes under the first typed character and the foot does not. The hint slot -follows the cursor: `→ providers · alt+s sort · enter switch · ctrl+t effort · esc` on a model, `← back · alt+s sort · enter choose · esc` inside its +follows the cursor: `→ hosts · alt+s sort · enter switch · ctrl+t effort · esc` on a model, `← back · alt+s sort · enter choose · esc` inside its providers, and `enter unpin · ← back · esc` on the provider already pinned, where `enter` takes the pin off — with `tab providers` and `tab back` in place of the arrows while you are mid-typing and the arrow would step over a character instead. The foot always names whichever diff --git a/internal/manual/chat/lanes.md b/internal/manual/chat/lanes.md index 6c04ef6c3..fe128ced6 100644 --- a/internal/manual/chat/lanes.md +++ b/internal/manual/chat/lanes.md @@ -1,13 +1,13 @@ -# Providers — which provider answers your model, and the question a slow one asks +# Hosts — which host answers your model, and the question a slow one asks -**The word is provider.** Earlier builds called the same thing a *lane*, and the +**The word is host.** Earlier builds called the same thing a *lane*, and the settings row, the picker's hints and this page all said so; `lane` still spells the setting on disk (`lane.talk`) and the file of learned speeds (`lanes.json`), but nothing you read says it any more. If you are looking for lanes, or for the machine or endpoint behind a model, this page is the one. -A model name is an address, not a provider. Behind one name there are usually a -dozen **providers** — different companies running the same model — and they are not +A model name is an address, not a host. Behind one name there are usually a +dozen **hosts** — different companies running the same model — and they are not alike: on a measured day in August, seventeen of them serving one model differed by **7×** on how long they took to say their first word and by **12×** on how fast they wrote, at roughly the same price. Which one answers you is often a bigger @@ -34,26 +34,26 @@ terminal a leading `~` means your home folder, and codeaf still reads it that wa is followed by a slash — `~/` is a path, `~deepseek/…` is a model. What that costs is the next section: a floating name and the build it points at are two -different names, and only one of them has providers behind it. +different names, and only one of them has hosts behind it. ## A model name that ends in latest — what the pointer names, and what via says instead `…-latest` is the other half of the same row. Two things about it are worth knowing, because neither is guessable. -**A pointer is not a provider, so what is learned is filed under what it points at.** +**A pointer is not a host, so what is learned is filed under what it points at.** `…-latest` names whichever dated build the model's makers published most recently — today `deepseek/deepseek-v4-flash-0731` — and it is that dated build the router publishes -providers for. So the providers codeaf asks about, the speeds it writes down, and the row it +hosts for. So the hosts codeaf asks about, the speeds it writes down, and the row it keeps in `~/.codeaf/v3/lanes.json` are all filed under the dated name, never under the pointer. That name is not something the screen says back to you, which is why it surprises people who go looking. The picker and the status line show the name **you** chose, and `via -cloudflare` names the provider that answered rather than the model it answered for. +cloudflare` names the host that answered rather than the model it answered for. **When the pointer moves, nothing is carried across.** The newer build is a different -model with its own providers and its own speeds, so it starts its own record from the sheet +model with its own hosts and its own speeds, so it starts its own record from the sheet the router publishes for it, and the older build's record stays where it is instead of being spent on a model nobody has measured. That is the same rule as everywhere else here: a measured thing is about the thing that was measured. @@ -61,29 +61,29 @@ a measured thing is about the thing that was measured. ## Auto, and who is actually choosing (this used to be called the lane) — the router first, and when codeaf takes over Left alone, codeaf is on **auto**, and `auto` means the router routes. OpenRouter -balances the providers behind your model on its own queues and prices, and codeaf -watches: every answer names the provider that served it, so the speed and the +balances the hosts behind your model on its own queues and prices, and codeaf +watches: every answer names the host that served it, so the speed and the quality of what the router hands you are learned exactly as if codeaf had asked -for them. You see the provider in the status line — `via cloudflare · 0.6s · 61 t/s` +for them. You see the host in the status line — `via cloudflare · 0.6s · 61 t/s` — and the picker's `auto` row tells you what codeaf would choose if it were choosing. -**Why did it pick that provider on the very first message?** Because on the first +**Why did it pick that host on the very first message?** Because on the first message nobody has chosen anything: no pin, no takeover earned yet, so the pick -is the router's own — whichever provider its balance landed on. The provider is +is the router's own — whichever host its balance landed on. The host is named in the status line so the choice is never invisible, and from that first answer on it is being learned like any other. **codeaf takes over when the router lets go.** If a model's answers start coming -back refused (a 429, a provider that cannot serve the shape) or unusable (the +back refused (a 429, a host that cannot serve the shape) or unusable (the thread lost, tool markup, a stream that had to be cut) — twice in a short while — -codeaf stops lending the router the choice and picks the provider itself, from the -providers it has been watching all along. Only a refusal the ROUTER earned counts: -once codeaf is the one choosing a provider — during a takeover, or under your +codeaf stops lending the router the choice and picks the host itself, from the +hosts it has been watching all along. Only a refusal the ROUTER earned counts: +once codeaf is the one choosing a host — during a takeover, or under your pin — a refusal of that pick is about the pick, not another strike against the router, so a takeover's own demands cannot keep it alive. The conversation says so once, in one sentence, and after about half an hour of good answers the -choice is the router's again. Pinning a provider yourself in `/model` ends it +choice is the router's again. Pinning a host yourself in `/model` ends it there and then: your word outranks either of them. **`openrouter` is `auto` without the safety.** It is the same router routing, and @@ -92,116 +92,116 @@ rather have the router's price balance than be rescued from its bad minute. The rest of this page — the closed set, the refusal walk, the probe — describes what happens while codeaf is choosing: during a takeover, and whenever you have -pinned a provider yourself. All of it is written about `routing` at `latency` or +pinned a host yourself. All of it is written about `routing` at `latency` or `price`. With the row at `simple` — which is what it ships as — none of it runs: no takeover, no ranking, no measuring, because that row sends exactly what you -asked for and nothing else; *How do I stop codeaf choosing the provider itself* below has it. With +asked for and nothing else; *How do I stop codeaf choosing the host itself* below has it. With the row at `off` the choosing stops too, and the last section says what that leaves standing. **A run started from a terminal is routed on the same terms.** `codeaf do`, `codeaf run` and `codeaf plan run` open no conversation and draw no status line, and they used to -take whatever provider the router happened to hand them. They fetch the same sheet now and +take whatever host the router happened to hand them. They fetch the same sheet now and rank it with the same arithmetic — so a headless machine, one that only ever runs work -from a terminal, is choosing between providers rather than between none, and every run +from a terminal, is choosing between hosts rather than between none, and every run leaves a record the next one starts from. Nobody is sitting in front of an errand, so it is the price ranking above that applies to it. `auto` is the answer only while your home's -provider row says auto; a pin in that home replaces this ranking at every terminal door. +host row says auto; a pin in that home replaces this ranking at every terminal door. **A model it has never sent to is not a model it knows nothing about.** The -public sheet names every provider serving it, and what codeaf has learned about +public sheet names every host serving it, and what codeaf has learned about a *company* — that this one is quick, that one queues — carries across every model that company serves. So the first request to a brand-new model is still routed, still has a clock on it, and asks for a fresh sheet in the background while it goes. You never wait for that fetch. -## The providers your message may go to are asked for by name — and the router may not go outside them +## The hosts your message may go to are asked for by name — and the router may not go outside them When codeaf has measured enough to have an opinion, it does not merely *rank* the -providers it wants. It **names the set it will accept, and closes it**: the -router may not serve your message from a provider outside that set. +hosts it wants. It **names the set it will accept, and closes it**: the +router may not serve your message from a host outside that set. The list used to be advice. The router read it, weighed it against its own queues and prices, and was free to hand your message to somebody else — and often -did. Over ten days of this build's own call record, the provider codeaf asked for -first served 29 requests in every 100; the provider a closed set names served 93. -Everything codeaf works out before sending — which providers can do the job at all, +did. Over ten days of this build's own call record, the host codeaf asked for +first served 29 requests in every 100; the host a closed set names served 93. +Everything codeaf works out before sending — which hosts can do the job at all, which are quick enough for how long this kind of work waits, what each one costs — was being spent on a list the router could put aside. **What it costs, and it is a real cost.** A closed set can run out. If every -provider in it is busy at once, your message is refused rather than handed to -whoever happened to be free. What follows is a move, not an ending: the provider +host in it is busy at once, your message is refused rather than handed to +whoever happened to be free. What follows is a move, not an ending: the host that refused comes off the set and the request goes straight to the next one in -it, with no wait (`trying another provider · 2 of 3`); and when the last one has +it, with no wait (`trying another host · 2 of 3`); and when the last one has gone, the set comes off the request entirely, so the router has its whole roster back for the one send that needs it. -**A set of one is never made this way.** One provider named with nothing to fall -back on is a pin — it is exactly what pinning a provider yourself sends — so codeaf -closes a set only when it has at least two providers it is happy with. With one, -it ranks what it has and leaves the router its usual freedom. Pinning a provider +**A set of one is never made this way.** One host named with nothing to fall +back on is a pin — it is exactly what pinning a host yourself sends — so codeaf +closes a set only when it has at least two hosts it is happy with. With one, +it ranks what it has and leaves the router its usual freedom. Pinning a host still means what it always did, and nothing here narrows a set you asked for. -## The providers one message may go to are decided once — why a retry walks the same set, and why something learned mid-answer waits for your next message +## The hosts one message may go to are decided once — why a retry walks the same set, and why something learned mid-answer waits for your next message -Before the first byte of a request leaves, codeaf decides which providers that +Before the first byte of a request leaves, codeaf decides which hosts that request may go to: the ranked few it asks for by name, and the ones it asks the router to skip. **That decision is made once and it lasts the whole request.** It matters because one message is often sent more than once without you seeing -it. A provider answers with a fault, a pool turns out to be full, the shape has to +it. A host answers with a fault, a pool turns out to be full, the shape has to be widened and tried again: each of those is the same request going out afresh. What changes between them is only what this request has learned about itself — -the providers that have already refused *it*, which are left off the next one. +the hosts that have already refused *it*, which are left off the next one. What does not change is the ranking. -So something measured while your message is in flight — the provider list +So something measured while your message is in flight — the host list finishing a refresh a second late, another conversation discovering that a -provider has got quick — is spent on your **next** message and not this one. That +host has got quick — is spent on your **next** message and not this one. That is deliberate. The line that tells you what is happening (`trying another -provider · 2 of 3`), the clock that decides when to stop waiting on a provider, and -the names on the request itself all have to be about one set of providers. A +host · 2 of 3`), the clock that decides when to stop waiting on a host, and +the names on the request itself all have to be about one set of hosts. A ranking that appeared on the third try would be a set nothing else had heard of, -and you would be told about a walk through providers that were never asked for. +and you would be told about a walk through hosts that were never asked for. If a request starts on a model codeaf has measured nothing about, it has nothing to rank and asks for nothing by name — the router chooses — and it stays that way -for the whole of that request even if the provider list lands halfway through. +for the whole of that request even if the host list lands halfway through. Your next message is routed. -## When the provider list says a machine cannot take tool calls, or is half down — why codeaf tries it anyway +## When the host list says a machine cannot take tool calls, or is half down — why codeaf tries it anyway -The public sheet carries three claims about each provider that codeaf used to +The public sheet carries three claims about each host that codeaf used to treat as final: whether it honours a tool call, what share of the last five minutes it was answering, and whether the router's own operators have marked it -down. A provider failing any of them was removed from the candidate set outright. +down. A host failing any of them was removed from the candidate set outright. -**They are opinions now, not doors.** A provider the sheet doubts is **ranked -last** — behind every provider nothing is doubted about, never asked first while -something better can serve you — and it is still there when the providers in +**They are opinions now, not doors.** A host the sheet doubts is **ranked +last** — behind every host nothing is doubted about, never asked first while +something better can serve you — and it is still there when the hosts in front of it are busy or refuse. About **one request in ten** is sent to it first -on purpose, because a provider nobody ever asks can never show the sheet was +on purpose, because a host nobody ever asks can never show the sheet was wrong about it. This changed because the sheet was measurably wrong. On 2026-09-10 a task was -answered three times in a row, six seconds each, by a provider the sheet flags as -unable to take tool calls — while the same task sat on a busy provider collecting +answered three times in a row, six seconds each, by a host the sheet flags as +unable to take tool calls — while the same task sat on a busy host collecting nine refusals, because the one that was working had been removed from every request carrying tools. -**What a provider's own answers say beats what the sheet says about it.** Once -codeaf has seen a provider return usable answers to this kind of work, the sheet's +**What a host's own answers say beats what the sheet says about it.** Once +codeaf has seen a host return usable answers to this kind of work, the sheet's doubt stops applying to it and it is ranked on its numbers like anything else. -That belief fades over about an hour if the provider stops answering well, so +That belief fades over about an hour if the host stops answering well, so nothing learned here is learned forever. One claim is still a closed door, and it is not the sheet's: when the **router -itself** answers that a provider cannot serve this model, that provider is not a +itself** answers that a host cannot serve this model, that host is not a candidate at any rank. That is an answer to a request codeaf really made, not a page published some minutes ago. -## Learning which provider finishes my work faster — why the first message does not go to the most expensive provider +## Learning which host finishes my work faster — why the first message does not go to the most expensive host Auto considers both the first words and the generation that must finish before the next step can run. Readable prose can arrive while you read. Reasoning and @@ -211,44 +211,44 @@ Completed calls teach codeaf how much of each kind to expect for that model, whether tools are available, and its reasoning setting. Recent evidence counts more; stale evidence gives way to the conversation's previous answers. A new conversation with no evidence still has no measurement of how long its first -answer will run. To compare providers, codeaf reads that absence as a typical -readable answer rather than no answer at all, so a provider that charges ten +answer will run. To compare hosts, codeaf reads that absence as a typical +readable answer rather than no answer at all, so a host that charges ten times as much to write does not win the first turn on its first word alone. It does not keep that comparison as a measurement. The request's output limit bounds a learned estimate. Capped, interrupted and unusable replies do not teach it that a complete answer is short. The measurements share the existing local routing history across sessions, with -a bounded number of remembered request types. Provider names, prices and speeds -come from the provider information and actual calls; there is no preferred-provider -list to maintain. A successful provider stays preferred for that conversation's -cache, while the slow-response monitor watches that provider and can still rescue +a bounded number of remembered request types. Host names, prices and speeds +come from the host information and actual calls; there is no preferred-host +list to maintain. A successful host stays preferred for that conversation's +cache, while the slow-response monitor watches that host and can still rescue a stalled request under the existing spending limits. Text arriving in a batch earns progress for its approximate token count, so a -provider that sends whole phrases is not judged as though each phrase were one +host that sends whole phrases is not judged as though each phrase were one token. Tool-only replies also teach the first-token and generation clocks. When a watched request fails and this call's own budget can pay for another -provider, that provider is tried before repeating the failed request. Rate +host, that host is tried before repeating the failed request. Rate limits still respect their retry delay. Without an affordable alternative, the existing bounded retries and wait reporting remain. -## Pinning one provider yourself (pinning a lane) — how to change the provider for a model, left and right arrows in the model picker, the @ after the model name, and whether codeaf do uses the lane I pinned +## Which host am I pinned to — pinning one host yourself (pinning a lane), how to change the host for a model, left and right arrows in the model picker, the @ after the model name, and whether codeaf do uses the lane I pinned -You can name the provider yourself. Open `/model` and press `→` (or `tab`) on the model: -its providers — the machines serving it — open under it, the cursor **moves into them**, -onto the provider you pinned or onto `auto` when you have not, and the list scrolls so the -model and every provider are in view. `enter` pins the provider under the cursor — every request +You can name the host yourself. Open `/model` and press `→` (or `tab`) on the model: +its hosts — the machines serving it — open under it, the cursor **moves into them**, +onto the host you pinned or onto `auto` when you have not, and the list scrolls so the +model and every host are in view. `enter` pins the host under the cursor — every request for that model goes there until you say otherwise — and `←` (or `tab`) walks back out. -The hint slot says which: `→ providers · alt+s sort · enter switch · ctrl+t effort · esc` on a +The hint slot says which: `→ hosts · alt+s sort · enter switch · ctrl+t effort · esc` on a model, `← back · alt+s sort · enter choose · esc` inside. On the default service, the `openrouter` row means "no opinion from me — let the router balance it". ## What enter on the openrouter row does — it chooses default and opens the list under it `enter` on the bare `openrouter` row writes the same answer the `default` row **inside** that -row's own list writes: ask for no provider, let the router balance. It is that answer reached +row's own list writes: ask for no host, let the router balance. It is that answer reached one press earlier, not a fourth option. **So it opens the list as well as choosing**, and the cursor lands on `default` with the @@ -262,62 +262,62 @@ this list — `←` or `tab` is the way back out. ## Going back to auto — unpinning with the same key that pinned, and filtering inside an open fold -**`enter` on the provider you are already pinned to takes the pin off.** It is a toggle on +**`enter` on the host you are already pinned to takes the pin off.** It is a toggle on the one key that put it there, and the hint slot says so while the cursor is on that row: -`enter unpin · ← back · esc`. The row goes back to `auto`, the `@provider` comes off the -model's name, and the next request carries no provider at all. The `auto` row at the top of +`enter unpin · ← back · esc`. The row goes back to `auto`, the `@host` comes off the +model's name, and the next request carries no host at all. The `auto` row at the top of the fold still does the same thing and is still the explicit way to say it — the toggle -exists because reaching that row meant walking `↑` past every provider in the list, and one +exists because reaching that row meant walking `↑` past every host in the list, and one press too far lands on another model's row, where `enter` switches the model instead. -One case is deliberately not a toggle: after the provider you pinned has refused the model +One case is deliberately not a toggle: after the host you pinned has refused the model (below), nothing is asking for it any more, so `enter` there **pins it again** rather than unpinning — which is the "pinning again puts it straight back" the refusal promises. -**Typing in the box while a fold is open filters the providers, not the models.** With -`morph`'s fold open, typing `mor` narrows it to the providers whose names carry those +**Typing in the box while a fold is open filters the hosts, not the models.** With +`morph`'s fold open, typing `mor` narrows it to the hosts whose names carry those letters and leaves the fold standing. The matching is the same as for a model id — every word you type has to match, prefix first — and a query that matches none of that model's -providers falls through to filtering the model list as it always has, closing the fold with +hosts falls through to filtering the model list as it always has, closing the fold with it. **But `@cloudflare` in the picker's box finds nothing.** Until 2026-09-17 typing it there -kept the models that provider serves and opened the first of them on it; the box searches +kept the models that host serves and opened the first of them on it; the box searches names only now, and no model id carries an `@`, so the list comes back empty. The two doors -onto a provider are still open and are the ones to use: `→` on a model lists its providers, +onto a host are still open and are the ones to use: `→` on a model lists its hosts, and `/model @cloudflare` from the box pins one outright. -**The provider you are pinned to is written on the model's name** — `deepseek-v4-flash@cloudflare` +**The host you are pinned to is written on the model's name** — `deepseek-v4-flash@cloudflare` on the line above the box and on a phone's status deck — with the same `@` you would type in `/model @cloudflare`. `/status` says it on a `lane` line under `model` — that one row keeps the old word because it is also the key `/status --json` prints. On `auto` and `openrouter` there is no `@`, and none once a pin has been retired. Pressing the -name opens the picker with the cursor on the pinned provider. +name opens the picker with the cursor on the pinned host. A pin is an instruction, so codeaf keeps it. It does not quietly send your work somewhere else because it thinks it knows better. **The pin belongs to your home, not to one conversation.** Every door reads the same profile row when it opens: `codeaf do`, `codeaf exec`, `codeaf plan`, `codeaf run` and -the background pass all honour the provider you picked, just as the chat does. A run from a -terminal and a task running overnight therefore ask for your pinned provider too. +the background pass all honour the host you picked, just as the chat does. A run from a +terminal and a task running overnight therefore ask for your pinned host too. -## A model with no providers measured yet — the model picker says no machine has been measured for this model, and no provider list opens +## A model with no hosts measured yet — the model picker says no machine has been measured for this model, and no host list opens **A model nobody has measured still opens**, onto `auto` and `openrouter` — and `openrouter` opens too, because the `default` row is always inside it. Where the machines would be there is one line: -`no provider has been measured for this model yet — providers show up after its first answer`. -Opening it asks for that model's list of providers in the background. With the routing +`no host has been measured for this model yet — hosts show up after its first answer`. +Opening it asks for that model's list of hosts in the background. With the routing row at `off` nothing opens at all. -## When the provider I pinned cannot serve the model — a machine that will not serve it, the one thing that ends a pin without me +## When the host I pinned cannot serve the model — a machine that will not serve it, the one thing that ends a pin without me -**There is exactly one thing that ends a pin without you: the provider you named +**There is exactly one thing that ends a pin without you: the host you named saying it will not serve that model at all.** That is not a wait and not a bad -afternoon — the router answers `No allowed providers are available for the -selected model. … but your request's provider.only preference permits only: -coreweave`, which is the wire saying this provider and this model do not go +afternoon — the router answers `No allowed hosts are available for the +selected model. … but your request's host.only preference permits only: +coreweave`, which is the wire saying this host and this model do not go together. Asking again buys the same 404, so codeaf stops asking, and says so once, in the conversation, at the moment it happens: @@ -327,29 +327,29 @@ coreweave cannot serve this model; routing on auto for this model until you pin What that means, exactly: -- **for that model**, every later request in this run goes out with no provider +- **for that model**, every later request in this run goes out with no host demanded at all — routed the way `auto` routes; - **the request that collected the refusal is widened and sent again**, once, so your answer still arrives. If that is refused too the turn ends, and says so; - **the line stays in the conversation.** It is not one of the dim retry notes the work chip collapses when an answer lands, so it is still on the screen after the turn finishes; -- **your settings row is not touched.** The `provider` row on the Providers tab still reads +- **your settings row is not touched.** The `host` row on the Providers tab still reads `pinned: coreweave`, exactly as you wrote it. What changes is everything that names the - provider **requests are going to**: the `@coreweave` comes off the model's name, the tail + host **requests are going to**: the `@coreweave` comes off the model's name, the tail on the `your model` row reads `auto (coreweave cannot serve this model)`, and the fold's mark moves to `auto`; -- **every other model still goes to that provider.** The refusal was about one +- **every other model still goes to that host.** The refusal was about one pairing; - **pinning again puts it straight back**, on the very next request — and that - includes choosing the provider you already had, which is a row that did not + includes choosing the host you already had, which is a row that did not change and an instruction that did; - it lasts until you pin again or you close the window, and a task the conversation starts inherits it rather than paying for the refusal again. -The sentence is said **once** per provider and model, for the whole run. +The sentence is said **once** per host and model, for the whole run. -## When a pinned provider goes quiet — the `switch to auto?` question, and how to say no to it +## When a pinned host goes quiet — the `switch to auto?` question, and how to say no to it It still has to do something about a wait, and what it does is **ask you**: @@ -358,14 +358,14 @@ coreweave is slow · switch to auto? (y) ``` Press **y** and the answer is fetched from somewhere else, at once, from the -provider that was already ranked second — no new decision made at the worst +host that was already ranked second — no new decision made at the worst possible moment. The question is asked **once** per answer, and it disappears the moment your answer starts arriving, because by then it is moot. -**You are only ever asked about a provider you pinned yourself.** On auto, codeaf -also asks for providers by name — the closed set above — but that set is its own, -so a slow provider in it is simply left: the answer is started somewhere else and -you are told what is happening (`trying another provider`) rather than asked to +**You are only ever asked about a host you pinned yourself.** On auto, codeaf +also asks for hosts by name — the closed set above — but that set is its own, +so a slow host in it is simply left: the answer is started somewhere else and +you are told what is happening (`trying another host`) rather than asked to decide anything. The question is what your own instruction earns. **`y` is the only key the question takes.** There is nothing to press to say no, because @@ -377,11 +377,11 @@ is still your own. Two things it does not do. It does **not** take the `y` out of a sentence you are typing: the key only counts while the box is empty, and while you are writing, `y` is a `y`. And it does **not** change your pin. Saying yes rescues -*this* answer; the next request goes to the provider you pinned, because that is +*this* answer; the next request goes to the host you pinned, because that is what pinning means. With nobody watching — a task running unattended, a standing order firing -overnight — there is nobody to ask, so a pinned provider that has gone quiet +overnight — there is nobody to ask, so a pinned host that has gone quiet past the patience for that kind of work borrows another one for that answer and says so in the log. An instruction whose author cannot be reached is honoured by getting them their answer. @@ -396,7 +396,7 @@ thought codeaf has timed for it, at the effort it was asked at, and from nothing else. So a thought that has gone quiet far beyond that model's usual thinking is -treated as a stall and rescued the same way a slow provider is: a second request +treated as a stall and rescued the same way a slow host is: a second request goes out and the status line shows `slow · trying …`. A deep thought that is still arriving is left alone for all the patience it needs, because leaving one costs a whole fresh thought and buys you nothing. @@ -408,14 +408,14 @@ just picked. **The ceiling on silence is a ceiling on a still wire.** A model writing reasoning is writing, so the clock the ceiling runs on is the time since the -provider last sent anything at all — readable or not. A thought that has been +host last sent anything at all — readable or not. A thought that has been arriving steadily for two minutes has never been silent for one second of it, and nothing acts on it. The moment the deltas stop, the ceiling starts from there and fires exactly where it always did. Keepalives buy nothing. A router that holds the connection open by saying nothing in a well-formed way is proof about the path and about nothing else, so -a provider that has stopped writing reaches the ceiling however politely it keeps +a host that has stopped writing reaches the ceiling however politely it keeps the line open. Before 2026-09-09 that clock ran from the last word you could READ, which is @@ -437,8 +437,8 @@ counting up under it. **The second is action, and it is ten seconds.** Ten seconds of nothing arriving is when codeaf stops waiting and does something about it: a second request to -another provider, and the line changes to `switching`. That is a ceiling, not a -target — a provider codeaf has timed is acted on at its own measured pace, which +another host, and the line changes to `switching`. That is a ceiling, not a +target — a host codeaf has timed is acted on at its own measured pace, which for a fast one is a second or two. Ten seconds is measured, not chosen. Across ten days of real calls the first @@ -455,16 +455,16 @@ and a second request costs money. They are never silent either — the same sentence is on their row. **Ten seconds always does something, even when a second request is too -expensive.** A second request to another provider costs real money, so every +expensive.** A second request to another host costs real money, so every rescue is priced before it goes out — against what THIS call may spend, which is how long it is allowed to keep trying converted into money at what a second of your waiting is worth. A rescue costing a couple of cents against a minute and a half of your time is afforded; one costing more than the whole wait is worth is -not. When a rescue is refused and the provider has sent nothing at all, not one +not. When a rescue is refused and the host has sent nothing at all, not one byte, codeaf stops that attempt instead and asks somewhere else. Before -2026-09-10 it did neither: four tasks that evening sat on one provider for six and +2026-09-10 it did neither: four tasks that evening sat on one host for six and seven minutes after the ten seconds were up, because the only way to act was the -one codeaf could not afford. If the provider IS sending something — the router is +one codeaf could not afford. If the host IS sending something — the router is talking, or the model is writing where you cannot see it — nothing is stopped, because nine such calls in ten turn out to be seconds from an answer. @@ -473,7 +473,7 @@ in any twenty requests, and at most a tenth of the last hour's bill, shared by everything running in codeaf at once. That is gone, and it is gone because it answered the wrong question — a count spread over twenty requests cannot tell the one that needs rescuing from the nineteen that do not, so it refused whichever -asked last. On 2026-09-11 that is exactly what happened: a provider wrote 604 +asked last. On 2026-09-11 that is exactly what happened: a host wrote 604 words in 86 seconds with somebody watching, the rescue was called for, and the quota said no on behalf of requests that had already finished. What bounds a rescue now is this call's own budget and how many requests one question may have @@ -483,12 +483,12 @@ running at once, which is four. at all, and tells you so in one line. It is the point where every model in the chain has had one fair try with a move between them: of the calls that recovered in ten days of logs, two thirds had landed by then, and the ones that took -longer were spending the time asking the same provider again — which codeaf no +longer were spending the time asking the same host again — which codeaf no longer does. **And that one number is the whole of how long a failed call goes on trying.** There is no separate allowance for how many times to ask, how long to wait out a -busy provider, how many providers to walk, or how many things to take off the +busy host, how many hosts to walk, or how many things to take off the request — each of those was its own number until 2026-09-11, and together they came to a total nobody could have told you. Now there is a clock, it scales with who the work is for, and it is the same clock for every kind of failure: @@ -500,8 +500,8 @@ who the work is for, and it is the same clock for every kind of failure: | a standing order, a check, a design pass | 9 minutes | | the one-token measurement behind the model list | 45 seconds | -While it is trying, the status row counts the providers rather than the tries: -`2 of 5` means the second of five providers that can serve this model, and when +While it is trying, the status row counts the hosts rather than the tries: +`2 of 5` means the second of five hosts that can serve this model, and when codeaf cannot tell how many there are it shows no number instead of a made-up one. @@ -522,9 +522,9 @@ response.attempts: 3 # every give-up above, three times as long said how many times one request would be repeated — so asking for more patience bought more identical requests inside the same deadline, which ended the call anyway. The intent behind the setting was always "try harder before you tell me -you could not", and trying harder is time: more providers walked, more shapes of +you could not", and trying harder is time: more hosts walked, more shapes of the request tried, longer waited out of a busy pool. **What it will never buy is -the same bytes sent to the same provider again.** If you had written a number into +the same bytes sent to the same host again.** If you had written a number into this row when it meant sends, it now means that many times the patience — a `3` you set to get three tries is three times ninety seconds. @@ -536,7 +536,7 @@ ceiling it was allowed. **Nothing waits behind a busy moment in silence.** When every request codeaf is allowed to have in the air at once is already in the air — which happens when -several windows and a task are working at the same time, or a provider has been +several windows and a task are working at the same time, or a host has been pacing the account — the next call queues. It says `connecting` while it does, with no countdown, because nothing in codeaf knows which of the calls ahead of it will finish first, and a countdown to a moment nobody can name is worse than @@ -551,11 +551,11 @@ longest was twelve minutes. ## Why is it writing one word at a time — it never stopped, it just crawled A stream does not have to stop completely to need rescuing. Once codeaf has -measured how quickly a provider normally puts visible words on the page, it watches +measured how quickly a host normally puts visible words on the page, it watches the gaps between those words together. A long run at a small fraction of that usual rate stops counting as progress toward the patience limit. If the crawl continues for that kind of work's full ceiling, codeaf acts just as it does on a -stream that went silent: it tries another provider, asks before leaving a pin, or +stream that went silent: it tries another host, asks before leaving a pin, or says the wait is real when there is nowhere to go. One slow gap is still only one slow gap. The judgment comes from the run of @@ -565,84 +565,84 @@ per-token rate, so ordinary batching does not look like a crawl. Hidden thinking does not count as a visible word, so a model that interleaves long thoughts between single words can still be rescued this way — but only once its MEASURED visible rate has collapsed. A pause between words is not -enough on its own, however long, as long as the provider is still writing +enough on its own, however long, as long as the host is still writing something. -If codeaf has never measured a visible rate for that provider, it invents none and +If codeaf has never measured a visible rate for that host, it invents none and cannot judge a crawl this way. Only a period with no visible progress long enough to reach the ordinary ceiling can then trigger action. -## Why a fast provider was skipped, or a cheap one never used — how long the work has to wait decides which providers it may go to +## Why a fast host was skipped, or a cheap one never used — how long the work has to wait decides which hosts it may go to Every kind of call this build makes says how long it is willing to wait before something is done about a silence: ten seconds for a chat turn, for a step of a task you are watching, and for the quick lookups behind a keypress; thirty for work running in the background; a minute for a standing pass; five seconds for -the one-token checks codeaf makes of a provider itself. That number is not only a -stopwatch. It is also what decides which providers the +the one-token checks codeaf makes of a host itself. That number is not only a +stopwatch. It is also what decides which hosts the request is allowed to go to at all. -Before sending, codeaf works out for every provider serving the model how long +Before sending, codeaf works out for every host serving the model how long it expects the WHOLE answer to take there — how long until the first word, plus -how long the rest takes at the speed that provider writes, plus the fact that a -provider which refuses four requests in five is really being asked five times. -Providers are ranked by that number, and any provider whose number is longer than +how long the rest takes at the speed that host writes, plus the fact that a +host which refuses four requests in five is really being asked five times. +Hosts are ranked by that number, and any host whose number is longer than the wait this kind of call is willing to sit through is **left off the request altogether**, by name, so the router cannot fall back onto it. -There is no separate rule and no threshold anybody picked. A provider is refused +There is no separate rule and no threshold anybody picked. A host is refused exactly when the answer is expected to take longer than this work waits. Two things follow from that, and both are deliberate: -- **The same provider is refused for one kind of call and used for another.** A - provider that takes twenty seconds is out of the question for something in +- **The same host is refused for one kind of call and used for another.** A + host that takes twenty seconds is out of the question for something in front of your typing and perfectly fine for a standing pass. -- **Nothing is ever refused when there is nothing better.** If every provider +- **Nothing is ever refused when there is nothing better.** If every host serving a model is beyond the limit, none of them is refused — the request goes to the best of them rather than nowhere. -The speed that counts is the whole answer and not just the first word. A provider +The speed that counts is the whole answer and not just the first word. A host can say its first word promptly and then write at two tokens a second, which is a healthy start and a four-minute answer; that is what the 2026-09-11 reading of a task step stuck for three and a half minutes turned out to be. -## A provider that is usually fast and sometimes takes a minute +## A host that is usually fast and sometimes takes a minute -For the answers you READ as they arrive, codeaf does not rank providers by their +For the answers you READ as they arrive, codeaf does not rank hosts by their typical speed. It ranks them by how long an unlucky request takes. -A provider that starts in three seconds nine times out of ten and in a minute the -tenth is not a three-second provider to whoever drew the tenth, and a typical +A host that starts in three seconds nine times out of ten and in a minute the +tenth is not a three-second host to whoever drew the tenth, and a typical figure cannot tell it apart from one that takes three seconds every time. So for -anything you watch, each provider is judged at roughly its own worst-in-ten, using +anything you watch, each host is judged at roughly its own worst-in-ten, using how much its answers have actually been seen to vary rather than an assumed -figure. A provider that is genuinely steady is barely moved by this and loses -nothing; an erratic one falls behind a slightly slower provider that is reliable. +figure. A host that is genuinely steady is barely moved by this and loses +nothing; an erratic one falls behind a slightly slower host that is reliable. For work nobody reads as it arrives, the typical figure is used instead — those calls are many and small and what matters is their total. -## When a provider suddenly gets slower than it has ever been +## When a host suddenly gets slower than it has ever been -Beliefs about a provider are built from many answers, which normally makes them +Beliefs about a host are built from many answers, which normally makes them steady and occasionally makes them stubborn: one bad answer against fifty good ones barely moves anything. So codeaf also watches for a **step change** — a -run of answers that is not bad luck but a different provider than the one it was +run of answers that is not bad luck but a different host than the one it was measuring. When it sees one, the old evidence is thrown away rather than averaged, and the next choice is made on what is happening now. -Before this, a provider whose writing speed collapsed about ninefold was still +Before this, a host whose writing speed collapsed about ninefold was still being chosen five steps later, over half an hour, because each slow answer arrived as one reading against a belief far too settled to move. -## When every provider is slow — `all providers slow`, which older builds spelled `all lanes slow` +## When every host is slow — `all hosts slow`, which older builds spelled `all lanes slow` Sometimes there is nowhere better to go — everything serving that model is believed slow at once, which happens when a whole region is having a bad afternoon. Switching would buy nothing, so codeaf says the true thing instead: ``` -all providers slow · still waiting · 12s +all hosts slow · still waiting · 12s ``` That line means the wait is real, it is not a stall this build can end, and @@ -658,7 +658,7 @@ it is asking you to sit through. ## When the answer is arriving too slowly to read This is not the same thing as the line above, and it took a real afternoon to -learn the difference. `all providers slow · still waiting` is about a **silence** — +learn the difference. `all hosts slow · still waiting` is about a **silence** — nothing is arriving. Sometimes words ARE arriving and the wait is just as real, because they are arriving at a crawl: @@ -666,9 +666,9 @@ because they are arriving at a crawl: answering slowly · nowhere faster · 1m 26s ``` -That line means codeaf measured the words appearing against the pace the provider +That line means codeaf measured the words appearing against the pace the host it asked for was expected to write at, found the stream far under it, and has -nowhere better to send the question — every other provider has been tried, or +nowhere better to send the question — every other host has been tried, or this call cannot pay for a second request. The answer is still coming and the words still appear as they arrive; the line is there so that the wait has a name. @@ -676,11 +676,11 @@ On 2026-09-11 the same call showed one nudge and then nothing for eighty-six seconds, because neither of the two lines codeaf had was true: it was not writing at any speed a person would call writing, and it was not silent either. -**A provider is judged against the pace its question was sent expecting**, not +**A host is judged against the pace its question was sent expecting**, not against its own recent form. That distinction is the whole fix: as codeaf learned -that one provider had slowed to a seventh of its usual speed, every stream it -served started to look normal *for that provider*, and the guard quietly stopped -firing. What it is held to now is the provider the routing choice named — the +that one host had slowed to a seventh of its usual speed, every stream it +served started to look normal *for that host*, and the guard quietly stopped +firing. What it is held to now is the host the routing choice named — the reason the request went out at all — so a router that quietly hands your question to something ten times slower is noticed. @@ -690,130 +690,130 @@ to something ten times slower is noticed. | --- | --- | | `via cloudflare · 0.6s · 61 t/s` | an ordinary answer, and who wrote it | | `deepseek-v4-flash@cloudflare` | you pinned cloudflare, and every request for the model goes there | -| `slow · trying parasail…` | a provider was late or its visible answer had slowed to a crawl; a second request is out and the first to answer wins | -| `refused · trying parasail…` | a provider said it will not serve this model; the answer has already moved | -| `parasail refused` | the provider that second request went to said no as well | +| `slow · trying parasail…` | a host was late or its visible answer had slowed to a crawl; a second request is out and the first to answer wins | +| `refused · trying parasail…` | a host said it will not serve this model; the answer has already moved | +| `parasail refused` | the host that second request went to said no as well | | `via parasail · rescued` | it worked, for this answer only | -| `coreweave is slow · switch to auto? (y)` | your pinned provider is quiet, and you can end the wait | -| `coreweave cannot serve this model; routing on auto for this model until you pin again` | the provider you pinned said no, so the pin is retired for this model | -| `all providers slow · still waiting · 12s` | everywhere is slow; nothing to be done but tell you, and how long you have waited | -| `answering slowly · nowhere faster · 1m 26s` | words ARE arriving, too slowly to be worth reading, and there is no faster provider to move to | +| `coreweave is slow · switch to auto? (y)` | your pinned host is quiet, and you can end the wait | +| `coreweave cannot serve this model; routing on auto for this model until you pin again` | the host you pinned said no, so the pin is retired for this model | +| `all hosts slow · still waiting · 12s` | everywhere is slow; nothing to be done but tell you, and how long you have waited | +| `answering slowly · nowhere faster · 1m 26s` | words ARE arriving, too slowly to be worth reading, and there is no faster host to move to | -## When a provider refuses to serve the model — a machine that will not serve my model +## When a host refuses to serve the model — a machine that will not serve my model `slow` and `refused` are two different facts and the row says which. **Slow** is -a wait: the provider is answering and taking its time, or its visible words have -slowed far below the rate codeaf measured for it. **Refused** is a provider saying +a wait: the host is answering and taking its time, or its visible words have +slowed far below the rate codeaf measured for it. **Refused** is a host saying it will not serve this model at all — the router answers -`No allowed providers are available for the selected model. Providers serving -: digitalocean, deepinfra, … but your request's provider.only preference -permits only: coreweave`, which means the provider codeaf asked for is not in the +`No allowed hosts are available for the selected model. Hosts serving +: digitalocean, deepinfra, … but your request's host.only preference +permits only: coreweave`, which means the host codeaf asked for is not in the set that serves this model right now. -A refusal is final for that provider, immediately: +A refusal is final for that host, immediately: -- the next request leaves at once, for a different provider, and does not name +- the next request leaves at once, for a different host, and does not name the refused one; -- that provider is taken out of the set codeaf will choose from for this model, +- that host is taken out of the set codeaf will choose from for this model, so it is not picked again later in the session; - if there is nowhere left to move to, the request itself is widened — the - demand for one provider is the first thing dropped — and the answer usually + demand for one host is the first thing dropped — and the answer usually arrives from wherever the router picks. This happens even when the last - provider tried failed some other way (busy, or went quiet): a widening that was + host tried failed some other way (busy, or went quiet): a widening that was put off for a move is always done before you are shown anything, with its `Retry 1/N: relaxed the endpoint filter` lines. If nothing lands, the error you - see is the most useful one — a provider's rate limit and its wait before an - earlier provider's refusal. + see is the most useful one — a host's rate limit and its wait before an + earlier host's refusal. -**A provider refusing your request is a move too, not the end of the turn.** When -the answer carries the name of the provider that produced it — a `400`, a `404`, -an account policy, a model that provider will not serve — that is one provider's +**A host refusing your request is a move too, not the end of the turn.** When +the answer carries the name of the host that produced it — a `400`, a `404`, +an account policy, a model that host will not serve — that is one host's answer about this request and the others have said nothing about it, so codeaf -sends the next one straight to a different provider with that one left off. It is -the same walk a busy provider gets, and until 2026-09-11 it was not: the turn +sends the next one straight to a different host with that one left off. It is +the same walk a busy host gets, and until 2026-09-11 it was not: the turn ended there, and the move only happened on your *next* message, after codeaf had remembered the refusal. What still ends a turn is a refusal that names **nobody** -— that is the router reading the request itself and saying no, and every provider +— that is the router reading the request itself and saying no, and every host alive would say the same thing. -If a later provider accepts the request and starts writing but that stream is +If a later host accepts the request and starts writing but that stream is cut, the cut is the failure codeaf acts on. The partial reply is cleared and the -existing bounded call retry routes around the provider that failed. An earlier +existing bounded call retry routes around the host that failed. An earlier `No endpoints found` answer is not shown as the final error after another -provider demonstrably accepted the request. +host demonstrably accepted the request. -## When a provider is too busy — a rate limit, too many requests, a 429, and how long codeaf stays away from it +## When a host is too busy — a rate limit, too many requests, a 429, and how long codeaf stays away from it -**Too many requests is not a refusal.** A provider that answers -`API error (429): Provider returned error (via Io Net)` has not said anything +**Too many requests is not a refusal.** A host that answers +`API error (429): Host returned error (via Io Net)` has not said anything about your request — its queue is full for the moment. So it is not written off the way a refusal is. It is **stepped around for a while**, and it comes back on its own. -- **When the answer names the provider, codeaf stops sending there.** Every - request after it goes to a different provider for as long as that one asked to +- **When the answer names the host, codeaf stops sending there.** Every + request after it goes to a different host for as long as that one asked to be left alone, and for **five minutes** when it named no time. - **And that includes the request that collected it.** Its next try is written - fresh, with the busy provider left off, so it walks on to another one instead + fresh, with the busy host left off, so it walks on to another one instead of queueing behind the same full queue. Before 2026-09-10 it did not: the request was written once and sent again unchanged, which is how a single ask - spent seventeen tries on one provider over eleven minutes and still ended + spent seventeen tries on one host over eleven minutes and still ended `too many requests`. You see the walk as `2 of 6` on the status row while it happens. -- **The same provider is only ever asked twice when it is the only one there - is** — a provider you pinned yourself, or a model with one provider behind it — - and then codeaf waits exactly as long as that provider asked for before trying +- **The same host is only ever asked twice when it is the only one there + is** — a host you pinned yourself, or a model with one host behind it — + and then codeaf waits exactly as long as that host asked for before trying again. That wait is shown as what it is: `waiting for coreweave · 12s`, - counting down to the moment the provider named. -- **Moving to another provider costs no wait at all.** A pause between tries is - what codeaf pays to ask the *same* provider again; going somewhere else is a + counting down to the moment the host named. +- **Moving to another host costs no wait at all.** A pause between tries is + what codeaf pays to ask the *same* host again; going somewhere else is a different request and it goes out immediately. -- **You never have to switch models to get past this.** When every provider +- **You never have to switch models to get past this.** When every host behind the model is busy at once, codeaf stops waiting and moves your turn to the next model instead, because another model is always quicker than a window. Work running inside a task has no other model to move to, so that is the one - place codeaf waits the window out — and it tells you which provider it is + place codeaf waits the window out — and it tells you which host it is waiting for and how long is left. - **It counts wherever the message arrived.** A rate limit can come back before a single word is written, or in the middle of a reply that had already started - arriving. The provider is stepped around either way. Before 2026-09-10 only the - first kind counted, so a busy provider that said "too many requests" halfway + arriving. The host is stepped around either way. Before 2026-09-10 only the + first kind counted, so a busy host that said "too many requests" halfway through a reply was handed the next request, and the one after that — three times in a minute and a half, on one measured turn. -- **A rate limit that names nobody is your whole account**, not one provider, and - nothing is stepped around: every provider behind the model is behind the same +- **A rate limit that names nobody is your whole account**, not one host, and + nothing is stepped around: every host behind the model is behind the same ceiling, so there is nowhere better to go and nothing to leave off the next request. codeaf waits **once**, for exactly as long as the answer itself asked for — and not at all when it asked for nothing, because a wait nobody named is a wait codeaf would be inventing — and then moves to another model, because a - second provider would only + second host would only spend the account's allowance faster, and a different model is not on the same allowance at all. With no model left to move to you are handed what the - provider said. On your screen it reads `we are being asked to slow down`. + host said. On your screen it reads `we are being asked to slow down`. (Until 2026-09-11 this kept re-sending the identical request behind a wait that doubled each time — 0.7s, 1.4, 2.8, 5.6 and on — for the whole of the time that kind of work is given: ninety seconds on a turn, four and a half minutes inside a task. Nothing changed between those sends, because there was nothing that could change.) -- **And a provider that keeps answering after you have stepped around it stops +- **And a host that keeps answering after you have stepped around it stops the walk.** The name in `(via Io Net)` is the upstream's, and not every upstream name is one the router will route around — so when the next request says "not that one" and that one answers it anyway, codeaf has learned that routing cannot help this request, and it goes on to the wider set and then to another model instead of asking a third time. Until 2026-09-11 only a plain refusal did this and a rate limit was exempt, which cost one measured task - eight sends to one provider over ninety seconds while six other providers on the + eight sends to one host over ninety seconds while six other hosts on the same model were answering in under five. -## What all providers have been ignored means — a refusal from nobody +## What all hosts have been ignored means — a refusal from nobody When the router answers `All -providers have been ignored`, no provider was ever asked: a list had removed the +hosts have been ignored`, no host was ever asked: a list had removed the whole set before the request left — either codeaf's own running list of slow and -unavailable providers, or the ignored providers set on your account. Nothing is -taken away from any provider on that answer, because a provider that never got the +unavailable hosts, or the ignored hosts set on your account. Nothing is +taken away from any host on that answer, because a host that never got the request has said nothing about it — it keeps its place for every other request. -If the provider you had pinned is the one nobody could reach, the pin itself is +If the host you had pinned is the one nobody could reach, the pin itself is still stood down and you are told, because a pairing your account cannot use is one to stop asking for. codeaf stops sending the list that emptied the set for that model and the next request lands, so this is at most one wasted round trip @@ -824,10 +824,10 @@ while the answer is moving, and if parasail refuses too the promise is **taken back** rather than left standing. The row reads `parasail refused`, which is what actually happened; it never says `trying …` about a request that has already failed. -## When the base refuses a provider choice — why a proxy may not honour my pinned lane +## When the base refuses a host choice — why a proxy may not honour my pinned lane Some bases -take no provider choice at all — a plain OpenAI-compatible endpoint behind +take no host choice at all — a plain OpenAI-compatible endpoint behind `CODEAF_BASE_URL`, a proxy that strips the field, a gateway that never heard of it. codeaf finds out by asking: your pin goes out on a real request, once, and if that is refused the same request is sent again without it — whether *that* @@ -835,38 +835,38 @@ lands is the answer, so an unrelated bad request never costs you your pin. If the base will not take the choice, you are told once, in the conversation: ``` -api.example.com does not take a provider choice; coreweave is not being asked for, and your requests still go out +api.example.com does not take a host choice; coreweave is not being asked for, and your requests still go out ``` Your work still goes out; only the choice is left off. The settings row says it too, so `pinned:` never stands as a claim about a request that did not carry it: `pinned: coreweave (not taken on this base)`. -## Providers switched off on your account — OpenRouter's ignored-providers list, and the one refused round trip it costs +## Hosts switched off on your account — OpenRouter's ignored-hosts list, and the one refused round trip it costs -Your OpenRouter account can carry its own ignored-providers list: providers you +Your OpenRouter account can carry its own ignored-hosts list: hosts you switched off and OpenRouter will not use. codeaf cannot read that list. -Separately, codeaf keeps its own running list of providers that are slow or have -refused. The two lists can leave no provider to ask even though neither list -emptied the set alone. OpenRouter then says `All providers have been ignored` -before any provider is asked. +Separately, codeaf keeps its own running list of hosts that are slow or have +refused. The two lists can leave no host to ask even though neither list +emptied the set alone. OpenRouter then says `All hosts have been ignored` +before any host is asked. -That sentence is the only thing that tells codeaf which providers your account -will not reach. Every provider codeaf has timed for this model that codeaf was +That sentence is the only thing that tells codeaf which hosts your account +will not reach. Every host codeaf has timed for this model that codeaf was not itself refusing in that request stops being counted as somewhere the request -can land. On the next request, codeaf drops the provider on its own list that is -nearest returning, and the request lands. A switched-off provider therefore +can land. On the next request, codeaf drops the host on its own list that is +nearest returning, and the request lands. A switched-off host therefore costs one refused round trip per model in a session, rather than one on every request. -If a provider later answers, codeaf counts it again immediately. Switching a -provider back on needs nothing from you. +If a host later answers, codeaf counts it again immediately. Switching a +host back on needs nothing from you. -The **privacy switch for providers that may train on paid prompts** is the same +The **privacy switch for hosts that may train on paid prompts** is the same kind of list, and OpenRouter names it: `0 endpoints out of 1 requested are available matching your guardrail restrictions and data policy … Paid model -training violation (account settings)`. When that answer is about a provider -codeaf asked for by name, the provider is remembered as out of reach for your +training violation (account settings)`. When that answer is about a host +codeaf asked for by name, the host is remembered as out of reach for your account — for **every model**, for **a day**, and across restarts (`~/.codeaf/v3/account-exclusions.json`) — so no later request names it and it costs one refused round trip, once. A strict pin on it is stood down on every @@ -878,16 +878,16 @@ The row promises that what you wrote is what goes on the wire, so the pin is sent — once — and OpenRouter is left to be the one that says no. You pay the refused round trip again on the first turn of a new window, and you get the `cannot serve this model` line in the conversation, in the same breath as the -`@provider` coming off the model on the status line. That is the trade: a +`@host` coming off the model on the status line. That is the trade: a sentence you can act on instead of a request that quietly went somewhere else. -## Providers (lanes) on a custom base URL, a proxy, a mirror, or a self-hosted router — `CODEAF_BASE_URL` +## Hosts (lanes) on a custom base URL, a proxy, a mirror, or a self-hosted router — `CODEAF_BASE_URL` -Providers are not tied to the OpenRouter hostname. Point codeaf at any base with +Hosts are not tied to the OpenRouter hostname. Point codeaf at any base with `CODEAF_BASE_URL` — a proxy in front of the router, a mirror, a router of your own, the router by its IP — and it **asks that base whether it publishes an endpoints page**: the first background fetch of a model's sheet is the question. -A base that answers with a page has providers exactly as the built-in endpoint does, +A base that answers with a page has hosts exactly as the built-in endpoint does, with the same auto ranking, pins, hedges and status line. Nothing about the address is inspected; a router is recognised by what it answers. @@ -898,60 +898,60 @@ A router that has the page but **does not publish that one model** says so in its own words, about the model, and nothing is remembered about the base. A 500, a timeout or a rate limit is a bad afternoon rather than an answer. -## Does a proxy honour my pinned provider, the lane I pinned — how a custom base answers +## Does a proxy honour my pinned host, the lane I pinned — how a custom base answers -**Whether a base honours a provider choice is learned the same way**, never from +**Whether a base honours a host choice is learned the same way**, never from its address. A base that served an endpoints page takes one. Any other base is asked once, and only once you have **pinned** something — a pin is the only -thing there is to ask with, so a base nobody pinned anything on is sent no provider +thing there is to ask with, so a base nobody pinned anything on is sent no host opinion at all, exactly as before. Your pin goes out on a real request; if the base refuses it, codeaf sends that request again once without it, and whether *that* lands is the answer. A base that refuses the choice, or that answers -without ever naming the provider that served, is remembered as not taking one and +without ever naming the host that served, is remembered as not taking one and **says so** (the refusal section above has the sentence). -**A proxy that forwards to the router but strips the provider name out of its +**A proxy that forwards to the router but strips the host name out of its answers is read as not taking your choice**, deliberately. codeaf cannot tell that proxy from one honouring your pin silently — nothing in the answer says -which provider served — so it tells you, sends later requests bare, and the proxy +which host served — so it tells you, sends later requests bare, and the proxy then routes your model however it likes. Your work still goes out; your pin is not honoured there, and you know rather than guess. Neither question costs an extra call of its own, and pointing `CODEAF_BASE_URL` somewhere else asks the new address afresh about both. -A directly connected service is simpler: it has one provider, so there is nothing to choose -between and no provider sheet to open. That is not a fault. The service name carried by the +A directly connected service is simpler: it has one host, so there is nothing to choose +between and no host sheet to open. That is not a fault. The service name carried by the model id is already the whole route. -## How do I stop codeaf choosing the provider itself — the simple routing mode, OpenRouter's default routing, and what my pinned provider still sends +## How do I stop codeaf choosing the host itself — the simple routing mode, OpenRouter's default routing, and what my pinned host still sends The `routing` row (`/settings` → **Providers**) has a fourth answer, **`simple`**, -for exactly this. Under it codeaf keeps no opinion of its own about the providers +for exactly this. Under it codeaf keeps no opinion of its own about the hosts behind your model, and sends none: -- **No provider pinned** — the request carries no routing preference at all: no sort - word, no price ceiling, no providers named or excluded. OpenRouter's own default - routing picks the provider, exactly as it would for a request codeaf had never +- **No host pinned** — the request carries no routing preference at all: no sort + word, no price ceiling, no hosts named or excluded. OpenRouter's own default + routing picks the host, exactly as it would for a request codeaf had never touched. There is no measuring, no second request hedged alongside yours, not even the one-token measurement sent while you type, and no takeover when answers come back refused. -- **A provider pinned** (`/model @deepseek`, or enter on the **provider** row) — your - turn demands exactly that one provider: `only`, fallbacks off, and nothing else +- **A host pinned** (`/model @deepseek`, or enter on the **host** row) — your + turn demands exactly that one host: `only`, fallbacks off, and nothing else rides along. Your word is the whole request. A pin written `borrow when slow` changes nothing here — there is no rescue running for it to borrow. The row is named `lane.talk` and that is its scope: the errands that run beside a turn go out bare (the next section). -What does not change: the provider that answered is still named on the status -line, and the `switch to auto?` question a slow pinned provider asks still has -somewhere to send you. A pin the router itself refuses — the provider saying it +What does not change: the host that answered is still named on the status +line, and the `switch to auto?` question a slow pinned host asks still has +somewhere to send you. A pin the router itself refuses — the host saying it cannot serve that model at all — is still retired for that model, with the same one-sentence note, and pinning again puts it straight back on the very next request. `simple` is not `off`. `off` stops the measuring, and with nothing measured -there is no provider to choose, no sheet of providers to open and no speed guard. +there is no host to choose, no sheet of hosts to open and no speed guard. `simple` leaves the pin standing: the one instruction you gave is the only one sent. **`simple` is also what the row ships as**, so this is what a home nobody has changed does; everything else this page describes — the ranking, the @@ -970,61 +970,61 @@ next section says how. That was not always true. Until 2026-09-13 those extra roads were built without the row and ran `latency` whatever you had written — which was quiet and wrong in one specific way. A road on `latency` is allowed to stand a pin down on -codeaf's own saved belief that your account cannot reach the provider, and that +codeaf's own saved belief that your account cannot reach the host, and that stand-down covers the whole window: your very next message, on `simple`, doing -nothing wrong, went out with no provider demanded while the status line still +nothing wrong, went out with no host demanded while the status line still read `@deepseek`. The row reaching every road is what closes it. -If you want to check: pin a provider, set `routing` to `simple`, and send a -message. Either the answer comes from the provider you named, or you get the -`cannot serve this model` sentence and the `@provider` disappears from the model +If you want to check: pin a host, set `routing` to `simple`, and send a +message. Either the answer comes from the host you named, or you get the +`cannot serve this model` sentence and the `@host` disappears from the model word. There is no third outcome — a bare request under a pin that is still being drawn is the bug above, and it is worth reporting. -## Does my pinned provider apply to the title, the memory reflex and a subharness too, or only to what I type +## Does my pinned host apply to the title, the memory reflex and a subharness too, or only to what I type **Only to the calls you are reading.** Under `routing: simple` the pinned -provider is demanded on your own turn, on a task room you are sitting in front +host is demanded on your own turn, on a task room you are sitting in front of, and on a headless `codeaf exec` you typed — all three are you, waiting. The -errands that run beside a turn send no provider name at all: a conversation's +errands that run beside a turn send no host name at all: a conversation's conversation title, the memory reflex, the question that routes your message, a hand asking a model about a document, a subharness node. The row is spelled `lane.talk` and the slot is its whole scope. That is what one refusal costs. A pin the router refuses is retired **per -provider and model** — one refused round trip, once — but an errand runs on a +host and model** — one refused round trip, once — but an errand runs on a model of its own, and before 2026-09-13 a single turn bought three of them: yours, the title's and the reflex's, on three different models, each with its -own 404 and none of them a provider you had asked for. One turn, one refusal, +own 404 and none of them a host you had asked for. One turn, one refusal, one sentence. Under `latency` and `price` nothing changes: there is no demand to scope, because a pin on those roads is drawn against everything the belief knows about -the providers behind each model. +the hosts behind each model. -## Turning provider routing off — endpoint routing, lane routing, all the same row +## Turning host routing off — endpoint routing, lane routing, all the same row Set routing off (`/settings`, or the `routing` row) and codeaf sends every request with no opinion at all. It still will not let you wait forever — a ceiling on how long a silence runs before *something* is said about it is not -steering, it is the promise this surface makes — but it stops choosing providers +steering, it is the promise this surface makes — but it stops choosing hosts for you, stops sending second requests, and stops spending anything on speed. **The row has four answers, and the two quiet ones are not the same nothing.** Left alone -it reads `simple`, and codeaf does not pick a provider for you at all — it asks for no -fastest provider and no cheapest one, sends no preference of its own, and your pin, if you +it reads `simple`, and codeaf does not pick a host for you at all — it asks for no +fastest host and no cheapest one, sends no preference of its own, and your pin, if you made one, is the whole request (the section above). Writing another word in the row turns -the choosing on everywhere: `latency` picks the fastest provider on every call, background +the choosing on everywhere: `latency` picks the fastest host on every call, background work included; `price` ranks on price alone on every call, your own turns included, which is you saying that speed is not worth money anywhere; and `off` is the paragraph above. Under `latency` and `price` the work you are not watching still weighs speed, at a quarter of the weight your own turns give it — a task ends when its slowest call ends, and a -provider that refuses four requests in five costs five sends for one answer, so its seconds +host that refuses four requests in five costs five sends for one answer, so its seconds are never free. That is the split the rest of this page describes. -`price` still measures providers and still chooses between them. `simple` and `off` stop the choosing. +`price` still measures hosts and still chooses between them. `simple` and `off` stop the choosing. **`off` does not stop the remembering, and that is deliberate.** codeaf still writes down -which provider answered and which one refused, because that is what lets a request that +which host answered and which one refused, because that is what lets a request that has just been refused go somewhere else instead of back to the same place — recovery is not steering, and a build that forgot a refusal the moment you switched routing off would be a build that could only ever retry into it. Nothing it remembers reaches the wire: diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 0d16fe18a..5b4f5017d 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -307,7 +307,7 @@ by its id against a narrow list of generation and sidecar words. ## Why the via name keeps changing on the model list It does not, not while the list is open. `via ` on a `/model` row is which -provider would typically serve that model, frozen when the list opened — so a turn +host would typically serve that model, frozen when the list opened — so a turn running underneath cannot make the names jump, and the `▲0.5s` and `58t/s` next to them stay still too. Close the list and open it again to see the latest. diff --git a/internal/manual/chat/services.md b/internal/manual/chat/services.md index 64d3c7712..f95343b57 100644 --- a/internal/manual/chat/services.md +++ b/internal/manual/chat/services.md @@ -1,44 +1,48 @@ -# Services — the places models come from +# Providers — the places models come from -## Add a key — connect a service, add an api key for another provider, use a different model service +Older builds called these **services** (or connections, or model services); this page and +the surfaces it describes now say **provider**. The word `codeaf services` in a shell is +something else again: long-running background processes, covered by their own page. -An api key for another provider, or another model service, is added here. Open `/connect` or -`/connections`. The `models` group lists DeepSeek, Z.ai, Moonshot, MiniMax, Alibaba Qwen, Codex, -Ollama and **Custom OpenAI-compatible API**, followed by any service already connected and, once -a custom connection is connected, an `add custom connection` row. Codex says `browser`; it signs +## Add a key — connect a provider, add an api key, use a different provider + +An api key for another provider, or another model provider, is added here. Open `/connect` or +`/connections`. The `providers` group lists DeepSeek, Z.ai, Moonshot, MiniMax, Alibaba Qwen, Codex, +Ollama and **Custom OpenAI-compatible API**, followed by any provider already connected and, once +a custom provider is connected, a `+ add a provider` row. Codex says `browser`; it signs in a ChatGPT plan instead of asking for an API key. Ollama needs no key. The other named vendors ask for theirs. -Pick a row and answer its fields. A successful listed service says +Pick a row and answer its fields. A successful listed provider says `deepseek-direct is connected · 6 models`; one without a list says only -`deepseek-direct is connected`. A service with more than one billing door names the one it +`deepseek-direct is connected`. A provider with more than one billing door names the one it bound: `z-ai-direct is connected · coding plan · 4 models` or `z-ai-direct is connected · pay-as-you-go · 10 models`. The Providers tab in `/settings` -then shows the service, door, safe spelling of its key, region and order. +then shows the provider, door, safe spelling of its key, region and order. -The default service remains first. With two or more services, `/model` groups models by -service in that order; with only the default service, the picker remains ungrouped. +The default provider remains first. With two or more providers, `/model` groups models by +provider in that order; with only the default provider, the picker remains ungrouped. -## Using codeaf with only a direct service — no OpenRouter key at all +## Using codeaf with only a direct provider — no OpenRouter key at all -Yes. When the conversation is on a model from a connected service, that service can carry +Yes. When the conversation is on a model from a connected provider, that provider can carry the turn without an OpenRouter key. Pressing `enter` sends the message; the setup screen does not open, and codeaf does not show `openrouter is not connected · enter on your message connects in a browser, or export OPENROUTER_API_KEY`. -Ollama counts as connected without a key because its local service explicitly needs none. +Ollama counts as connected without a key because its local provider explicitly needs none. The small background calls follow the same road — naming a session, titling a task, the reflex and the judges — which normally use the models on the reflex and small-work rows. If one of those -models belongs to the default service and that service has no key, the call instead uses -the conversation's model on the connected service. Tools, tasks and child agents launched +models belongs to the default provider and that provider has no key, the call instead uses +the conversation's model on the connected provider. Tools, tasks and child agents launched from that turn inherit the same rule, so none of them makes an OpenRouter request. If the -default service does have a key, those calls keep using their configured models as usual. +default provider does have a key, those calls keep using their configured models as usual. -## What model do I get after connecting a service — why did my model change +## What model do I get after connecting a provider — why did my model change A successful connection from `/connect`, or a reconnect from the Providers tab in -`/settings`, moves this conversation onto that service in the same moment. A plan door's +`/settings`, moves this conversation onto that provider in the same moment. A plan door's first documented model wins. Otherwise codeaf uses the vendor's preferred model when the -service listed it or published no list, then the first model the service listed. With no +provider listed it or published no list, then the first model the provider listed. With no preferred or listed model there is no move and no extra sentence. For example, the connection line @@ -56,7 +60,7 @@ the environment it started with. If it started before that variable existed, run the variable. `--no-host` has the same immediate result inside its one process. Under `--host` or -`--at`, connecting a service is absent because the profile behind the conversation is +`--at`, connecting a provider is absent because the profile behind the conversation is not the local profile the panel could write. ## Use my own DeepSeek key — connecting DeepSeek, GLM, Kimi, Qwen or MiniMax directly @@ -66,30 +70,30 @@ Open `/connect` and choose the vendor in the `models` group. DeepSeek and MiniMa choice with `International` under the cursor and `China` below it; a region is never typed. Up and down, or `ctrl+p` and `ctrl+n`, move the cursor. A letter jumps to a region whose name starts with it, enter takes the row under the cursor and opens -`your key`, and esc returns to the service row with nothing saved. The same choice -opens when reconnecting one of these services from its Providers row in `/settings`. -Z.ai is the direct service for GLM and Moonshot is the direct service for Kimi. -MiniMax, Ollama and **Custom OpenAI-compatible API** are single-door services. MiniMax makes no plan +`your key`, and esc returns to the provider row with nothing saved. The same choice +opens when reconnecting one of these providers from its Providers row in `/settings`. +Z.ai is the direct provider for GLM and Moonshot is the direct provider for Kimi. +MiniMax, Ollama and **Custom OpenAI-compatible API** are single-door providers. MiniMax makes no plan claim because its plan and metered traffic currently have no wire-level difference codeaf can use to prove which balance answered. -A service name cannot be confused with the author part of a model already on the default -service. When `deepseek` is already an author there, codeaf connects the direct service +A provider name cannot be confused with the author part of a model already on the default +provider. When `deepseek` is already an author there, codeaf connects the direct provider under `deepseek-direct` in that same attempt. The region and key are not asked for twice, and its models read `deepseek-direct/`. -## Why is my service called z-ai-direct — I connected Z.ai, the name changed +## Why is my provider called z-ai-direct — I connected Z.ai, the name changed -A service may not be written with a name the default service already uses for a model +A provider may not be written with a name the default provider already uses for a model author. codeaf appends `-direct` and finishes the connection in the same attempt, so the region and key are not asked for twice. The connect line tells you the name it used, for example `z-ai-direct is connected · coding plan · 4 models`, and those models read `z-ai-direct/`. DeepSeek follows the same rule: it becomes `deepseek-direct`, and its models read `deepseek-direct/`. -## Connect a service — what is asked for, and what codeaf checks before it saves anything +## Connect a provider — what is asked for, and what codeaf checks before it saves anything -Open `/connect` and choose a row in `models`. DeepSeek asks for `your key`. Z.ai, +Open `/connect` and choose a row in `providers`. DeepSeek asks for `your key`. Z.ai, Moonshot and Alibaba Qwen ask `your region` with one row per region: `International` is first and starts under the cursor, then `China`. Up and down, or `ctrl+p` and `ctrl+n`, move the cursor; a letter jumps to a region whose name starts with it; @@ -100,7 +104,7 @@ name of an environment variable, such as `$DEEPSEEK_API_KEY`. A key with the wrong shape is stopped before any call: `that is not the shape of a deepseek key — they start with sk-`. A refusal carries the -service's own answer, cut at 120 characters on a word boundary: +provider's own answer, cut at 120 characters on a word boundary: `deepseek refused that key — Authentication Fails, Your api key is invalid`. No answer is different: `deepseek did not answer · nothing was saved`. @@ -118,13 +122,13 @@ the same sentence used during a turn, for example The bound door is saved and every later request uses it. codeaf does not silently probe or change billing doors while a turn runs. Only an explicit reconnect rechecks them: -re-enter the service from its Providers row in `/settings`, or press `ctrl+r` there to -reuse the saved details. Disconnecting and reconnecting the service through `/connect` +re-enter the provider from its Providers row in `/settings`, or press `ctrl+r` there to +reuse the saved details. Disconnecting and reconnecting the provider through `/connect` does the same check. Where a door has no fixed catalog, its model listing is believed after the one-token check succeeds. -A payment refusal proves a key authenticated. For an unchanged one-door service, the -service is connected and stored as before. For a multi-door service, codeaf tries the +A payment refusal proves a key authenticated. For an unchanged one-door provider, the +provider is connected and stored as before. For a multi-door provider, codeaf tries the remaining doors; if every one refuses for plan or payment reasons, it stores nothing and says, for example, `z-ai accepted the key but the account cannot pay — Insufficient balance or no resource package. Please recharge.` @@ -132,7 +136,7 @@ A payment refusal on OpenRouter also asks for its balance again, subject to the 30-second quiet period after the last completed read. If OpenRouter says it can afford a smaller positive output cap, codeaf retries that request once with that cap. The final refusal keeps the vendor's whole sentence. -A plain `429` with no recognised payment or plan code still means the service is busy and +A plain `429` with no recognised payment or plan code still means the provider is busy and is waited out. Every saved key lives in the profile `config.json`, owner-readable only. ## Z.ai coding-plan models — why only four GLM models are listed @@ -150,11 +154,11 @@ reset time it also says, for example, `resets at 18:30 UTC · /connect can switch to pay-as-you-go`. An account that cannot pay remains terminal; a spent plan window is not the same thing. -Each connected plan service has a Providers setting named `when the plan is paused`. +Each connected plan provider has a Providers setting named `when the plan is paused`. It defaults to `wait`, which never sends the turn to a metered door. Choose -`use pay-as-you-go` only when you want that service to spend through its metered door. +`use pay-as-you-go` only when you want that provider to spend through its metered door. During overflow the status line names it, for example -`writing · 4s · pay-as-you-go 61 t/s`. The setting is per service. +`writing · 4s · pay-as-you-go 61 t/s`. The setting is per provider. ## Is codeaf supported by Zhipu for the coding plan @@ -162,56 +166,56 @@ Zhipu lists the tools its plan covers. codeaf is not currently listed; a request drafted but has not been sent. codeaf identifies itself as codeaf and does not pretend to be another supported client. -## What a service without a model list can and cannot do +## What a provider without a model list can and cannot do A common reason for “why can't it make pictures any more?” is that the conversation now -uses a service without a model list. The answer depends on that service's empty catalog, +uses a provider without a model list. The answer depends on that provider's empty catalog, not on the picture tool itself. -A service whose model-list check proves absent says `deepseek is connected` +A provider whose model-list check proves absent says `deepseek is connected` with no count. Its picker group contains one dim row: -`no list from this service · type a model id`. Type a model id to use one; codeaf does not invent a catalog. +`lists no models · type a model id`. Type a model id to use one; codeaf does not invent a catalog. -The vendored list fact is only the expectation from the documentation survey. A service that was expected to have no list -but answers the check gets the listed behaviour immediately: its model count, picker group and service-scoped cache all use +The vendored list fact is only the expectation from the documentation survey. A provider that was expected to have no list +but answers the check gets the listed behaviour immediately: its model count, picker group and provider-scoped cache all use the ids it returned, with no reconnect. An empty catalog also means codeaf cannot know which picture-making, speech or video -models that service offers. Those tools are off the belt for that service—absent rather -than present and broken. Text models can still be named and used. A direct service has -one provider, so there is nothing to choose between; that is not a fault. +models that provider offers. Those tools are off the belt for that provider—absent rather +than present and broken. Text models can still be named and used. A direct provider has +one host, so there is nothing to choose between; that is not a fault. -## Remove a key — disconnect a service, delete a key, stop using a service +## Remove a key — disconnect a provider, delete a key, stop using a provider Open `/connect` and press `enter` on a connected row. The row first says `enter again to disconnect`; press `enter` a second time to confirm. When no turn is using it, codeaf removes its saved key and says `deepseek is disconnected · its models are gone from the picker`. -A service answering the current turn cannot be cut: -`deepseek is answering right now · try again in a moment`. If this conversation used the removed service, codeaf either says +A provider answering the current turn cannot be cut: +`deepseek is answering right now · try again in a moment`. If this conversation used the removed provider, codeaf either says the disconnected sentence first and then says `this conversation was on deepseek-direct/deepseek-v4-pro · it is now on ~deepseek/deepseek-v4-flash-latest`, or, when nothing can replace it, -`this conversation was on deepseek-direct/deepseek-v4-pro and nothing else here can take it · connect a service or pick a model`. +`this conversation was on deepseek-direct/deepseek-v4-pro and nothing else here can take it · connect a provider or pick a model`. -## Model names carry the service they came from +## Model names carry the provider they came from -The default service's model ids remain unchanged and unqualified. A model from another -service is written `/`, such as +The default provider's model ids remain unchanged and unqualified. A model from another +provider is written `/`, such as `deepseek-direct/deepseek-v4-pro`. That first segment is how the conversation remembers -where the model can be reached. With two or more connected services, `/model` shows a dim -heading for each service, default first, in the order shown in the Providers tab. A -custom connection's heading is the name you gave it. +where the model can be reached. With two or more connected providers, `/model` shows a dim +heading for each provider, default first, in the order shown in the Providers tab. A +custom provider's heading is the name you gave it. -The status line uses the same spelling: an unqualified default-service id, and -`/` for every other service. It does not shorten -`ollama/llama3.2:latest` to `llama3.2:latest`, because two services may publish the -same model name. `via ` belongs only to a default-service model with router -providers. A direct-service row and status line draw no `via` at all and open no provider -sheet; that service has one road, not a choice of providers. +The status line uses the same spelling: an unqualified default-provider id, and +`/` for every other provider. It does not shorten +`ollama/llama3.2:latest` to `llama3.2:latest`, because two providers may publish the +same model name. `via ` belongs only to a default-provider model with router +hosts. A direct-provider row and status line draw no `via` at all and open no host +sheet; that provider has one road, not a choice of hosts. ## Why does my plan show no cost instead of unbilled or could not be priced? -Phase 1 records no cost for a direct service. Its calls therefore add nothing to the +Phase 1 records no cost for a direct provider. Its calls therefore add nothing to the spend page and show no invented `$0.00`. This does not mean the vendor charged nothing; consult that account for its bill and limits. @@ -221,9 +225,9 @@ rather than saying a subscription call was charged but could not be priced. Dire whose usage block does arrive still record their model call and token counts without an invented price. -A direct service has one provider, so there is no provider picker and nothing to +A direct provider has one host, so there is no host picker and nothing to choose between. That is not a fault. Price caps, privacy negotiation and provider routing -belong to the default routed service and are not applied to a direct call. +belong to the default routed provider and are not applied to a direct call. ## A local runner — Ollama, LM Studio, vLLM, llama.cpp @@ -233,9 +237,9 @@ the usual one, choose **Custom OpenAI-compatible API**, then enter its base URL requires. The connection check asks the local runner for its model list first. When it answers, -its models appear under the service's heading in `/model`; when that address is absent, +its models appear under the provider's heading in `/model`; when that address is absent, the runner can still connect and its group asks for a model id. A local -service has one provider, so there is nothing to choose between and that is not a fault. +provider has one host, so there is nothing to choose between and that is not a fault. ## Custom OpenAI-compatible API — a proxy, a gateway, or your own endpoint @@ -245,42 +249,42 @@ checks the address before saving anything, then asks `name` before `your key`. T box opens on the host's own spelling: `localhost` for a local runner, `127-0-0-1` for the loopback address, the host for anything else. Clearing the box takes that default again. A name cannot carry `/` or a space, and a refused name reopens the box with the -reason: the slash is what separates connection from model in a model id, and a space -would travel into every id the connection qualifies. A name another service or a -default-service model author already uses is not asked twice about: codeaf takes an +reason: the slash is what separates provider from model in a model id, and a space +would travel into every id the provider qualifies. A name another provider or a +default-provider model author already uses is not asked twice about: codeaf takes an available spelling (`localhost-direct`, then numbered ones) and the connect line names what it used. -That name is the connection everywhere. It is the row's name in `/connect` and on the +That name is the provider everywhere. It is the row's name in `/connect` and on the Providers tab, the heading its models sit under in `/model`, and the first segment of -every model id it serves, so a model on a connection named `homelab` reads +every model id it serves, so a model on a provider named `homelab` reads `homelab/glm-5.3` and `/model homelab/glm-5.3` moves onto it. A refusal or a success -names the connection by the name it was given; neither switches back to `custom`. +names the provider by the name it was given; neither switches back to `custom`. -Several custom connections coexist, each under the name you gave it, each with its own +Several custom providers coexist, each under the name you gave it, each with its own key, its own rows and its own picker group. On /connect the **Custom OpenAI-compatible API** row -becomes that first connection's edit door once one is connected and an `add custom -connection` row connects a new one; with none connected yet, **Custom OpenAI-compatible API** is the +becomes that first provider's edit door once one is connected and a `+ add a +provider` row connects a new one; with none connected yet, **Custom OpenAI-compatible API** is the door onto the first. -On the Providers tab in `/settings` each custom connection is a row of its own. `enter` +On the Providers tab in `/settings` each custom provider is a row of its own. `enter` opens it for editing with the address and name pre-filled, and an empty key box keeps the saved key. A changed name is a rename: every model id already picked under the old name is re-spelled with the new one, the conversation's own pick first (a turn still answering is waited out), and with it the stored ones: reasoning levels, the worker, checker and planner pins, role pins, the fallback chain and the capability slots. A rename changes a label and nothing else; it does not move the conversation onto a different model. `ctrl+r` on the row -reconnects with the saved details. The `add custom connection` row runs the same three -questions for a new connection, so the tab never sends you to `/connect` to add one. -The `active connection` row reads +reconnects with the saved details. The `+ add a provider` row runs the same three +questions for a new provider, so the tab never sends you to `/connect` to add one. +The `active provider` row reads `answering on localhost · enter moves it to homelab` and enter does that, wrapping past -the last connection back to the first; which one is active is read from the model the +the last provider back to the first; which one is active is read from the model the conversation is on, so there is nothing else to store. The row is absent while no -custom connection is connected, and when the next one has no model list yet the move +custom provider is connected, and when the next one has no model list yet the move says so instead: `no model list for homelab yet · reconnect it (ctrl+r on its row) or type a model id in /model`. -In Phase 1 a **Custom OpenAI-compatible API** service must provide the compatible chat path. codeaf +In Phase 1 a **Custom OpenAI-compatible API** provider must provide the compatible chat path. codeaf tries `GET /models` first; the models from an answered list fill its picker group. -When that address is absent, codeaf connects the service without inventing rows and the -picker asks you to type a model id. Direct calls record no cost in Phase 1 and have one provider. +When that address is absent, codeaf connects the provider without inventing rows and the +picker asks you to type a model id. Direct calls record no cost in Phase 1 and have one host. diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 5f4cc9971..6865ab9dc 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -123,14 +123,14 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"why is my service called z-ai-direct", "services"}, {"do I need an openrouter key if I connected z.ai", "services"}, {"why did my model change after I connected z.ai", "services"}, - {"what model does codeaf use after I connect a service", "services"}, + {"what model does codeaf use after I connect a provider", "services"}, {"I only have a zhipu key can I use codeaf", "services"}, {"what happens when my plan runs out", "services"}, {"will it spend pay as you go automatically", "services"}, {"why are only four glm models listed", "services"}, {"is codeaf supported by zhipu", "services"}, - {"how do I reconnect a model service", "services"}, - {"I exported the model service key after the engine started", "services"}, + {"how do I reconnect a model provider", "services"}, + {"I exported the model provider key after the engine started", "services"}, {"why does /connect say connections are unavailable", "accounts"}, {"connect says unavailable on my own machine", "accounts"}, {"credentials.json is damaged but where are my models", "accounts"}, @@ -498,7 +498,7 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { // own sentence and in the words somebody reaches for after reading it. {"all providers have been ignored", "lanes"}, {"I switched off some providers in my openrouter account", "lanes"}, - {"does codeaf know which providers my account has turned off", "lanes"}, + {"does codeaf know which hosts my account has turned off", "lanes"}, {"why did every provider get ignored", "lanes"}, {"why does it say refused instead of slow", "models-and-cost"}, {"why does it say paid model training violation", "models-and-cost"}, @@ -2706,14 +2706,14 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { // codeaf has timed anything of theirs. {"does codeaf do pick the fastest endpoint too", "lanes"}, {"does a headless run choose between lanes", "lanes"}, - {"why did it pick that provider on my very first message", "lanes"}, + {"why did it pick that host on my very first message", "lanes"}, {"why did my first message go to the most expensive provider", "lanes"}, // Naming the machine yourself — asked as the worry underneath it, which // is whether a pin is honoured — and reading the line that says which // machine actually answered. {"will it send my work to a different lane than the one I pinned", "lanes"}, {"does codeaf do use the lane I pinned", "lanes"}, - {"is my pinned provider used when I run from a terminal", "lanes"}, + {"is my pinned host used when I run from a terminal", "lanes"}, // And the one thing that ends a pin without the person: the router // saying that machine cannot serve that model at all (issue #456). It // is asked as somebody reads it on the screen and wants to know what it @@ -2734,8 +2734,8 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { // The picker's lanes, asked by somebody who pressed the arrows and saw // nothing move, and by somebody reading `@cloudflare` on the name // (docs/design/lanes-picker/DESIGN.md). - {"how do I change the provider for a model", "lanes"}, - {"which provider am I pinned to", "lanes"}, + {"how do I change the host for a model", "lanes"}, + {"which host am I pinned to", "lanes"}, {"left and right arrows in the model picker do nothing", "lanes"}, {"what does the @ after the model name mean", "lanes"}, {"the model picker says no machine has been measured for this model yet", "lanes"}, @@ -3004,11 +3004,11 @@ func TestTheServicesPageNamesCustomListingDiscoveryAndDisconnectConfirmation(t * for _, sentence := range []string{ "enter again to disconnect", "the disconnected sentence first and then says", - "A direct-service row and status line draw no `via` at all and open no provider\nsheet", - "That name is the connection everywhere", - "a **Custom OpenAI-compatible API** service must provide the compatible chat path", + "A direct-provider row and status line draw no `via` at all and open no host\nsheet", + "That name is the provider everywhere", + "a **Custom OpenAI-compatible API** provider must provide the compatible chat path", "tries `GET /models` first", - "When that address is absent, codeaf connects the service without inventing rows", + "When that address is absent, codeaf connects the provider without inventing rows", "z-ai-direct is connected · coding plan · 4 models", "z-ai-direct is connected · pay-as-you-go · 10 models", "when the plan is paused", diff --git a/internal/manual/pages/connections.md b/internal/manual/pages/connections.md index 78f055b55..a5c3c457c 100644 --- a/internal/manual/pages/connections.md +++ b/internal/manual/pages/connections.md @@ -34,7 +34,7 @@ space between them**: the part that is yours, then the key. > `yourcompany sk-live-1234` -The question says which part it wants first, in the words that service uses for +The question says which part it wants first, in the words that account uses for it — a domain, a site name, a workspace. An account connected with a key shows as connected and **nothing else**. It does @@ -98,7 +98,7 @@ Three ways in, and they are the same connection: Pick one and either a browser page opens for you to sign in, or a line opens for you to paste the key into. - **From the settings page.** `⚙` settings under **Connections** is the same - list at rest, and it connects too: enter on a service opens the sign-in, or + list at rest, and it connects too: enter on an account opens the sign-in, or opens the key box **on the row itself**. It is the same box, and the page stays where it was — the account you just connected gains its tick and opens on what it may do, under your cursor. @@ -182,7 +182,7 @@ Both lists — `/connect` and the settings page — show each account, whether i connected, and the address it is connected as, and both are where you disconnect one. What you have connected is at the top, flat; everything else is under **the word it is filed by** — billing, support, crm, calls & meetings — because a few -hundred services is a list you search rather than one you read. Typing narrows +hundred accounts is a list you search rather than one you read. Typing narrows it, and it narrows on the category as well as on the name: `billing` finds Stripe, Chargebee and Recurly, none of which contain the word. @@ -203,7 +203,7 @@ under its name is **what it may do, without opening it** — the same three word the rows inside carry, so four accounts can be audited by reading rather than by expanding. Under them, what you could connect: one row each, the word saying what pressing enter will ask you for (`key` or `sign in`), and the sentence -about what a service is for shown **only under the row your cursor is on**. Two +about what an account is for shown **only under the row your cursor is on**. Two hundred sentences at once is not a catalog, it is a wall. Disconnecting takes effect immediately: codeaf forgets the account on this @@ -212,14 +212,14 @@ the first one was. ## What a key account can do -One tool, and it is the account itself: codeaf makes the calls that service's +One tool, and it is the account itself: codeaf makes the calls that account's own documentation describes. Reading is free to try. **Anything that changes something — creating, updating, deleting — stops and asks you first**, with the -service, what it is about to do and where, in the question. That is the same +account, what it is about to do and where, in the question. That is the same rule that stands over sending a message, for the same reason: it happens in your name, in a system other people can see, and there is no undo. -What codeaf does not have is a hand-written tool per service. There are hundreds +What codeaf does not have is a hand-written tool per account. There are hundreds of them and no two agree on what a contact is, so it reads their documentation the way you would rather than pretending to know in advance. Expect it to say what it is about to call. @@ -237,7 +237,7 @@ conversation. None of that list is written into codeaf, so an account that gains a tool next month is an account codeaf picks that tool up from, with nothing to change here. -| Service | Address | What it brings | +| Account | Address | What it brings | | --- | --- | --- | | Airtable | `https://mcp.airtable.com/mcp` | your bases, tables and records | | Atlassian | `https://mcp.atlassian.com/v1/mcp/authv2` | Jira issues and Confluence pages | diff --git a/internal/tui3/addprovider.go b/internal/tui3/addprovider.go new file mode 100644 index 000000000..58bf19ba2 --- /dev/null +++ b/internal/tui3/addprovider.go @@ -0,0 +1,434 @@ +package tui3 + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/modelsource" + + "charm.land/bubbletea/v2" +) + +// Known ports to probe for local model servers on loopback (issue #1508). +var localProbePorts = []struct { + port int + name string +}{ + {11434, "Ollama :11434"}, + {1234, "LM Studio :1234"}, + {8000, "vLLM :8000"}, + {8080, "llama.cpp :8080"}, + {8317, "127.0.0.1:8317"}, +} + +// LocalServerProbe represents a running model server discovered on this machine. +type LocalServerProbe struct { + Port int + Name string + Address string + Models int +} + +var ( + errAuthRequired = errors.New("authentication required") + errEmptyModelList = errors.New("lists no models") + localProbeClient = &http.Client{Timeout: 600 * time.Millisecond} +) + +// probeLoopbackPort probes a single local loopback port for an OpenAI-compatible /v1/models endpoint. +func probeLoopbackPort(ctx context.Context, port int, name string) *LocalServerProbe { + addr := fmt.Sprintf("http://127.0.0.1:%d/v1", port) + reqURL := addr + "/models" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil + } + resp, err := localProbeClient.Do(req) + if err != nil { + return nil + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil + } + var body struct { + Data []any `json:"data"` + Models []any `json:"models"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil + } + count := len(body.Data) + if count == 0 { + count = len(body.Models) + } + return &LocalServerProbe{ + Port: port, + Name: name, + Address: addr, + Models: count, + } +} + +// ProbeLocalServers probes all usual local ports concurrently with a short timeout. +func ProbeLocalServers(ctx context.Context) []LocalServerProbe { + ctx, cancel := context.WithTimeout(ctx, 1*time.Second) + defer cancel() + + var ( + mu sync.Mutex + results []LocalServerProbe + wg sync.WaitGroup + ) + + for _, target := range localProbePorts { + wg.Add(1) + go func(p int, n string) { + defer wg.Done() + if probe := probeLoopbackPort(ctx, p, n); probe != nil { + mu.Lock() + results = append(results, *probe) + mu.Unlock() + } + }(target.port, target.name) + } + wg.Wait() + return results +} + +// ProbeOpenAIEndpoint tests an address live and returns its model count or error. +func ProbeOpenAIEndpoint(ctx context.Context, address, key string) (int, error) { + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + address = strings.TrimRight(strings.TrimSpace(address), "/") + if address == "" { + return 0, errors.New("empty address") + } + reqURL := address + "/models" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return 0, err + } + if key != "" { + req.Header.Set("Authorization", "Bearer "+key) + } + resp, err := localProbeClient.Do(req) + if err != nil { + var uerr *url.Error + if errors.As(err, &uerr) && uerr.Err != nil { + return 0, uerr.Err + } + return 0, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return 0, errAuthRequired + } + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + var body struct { + Data []any `json:"data"` + Models []any `json:"models"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return 0, err + } + count := len(body.Data) + if count == 0 { + count = len(body.Models) + } + return count, nil +} + +// localProbesFoundMsg is the answer to the panel's launch probe: the model +// servers already answering on this machine, or none. +type localProbesFoundMsg struct { + probes []LocalServerProbe +} + +// customAddressCheckedMsg is what the address check came back with. A nil err +// means the address listed models; errAuthRequired means it answered and asked +// for a key, which is a live address all the same. +type customAddressCheckedMsg struct { + address string + count int + err error +} + +// ── the add-a-provider panel ────────────────────────────────────────────────── +// +// One panel, three doors ([app.openAddProvider]): the /connect panel's add row +// mints a custom draft and then shows what can fill it, /model offers the same +// panel beside its list (ctrl+o), and the settings sheet's Providers tab keeps +// its own row flow. The mint and the connect behind every row are the ONE flow +// (startModelConnect, startCustomAdd, modelEntryAnswer) — the panel is a door, +// never a second implementation. + +type addProviderItem struct { + heading bool + title string + detail string + probe *LocalServerProbe + // custom marks the typed-address row: the panel's own door onto the mint + // flow the add row started. + custom bool + // sourceID is the vendored provider a row starts (startModelConnect). + sourceID string +} + +type addProviderPanel struct { + open bool + + items []addProviderItem + cursor int + + // loading is whether the local probe is still out. + loading bool + // entry is the flow's answer box while the panel hosts it: an address a + // probe prefilled, then the name and key the mint flow asks for + // (modelEntryAnswer). Nil while the list is being walked. + entry *keyEntry + // err is why the last probe found nothing worth saying. Empty draws nothing. + err string +} + +func (p *addProviderPanel) rebuild(probes []LocalServerProbe, catalog []modelsource.Source) { + var items []addProviderItem + if p.loading { + items = append(items, addProviderItem{heading: true, title: "looking on this machine…"}) + } else if len(probes) > 0 { + items = append(items, addProviderItem{heading: true, title: "found on this machine"}) + for i := range probes { + pr := probes[i] + items = append(items, addProviderItem{ + title: pr.Name, + detail: strconv.Itoa(pr.Models) + " models", + probe: &probes[i], + }) + } + } + items = append(items, addProviderItem{heading: true, title: "providers"}) + if len(catalog) == 0 { + catalog = modelsource.Vendored() + } + for _, source := range catalog { + switch { + case strings.EqualFold(source.ID, modelsource.CustomID), strings.EqualFold(source.ID, modelsource.DefaultID): + // Custom has its own typed-address row below; openrouter is the + // default service and connects through its own key, not here. + case source.ID == "codex": + items = append(items, addProviderItem{title: source.Name, detail: "browser", sourceID: source.ID}) + case source.KeyOptional: + items = append(items, addProviderItem{title: source.Name, detail: "address", sourceID: source.ID}) + case len(source.Regions) > 0: + items = append(items, addProviderItem{title: source.Name, detail: "region · key", sourceID: source.ID}) + default: + items = append(items, addProviderItem{title: source.Name, detail: "key", sourceID: source.ID}) + } + } + items = append(items, addProviderItem{ + title: "any OpenAI-compatible server", detail: "address · key", custom: true, + }) + p.items = items + p.cursor = 0 + for i, it := range p.items { + if !it.heading { + p.cursor = i + break + } + } +} + +func (p *addProviderPanel) move(delta int) { + if len(p.items) == 0 { + return + } + next := p.cursor + delta + for next >= 0 && next < len(p.items) { + if !p.items[next].heading { + p.cursor = next + return + } + next += delta + } +} + +func (p *addProviderPanel) current() (addProviderItem, bool) { + if p.cursor >= 0 && p.cursor < len(p.items) && !p.items[p.cursor].heading { + return p.items[p.cursor], true + } + return addProviderItem{}, false +} + +func (p *addProviderPanel) close() { + *p = addProviderPanel{} +} + +// height is how many rows the panel wants from the frame's overlay budget. +func (a *app) addPanelKey(msg tea.KeyPressMsg) tea.Cmd { + p := &a.addPanel + if p.entry != nil { + switch msg.String() { + case "esc": + p.entry = nil + p.err = "" + a.touch() + case "enter": + entry := p.entry + p.entry = nil + return a.modelEntryAnswer(entry) + default: + p.entry.typeInto(msg) + a.touch() + } + return nil + } + switch msg.String() { + case "esc": + p.close() + a.touch() + case "up", "ctrl+p": + p.move(-1) + a.touch() + case "down", "ctrl+n": + p.move(1) + a.touch() + case "enter": + item, ok := p.current() + if !ok { + return nil + } + p.close() + a.touch() + switch { + case item.custom: + return a.startCustomAdd(false) + case item.sourceID != "": + source, found := a.modelSource(item.sourceID) + if !found { + return nil + } + return a.startModelConnect(modelConnectionStatus(source, false), false) + case item.probe != nil: + // A LIVE LOCAL SERVER MINTS A CUSTOM DRAFT WITH ITS ADDRESS + // PREFILLED: the answer box the mint flow raises comes up already + // carrying the address that answered, so the only thing left to + // type is the key it may want. + cmd := a.startCustomAdd(false) + if entry := a.connPanel.entry; entry != nil { + entry.box.setText(item.probe.Address) + } + return cmd + } + } + return nil +} + +func (p *addProviderPanel) height(width int) int { + if !p.open { + return 0 + } + want := len(p.items) + if p.entry != nil { + want += 3 + } + if p.err != "" { + want++ + } + if want < 1 { + return 1 + } + return want +} + +// draw is the panel as rows. Headings dim, the cursor's row accent, and the +// flow's answer box hangs under the list while it is open — the panel is a +// short list, so both fit where a taller list would have to choose. +func (p *addProviderPanel) draw(width, n int, pal palette, hover int) []string { + if !p.open || n < 1 { + return nil + } + rows := make([]string, 0, n) + // THE BOX DRAWS LAST AND THE LIST GIVES IT ROOM: the question being answered + // is why the panel is up, and a list that scrolled the box off would be a + // list that swallowed a keystroke. + box := []string(nil) + if p.entry != nil { + box, _, _ = keyBoxLines(p.entry, pal, width, 2, 3) + } + list := n - len(box) + if p.err != "" { + list-- + } + for at := 0; at < len(p.items) && len(rows) < list; at++ { + item := p.items[at] + line := "" + switch { + case item.heading: + line = pal.dim(fit(" "+item.title, width)) + case at == p.cursor: + line = pal.accent(" › ") + pal.ink(fit(item.title+" "+item.detail, width-4)) + default: + line = pal.dim(fit(" "+item.title+" "+item.detail, width)) + } + rows = append(rows, line) + } + for _, line := range box { + if len(rows) >= n { + break + } + rows = append(rows, line) + } + if p.err != "" && len(rows) < n { + rows = append(rows, pal.dim(fit(" "+p.err, width))) + } + return rows +} + +// ── the app's door onto the panel ──────────────────────────────────────────── +// +// addProviderRowWord rides [app.modelList] as a row; this is what enter on it +// opens. The panel is a door and not a second implementation: every row it +// activates ends in the mint flow (startModelConnect, startCustomAdd). + +// localServersProbedMsg is the probe's landing: the servers the loopback walk +// found, ready to be drawn as rows. +type localServersProbedMsg struct { + probes []LocalServerProbe +} + +// probeLocalServersCmd walks the known local ports once, off the loop. +func probeLocalServersCmd() tea.Cmd { + return func() tea.Msg { + return localServersProbedMsg{probes: ProbeLocalServers(context.Background())} + } +} + +// openAddProvider raises the panel. From the settings sheet the tab keeps its +// own add row ([startCustomAdd]); from everywhere else the panel is the door: +// it opens, walks the machine for live servers, and rebuilds on their landing. +func (a *app) openAddProvider(inSheet bool) tea.Cmd { + if inSheet { + return a.startCustomAdd(true) + } + p := &a.addPanel + p.open = true + p.loading = true + p.rebuild(nil, nil) + a.touch() + // THE FRAME AND THE WALK GO OUT TOGETHER: the panel is up this frame, and + // the probe's landing rebuilds it with what the machine answered. + return tea.Batch(a.frameTick(), probeLocalServersCmd()) +} diff --git a/internal/tui3/addprovider_test.go b/internal/tui3/addprovider_test.go new file mode 100644 index 000000000..0342c60f2 --- /dev/null +++ b/internal/tui3/addprovider_test.go @@ -0,0 +1,129 @@ +package tui3 + +import ( + "context" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" +) + +func TestProbeOpenAIEndpointSuccess(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/models" { + http.NotFound(w, r) + return + } + resp := map[string]any{ + "data": []map[string]any{ + {"id": "m1"}, + {"id": "m2"}, + {"id": "m3"}, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer ts.Close() + + count, err := ProbeOpenAIEndpoint(context.Background(), ts.URL+"/v1", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if count != 3 { + t.Fatalf("expected 3 models, got %d", count) + } +} + +func TestProbeOpenAIEndpointAuthRequired(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + resp := map[string]any{"data": []map[string]any{{"id": "secret-model"}}} + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer ts.Close() + + _, err := ProbeOpenAIEndpoint(context.Background(), ts.URL+"/v1", "") + if err != errAuthRequired { + t.Fatalf("expected errAuthRequired, got %v", err) + } + + count, err := ProbeOpenAIEndpoint(context.Background(), ts.URL+"/v1", "test-key") + if err != nil { + t.Fatalf("unexpected error with key: %v", err) + } + if count != 1 { + t.Fatalf("expected 1 model, got %d", count) + } +} + +func TestProbeLoopbackPort(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Skip("cannot bind to loopback ephemeral port") + } + port := listener.Addr().(*net.TCPAddr).Port + + ts := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/models") { + resp := map[string]any{ + "data": []map[string]any{ + {"id": "local-model-1"}, + {"id": "local-model-2"}, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + return + } + http.NotFound(w, r) + }), + } + go ts.Serve(listener) + defer ts.Close() + + probe := probeLoopbackPort(context.Background(), port, "test-server :"+strconv.Itoa(port)) + if probe == nil { + t.Fatalf("expected probe to find server on port %d, got nil", port) + } + if probe.Models != 2 { + t.Fatalf("expected 2 models, got %d", probe.Models) + } +} + +func TestAddProviderPanelRebuild(t *testing.T) { + var p addProviderPanel + p.rebuild(nil, nil) + if len(p.items) == 0 { + t.Fatal("expected items, got none") + } + if p.items[0].title != "providers" { + t.Fatalf("expected 'providers' heading, got %q", p.items[0].title) + } + if p.cursor != 1 { + t.Fatalf("expected cursor at first item (1), got %d", p.cursor) + } + + probes := []LocalServerProbe{ + {Port: 8317, Name: "127.0.0.1:8317", Address: "http://127.0.0.1:8317/v1", Models: 12}, + } + p.rebuild(probes, nil) + if p.items[0].title != "found on this machine" { + t.Fatalf("expected 'found on this machine' heading, got %q", p.items[0].title) + } + if p.cursor != 1 { + t.Fatalf("expected cursor on first probed item (1), got %d", p.cursor) + } + cur, ok := p.current() + if !ok || cur.probe == nil || cur.probe.Port != 8317 { + t.Fatalf("expected cursor on probe 8317, got %+v", cur) + } +} diff --git a/internal/tui3/app.go b/internal/tui3/app.go index a876de230..e07a2d158 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -1435,6 +1435,12 @@ type app struct { // reader could reach before it existed would be a race on this field. news *doorbell leaving *doorbell + // landedBell and serviceLands are the third and fourth of those doors: a + // provider listing that a launch warm or a ctrl+r walk stocked behind the + // frame (servicelands.go). Made with the surface for the same reason news + // is — the fan-out may ring before Init — and read only on the loop. + landedBell *doorbell + serviceLands *serviceLands // frontGen counts the conversations this window has taken up, and it is // WHICH ONE IS IN FRONT rather than how many there have been: a door asked // of one conversation and answered after the person switched to another @@ -1798,6 +1804,11 @@ type app struct { connAsks []connAsk conns Connections connPanel connectPanel + // addPanel the add-a-provider door ([app.openAddProvider]): opened from + // the model picker's last row, it walks the machine for live servers and + // offers the vendored catalog beside them. Every row it activates ends in + // the one mint flow (startModelConnect, startCustomAdd). + addPanel addProviderPanel // sources and sourceModels are the live model-service side of /connect. // The default catalog still comes through models; only additional services // live in sourceModels, keyed by their stable persisted id. @@ -2193,7 +2204,16 @@ type app struct { // list reopened while it is out must not start a second one. refreshModels func(ctx context.Context) ([]Model, time.Time, error) serviceModelRefresh func(context.Context, modelsource.Connected, []Model) ([]Model, error) - modelsFetching bool + // refreshAllModels is [Options.RefreshAllModels]: ctrl+r walks every + // provider, not only the default catalog. + refreshAllModels func(ctx context.Context) + // warmEmptyProviders is [Options.WarmEmptyProviders]: the launch fetch. + warmEmptyProviders func(ctx context.Context) + // onServiceModels is [Options.OnServiceModels]: one provider's listing + // changed behind the frame. + onServiceModels func(source, address string) + providerFetchError func(id string) string + modelsFetching bool // sheet is the settings panel (settings.go): the FIRST fullscreen thing this // surface drew, and the only overlay that is modal for the pointer as well @@ -2901,6 +2921,10 @@ func newApp(ctx context.Context, opts Options) *app { sources: opts.Sources, refreshModels: opts.RefreshModels, serviceModelRefresh: opts.RefreshModelsForService, + refreshAllModels: opts.RefreshAllModels, + warmEmptyProviders: opts.WarmEmptyProviders, + onServiceModels: opts.OnServiceModels, + providerFetchError: opts.ProviderFetchError, history: opts.History, draftFile: opts.DraftFile, artifacts: opts.ArtifactsIndex, @@ -3362,10 +3386,17 @@ func (a *app) Init() tea.Cmd { a.setupDemoCmd(), a.checkForUpdate(), a.launchCredits(), a.creditWake.waitRing(), titleSend(a.titleSent), // AND THE TWO DOORS INTO THE LOOP FROM ELSEWHERE, each with its one // command parked on it (doorbell.go). - a.news.waitRing(), a.leaving.waitRing(), + a.news.waitRing(), a.leaving.waitRing(), a.landedBell.waitRing(), // AND THE TEAMS' FIRST READ, when the seam held nothing to load above // (teamseam.go); nil on every local launch. a.teamsWrite()} + if a.warmEmptyProviders != nil { + warm := a.warmEmptyProviders + standing = append(standing, func() tea.Msg { + warm(context.Background()) + return nil + }) + } if a.welcome.animating() { standing = append(standing, a.wake()) } @@ -3562,6 +3593,26 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { // one command waiting on it (doorbell.go). return a, a.news.waitRing() + case localServersProbedMsg: + // THE PROBE LANDED: the panel is open and waiting for exactly this. + p := &a.addPanel + p.loading = false + p.rebuild(msg.probes, nil) + a.touch() + return a, nil + case serviceModelsLandedMsg: + // A PROVIDER'S LISTING LANDED BEHIND THE FRAME (servicelands.go): a + // launch warm or a ctrl+r walk stocked that provider's compartment off + // the loop. The desk is read HERE, on the loop, each pair's memo is + // dropped, and an open picker restocks — so a group fills without a + // reopen. The door is parked again in the same breath (doorbell.go). + if a.serviceLands != nil { + for _, pair := range a.serviceLands.take() { + a.serviceModelsLanded(pair[0], pair[1]) + } + } + return a, a.landedBell.waitRing() + case sigQuitMsg: // A REAL SIGNAL, forwarded by this package's own handler (tui3.go's // [forwardSignals]) because Bubble Tea answers an interrupt by returning diff --git a/internal/tui3/commands.go b/internal/tui3/commands.go index 07b9c5a08..814536b25 100644 --- a/internal/tui3/commands.go +++ b/internal/tui3/commands.go @@ -75,7 +75,7 @@ var commands = []command{ {name: "settings", desc: "open the settings panel · ctrl+,", alias: []string{"set", "config"}}, // It sits under /settings because it is the other half of the same errand: // one is what this surface may do, the other is what it may reach. - {name: "connect", desc: "your connected accounts · connect another", alias: []string{"connections"}}, + {name: "connect", desc: "providers and accounts · connect another", alias: []string{"connections"}}, // THE VOCABULARY OF THE FRESH START IS BORROWED AND NOT INVENTED. /clear is // what a terminal person's fingers type, /reset is what a chat person's do, // and both of them mean the thing this surface calls /new — so all three land diff --git a/internal/tui3/connectcaps.go b/internal/tui3/connectcaps.go index 795190dea..df8c8937e 100644 --- a/internal/tui3/connectcaps.go +++ b/internal/tui3/connectcaps.go @@ -450,7 +450,7 @@ const otherWord = "other" // existed — so the merge order between this branch and the one that fills the // field cannot break anything. func groupConnections(rows []connect.Status) []connGroup { - models := connGroup{head: "models", models: true} + models := connGroup{head: "providers", models: true} held := connGroup{held: true} byCategory := map[string][]connect.Status{} categorized := false @@ -1115,7 +1115,13 @@ func (a *app) connEntryKey(msg tea.KeyPressMsg) tea.Cmd { s.rebuildEntryAt(back) return nil } - if _, model := modelConnectionSource(id); model { + if raw, model := modelConnectionSource(id); model { + // A CLOSED CHOICE IS A MENU ANSWER, NOT A CONNECTION ANSWER: the + // four-action menu's verbs are dispatched here and never reach the + // typed-answer flow ([app.modelEntryAnswer]). + if entry.choosing() && isServiceMenuChoices(entry) { + return a.modelServiceMenuChoice(raw, answer) + } return a.modelEntryAnswer(entry) } // The row says it is being checked from here until the answer lands on diff --git a/internal/tui3/connectkey_test.go b/internal/tui3/connectkey_test.go index 77a510409..18b6e8f14 100644 --- a/internal/tui3/connectkey_test.go +++ b/internal/tui3/connectkey_test.go @@ -489,7 +489,7 @@ func bigCatalog(n int) []connect.Status { auth = connect.AuthKey } rows = append(rows, connect.Status{Service: connect.Service{ - ID: id, Name: "Service " + itoa(i), Blurb: "what " + id + " is for", Auth: auth, + ID: id, Name: "Provider " + itoa(i), Blurb: "what " + id + " is for", Auth: auth, }}) } // One row with a name worth searching for, and a key rather than a sign-in. @@ -583,7 +583,7 @@ func TestTheConnectPanelSaysHowEachServiceConnects(t *testing.T) { // A browser row carries the other tag. "s" matches every service in the // catalog, so the sign-in half is on screen too. drive(t, a, key("ctrl+u")) - for _, r := range "service 0" { + for _, r := range "provider 0" { drive(t, a, key(string(r))) } if screen = strings.Join(plainOverlay(a), "\n"); !strings.Contains(screen, signInTag) { @@ -1040,7 +1040,7 @@ func TestAConnectPanelWithoutCategoriesKeepsItsBlankGap(t *testing.T) { if p.owner[1] != -1 { t.Fatalf("the gap answers to row %d", p.owner[1]) } - if !strings.Contains(lines[2], "Service 0") { + if !strings.Contains(lines[2], "Provider 0") { t.Fatalf("the catalog does not start under the gap: %q", lines[2]) } } diff --git a/internal/tui3/firstrun.go b/internal/tui3/firstrun.go index 5cf7c2ac6..082450c8e 100644 --- a/internal/tui3/firstrun.go +++ b/internal/tui3/firstrun.go @@ -917,10 +917,10 @@ func setupTitle(s *setupFlow) string { // ever sees said one name in the letterforms and a different one in the // sentence three rows under them. const ( - setupKeyWord = product + " talks to models on its default service through openrouter, on your key and your card. " + + setupKeyWord = product + " talks to models on its default provider through openrouter, on your key and your card. " + "nothing is sent until you do." setupKeyURL = "https://openrouter.ai/settings/keys" - setupConnectWord = "sign in once in your browser. openrouter makes the default service's key for this profile; " + + setupConnectWord = "sign in once in your browser. openrouter makes the default provider's key for this profile; " + product + " stores it on this machine. no prompt is sent and no model is called." setupConnectStartingWord = "opening a private return address on this machine…" setupConnectWaitingWord = "finish signing in in your browser. this page will continue when openrouter sends you back." diff --git a/internal/tui3/firstrun_test.go b/internal/tui3/firstrun_test.go index 81ca4d0fc..6329cfa34 100644 --- a/internal/tui3/firstrun_test.go +++ b/internal/tui3/firstrun_test.go @@ -144,7 +144,7 @@ func TestEnterConnectsOpenRouterInTheBrowserAndHandsTheKeyToThisProcess(t *testi t.Cleanup(func() { processOpener = was }) screen := setupScreen(a) - for _, want := range []string{"connect openrouter", "default service", "sign in once in your browser", "enter connects in browser", "paste a key"} { + for _, want := range []string{"connect openrouter", "default provider", "sign in once in your browser", "enter connects in browser", "paste a key"} { if !strings.Contains(screen, want) { t.Fatalf("the browser connection must say %q; got:\n%s", want, screen) } diff --git a/internal/tui3/homeslash_test.go b/internal/tui3/homeslash_test.go index d59b7d68d..6e5b2388c 100644 --- a/internal/tui3/homeslash_test.go +++ b/internal/tui3/homeslash_test.go @@ -220,7 +220,7 @@ func TestHomeSlashSmokeWalks(t *testing.T) { if text := homeText(a); !strings.Contains(text, a.targetPickFoot()) { t.Fatalf("the foot does not name the list's own keys:\n%s", text) } - for _, want := range []string{targetPickWalkWord, "→ providers", sortKeyWord, "enter choose", effortKeyWord, targetPickLeaveWord} { + for _, want := range []string{targetPickWalkWord, "→ hosts", sortKeyWord, "enter choose", effortKeyWord, targetPickLeaveWord} { if !strings.Contains(a.targetPickFoot(), want) { t.Fatalf("the foot on a model row does not name %q: %q", want, a.targetPickFoot()) } diff --git a/internal/tui3/hostlink.go b/internal/tui3/hostlink.go index d667ada9c..8e1598445 100644 --- a/internal/tui3/hostlink.go +++ b/internal/tui3/hostlink.go @@ -329,7 +329,7 @@ func (a *app) takeLinkNotice() { // from before the status line's news crossed a connection. It is in the // engine-host sentences' own voice (cmd/codeaf's busyEngineHostSentence): the // machine is older, nothing is wrong, and it says when that changes. -const newsSilenceNote = "this conversation's engine is an older codeaf, so the provider and tok/s are not shown — they come back once it picks up this build" +const newsSilenceNote = "this conversation's engine is an older codeaf, so the host and tok/s are not shown — they come back once it picks up this build" // sayNewsSilence says [newsSilenceNote] once per window, the first time a turn // that produced an answer ends on an engine that has sent no news. diff --git a/internal/tui3/input.go b/internal/tui3/input.go index 663bbc33d..6d9bf9f64 100644 --- a/internal/tui3/input.go +++ b/internal/tui3/input.go @@ -568,6 +568,13 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd { return a.connectPanelKey(msg) } + // And the add-provider panel, which is that panel's door raised from the + // model picker's last row: opened by a row, nothing being typed under it, + // and esc leaving the conversation exactly as it was (addprovider.go). + if a.addPanel.open && msg.String() != "ctrl+c" { + return a.addPanelKey(msg) + } + // And the harness panel, which is that panel's twin in every respect that // matters here: opened by a command, nothing being typed under it, and esc // leaving the conversation exactly as it was (harnesspanel.go). diff --git a/internal/tui3/keys_test.go b/internal/tui3/keys_test.go index c9b682bf6..f986c1ac2 100644 --- a/internal/tui3/keys_test.go +++ b/internal/tui3/keys_test.go @@ -67,7 +67,7 @@ func TestThePhaseClockAsksTheQuestionAndReportsAWaitNothingCanEnd(t *testing.T) }, { what: "a wait with nowhere better to go", news: PhaseNews{Phase: session.PhaseAllSlow, Since: now.Add(-12 * time.Second)}, - want: "all providers slow · still waiting · 12s", + want: "all hosts slow · still waiting · 12s", }} { news := c.news news.Model, news.Role, news.At = phaseModel, lane.RoleTalk, now diff --git a/internal/tui3/lanes.go b/internal/tui3/lanes.go index f45e99838..5a45f1dc3 100644 --- a/internal/tui3/lanes.go +++ b/internal/tui3/lanes.go @@ -1037,7 +1037,7 @@ func laneQuantRank(quant string) int { func (a *app) pinLane(model, name string) { slot := laneSlotFor(model) if a.hosted() { - a.note(a.host + " owns the provider · change it on that machine") + a.note(a.host + " owns the host · change it on that machine") return } if err := config.SetLane(a.profileDir, slot, name); err != nil { @@ -1046,14 +1046,14 @@ func (a *app) pinLane(model, name string) { } _ = config.SetLaneBorrow(a.profileDir, slot, false) a.laneRowChanged() - a.noteFacts("provider · "+strings.ToLower(name), name) + a.noteFacts("host · "+strings.ToLower(name), name) a.touch() } // clearLanePin puts the row back to auto. func (a *app) clearLanePin(model string) { if a.hosted() { - a.note(a.host + " owns the provider · change it on that machine") + a.note(a.host + " owns the host · change it on that machine") return } if err := config.SetLane(a.profileDir, laneSlotFor(model), config.LaneAuto); err != nil { @@ -1061,7 +1061,7 @@ func (a *app) clearLanePin(model string) { return } a.laneRowChanged() - a.noteFacts("provider · auto", config.LaneAuto) + a.noteFacts("host · auto", config.LaneAuto) a.touch() } @@ -1071,7 +1071,7 @@ func (a *app) clearLanePin(model string) { // makes, and this is about the machines behind one model. func (a *app) setLaneRouterOnly(model string) { if a.hosted() { - a.note(a.host + " owns the provider · change it on that machine") + a.note(a.host + " owns the host · change it on that machine") return } if err := config.SetLane(a.profileDir, laneSlotFor(model), config.LaneOpenRouter); err != nil { @@ -1079,7 +1079,7 @@ func (a *app) setLaneRouterOnly(model string) { return } a.laneRowChanged() - a.noteFacts("provider · openrouter", config.LaneOpenRouter) + a.noteFacts("host · openrouter", config.LaneOpenRouter) a.touch() } diff --git a/internal/tui3/lanes_test.go b/internal/tui3/lanes_test.go index 7cb38d1cd..ad40146d4 100644 --- a/internal/tui3/lanes_test.go +++ b/internal/tui3/lanes_test.go @@ -668,7 +668,7 @@ func TestTheSettingsModelRowUnfoldsItsLanes(t *testing.T) { t.Fatal("← left the lanes open") } // Back on the model's row, the foot offers the fold again. - if !strings.Contains(a.sheet.keysLine(), "→ or tab providers") { + if !strings.Contains(a.sheet.keysLine(), "→ or tab hosts") { t.Fatalf("the foot on the model row reads %q", a.sheet.keysLine()) } } @@ -746,8 +746,8 @@ func TestTheLaneRowOpensTheMachines(t *testing.T) { a, dir := laneSheet(t) cursorTo(t, a, config.LaneSettingKey(talkSlot)) - if !sheetHas(a, "provider") { - t.Fatal("the providers tab has no provider row") + if !sheetHas(a, "host") { + t.Fatal("the providers tab has no host row") } drive(t, a, key("enter")) if a.sheet.sel == nil { @@ -853,7 +853,7 @@ func TestAMediaSlotPickerHasNoLanes(t *testing.T) { if a.sheet.sel.pick.laneSlot != "" { t.Fatalf("the looking row's picker is armed for lane slot %q", a.sheet.sel.pick.laneSlot) } - if strings.Contains(a.sheet.keysLine(), "tab providers") { + if strings.Contains(a.sheet.keysLine(), "tab hosts") { t.Fatalf("the hint offers a fold the looking row does not have: %q", a.sheet.keysLine()) } } diff --git a/internal/tui3/modelrefresh.go b/internal/tui3/modelrefresh.go index cb38a2bb4..060d9dbdb 100644 --- a/internal/tui3/modelrefresh.go +++ b/internal/tui3/modelrefresh.go @@ -84,6 +84,9 @@ type modelsFetchedMsg struct { at time.Time err error shown map[string]bool + // all marks the walk-every-provider chord; rows, at and err are empty + // because each provider answers through [Options.OnServiceModels]. + all bool } // offersRefresh is whether this list names the key and answers it right now: @@ -146,7 +149,27 @@ func (a *app) armRefresh() { // client (internal/catalog's fetch), so nothing here keeps a second clock that // could disagree with it. func (a *app) fetchModels() tea.Cmd { - if a.refreshModels == nil || !a.pick.offersRefresh() { + if !a.pick.offersRefresh() { + return nil + } + // ctrl+r REFRESHES EVERY PROVIDER (issue #1508), not only the router's + // catalog: when the door walks all of them the chord is handed there, and + // each provider's group restocks itself as its fetch lands + // ([app.onServiceModels]). A door without the walk keeps the old single- + // catalog fetch. Either way the fetch is a command and the picker never + // waits on it. + if a.refreshAllModels != nil { + a.modelsFetching, a.pick.fetching = true, true + walk, ctx := a.refreshAllModels, a.ctx + if ctx == nil { + ctx = context.Background() + } + return func() tea.Msg { + walk(ctx) + return modelsFetchedMsg{shown: map[string]bool{}, all: true} + } + } + if a.refreshModels == nil { return nil } a.modelsFetching, a.pick.fetching = true, true @@ -174,6 +197,13 @@ func (a *app) fetchModels() tea.Cmd { func (a *app) modelsFetched(msg modelsFetchedMsg) { a.modelsFetching, a.pick.fetching = false, false a.touch() + if msg.all { + // The walk-everything fetch answers through [Options.OnServiceModels], + // one provider at a time, as each lands. Nothing is carried here: the + // open picker has already been restocked per provider, and a provider + // that refused names its own reason in its own group. + return + } list := keepModels(msg.rows, chatModel) err := msg.err if err == nil && (len(list) == 0 || msg.at.IsZero()) { diff --git a/internal/tui3/modelregion_test.go b/internal/tui3/modelregion_test.go index e72ace1c2..e117123f7 100644 --- a/internal/tui3/modelregion_test.go +++ b/internal/tui3/modelregion_test.go @@ -238,6 +238,12 @@ func TestTheProvidersSheetAsksWithTheSameChoice(t *testing.T) { t.Fatal("Providers did not draw the connected Z.ai service") } drive(t, a, key("enter")) + // ENTER ON A CONNECTED SERVICE OPENS ITS FOUR-ACTION MENU now; the region + // answer lives behind the edit door, which the menu names "rename". + if !strings.Contains(strings.Join(sheetLabels(a), "\n"), "change key") { + t.Fatal("enter on a connected service did not open the four-action menu") + } + drive(t, a, key("down"), key("enter")) screen := strings.Join(sheetLabels(a), "\n") if !strings.Contains(screen, "your region") || diff --git a/internal/tui3/models.go b/internal/tui3/models.go index cf59b7b2a..c3b37b459 100644 --- a/internal/tui3/models.go +++ b/internal/tui3/models.go @@ -103,8 +103,10 @@ type Model struct { // Group is the connected service heading this row sits under. It is empty // on the single-service path, which keeps that picker's output unchanged. Group string `json:"-"` + GroupHead string `json:"-"` GroupOrder int `json:"-"` Unavailable bool `json:"-"` + AddProvider bool `json:"-"` // Notice is display text for an unavailable group row. Such a row has no // ID: a sentence explaining an empty service is not a model and therefore // cannot be selected, pinned, unfolded, or handed to a wire-facing path. @@ -267,6 +269,18 @@ func (a *app) forgetModelList(source, base string) { a.modelLists.forget(modelCacheNameFor(source, base)) } +// serviceModelsLanded is [Options.OnServiceModels]'s body: one provider's +// listing was stocked behind the frame (a launch warm or a ctrl+r walk). The +// memo under that pair is a reading from before the fetch, so it is dropped; +// an open picker is restocked so its group fills WITHOUT a reopen. +func (a *app) serviceModelsLanded(source, address string) { + a.forgetModelList(source, address) + if a.pick.open { + a.pick.restock(a.modelPickerList()) + } + a.touch() +} + // WriteModelCache replaces the cache with models. It is called from the door // after a catalog fetch has succeeded — never from the picker, which must not // spend I/O on the keystroke path — and it writes through a temporary file so a diff --git a/internal/tui3/modelservices.go b/internal/tui3/modelservices.go index 878836f42..da2fac11f 100644 --- a/internal/tui3/modelservices.go +++ b/internal/tui3/modelservices.go @@ -21,7 +21,7 @@ import ( // package keeps a model service named "google" separate from a Google account. const modelConnectionPrefix = "model-service:" -const noServiceModelListWord = "no list from this service · type a model id" +const noServiceModelListWord = "lists no models · type a model id" type modelConnectStep uint8 @@ -181,7 +181,7 @@ func (a *app) modelConnectionRows() []connect.Status { // service and would put a second door onto the first connection here. if len(customInstances(a.sources)) > 0 { rows = append(rows, connect.Status{Service: connect.Service{ - ID: modelConnectionID(customAddRowID), Name: "add custom connection", + ID: modelConnectionID(customAddRowID), Name: "+ add a provider", Blurb: "address · key", Auth: connect.AuthKey, Category: "models", }}) } @@ -193,7 +193,7 @@ func (a *app) modelConnectionRows() []connect.Status { // somewhere to move to, whatever the panel's other doors are. if reading, ok := switchReading(a.conversationModel(), a.sources); ok { rows = append(rows, connect.Status{Service: connect.Service{ - ID: modelConnectionID(connectionSwitchRowID), Name: "active connection", + ID: modelConnectionID(connectionSwitchRowID), Name: "active provider", Blurb: reading.sentence, Auth: connect.AuthKey, Category: "models", }}) @@ -599,10 +599,10 @@ func (a *app) modelEntryAnswer(entry *keyEntry) tea.Cmd { // two characters it cannot carry. func connectionNameFault(name string) string { if strings.Contains(name, "/") { - return "a connection name cannot contain / · the slash is what separates connection from model" + return "a provider name cannot contain / · the slash is what separates provider from model" } if strings.ContainsFunc(name, unicode.IsSpace) { - return "a connection name cannot contain spaces · they would travel into every model id" + return "a provider name cannot contain spaces · they would travel into every model id" } return "" } @@ -1075,7 +1075,7 @@ func deferredMoveWord(written string) string { } func serviceStrandedWord(was string) string { - return "this conversation was on " + was + " and nothing else here can take it · connect a service or pick a model" + return "this conversation was on " + was + " and nothing else here can take it · connect a provider or pick a model" } func (a *app) modelServiceMessage(line string) { @@ -1298,7 +1298,7 @@ const connectionSwitchRowID = "switch-connection" // never a second implementation of the mint or the connect. func customAddRow() *modelServiceRow { return &modelServiceRow{ - id: customAddRowID, name: "add custom connection", + id: customAddRowID, name: "+ add a provider", value: "an OpenAI-compatible base URL · a name of your own", addCustom: true, } } @@ -1359,7 +1359,7 @@ func (s *sheet) connectionSwitcherRow() *modelServiceRow { if !ok { return nil } - return &modelServiceRow{name: "active connection", value: reading.sentence, switcher: true} + return &modelServiceRow{name: "active provider", value: reading.sentence, switcher: true} } // serviceWrittenWord is the name a person calls a service in the switcher's @@ -1515,3 +1515,116 @@ func (a *app) reconnectModelService(id string) tea.Cmd { } return nil } + +// addProviderRowWord is the model picker's last row: the door to connect +// another provider, living in the same list the choice is made in. +const addProviderRowWord = "+ add a provider" + +const cannotListModelsWord = "can't list models · " +const listRetryWord = " · ctrl+r retry" + +// serviceGroupHead is a block's head line: the service as written, its +// address when it has one, and how many models it listed. Two facts a person +// reads before they read a single row under it. +func serviceGroupHead(source modelsource.Source, count int) string { + name := strings.TrimSpace(source.Written) + if name == "" { + name = strings.TrimSpace(source.Name) + } + line := "" + if addr := strings.TrimSpace(source.Address); addr != "" { + line = strings.TrimPrefix(strings.TrimPrefix(addr, "https://"), "http://") + " · " + } + word := "models" + if count == 1 { + word = "model" + } + return name + " " + line + itoa(count) + " " + word +} + +// defaultServiceRow is the Providers tab's first row: the default provider, +// named with its address, its model count and whether a key is set. Nil when +// the default service is not connected — the tab then reads only what is. +func defaultServiceRow(dir string, sources modelsource.Set) *modelServiceRow { + service, ok := sources.ByID(modelsource.DefaultID) + if !ok { + return nil + } + parts := make([]string, 0, 3) + if addr := strings.TrimSpace(service.Source.Address); addr != "" { + addr = strings.TrimPrefix(strings.TrimPrefix(addr, "https://"), "http://") + parts = append(parts, addr) + } + if count := len(readModelCacheName(modelCacheNameFor(service.Source.ID, service.Source.Address))); count > 0 { + parts = append(parts, itoa(count)+" models") + } + switch { + case strings.TrimSpace(service.Key) != "": + parts = append(parts, "key set") + case service.Source.KeyOptional: + parts = append(parts, "no key needed") + default: + parts = append(parts, "no key") + } + return &modelServiceRow{id: modelsource.DefaultID, name: strings.TrimSpace(service.Source.Written), value: strings.Join(parts, " · ")} +} + +// modelServiceMenuChoices is what enter on a connected provider row offers: +// the four things a row about a live connection can actually do. +func modelServiceMenuChoices() []entryChoice { + return []entryChoice{ + {ID: "refresh", Name: "refresh models"}, + {ID: "rename", Name: "rename"}, + {ID: "key", Name: "change key"}, + {ID: "disconnect", Name: "disconnect"}, + } +} + +// modelServiceMenuChoice acts on the answer the four-action menu received. +// Every branch is a door this surface already had; the menu only holds the +// doors together in one place. +func (a *app) modelServiceMenuChoice(id, action string) tea.Cmd { + switch action { + case "refresh": + return a.reconnectModelService(id) + case "rename": + source, ok := a.modelSource(id) + if !ok { + return nil + } + return a.startModelConnect(modelConnectionStatus(source, true), true) + case "key": + source, ok := a.modelSource(id) + if !ok { + return nil + } + _ = a.startModelConnect(modelConnectionStatus(source, true), true) + // A KEY CHANGE SKIPS THE ADDRESS: the connection already has one, so + // the flow jumps straight to the key box over the same draft. + if draft := a.modelDraft; draft != nil { + draft.step = modelConnectKey + a.showModelEntry(newModelEntry(modelConnectionID(id), draft.source.Name, "key", nil, true), true) + } + return nil + case "disconnect": + a.disconnectModelService(id) + return nil + } + return nil +} + +// isServiceMenuChoices tells the menu answer from any other closed-choice +// entry that shares its id — a region answer opens a choice entry over the +// same connection, and a menu verb must never swallow a region answer. +func isServiceMenuChoices(entry *keyEntry) bool { + choices := modelServiceMenuChoices() + if len(entry.choices) != len(choices) { + return false + } + for i, choice := range entry.choices { + if choice.ID != choices[i].ID { + return false + } + } + return true +} diff --git a/internal/tui3/modelservices_test.go b/internal/tui3/modelservices_test.go index fc74ee0cc..99739cbe5 100644 --- a/internal/tui3/modelservices_test.go +++ b/internal/tui3/modelservices_test.go @@ -331,7 +331,9 @@ func TestConnectingAServiceFromProvidersMovesTheConversationOntoItsPreferredMode t.Fatal("Providers did not draw the connected Z.ai service") } drive(t, a, key("enter")) - drive(t, a, key("enter")) + // ENTER ON A CONNECTED SERVICE OPENS ITS FOUR-ACTION MENU; "change key" is + // the third verb, and it is the road to the key box. + drive(t, a, key("down"), key("down"), key("enter")) if a.sheet.conn.entry == nil || !a.sheet.conn.entry.secret { t.Fatal("the Providers road did not reach the key box") } @@ -1062,7 +1064,8 @@ func TestOneServiceDrawsThePickerExactlyAsItDidBefore(t *testing.T) { // surface (pickersort.go) — so `gpt-5-classic` stands above the model in use. // The mark is still on the model in use, which is what this test is about. want := " gpt-5-classic\n" + - "› openai/gpt-4.1-mini 1M" + "› openai/gpt-4.1-mini 1M\n" + + " + add a provider" if rendered := plain(strings.Join(got, "\n")); rendered != want { t.Fatalf("one-service picker changed:\ngot %q\nwant %q", rendered, want) } @@ -1168,7 +1171,7 @@ func TestTheModelServiceWordsAreExactAndVendorWordsStopAtAWordBoundary(t *testin if got := serviceMovedWord("deepseek-direct/deepseek-v4-pro", "~deepseek/deepseek-v4-flash-latest"); got != "this conversation was on deepseek-direct/deepseek-v4-pro · it is now on ~deepseek/deepseek-v4-flash-latest" { t.Errorf("moved word = %q", got) } - if got := serviceStrandedWord("deepseek-direct/deepseek-v4-pro"); got != "this conversation was on deepseek-direct/deepseek-v4-pro and nothing else here can take it · connect a service or pick a model" { + if got := serviceStrandedWord("deepseek-direct/deepseek-v4-pro"); got != "this conversation was on deepseek-direct/deepseek-v4-pro and nothing else here can take it · connect a provider or pick a model" { t.Errorf("stranded word = %q", got) } if got := engineVariableWord("DEEPSEEK_API_KEY"); got != "the engine process reads $DEEPSEEK_API_KEY from its own environment" { @@ -1302,7 +1305,7 @@ func TestDisconnectingAServiceLeavesTheConversationOnSomethingItCanReach(t *test }, { name: "nothing else can take it", wantModel: "deepseek-direct/deepseek-v4-pro", - want: "this conversation was on deepseek-direct/deepseek-v4-pro and nothing else here can take it · connect a service or pick a model", + want: "this conversation was on deepseek-direct/deepseek-v4-pro and nothing else here can take it · connect a provider or pick a model", }, } { t.Run(testCase.name, func(t *testing.T) { @@ -1682,8 +1685,8 @@ func TestConnectedServicesAppearUnderProvidersAndEmptinessDrawsNothing(t *testin // with no custom connection yet is the one that needs the door (customAddRow). addRows, connectionRows := 0, 0 for _, item := range a.sheet.items { - if item.head == "services" { - t.Fatal("an empty profile drew the services head") + if item.head == "providers" { + t.Fatal("an empty profile drew the providers head") } if item.service != nil && item.service.switcher { t.Fatal("an empty profile drew the switcher row") @@ -1714,11 +1717,11 @@ func TestConnectedServicesAppearUnderProvidersAndEmptinessDrawsNothing(t *testin foundHead, foundRow := false, false keyAt, headAt, rowAt := -1, -1, -1 for at, item := range a.sheet.items { - foundHead = foundHead || item.head == "services" + foundHead = foundHead || item.head == "providers" if item.row.Key == config.KeyAPIKey { keyAt = at } - if item.head == "services" { + if item.head == "providers" { headAt = at } if item.service != nil && item.service.name == "deepseek-direct" { @@ -1731,8 +1734,14 @@ func TestConnectedServicesAppearUnderProvidersAndEmptinessDrawsNothing(t *testin if !foundHead || !foundRow { t.Fatalf("Providers did not draw the connected service: %+v", a.sheet.items) } - if keyAt < 0 || headAt != keyAt+1 || rowAt != headAt+1 { - t.Fatalf("the services section is not immediately under the openrouter key: key=%d head=%d row=%d", keyAt, headAt, rowAt) + if keyAt < 0 || headAt != keyAt+1 || rowAt != headAt+2 { + t.Fatalf("the providers section is not immediately under the openrouter key: key=%d head=%d row=%d", keyAt, headAt, rowAt) + } + // THE DEFAULT PROVIDER LEADS THE SECTION: the head, then openrouter with + // its address and key status, then the persisted connections. + defaultRow := a.sheet.items[headAt+1].service + if defaultRow == nil || defaultRow.id != modelsource.DefaultID { + t.Fatalf("the default provider does not lead the section: %+v", a.sheet.items[headAt+1]) } } @@ -1906,13 +1915,15 @@ func lineIndex(lines []string, match func(string) bool) int { return -1 } -// headingLines counts the drawn service headings equal to group. A heading is a -// dim line whose whole text is the group's name; a model's own row carries more -// than the name, so this counts headings and never rows. +// headingLines counts the drawn service headings for group. A heading is a dim +// line that is the group's name alone, or — since the heads name the address +// and the model count too ([serviceGroupHead]) — the name followed by the head +// line's wide separator. A model's own row never carries either, so this counts +// headings and never rows. func headingLines(lines []string, group string) int { count := 0 for _, line := range lines { - if strings.TrimSpace(line) == group { + if t := strings.TrimSpace(line); t == group || strings.HasPrefix(t, group+" ") { count++ } } @@ -1963,7 +1974,7 @@ func TestTwoCustomConnectionsGroupApartInThePicker(t *testing.T) { } for _, id := range []string{"homelab/qwen-local", "studio/mistral-local"} { group := id[:strings.Index(id, "/")] - headAt := lineIndex(lines, func(line string) bool { return line == group }) + headAt := lineIndex(lines, func(line string) bool { return line == group || strings.HasPrefix(line, group+" ") }) modelAt := lineIndex(lines, func(line string) bool { return strings.Contains(line, id) }) if headAt < 0 || modelAt < 0 || headAt > modelAt { t.Fatalf("the %q heading did not stand above %q:\n%s", group, id, drawn) @@ -2059,8 +2070,8 @@ func TestACustomConnectionWithNoListKeepsItsPlaceInThePicker(t *testing.T) { if !strings.Contains(drawn, noServiceModelListWord) { t.Fatalf("the listing-less service drew no notice row:\n%s", drawn) } - homelabAt := lineIndex(lines, func(line string) bool { return line == "homelab" }) - studioAt := lineIndex(lines, func(line string) bool { return line == "studio" }) + homelabAt := lineIndex(lines, func(line string) bool { return line == "homelab" || strings.HasPrefix(line, "homelab ") }) + studioAt := lineIndex(lines, func(line string) bool { return line == "studio" || strings.HasPrefix(line, "studio ") }) if homelabAt < 0 || studioAt < 0 || homelabAt > studioAt { t.Fatalf("the listing-less service was drawn out of order:\n%s", drawn) } diff --git a/internal/tui3/modeltable.go b/internal/tui3/modeltable.go index eb17d45d2..14519f6c9 100644 --- a/internal/tui3/modeltable.go +++ b/internal/tui3/modeltable.go @@ -143,7 +143,7 @@ const modelHead = "model" // to it ([colTable]), because the providers behind one model are read exactly // the way the models are: down the page, comparing. A ranked `·` tail put each // row's price wherever that row's note happened to end. -const laneHead = "provider" +const laneHead = "host" var laneColumns = []tableColumn{ {head: "first", right: true, sorts: true, up: true}, diff --git a/internal/tui3/modeltable_test.go b/internal/tui3/modeltable_test.go index 24cb1f09e..38d02d309 100644 --- a/internal/tui3/modeltable_test.go +++ b/internal/tui3/modeltable_test.go @@ -457,7 +457,7 @@ func TestTheMachinesAreASecondFoldUnderOpenrouter(t *testing.T) { } // The foot says the row can be opened, on the row that can be. drive(t, a, key("down")) - if got := a.pick.keysHint(); !strings.Contains(got, "→ providers") { + if got := a.pick.keysHint(); !strings.Contains(got, "→ hosts") { t.Fatalf("the openrouter row does not offer its fold: %q", got) } drive(t, a, key("right")) diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index 05ab8c536..3c8871992 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -532,7 +532,7 @@ var notices = []notice{ { id: "connect-accounts", slot: slotHint, armed: ready, - text: "/connect links Notion, Slack and other services", + text: "/connect links Notion, Slack and other accounts", retire: eventConnectOpened, }, { diff --git a/internal/tui3/ordinarylaunch_test.go b/internal/tui3/ordinarylaunch_test.go index 8f10d730c..9adf650b6 100644 --- a/internal/tui3/ordinarylaunch_test.go +++ b/internal/tui3/ordinarylaunch_test.go @@ -115,8 +115,8 @@ func TestTheSetupOpensOnAnOrdinaryLaunchWithNoKey(t *testing.T) { if !strings.Contains(screen, "setting up") { t.Fatalf("the setup's own title is not on the screen:\n%s", screen) } - if !strings.Contains(screen, "default service") { - t.Fatalf("the OpenRouter connection was not scoped to the default service:\n%s", screen) + if !strings.Contains(screen, "default provider") { + t.Fatalf("the OpenRouter connection was not scoped to the default provider:\n%s", screen) } }) } diff --git a/internal/tui3/palette.go b/internal/tui3/palette.go index 814cbcdb9..48e60b911 100644 --- a/internal/tui3/palette.go +++ b/internal/tui3/palette.go @@ -451,6 +451,13 @@ func (p *picker) rank() { if len(tokens) == 0 { p.cursorToCurrent() } + // THE DOOR IS NOT A MODEL AND NEVER RANKS: the add-provider row rides the + // list's end whatever the sort reads, because a door is not a row a + // column sorts. + sort.SliceStable(p.hits, func(a, b int) bool { + return !p.all[p.hits[a]].AddProvider && p.all[p.hits[b]].AddProvider + }) + p.relist() } // narrowFold answers the filter box AS A QUESTION ABOUT THE MACHINES ALREADY ON @@ -2001,8 +2008,17 @@ func (p *picker) groupBefore(at int) string { if model.Group == "" { return "" } + // THE HEAD NAMES MORE THAN THE SERVICE when the list knows the address and + // the count ([GroupHead]): a block of ten rows under `openrouter` is a + // block under `openrouter openrouter.ai · 547 models`, and a person + // reading the table reads which machine and how much of it without opening + // anything. + head := model.GroupHead + if head == "" { + head = model.Group + } if at == p.top || at == 0 { - return model.Group + return head } previous := p.list[at-1] if previous.lane != laneNone { @@ -2010,7 +2026,7 @@ func (p *picker) groupBefore(at int) string { } before := p.all[p.hits[previous.hit]] if before.Group != model.Group { - return model.Group + return head } return "" } @@ -2166,15 +2182,15 @@ func laneAutoSaid(routing string) laneAutoSay { if config.RoutingWord(routing) == config.RoutingSimple { return laneAutoSay{ note: laneAutoNote, - about: "which provider answers your model. routing is simple, so auto " + - "sends no choice of ours at all and openrouter's own routing answers; a provider " + + about: "which host answers your model. routing is simple, so auto " + + "sends no choice of ours at all and openrouter's own routing answers; a host " + "you pin is the whole request. enter opens them all with what has been " + "measured of each.", } } return laneAutoSay{ note: laneAutoNote, - about: "which provider answers your model. auto picks the fastest one " + + about: "which host answers your model. auto picks the fastest one " + "each answer; enter opens them all with what has been measured of each.", chooses: true, } @@ -2199,7 +2215,7 @@ const laneAutoNote = "auto-route based on /settings" // nothing behind the model has been measured. It is a sentence a person would // say, it draws no number, and it says when that changes — which is the whole // of what somebody who pressed `→` on the model needs to know about the gap. -const laneUnmeasured = "no provider has been measured for this model yet — providers show up after its first answer" +const laneUnmeasured = "no host has been measured for this model yet — hosts show up after its first answer" // lineUnder is the dim line drawn under one row, and empty under all but one of // them: [laneUnmeasured], under the `auto` row of a fold with no providers in @@ -2533,11 +2549,11 @@ const ( // THE EFFORT KEY IS NAMED HERE BECAUSE THE BOX STOPPED NAMING IT // ([pickerHint]), and this is the row it works on: inside a fold the cursor // is on a machine and `ctrl+t` has no model to dial. - pickerKeysModel = "→ providers · " + sortKeyWord + " · enter switch · " + effortKeyWord + " · esc" + pickerKeysModel = "→ hosts · " + sortKeyWord + " · enter switch · " + effortKeyWord + " · esc" // pickerKeysModelTab is the same row with the caret somewhere inside what is // typed, where `→` steps over a character instead ([picker.foldKey]) and // only `tab` opens. - pickerKeysModelTab = "tab providers · " + sortKeyWord + " · enter switch · " + effortKeyWord + " · esc" + pickerKeysModelTab = "tab hosts · " + sortKeyWord + " · enter switch · " + effortKeyWord + " · esc" // pickerKeysFold is a row inside an open fold: enter chooses that provider, // `←` walks back out to the model. pickerKeysFold = "← back · " + sortKeyWord + " · enter choose · esc" @@ -2619,9 +2635,9 @@ func (p *picker) keysParts() (string, string, string) { if p.editing() && p.filter.cursor > 0 { back = "tab back" } - open := "→ providers" + open := "→ hosts" if p.editing() && p.filter.cursor < len(p.filter.value) { - open = "tab providers" + open = "tab hosts" } // AND THE SORT IS NAMED WHEREVER THE TABLE IS DRAWN, because it is the LIST's // key rather than the cursor's — the same reason the refresh key is in the @@ -2670,7 +2686,7 @@ func (p *picker) keysParts() (string, string, string) { // [filterFor]). func (a *app) openPicker() { a.noticeEvent(eventModelListOpened) - a.pick.startFor(a.modelList(), a.model, chatModel) + a.pick.startFor(a.modelPickerList(), a.model, chatModel) // THE PIN IS A SNAPSHOT, exactly as the model in use is: it is what marks a // row inside an open fold, and what the row in use says `via`, and neither // of those can change while a modal overlay owns the keyboard. @@ -2708,7 +2724,7 @@ func (a *app) openTaskPicker(id uint64) { if node := a.tasks[id]; node != nil { current = firstNonEmpty(node.nextModel, node.model) } - a.pick.startFor(a.modelList(), current, chatModel) + a.pick.startFor(a.modelPickerList(), current, chatModel) a.pick.task = id a.armRefresh() a.touch() @@ -2727,6 +2743,20 @@ func (a *app) openTaskPicker(id uint64) { // a way round it. func (a *app) modelList() []Model { return a.modelsFor(chatModel) } +// modelPickerList is the model overlay's list: the chat ladder, then the +// door. THE DOOR IS A ROW WITH NO ID A WIRE WOULD TAKE: AddProvider routes +// enter to the add-provider flow before any switchModel can see it. It rides +// the picker and no other reader of the ladder — context measurement, the +// sort laws and the slots all read [app.modelList] whole. +func (a *app) modelPickerList() []Model { + list := a.modelsFor(chatModel) + return append(list, Model{ + ID: addProviderRowWord, + AddProvider: true, + GroupOrder: len(a.sources.All()) + 1, + }) +} + // modelsFor is that same source order, asked ONE SLOT'S question instead of the // chat law's ([modelFilter], models.go). // @@ -2755,9 +2785,27 @@ func (a *app) modelsFor(keep modelFilter) []Model { if group == "" { group = strings.ToLower(strings.TrimSpace(service.Source.Name)) } + head := serviceGroupHead(service.Source, len(models)) if len(models) == 0 { + // A SERVICE THAT LISTS NOTHING SAYS WHY. The bare placeholder named + // the fact; the state names the reason: a fetch that failed carries + // its reason and the retry, a fetch still out says so, and a + // provider that answered with nothing says that instead + // ([app.providerFetchError]). + notice := noServiceModelListWord + // THE ERROR DOOR IS AN OPTION, NOT A GIVEN: older seams construct an + // app without one ([Options.ProviderFetchError]), and a nil reading + // of a field this surface owns is a panic on a picker that was + // already drawing. + if a.providerFetchError != nil { + if err := a.providerFetchError(service.Source.ID); err != "" { + notice = cannotListModelsWord + err + listRetryWord + } else if a.modelsFetching { + notice = modelsFetching + } + } grouped = append(grouped, Model{ - Notice: noServiceModelListWord, Group: group, GroupOrder: order, Unavailable: true, + Notice: notice, Group: group, GroupHead: head, GroupOrder: order, Unavailable: true, }) continue } @@ -2767,6 +2815,7 @@ func (a *app) modelsFor(keep modelFilter) []Model { model.Direct = true } model.Group, model.GroupOrder = group, order + model.GroupHead = head grouped = append(grouped, model) } } @@ -2989,6 +3038,13 @@ func (a *app) pickerKey(msg tea.KeyPressMsg) tea.Cmd { return nil } if ok { + // THE LAST ROW IS NOT A MODEL. It is the door to connect another + // provider, and enter on it opens that flow with the list behind + // it — a model row applies and stays, this row opens and leaves. + if chosen.AddProvider { + a.pick.close() + return a.openAddProvider(false) + } // One list, two subjects, decided where the list was opened: a node when // the model word in its room was pressed, and the conversation every // other time (palette.go's [app.openTaskPicker]). @@ -3177,6 +3233,8 @@ func (a *app) overlayHeight() int { want = a.roster.height(width) case a.shelf.open: want = a.shelf.height(width) + case a.addPanel.open: + want = a.addPanel.height(width) case a.connPanel.open: want = a.connPanel.height(width) case a.harnPanel.open: @@ -3229,6 +3287,11 @@ func (a *app) overlayRows(width, n int) []string { hover = a.hot.index } switch { + // THE ADD-PROVIDER PANEL RIDES THE SAME OVERLAY BUDGET AS THE PICKER IT + // WAS OPENED OVER: it is modal, it owns the keyboard, and it draws in the + // rows the frame hands out (addprovider.go). + case a.addPanel.open: + return a.addPanel.draw(width, n, a.pal, hover) case a.pick.open: return a.pick.rows(width, n, a.pal, hover, a.reasoningFor) case a.effPick.open: @@ -3237,6 +3300,8 @@ func (a *app) overlayRows(width, n int) []string { return a.roster.rows(width, n, a.pal, hover) case a.shelf.open: return a.shelf.rows(width, n, a.pal, hover) + case a.addPanel.open: + return a.addPanel.draw(width, n, a.pal, hover) case a.connPanel.open: return a.connPanel.draw(width, n, a.pal, hover) case a.harnPanel.open: diff --git a/internal/tui3/palette_test.go b/internal/tui3/palette_test.go index 4f3941daa..e8626f365 100644 --- a/internal/tui3/palette_test.go +++ b/internal/tui3/palette_test.go @@ -26,6 +26,11 @@ func pickerApp(t *testing.T, agent *fakeAgent, models []Model) *app { func pickerIDs(a *app) []string { out := make([]string, 0, len(a.pick.hits)) for _, at := range a.pick.hits { + // THE DOOR IS NOT A MODEL: the add-provider row rides the list's end + // and is chosen, not compared — these helpers read the models. + if a.pick.all[at].AddProvider { + continue + } out = append(out, a.pick.all[at].ID) } return out @@ -214,7 +219,10 @@ func TestThePickerIsBottomAnchoredAndMarksTheCurrentModel(t *testing.T) { // it — that is what "bottom-anchored" means here, and it is where the caret // has to be. The only thing below it is the status line, which is the last // row of every frame as of the status-down wave (view.go). - tail := lines[len(lines)-1-len(pickerCatalog) : len(lines)-1] + // THE DOOR IS A ROW OF THE LIST ([app.modelPickerList]), so the drawn list + // carries one row more than the catalog: the window is read one row further + // up, and the door is the row the loop below does not walk. + tail := lines[len(lines)-2-len(pickerCatalog) : len(lines)-1] // THE ROWS ARE IN THE LIST'S OWN ORDER, which is its first column — the name, // ascending — because every table on this surface opens sorted (pickersort.go). // This test is about WHERE the list sits and not what order it is in, so it @@ -226,7 +234,9 @@ func TestThePickerIsBottomAnchoredAndMarksTheCurrentModel(t *testing.T) { } // The foot keeps no blank under the box (view.go's [app.footClearance]), so // the filter box is the row directly above the list. - box := lines[len(lines)-len(pickerCatalog)-2] + // THE DOOR IS A ROW OF THE LIST TOO ([app.modelPickerList]), so the box is + // one row further up than the catalog alone would put it. + box := lines[len(lines)-len(pickerCatalog)-3] if !strings.Contains(box, rowAll(pickerHintFieldsBare)) { t.Fatalf("the filter box is %q, want the hint", box) } @@ -234,7 +244,7 @@ func TestThePickerIsBottomAnchoredAndMarksTheCurrentModel(t *testing.T) { // chip costs the text ([draftBlockTacked]): the chip is not editable, so the // first character a person types goes to its right. wantX := len(inputPad) + 2 + ansi.StringWidth(slashPickerTack) + 1 - if caretY != a.height-2-len(pickerCatalog) || caretX != wantX { + if caretY != a.height-2-len(pickerCatalog)-1 || caretX != wantX { t.Fatalf("the caret is at %d,%d — it belongs in the filter box after the tack (x=%d)", caretX, caretY, wantX) } diff --git a/internal/tui3/palettephone_test.go b/internal/tui3/palettephone_test.go index 4416da14d..6cd4bbb4c 100644 --- a/internal/tui3/palettephone_test.go +++ b/internal/tui3/palettephone_test.go @@ -213,11 +213,16 @@ func TestThePhoneWindowKeepsThePairWhole(t *testing.T) { func TestThePhoneCursorRowFitsWholeAtTheWindowEdge(t *testing.T) { a := phonePicker(t, phoneWidth) // Six rows holds three wrapped models; the catalog has four, so the cursor - // walking to the last one has to scroll. + // walking to the last one has to scroll. The walk goes to the LAST MODEL, + // not the list's end — the door below it is a row of the list, not a model + // ([app.modelPickerList]). const n = 6 + // The walk clamps at the list's end — which is now the door row — so one + // more down than the models have, then one up, lands on the last model. for range phoneCatalog { drive(t, a, key("down")) } + drive(t, a, key("up")) lines := a.pick.rows(a.width, n, a.pal, -1, a.reasoningFor) at := -1 for i, line := range lines { @@ -254,7 +259,9 @@ func TestTheWiderTiersAreByteIdenticalToTheOneLineLaw(t *testing.T) { // ([picker.headLines]); the law this test holds is about the rows. head := a.pick.headLines(width) lines := overlayBlock(a) - if len(lines) != len(phoneCatalog)+head { + // THE DOOR IS A ROW OF THE LIST ([app.modelPickerList]): the + // add-provider row sits under the models and costs its own line. + if len(lines) != len(phoneCatalog)+head+1 { t.Fatalf("at %d columns the list is %d rows for %d models under %d heading lines", width, len(lines), len(phoneCatalog), head) } diff --git a/internal/tui3/phase.go b/internal/tui3/phase.go index 8120d7a71..4c7f659ab 100644 --- a/internal/tui3/phase.go +++ b/internal/tui3/phase.go @@ -602,7 +602,7 @@ func phaseFields(news PhaseNews, now time.Time) []rowField { // visible half of the controller's report, and the alternative — which is // what this surface did before — is a person watching a line that says // nothing while a real wait runs. - return []rowField{rowSay("all providers slow"), rowSay("still waiting"), rowSay(countUpWord(since))} + return []rowField{rowSay("all hosts slow"), rowSay("still waiting"), rowSay(countUpWord(since))} case provider.PhaseBelowPace: // THE ANSWER IS ARRIVING AND IT IS TOO SLOW TO READ, and there is no // faster machine to move it to. It is a different sentence from the one diff --git a/internal/tui3/pickersort_test.go b/internal/tui3/pickersort_test.go index eba107294..928be631a 100644 --- a/internal/tui3/pickersort_test.go +++ b/internal/tui3/pickersort_test.go @@ -436,11 +436,11 @@ func TestTheFootReadsInOneOrderAtEveryLevel(t *testing.T) { want []string }{ {"a model row", nil, - []string{"→ providers", sortKeyWord, "enter switch", effortKeyWord}}, + []string{"→ hosts", sortKeyWord, "enter switch", effortKeyWord}}, {"the auto row", []tea.Msg{key("right")}, []string{"← back", sortKeyWord, "enter choose"}}, {"the openrouter row", []tea.Msg{key("right"), key("down")}, - []string{"← back", "→ providers", sortKeyWord, "enter choose"}}, + []string{"← back", "→ hosts", sortKeyWord, "enter choose"}}, {"a machine", []tea.Msg{key("right"), key("down"), key("right")}, []string{"← back", sortKeyWord, "enter choose"}}, } { @@ -707,8 +707,8 @@ func TestASortOrdersRowsInsideAServiceAndNeverTheServices(t *testing.T) { } // AND EVERY HEADING IS STILL DRAWN ONCE, in the order the services are held. lines := groupPickerLines(a) - homelabAt, studioAt := lineIndex(lines, func(l string) bool { return l == "homelab" }), - lineIndex(lines, func(l string) bool { return l == "studio" }) + homelabAt, studioAt := lineIndex(lines, func(l string) bool { return l == "homelab" || strings.HasPrefix(l, "homelab ") }), + lineIndex(lines, func(l string) bool { return l == "studio" || strings.HasPrefix(l, "studio ") }) if homelabAt < 0 || studioAt < 0 || homelabAt > studioAt { t.Fatalf("the sorted list drew its headings out of order:\n%s", strings.Join(lines, "\n")) } diff --git a/internal/tui3/providerword_law_test.go b/internal/tui3/providerword_law_test.go index 74d063e08..e0ecb8071 100644 --- a/internal/tui3/providerword_law_test.go +++ b/internal/tui3/providerword_law_test.go @@ -50,8 +50,73 @@ var laneWordLaw = regexp.MustCompile(`(?i)\blanes?\b`) // was promised a stable spelling — so it stays, and the manual's own account of // `/status` says the row keeps the old word and why. Everything else here is an // import path, which is a package name and not a sentence. -var laneWordAllowed = map[string]string{ - "lane": "statusdeck.go", +// laneWordAllowed keeps only the /status --json key; see retiredWordAllowed. +var laneWordAllowed = map[string]string{} + +// THE SECOND AND THIRD LAWS: one word for the thing you connect and hold a key +// for (provider), one word for the machine that served one answer (host). +// Issue #1508 found the same thing called service, model service, connection, +// custom connection, active connection and the `models` group head — and the +// routing word `provider` sitting on the same tab. The service and connection +// words are banned outright here and every literal that may still carry them is +// named below with the OTHER meaning it has; the host law bans the routing +// phrases (`→ providers`, `tab providers`, `provider · `, `all providers slow`, +// the measured-nothing line) rather than the bare word, because the word +// `provider` in its own right is the law of the first paragraph. + +// serviceWordLaw and connectionWordLaw are the standalone words in either +// number, word-bounded on both sides and case-insensitive. +var serviceWordLaw = regexp.MustCompile(`(?i)\bservices?\b`) +var connectionWordLaw = regexp.MustCompile(`(?i)\bconnections?\b`) + +// providerHostLaw catches `provider` in the ROUTING meaning, the meaning issue +// #1508 moves to `host`: the hints, the row prefixes, the fold empty line and +// the slow-all line. A literal that only names the thing you connect does not +// match any of these shapes. +var providerHostLaw = regexp.MustCompile(`(?i)(→ ?providers?\b|tab providers?\b|providers? · |all providers slow|no providers? has been measured|served by providers?\b)`) + +// retiredWordAllowed is every string literal that may still carry a retired +// word, each with the OTHER meaning that keeps it: an account (Slack, Google, a +// tool server), a long-running process (`codeaf services`), the ssh or session +// wire, a machinery identifier a script reads, or a demo not on this surface. +var retiredWordAllowed = []struct{ text, where string }{ + // machinery: ids and prefixes a person never reads as a sentence + {"model-service:", "modelservices.go"}, + {"new-custom-connection", "modelservices.go"}, + {"switch-connection", "modelservices.go"}, + {"connections", "commands.go"}, + {"Connections", "connectcaps.go"}, + {"connection", "statusdeck.go"}, + {"lane", "statusdeck.go"}, + {"lost the connection", "taskending.go"}, + {"each of the five can be pinned on its own in /settings → Providers", "crew.go"}, + // the account connect flow: the ACT of connecting, not the thing + {" connection didn't complete", "connect.go"}, + {" connection didn't complete", "connectcaps.go"}, + {"openrouter did not start a browser connection", "firstrun.go"}, + {"openrouter connection cancelled · enter tries again or paste a key", "firstrun.go"}, + // the session wire, not a provider + {"this connection cannot carry a file · the words were not sent", "attach.go"}, + {"a connection holds one conversation at a time", "keeper.go"}, + {"this connection cannot replace a pending request", "questionconversation.go"}, + {"connections are unavailable here", "connectpanel.go"}, + // ssh, in Session settings + {"seconds an ssh connection stays reusable after it closes, so a quick ", "settings.go"}, + {"how many unanswered heartbeats end a dead connection — three with the ", "settings.go"}, + // a long-running process and a demo document, not a model source + {"Rows already carry a foreign key into it and the migration is one file.\n+ one place to back up\n- another service to run locally", "questiondemo.go"}, + {"the ledger and the rest of the project share one connection", "questiondemo.go"}, + // a team or host connection, not a model source (dev commits of Sep 2026) + {". changing them is not available over this connection.", "host.go"}, + {"changing them is not available over this connection", "settings.go"}, + {" the teams inherit that machine's Settings · changing them is not available over this connection", "settings.go"}, + {"Wrap up first is not offered over this connection: Close now, or Cancel", "teamclose.go"}, + {"delete is not available over this connection", "teamclose.go"}, + {"the inbox and the spend are not available over this connection", "teamspage.go"}, + {"its closing report is kept where the team ran, and is not readable over this connection", "teamspagedraw.go"}, + // the crew provider list (daily caps, price ceilings), not the routing tab + {" · providers · ", "crewpanel.go"}, + {"walk the models row · the providers · a model's routes", "crewpanel.go"}, } // TestNoPersonFacingStringInThisSurfaceSaysLane walks every string literal this @@ -94,14 +159,28 @@ func TestNoPersonFacingStringInThisSurfaceSaysLane(t *testing.T) { return true } text, err := strconv.Unquote(lit.Value) - if err != nil || !laneWordLaw.MatchString(text) { + if err != nil { return true } - if where, allowed := laneWordAllowed[text]; allowed && where == name { - return true + for _, kept := range retiredWordAllowed { + if kept.text == text && kept.where == name { + return true + } + } + switch { + case laneWordLaw.MatchString(text): + t.Errorf("%s:%d says %q — the person-facing word for the machine behind a model is `provider` (issue #1023)", + name, fset.Position(lit.Pos()).Line, text) + case serviceWordLaw.MatchString(text): + t.Errorf("%s:%d says %q — the person-facing word for the thing you connect is `provider`, not `service` (issue #1508)", + name, fset.Position(lit.Pos()).Line, text) + case connectionWordLaw.MatchString(text): + t.Errorf("%s:%d says %q — the person-facing word for the thing you connect is `provider`, not `connection` (issue #1508)", + name, fset.Position(lit.Pos()).Line, text) + case providerHostLaw.MatchString(text): + t.Errorf("%s:%d says %q — the person-facing word for the machine that served one answer is `host` (issue #1508)", + name, fset.Position(lit.Pos()).Line, text) } - t.Errorf("%s:%d says %q — the person-facing word for the machine behind a model is `provider` (issue #1023)", - name, fset.Position(lit.Pos()).Line, text) return true }) } diff --git a/internal/tui3/render.go b/internal/tui3/render.go index 2c8f83947..5673e6c5b 100644 --- a/internal/tui3/render.go +++ b/internal/tui3/render.go @@ -3857,6 +3857,8 @@ func (a *app) hintWord() string { // that a person cannot see any other way: the pointer being somewhere else // looks exactly like the pointer being broken until a line says otherwise. return "drag to select · any key ends it" + case a.addPanel.open: + return "↑↓ move · enter connect · esc" case a.pick.open: // AND THE CREW IS NAMED BESIDE THE KEYS, because this list is where a // person lands when a crew change did not change anything they can see. diff --git a/internal/tui3/servicelands.go b/internal/tui3/servicelands.go new file mode 100644 index 000000000..794706c3a --- /dev/null +++ b/internal/tui3/servicelands.go @@ -0,0 +1,35 @@ +package tui3 + +// serviceLands is the desk between a fetch goroutine and the update loop: the +// pairs a warm or a ctrl+r walk landed since the loop last read. The goroutine +// that stocked a compartment writes here and rings [app.landedBell]; the Update +// that takes the ring reads and clears the desk ON the loop, drops the memo +// under each pair, and restocks an open picker — so a group fills WITHOUT a +// reopen and never off the loop (issue #1508). +type serviceLands struct { + pairs map[[2]string]bool +} + +// put records one landed pair. Safe from any goroutine. +func (l *serviceLands) put(source, address string) { + // The desk is written before the bell is rung and read on the loop after + // the ring; the loop's read is the synchronisation point, so the write + // happens-before every read. A second write of the same pair before the + // loop looks collapses onto the first — one ring says both. + l.pairs[[2]string{source, address}] = true +} + +// take reads and clears the desk. Called on the loop only. +func (l *serviceLands) take() [][2]string { + out := make([][2]string, 0, len(l.pairs)) + for pair := range l.pairs { + out = append(out, pair) + } + l.pairs = map[[2]string]bool{} + return out +} + +// serviceModelsLandedMsg is the message the door carries. Nothing is read out +// of it: the pairs are on the desk ([serviceLands]) by the time it arrives, +// and it exists only to bring the loop back around to read them. +type serviceModelsLandedMsg struct{} diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index 191a82c23..6a3bfb765 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -402,7 +402,7 @@ var settingUI = map[string]settingMeta{ // is most of what makes this row worth having. config.KeyModelFallbacks: { tab: tabSession, label: "fallback models", widget: widgetText, - about: "where a conversation goes when no provider will take the request: " + + about: "where a conversation goes when no host will take the request: " + "slugs, comma-separated, first tried first. Blank picks the nearest one.", }, // It sits with the model rows and not with the approval ones because the @@ -713,12 +713,12 @@ var settingUI = map[string]settingMeta{ // differences the session pays attention to. config.KeyRouting: { tab: tabProviders, label: "routing", widget: widgetCycle, - about: "one model is served by many providers. simple is the one it ships with and " + - "sends no preference of ours — no pinned provider means the router's own default " + - "answers, and a pinned provider is the whole request; latency asks for the fastest " + + about: "one model is served by many hosts. simple is the one it ships with and " + + "sends no preference of ours — no pinned host means the router's own default " + + "answers, and a pinned host is the whole request; latency asks for the fastest " + "and demotes one that keeps being slow; price asks for the cheapest; off asks " + "for nothing, measures nothing, and leaves the two rows above it with no " + - "provider to name. a change here takes effect on your next message.", + "host to name. a change here takes effect on your next message.", }, // AND UNDER IT, THE MACHINE ITSELF. routing is about what every request // prefers; this is about which endpoint your conversation actually lands on. @@ -728,11 +728,11 @@ var settingUI = map[string]settingMeta{ // picker's own `auto` row reads. A sentence spelled here as well would be // this panel promising a takeover on a routing that runs none. config.LaneSettingKey(talkSlot): { - tab: tabProviders, label: "provider", widget: widgetLane, + tab: tabProviders, label: "host", widget: widgetLane, }, config.KeyLaneGuard: { tab: tabProviders, label: "speed guard", widget: widgetToggle, - about: "an answer that is slow to start is asked of the next-best provider as well, " + + about: "an answer that is slow to start is asked of the next-best host as well, " + "and you read whichever replies first. One extra call, under a tenth of spend.", }, // AND THE OTHER HALF OF THE SAME QUESTION: the three rows above are about @@ -743,7 +743,7 @@ var settingUI = map[string]settingMeta{ tab: tabProviders, label: "prompt profile", widget: widgetCycle, about: "how much codeaf tells the model before you type. auto reads the model's " + "context window and goes lean under 32,000 tokens; lean and full say so yourself, " + - "for a provider that reports a window its model does not really have.", + "for a host that reports a window its model does not really have.", }, // ── Teams ─────────────────────────────────────────────────────────────── // The five defaults every team inherits, in the order a person reaches @@ -1429,6 +1429,7 @@ func (s *sheet) build() { return } door := false + routingHeadDone := false for _, row := range s.tabRows() { // THE THREE SEATS ARE ONE ROW HERE, standing where the first of them // would ([sheet.crewDoorItem]). @@ -1440,6 +1441,14 @@ func (s *sheet) build() { continue } meta, _ := s.metaFor(row) + // THE ROUTING SECTION IS NAMED WHERE IT STARTS. host, routing and + // speed guard are three answers to one question — where a request + // goes — and they read as a block only when something says so. + if !routingHeadDone && (row.Key == config.KeyRouting || + row.Key == config.LaneSettingKey(talkSlot) || row.Key == config.KeyLaneGuard) { + s.items = append(s.items, sheetItem{head: "routing"}) + routingHeadDone = true + } s.items = append(s.items, sheetItem{row: row, meta: meta}) if row.Key == config.KeyAPIKey && !s.sources.Empty() { // THE EMPTY PROFILE KEEPS THE DOOR AND DRAWS NOTHING ELSE: no services @@ -1448,19 +1457,29 @@ func (s *sheet) build() { // decoration. The add row is an action, not decoration — a profile with // no custom connection yet is the one that needs the door — so it stands // alone when no service row stands beside it (customAddRow). + // THE EMPTY PROFILE KEEPS THE DOOR AND DRAWS NOTHING ELSE: no + // providers head, no default row, no connection rows — a row + // that could do nothing is decoration, and the add row stands + // alone when no service row stands beside it (the emptiness law). services := modelServiceRows(s.profileDir, s.sources) if len(services) > 0 { - s.items = append(s.items, sheetItem{head: "services"}) + s.items = append(s.items, sheetItem{head: "providers"}) + // THE DEFAULT PROVIDER LEADS: openrouter answers by default + // and is the row a person reads first. + if defaultRow := defaultServiceRow(s.profileDir, s.sources); defaultRow != nil { + s.items = append(s.items, sheetItem{service: defaultRow}) + } for _, service := range services { s.items = append(s.items, sheetItem{service: service}) } - s.items = append(s.items, sheetItem{service: customAddRow()}) + // THE SWITCHER RIDES BETWEEN THE CONNECTIONS AND THE DOOR: + // the add row is last, because the list reads as the + // providers you have, and the door to add another closes it. if switcher := s.connectionSwitcherRow(); switcher != nil { s.items = append(s.items, sheetItem{service: switcher}) } - } else { - s.items = append(s.items, sheetItem{service: customAddRow()}) } + s.items = append(s.items, sheetItem{service: customAddRow()}) } // THE ROLES SECTION HANGS OFF THE ROW IT WRITES. Every pin those rows // set lands in "pinned roles" and nowhere else, so it is drawn @@ -2349,6 +2368,16 @@ func (a *app) activate() tea.Cmd { a.switchActiveConnection() return nil } + // ENTER ON A CONNECTED SERVICE OFFERS ITS FOUR ACTIONS: refresh the + // model list, rename the connection, change its key, or disconnect. + // The menu is the row's own choice entry — the same closed-answer box + // a region answer uses — so the four verbs are read, walked and + // answered by the code every other entry already runs. + if _, isModel := modelConnectionSource(modelConnectionID(item.service.id)); isModel { + s.conn.entry = newModelChoiceEntry(modelConnectionID(item.service.id), item.service.name, "", modelServiceMenuChoices()) + s.build() + return nil + } // ENTER ON A CONNECTED SERVICE IS ITS EDIT: the id is kept, the // answers prefill, and a changed name is a rename whose re-prefix the // connect result carries (modelservices.go's reprefixRenamedModel). @@ -2491,7 +2520,7 @@ func (a *app) openLaneList() bool { return false } sel := &sheetSelect{ - key: config.ModelSettingKey(talkSlot), label: "provider · " + a.model, + key: config.ModelSettingKey(talkSlot), label: "host · " + a.model, keep: filterFor(config.ModelSettingKey(talkSlot)), } sel.pick.startFor(a.modelsFor(sel.keep), a.model, sel.keep) @@ -3685,7 +3714,7 @@ func (s *sheet) keysLine() string { if _, inside := s.sel.pick.laneUnder(); inside { return "↑↓ move · ← or tab back · " + sortKeyWord + " · enter choose · esc cancel · type to filter" } - return "↑↓ move · → or tab providers · " + sortKeyWord + " · enter choose · esc cancel · type to filter" + return "↑↓ move · → or tab hosts · " + sortKeyWord + " · enter choose · esc cancel · type to filter" case s.conn.entry != nil: return s.connKeysLine() case s.onConnections(): diff --git a/internal/tui3/settingspend.go b/internal/tui3/settingspend.go index 979c141a2..f4cb073fa 100644 --- a/internal/tui3/settingspend.go +++ b/internal/tui3/settingspend.go @@ -476,5 +476,5 @@ const ( spendUnwrittenWord = "unwritten" spendUnwrittenSaid = "spending records could not be written" spendUnbilledWord = "unbilled" - spendUnbilledSaid = "calls the provider charged for and could not be priced" + spendUnbilledSaid = "calls the host charged for and could not be priced" ) diff --git a/internal/tui3/tui3.go b/internal/tui3/tui3.go index f0b638181..40e1077d1 100644 --- a/internal/tui3/tui3.go +++ b/internal/tui3/tui3.go @@ -823,6 +823,27 @@ type Options struct { // shelf. The connect command runs it off the event loop, just as ctrl+r runs // RefreshModels, so opening /model never waits on the network. RefreshModelsForService func(context.Context, modelsource.Connected, []Model) ([]Model, error) + // RefreshAllModels refreshes the default catalog AND every connected + // provider's listing, on the same ctrl+r chord (issue #1508). One provider's + // failure must not stop the others: the door walks them all and reports + // nothing here — the groups say their own reasons. The door owns the + // per-provider memo drops and the open picker's restock through + // OnServiceModels; when this is set it REPLACES the single-catalog meaning + // of the chord and the surface offers the key unconditionally as before. + RefreshAllModels func(ctx context.Context) + // WarmEmptyProviders fetches, off the loop, every connected provider whose + // cache is missing or empty. The door calls it once at launch (issue + // #1508's first acceptance); groups fill as each fetch lands, without a + // reopen. Nil keeps the old launch: cache only, nothing fetched. + WarmEmptyProviders func(ctx context.Context) + // OnServiceModels tells the surface one provider's listing changed — a warm + // or a ctrl+r fetch stocked that provider's compartment. The surface drops + // its memo for the pair and restocks an open picker. Nil is a door that + // never lands anything. + OnServiceModels func(source, address string) + // ProviderFetchError reports the error from the most recent fetch attempt + // for a connected provider, if any, for rendering status lines in /model. + ProviderFetchError func(id string) string // ProfileDir is the profile the settings panel reads and writes — the same // directory internal/config resolves every other row out of. Empty is the @@ -1344,6 +1365,23 @@ func Run(ctx context.Context, opts Options) error { program = append(program, tea.WithWindowSize(opts.Width, opts.Height)) } surface := newApp(ctx, opts) + // A FETCH THAT LANDS OFF THE LOOP STILL HAS TO BE READ ON IT: the door's + // [Options.OnServiceModels] is called from a goroutine, so it is wrapped to + // SEND a message to this program rather than touch the app directly. The + // wrapper is what the process's fan-out sees. + if opts.OnServiceModels != nil { + // THE DOORBELL, NOT THE SEND: nothing in this surface may call + // Program.Send (doorbell_test.go's law). The callback arrives on a + // goroutine, so it writes one pair into the desk the loop reads + // (serviceLands) and rings the surface's own door; the Update that + // takes the ring reads the desk ON the loop. + surface.serviceLands = &serviceLands{pairs: map[[2]string]bool{}} + surface.landedBell = newDoorbell(serviceModelsLandedMsg{}) + opts.OnServiceModels = func(source, address string) { + surface.serviceLands.put(source, address) + surface.landedBell.ring() + } + } p := tea.NewProgram(surface, program...) // AND THE ENGINE IS GIVEN SOMEWHERE TO PUT ITS NEWS, and the loop a door to // be rung through that never waits for it ([listenForNews], doorbell.go). They