diff --git a/cmd/aforge/chatv3_modelshelf.go b/cmd/aforge/chatv3_modelshelf.go index e8154075d9..554352bd49 100644 --- a/cmd/aforge/chatv3_modelshelf.go +++ b/cmd/aforge/chatv3_modelshelf.go @@ -30,19 +30,26 @@ type v3ModelShelf struct { // router, with the same key, into the same cache file. options catalog.Options mu sync.RWMutex - direct map[string][]tui3.Model + direct map[string]serviceCompartment +} + +type serviceCompartment struct { + address string + door string + models []tui3.Model } func newV3ModelShelf(models *catalog.Catalog, options catalog.Options) *v3ModelShelf { - shelf := &v3ModelShelf{options: options, direct: make(map[string][]tui3.Model)} + shelf := &v3ModelShelf{options: options, direct: make(map[string]serviceCompartment)} shelf.current.Store(models) return shelf } // setSources keeps the shelf's connected-service compartments aligned with the -// live profile. Existing compartments survive so a connection result already -// fetched into the shelf is not thrown away; a cold process fills them from the -// service-scoped picker cache without touching the network. +// live profile. A compartment survives only while its address and proved door +// do: reconnecting to another billing road must replace a wider catalog left by +// the old one. A fixed door catalog is seeded directly without a fetch; every +// other cold compartment reads the service-scoped cache. func (s *v3ModelShelf) setSources(sources modelsource.Set) { if s == nil || sources.Empty() { return @@ -53,13 +60,15 @@ func (s *v3ModelShelf) setSources(sources modelsource.Set) { for _, service := range sources.All()[1:] { id := strings.ToLower(strings.TrimSpace(service.Source.ID)) keep[id] = true - if _, ok := s.direct[id]; !ok { - if service.Source.Listing == modelsource.ListingModels { - s.direct[id] = tui3.CachedModelsFor(service.Source.ID, service.Address) - } else { - s.direct[id] = nil - } + address, door := serviceCompartmentIdentity(service) + if held, ok := s.direct[id]; ok && held.address == address && held.door == door { + continue + } + rows := fixedDoorModels(service) + if len(rows) == 0 && service.Source.Listing == modelsource.ListingModels { + rows = tui3.CachedModelsFor(service.Source.ID, service.Address) } + s.direct[id] = serviceCompartment{address: address, door: door, models: rows} } for id := range s.direct { if !keep[id] { @@ -78,13 +87,26 @@ func (s *v3ModelShelf) modelsForService(service modelsource.Connected) []tui3.Mo if strings.EqualFold(strings.TrimSpace(service.Source.ID), modelsource.DefaultID) { return v3Models(s) } - if service.Source.Listing != modelsource.ListingModels { + if service.Source.Listing != modelsource.ListingModels && len(service.Door.Models) == 0 { return nil } id := strings.ToLower(strings.TrimSpace(service.Source.ID)) + address, door := serviceCompartmentIdentity(service) s.mu.RLock() - rows := append([]tui3.Model(nil), s.direct[id]...) + held, ok := s.direct[id] + rows := append([]tui3.Model(nil), held.models...) s.mu.RUnlock() + if ok && held.address == address && held.door == door && len(rows) > 0 { + return rows + } + // A PLAN DOOR'S CATALOG IS VENDORED AND COSTS NO FILE. [fixedDoorModels] + // reads `service.Door.Models` — the four documented ids the plan covers, + // held in memory since the profile was read — so this rung stays on the + // right side of the law below and answers a bound plan door whose + // compartment is empty or is still holding the other door's list. + if fixed := fixedDoorModels(service); len(fixed) > 0 { + return fixed + } // AN EMPTY COMPARTMENT IS AN EMPTY ANSWER, and the surface falls to its own // rung below this one. This used to read the service's cache file here — // os.ReadFile plus a JSON parse of the whole list, with no memo in front of @@ -117,8 +139,9 @@ func (s *v3ModelShelf) refreshService(ctx context.Context, service modelsource.C return nil, err } id := strings.ToLower(strings.TrimSpace(service.Source.ID)) + address, door := serviceCompartmentIdentity(service) s.mu.Lock() - s.direct[id] = append([]tui3.Model(nil), seed...) + s.direct[id] = serviceCompartment{address: address, door: door, models: append([]tui3.Model(nil), seed...)} s.mu.Unlock() if err := tui3.WriteModelCacheFor(service.Source.ID, service.Address, seed); err != nil { return nil, err @@ -133,13 +156,28 @@ func (s *v3ModelShelf) refreshService(ctx context.Context, service modelsource.C } rows := v3Models(fresh) id := strings.ToLower(strings.TrimSpace(service.Source.ID)) + address, door := serviceCompartmentIdentity(service) s.mu.Lock() - s.direct[id] = append([]tui3.Model(nil), rows...) + s.direct[id] = serviceCompartment{address: address, door: door, models: append([]tui3.Model(nil), rows...)} s.mu.Unlock() _ = tui3.WriteModelCacheFor(service.Source.ID, service.Address, rows) return rows, nil } +func serviceCompartmentIdentity(service modelsource.Connected) (address, door string) { + return strings.TrimRight(strings.TrimSpace(service.Address), "/"), strings.ToLower(strings.TrimSpace(service.Door.ID)) +} + +func fixedDoorModels(service modelsource.Connected) []tui3.Model { + rows := make([]tui3.Model, 0, len(service.Door.Models)) + for _, id := range service.Door.Models { + if id = strings.TrimSpace(id); id != "" { + rows = append(rows, tui3.Model{ID: id}) + } + } + return rows +} + // ModelsNow is the list on the shelf, answered without waiting — nil while a // lazily loaded launch catalog is still warming, and nil for a nil shelf, so // every reader falls through to the disk cache exactly as it did before. diff --git a/cmd/aforge/chatv3_modelshelf_test.go b/cmd/aforge/chatv3_modelshelf_test.go index 251d8f3121..eeeb016cfe 100644 --- a/cmd/aforge/chatv3_modelshelf_test.go +++ b/cmd/aforge/chatv3_modelshelf_test.go @@ -43,7 +43,7 @@ func TestAConnectedServiceRefreshLandsOnTheProcessShelf(t *testing.T) { discovery := catalog.Options{BaseURL: defaultHost.URL(), APIKey: defaultService.Key, Dir: dir} launch := catalog.Load(t.Context(), discovery) shelf := newV3ModelShelf(launch, discovery) - custom := modelsource.Vendored()[4] + custom := modelsource.Vendored()[6] outcome, err := config.ConnectService(t.Context(), dir, config.PersistedSource{ ID: custom.ID, Written: "localhost", Address: directHost.URL(), Key: "direct-key", Order: 1, }, custom, nil) @@ -115,6 +115,47 @@ func TestAConnectedServiceRefreshLandsOnTheProcessShelf(t *testing.T) { } } +func TestAReboundDoorReplacesTheShelfAndSeedsItsFixedCatalogWithoutAFetch(t *testing.T) { + for _, testCase := range []struct { + name string + oldAddress string + oldDoor string + }{ + {name: "address changed", oldAddress: "https://metered.example/v1", oldDoor: "coding-plan"}, + {name: "door changed on one host", oldAddress: "https://plan.example/v1", oldDoor: "metered"}, + } { + t.Run(testCase.name, func(t *testing.T) { + shelf := &v3ModelShelf{direct: map[string]serviceCompartment{ + "z-ai": { + address: testCase.oldAddress, door: testCase.oldDoor, + models: []tui3.Model{{ID: "metered-one"}, {ID: "metered-two"}}, + }, + }} + plan := modelsource.Door{ + ID: "coding-plan", Name: "coding plan", Address: "https://plan.example/v1", + Models: []string{"glm-5.3", "glm-5.3-flash", "glm-5.3[1m]", "glm-5.3-flash[1m]"}, + } + service := modelsource.Connected{ + Source: modelsource.Source{ID: "z-ai", Written: "z-ai", Listing: modelsource.ListingModels}, + Address: plan.Address, Door: plan, + } + shelf.setSources(modelsource.NewSet( + modelsource.Connected{Source: modelsource.DefaultSource("https://router.example/v1")}, + service, + )) + + got := shelf.modelsForService(service) + if len(got) != 4 || got[0].ID != "glm-5.3" || got[3].ID != "glm-5.3-flash[1m]" { + t.Fatalf("rebound shelf = %+v, want only the fixed plan catalog", got) + } + held := shelf.direct["z-ai"] + if held.address != plan.Address || held.door != plan.ID { + t.Fatalf("compartment identity = %+v", held) + } + }) + } +} + const shelfRow = `{"id":"vendor/old","architecture":{"input_modalities":["text"],"output_modalities":["text"]}}` const shelfNewRow = `{"id":"vendor/shipped-this-morning","architecture":{"input_modalities":["text","image"],"output_modalities":["text"]}}` diff --git a/docs/changes/unreleased/861-plan-first-doors.md b/docs/changes/unreleased/861-plan-first-doors.md new file mode 100644 index 0000000000..34ab5b333d --- /dev/null +++ b/docs/changes/unreleased/861-plan-first-doors.md @@ -0,0 +1,26 @@ +--- +kind: added +title: a service's subscription door is tried before its metered one +pr: 861 +surface: [chat, engine, docs] +invalidates: + - "A connected service had ONE address. A vendor that sells both a subscription and metered credit now carries ordered doors, and connect binds the first that answers — the subsidised one first. The bound door is persisted and re-probed only on an explicit reconnect; a row written before doors existed keeps its metered address and does not drift onto a subscription." + - "`internal/paymentrefusal` read a vendor's prose before its numeric code, and Z.ai's exhausted-window message contains the words \"Insufficient balance\". A five-hour window that will reset was therefore reported as a terminal \"the account cannot pay\". The code decides now; prose is the last resort for a vendor that sends none." + - "An exhausted plan window was treated like a refusal and walked to the next door, which meant probing a metered account with somebody's money and binding it. It is proof the plan door works — the key is valid and the window resets — so it binds that door and stops." + - "Nothing sends a paused turn to a metered door unless `when the plan is paused` is set to `use pay-as-you-go`. An absent OR unrecognised value reads as `wait`, and with the opt-in set the status line names the door while it is in use." + - "The metered overflow is bought AT MOST ONCE FOR A WHOLE TURN, not once per request. It was a local of one dispatch, so a repaired body, a relaxation rung, a hedge arm or the turn's own model hop could each have bought another metered attempt after the first one was already authorised. One guard now travels with the turn's context and every re-entry derives from it." + - "MiniMax's plan and metered balances share a host, a bearer, the models and the request, so nothing on the wire says which answered. It ships as ONE door with no plan claim; two would have labelled ordinary metered spending as a subscription." + - "The Z.ai coding endpoint's `/models` route lists ten models and its documentation covers two. The plan door offers the four documented ids — `glm-5.3`, `glm-5.3-flash` and their `[1m]` forms — rather than eight choices the plan refuses with `1311`." + - "aforge identifies itself as aforge on every direct request, including the tool-call loop, which previously bypassed that transport entirely. Zhipu lists the tools its plan covers and aforge is not among them; a listing request is drafted and unsent, and the page says exactly that." + - "Not every request aforge made said who it was. The completion door stamped `User-Agent: aforge` and the receipt route, built beside it, sent Go's default `Go-http-client/1.1`. One method on the client decides identity for both now, and the attribution headers that name aforge to one router's ranking page travel only to that router rather than to every base." + - "A direct stream that ended without its usage block was sent to that vendor's nonexistent `/generation` route with the plan bearer and OpenRouter's attribution, then written as `unbilled` even though the plan charged nothing per call. A direct service is never asked for a routed receipt now; the unmeasured call writes no ledger row, while OpenRouter still receives its receipt request with `User-Agent: aforge` and its own attribution." + - "A service with regions asked for one as a TYPED answer: `your region` was a box with `International, China` beneath it, and a mismatch put `pick one of: …` in the feed before opening the box again. It is a CHOICE now — one named row per catalog region, the first under the cursor, walked with up and down and taken with enter — in `/connect` and on the settings service row alike. Nothing types a region and there is no refusal, because every answer comes from the service's own closed list." + - "A service whose written name collided with an OpenRouter model author was REFUSED, and the person had to retype the region and key to connect under the suggested name. The suggested name is taken on the first attempt now, and the connect line names it." + - "A turn was believed to need the default provider's key even while its conversation model was on a connected service, so enter opened the browser connection instead of sending. The connected service carries the turn now, and the surface says nothing about OpenRouter in that state." + - "Every background seat was believed to resolve through the default service, so a direct-only conversation's crew found no key. A seat the service cannot fill now falls to the conversation's live model on that connected service; a present default-provider key keeps the configured crew unchanged." + - "Connecting a service left the conversation on its old model, so a person who had only just connected Z.ai was still spending on the default service until they opened `/model`. The conversation moves onto the service's preferred model in the same moment now, by one documented rule, and the sentence names the model it left." +--- + +Stacked on #800. Verified against a real Z.ai Team Plan key with both hosts behind recording +proxies: the plan host answers `GET /models` with 200, and a key with no credit answers 429 `1113` +on the metered host while the plan door serves normally. diff --git a/internal/config/config.go b/internal/config/config.go index 81b163ada8..f2e3517487 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -851,14 +851,21 @@ func ClientConfigFor(sources modelsource.Set, model string) provider.Config { model, level := roles.SplitEffort(model) effort, _ := provider.ParseEffort(level) service, bare := sources.For(model) - return provider.Config{ + configured := provider.Config{ APIKey: service.Key, BaseURL: service.Address, Model: bare, Direct: !strings.EqualFold(strings.TrimSpace(service.Source.ID), modelsource.DefaultID), KeyOptional: service.Source.KeyOptional, + BillingDoor: service.Door.Name, Effort: effort, } + if service.Overflow != nil { + configured.PlanOverflow = service.Overflow.Address + configured.PlanOverflowDoor = service.Overflow.Name + configured.OverflowOnPlanPause = service.PlanPaused == PlanPausedUseMeter + } + return configured } // WithoutSeatPin is the settings a raw request path takes: the same account, diff --git a/internal/config/sources.go b/internal/config/sources.go index 7e72169c3c..a26b5770f9 100644 --- a/internal/config/sources.go +++ b/internal/config/sources.go @@ -12,11 +12,17 @@ import ( "github.com/Agent-Field/aforge-v2/internal/modelsource" "github.com/Agent-Field/aforge-v2/internal/paymentrefusal" + "github.com/Agent-Field/aforge-v2/internal/provider" "github.com/Agent-Field/aforge-v2/internal/trace" ) const keyModelSources = "model_sources" +const ( + PlanPausedWait = "wait" + PlanPausedUseMeter = "use pay-as-you-go" +) + // PersistedSource is one non-default service in the profile. Address is kept // only for custom services; a vendored row derives it from its region. type PersistedSource struct { @@ -26,6 +32,13 @@ type PersistedSource struct { Address string `json:"address,omitempty"` Key string `json:"key,omitempty"` KeyEnv string `json:"key_env,omitempty"` + // Door is the billing road explicitly proved at connect time. Empty belongs + // to a pre-door row and resolves to its old metered address, never to a new + // subscription road that has not been proved for that key. + Door string `json:"door,omitempty"` + // PlanPaused is the person's per-service answer. Empty is wait, preserving + // the fixed-price account unless they deliberately opt into metered spend. + PlanPaused string `json:"when_plan_paused,omitempty"` // Listed records what the service itself answered at connect time. Nil is an // older row that still follows the vendored hint; false and true override it. Listed *bool `json:"listed,omitempty"` @@ -63,6 +76,10 @@ func WriteSources(profileDir string, rows []PersistedSource) error { row.Written = strings.TrimSpace(row.Written) row.Region = strings.TrimSpace(row.Region) row.KeyEnv = strings.TrimSpace(row.KeyEnv) + row.Door = strings.TrimSpace(row.Door) + if strings.TrimSpace(row.PlanPaused) != PlanPausedUseMeter { + row.PlanPaused = "" + } if row.ID != "custom" { row.Address = "" } else { @@ -130,10 +147,17 @@ func resolveSources(defaultKey, defaultBase string, rows []PersistedSource, keyA source.Listing = modelsource.ListingNone } } + door := resolvedSourceDoor(row, source) address := resolvedSourceAddress(row, source) + var overflow *modelsource.Door + if metered, ok := source.MeteredDoor(); ok && door.ID != "" && !door.Metered { + metered.Address = resolvedDoorAddress(row, source, metered) + overflow = &metered + } source.Address = address connected = append(connected, modelsource.Connected{ Source: source, Key: keyAt(row, source), Address: address, + Door: door, Overflow: overflow, PlanPaused: resolvedPlanPaused(row), }) } return modelsource.NewSet(connected...) @@ -143,12 +167,63 @@ func resolvedSourceAddress(row PersistedSource, source modelsource.Source) strin if source.ID == "custom" { return strings.TrimRight(strings.TrimSpace(row.Address), "/") } + if door := resolvedSourceDoor(row, source); door.ID != "" { + return resolvedDoorAddress(row, source, door) + } + return resolvedRegionAddress(row, source) +} + +func resolvedRegionAddress(row PersistedSource, source modelsource.Source) string { + if address := selectedRegionAddress(row, source); address != "" { + return address + } + return strings.TrimRight(strings.TrimSpace(source.Address), "/") +} + +func selectedRegionAddress(row PersistedSource, source modelsource.Source) string { for _, region := range source.Regions { if strings.EqualFold(strings.TrimSpace(region.ID), strings.TrimSpace(row.Region)) { return strings.TrimRight(strings.TrimSpace(region.Address), "/") } } - return strings.TrimRight(strings.TrimSpace(source.Address), "/") + return "" +} + +func resolvedSourceDoor(row PersistedSource, source modelsource.Source) modelsource.Door { + for _, door := range source.Doors { + if strings.EqualFold(strings.TrimSpace(door.ID), strings.TrimSpace(row.Door)) { + door.Address = resolvedDoorAddress(row, source, door) + return door + } + } + // A row connected before billing doors existed was on the metered region. + // Keeping it there is both backward compatibility and the money law: a + // reload may not silently move an account to a different billing product. + if strings.TrimSpace(row.Door) == "" { + for _, door := range source.Doors { + if door.Metered { + door.Address = resolvedDoorAddress(row, source, door) + return door + } + } + } + return modelsource.Door{} +} + +func resolvedDoorAddress(row PersistedSource, source modelsource.Source, door modelsource.Door) string { + if door.Metered { + if region := selectedRegionAddress(row, source); region != "" { + return region + } + } + return strings.TrimRight(strings.TrimSpace(door.Address), "/") +} + +func resolvedPlanPaused(row PersistedSource) string { + if strings.TrimSpace(row.PlanPaused) == PlanPausedUseMeter { + return PlanPausedUseMeter + } + return PlanPausedWait } // ConnectService proves a key and says what it reaches, and writes nothing on @@ -167,7 +242,10 @@ func ConnectService(ctx context.Context, profileDir string, row PersistedSource, } } if suggestion, collides := modelsource.Collides(row.Written, taken, authors); collides { - return modelsource.Outcome{Kind: modelsource.OutcomeCollides, Suggestion: suggestion}, nil + // A NAME THAT CANNOT BE USED IS NOT A QUESTION TO ASK A PERSON TWICE. + // The program knows the available spelling, so the first attempt takes it + // and the connection line says which name it used. + row.Written = suggestion } if written := strings.TrimSpace(row.Written); written != "" { src.Written = written @@ -186,6 +264,9 @@ func ConnectService(ctx context.Context, profileDir string, row PersistedSource, if src.KeyShape != nil && !src.KeyShape(key) { return modelsource.Outcome{Kind: modelsource.OutcomeWrongShape}, nil } + if len(src.Doors) > 1 { + return connectServiceDoors(ctx, profileDir, row, src, key) + } address := resolvedSourceAddress(row, src) listing := src.Probe if listing.Method == "" && listing.Address == "" { @@ -248,12 +329,93 @@ func ConnectService(ctx context.Context, profileDir string, row PersistedSource, return modelsource.Outcome{Kind: modelsource.OutcomeConnected}, nil } +func connectServiceDoors(ctx context.Context, profileDir string, row PersistedSource, src modelsource.Source, key string) (modelsource.Outcome, error) { + probe := src.DoorProbe() + if probe.Method == "" || probe.Address == "" { + return modelsource.Outcome{Kind: modelsource.OutcomeUnanswered}, nil + } + lastWords := "" + unanswered := false + for _, door := range src.OrderedDoors(key) { + address := resolvedDoorAddress(row, src, door) + status, body, answered := runServiceProbe(ctx, address, key, probe) + if !answered { + unanswered = true + continue + } + classification := paymentrefusal.Classify(status, body) + if classification == paymentrefusal.NoPlan || classification == paymentrefusal.Payment { + lastWords = withoutExactSecret(vendorWords(body), key) + continue + } + if classification == paymentrefusal.WindowExhausted { + // A spent window proves this door: the key authenticated and the plan + // exists. Walking onward would make a paid probe and persist a metered + // binding even though the person's setting still says wait. + return connectAtDoor(ctx, profileDir, row, src, key, door, true, paymentrefusal.ResetAt(body)) + } + if !acceptsStatus(probe.Accepts, status) { + return modelsource.Outcome{Kind: modelsource.OutcomeRefused, VendorSaid: withoutExactSecret(vendorWords(body), key)}, nil + } + + return connectAtDoor(ctx, profileDir, row, src, key, door, false, "") + } + if lastWords != "" && !unanswered { + return modelsource.Outcome{Kind: modelsource.OutcomeAccountCannotPay, VendorSaid: lastWords}, nil + } + return modelsource.Outcome{Kind: modelsource.OutcomeUnanswered}, nil +} + +func connectAtDoor(ctx context.Context, profileDir string, row PersistedSource, src modelsource.Source, key string, door modelsource.Door, paused bool, reset string) (modelsource.Outcome, error) { + address := resolvedDoorAddress(row, src, door) + row.Door = door.ID + outcome := modelsource.Outcome{ + Kind: modelsource.OutcomeConnected, Door: door, + PlanPaused: paused, PlanReset: strings.TrimSpace(reset), + } + if metered, ok := src.MeteredDoor(); ok && !door.Metered { + metered.Address = resolvedDoorAddress(row, src, metered) + outcome.Overflow = &metered + } + if len(door.Models) > 0 { + listed := true + row.Listed = &listed + outcome.Listed = true + outcome.ModelIDs = append([]string(nil), door.Models...) + outcome.Models = len(outcome.ModelIDs) + } else if listing := src.Probe; listing.Method != "" || listing.Address != "" { + listStatus, listBody, listAnswered := runServiceProbe(ctx, address, key, listing) + if listAnswered && acceptsStatus(listing.Accepts, listStatus) { + if listed, ok := listedOutcome(listBody); ok { + outcome = listed + outcome.Door = door + outcome.PlanPaused = paused + outcome.PlanReset = strings.TrimSpace(reset) + if metered, found := src.MeteredDoor(); found && !door.Metered { + metered.Address = resolvedDoorAddress(row, src, metered) + outcome.Overflow = &metered + } + value := true + row.Listed = &value + } + } else if listAnswered && listingIsAbsent(listStatus) { + value := false + row.Listed = &value + } + } + if err := persistConnectedSource(profileDir, row); err != nil { + return modelsource.Outcome{}, err + } + return outcome, nil +} + func runServiceProbe(ctx context.Context, address, key string, probe modelsource.Probe) (int, []byte, bool) { request, err := http.NewRequestWithContext(ctx, probe.Method, strings.TrimRight(address, "/")+probe.Address, bytes.NewBufferString(probe.Body)) if err != nil { return 0, nil, false } request.Header.Set("Accept", "application/json") + request.Header.Set("User-Agent", provider.DirectUserAgent) if probe.Body != "" { request.Header.Set("Content-Type", "application/json") } diff --git a/internal/config/sources_test.go b/internal/config/sources_test.go index e964fc6023..54bf1c4cb0 100644 --- a/internal/config/sources_test.go +++ b/internal/config/sources_test.go @@ -150,6 +150,7 @@ func TestAListingWinsEvenWhenTheBillableProbeCannotBePaid(t *testing.T) { server.RefuseCompletion(http.StatusTooManyRequests, `{"code":"1113","message":"Insufficient balance or no resource package. Please recharge."}`) source := vendoredSource(t, "z-ai") source.Address = server.URL() + source.Doors = nil row := PersistedSource{ID: source.ID, Written: source.Written, Key: "zai-key", Order: 1} dir := t.TempDir() outcome, err := ConnectService(context.Background(), dir, row, source, nil) @@ -173,6 +174,7 @@ func TestAListinglessProbeConnectsWithoutInventingACount(t *testing.T) { server.Listingless() source := vendoredSource(t, "z-ai") source.Address = server.URL() + source.Doors = nil row := PersistedSource{ID: source.ID, Written: source.Written, Key: "zai-key", Order: 1} outcome, err := ConnectService(context.Background(), t.TempDir(), row, source, nil) if err != nil || outcome.Kind != modelsource.OutcomeConnected || outcome.Listed || outcome.Models != 0 { @@ -194,6 +196,7 @@ func TestAPaymentRefusalAcceptsTheKeyAndStoresTheAccount(t *testing.T) { server.RefuseCompletion(http.StatusTooManyRequests, `{"code":"1113","message":"Insufficient balance or no resource package. Please recharge."}`) source := vendoredSource(t, "z-ai") source.Address = server.URL() + source.Doors = nil dir := t.TempDir() outcome, err := ConnectService(context.Background(), dir, PersistedSource{ ID: source.ID, Written: source.Written, Key: "zai-key", Order: 1, @@ -208,6 +211,168 @@ func TestAPaymentRefusalAcceptsTheKeyAndStoresTheAccount(t *testing.T) { } } +func twoDoorSource(t *testing.T, plan, metered *sourcestub.Server) modelsource.Source { + t.Helper() + source := vendoredSource(t, "z-ai") + source.Doors = []modelsource.Door{ + {ID: "coding-plan", Name: "coding plan", Address: plan.URL(), Models: []string{"glm-5.3-flash"}}, + {ID: "metered", Name: "pay-as-you-go", Address: metered.URL(), Metered: true}, + } + source.Probe.Timeout = 100 * time.Millisecond + return source +} + +func TestAPlanKeyBindsToThePlanDoor(t *testing.T) { + t.Setenv("ZHIPU_API_KEY", "") + plan, metered := sourcestub.New("too-wide"), sourcestub.New("metered") + defer plan.Close() + defer metered.Close() + metered.RefuseCompletion(http.StatusTooManyRequests, `{"code":"1113","message":"empty"}`) + dir := t.TempDir() + outcome, err := ConnectService(context.Background(), dir, PersistedSource{ + ID: "z-ai", Written: "z-ai", Key: "plan-test-key", Order: 1, + }, twoDoorSource(t, plan, metered), nil) + if err != nil || outcome.Kind != modelsource.OutcomeConnected || outcome.Door.ID != "coding-plan" || + !reflect.DeepEqual(outcome.ModelIDs, []string{"glm-5.3-flash"}) { + t.Fatalf("plan outcome = %+v, %v", outcome, err) + } + if len(plan.Requests()) != 1 || len(metered.Requests()) != 0 || plan.Requests()[0].Agent != provider.DirectUserAgent { + t.Fatalf("requests: plan=%d metered=%d", len(plan.Requests()), len(metered.Requests())) + } + rows := PersistedSources(dir) + if len(rows) != 1 || rows[0].Door != "coding-plan" { + t.Fatalf("bound row = %+v", rows) + } +} + +func TestAnExhaustedWindowBindsThePlanAndDoesNotSpendAMeteredProbe(t *testing.T) { + t.Setenv("ZHIPU_API_KEY", "") + plan, metered := sourcestub.New("too-wide"), sourcestub.New("metered") + defer plan.Close() + defer metered.Close() + plan.RefuseCompletion(http.StatusTooManyRequests, `{"code":"1316","message":"Usage limit reached for the past 5 hours. Your limit will reset at 18:30 UTC"}`) + dir := t.TempDir() + outcome, err := ConnectService(context.Background(), dir, PersistedSource{ + ID: "z-ai", Written: "z-ai", Key: "paused-plan-test-key", Order: 1, + }, twoDoorSource(t, plan, metered), nil) + if err != nil || outcome.Kind != modelsource.OutcomeConnected || outcome.Door.ID != "coding-plan" || + !outcome.PlanPaused || outcome.PlanReset != "18:30 UTC" || outcome.Overflow == nil || !outcome.Overflow.Metered { + t.Fatalf("paused plan outcome = %+v, %v", outcome, err) + } + // A spent window proves the key authenticated and the plan exists. Treating + // it as a refusal would buy a metered probe and persist that paid road while + // the person's setting still says wait. + if len(plan.Requests()) != 1 || len(metered.Requests()) != 0 { + t.Fatalf("paused connection spent elsewhere: plan=%d metered=%d", len(plan.Requests()), len(metered.Requests())) + } + if rows := PersistedSources(dir); len(rows) != 1 || rows[0].Door != "coding-plan" || rows[0].PlanPaused != "" { + t.Fatalf("paused plan binding = %+v", rows) + } +} + +func TestAKeyWithNoPlanFallsBackToPayAsYouGo(t *testing.T) { + t.Setenv("ZHIPU_API_KEY", "") + plan, metered := sourcestub.New(), sourcestub.New("glm-5.3-flash") + defer plan.Close() + defer metered.Close() + plan.RefuseCompletion(http.StatusTooManyRequests, `{"code":"1309","message":"plan expired"}`) + dir := t.TempDir() + outcome, err := ConnectService(context.Background(), dir, PersistedSource{ + ID: "z-ai", Written: "z-ai", Key: "metered-test-key", Order: 1, + }, twoDoorSource(t, plan, metered), nil) + if err != nil || outcome.Kind != modelsource.OutcomeConnected || outcome.Door.ID != "metered" || outcome.Models != 1 { + t.Fatalf("metered outcome = %+v, %v", outcome, err) + } + if len(plan.Requests()) != 1 || len(metered.Requests()) != 2 { + t.Fatalf("requests: plan=%d metered=%d", len(plan.Requests()), len(metered.Requests())) + } + if rows := PersistedSources(dir); len(rows) != 1 || rows[0].Door != "metered" { + t.Fatalf("bound row = %+v", rows) + } +} + +func TestEveryDoorRefusingIsStillCannotPay(t *testing.T) { + t.Setenv("ZHIPU_API_KEY", "") + plan, metered := sourcestub.New(), sourcestub.New() + defer plan.Close() + defer metered.Close() + plan.RefuseCompletion(http.StatusTooManyRequests, `{"code":"1311","message":"model is not in this plan"}`) + metered.RefuseCompletion(http.StatusTooManyRequests, `{"code":"1113","message":"Please recharge."}`) + dir := t.TempDir() + outcome, err := ConnectService(context.Background(), dir, PersistedSource{ + ID: "z-ai", Written: "z-ai", Key: "refused-test-key", Order: 1, + }, twoDoorSource(t, plan, metered), nil) + if err != nil || outcome.Kind != modelsource.OutcomeAccountCannotPay || outcome.VendorSaid != "Please recharge." { + t.Fatalf("refused outcome = %+v, %v", outcome, err) + } + if len(PersistedSources(dir)) != 0 { + t.Fatal("every refused door still stored a service") + } +} + +func TestABadKeyDoesNotWalkEveryDoor(t *testing.T) { + t.Setenv("ZHIPU_API_KEY", "") + plan, metered := sourcestub.New(), sourcestub.New() + defer plan.Close() + defer metered.Close() + plan.RefuseCompletion(http.StatusUnauthorized, `{"error":{"message":"token expired or incorrect"}}`) + outcome, err := ConnectService(context.Background(), t.TempDir(), PersistedSource{ + ID: "z-ai", Written: "z-ai", Key: "bad-test-key", Order: 1, + }, twoDoorSource(t, plan, metered), nil) + if err != nil || outcome.Kind != modelsource.OutcomeRefused || outcome.VendorSaid != "token expired or incorrect" { + t.Fatalf("bad-key outcome = %+v, %v", outcome, err) + } + if len(plan.Requests()) != 1 || len(metered.Requests()) != 0 { + t.Fatalf("bad key walked doors: plan=%d metered=%d", len(plan.Requests()), len(metered.Requests())) + } +} + +func TestOnlyAnExplicitReconnectRebindsTheDoor(t *testing.T) { + t.Setenv("ZHIPU_API_KEY", "") + plan, metered := sourcestub.New(), sourcestub.New("glm-5.3-flash") + defer plan.Close() + defer metered.Close() + source := twoDoorSource(t, plan, metered) + dir := t.TempDir() + row := PersistedSource{ID: "z-ai", Written: "z-ai", Key: "reconnect-test-key", Order: 1} + if outcome, err := ConnectService(context.Background(), dir, row, source, nil); err != nil || outcome.Door.ID != "coding-plan" { + t.Fatalf("first connection = %+v, %v", outcome, err) + } + plan.RefuseCompletion(http.StatusTooManyRequests, `{"code":"1309","message":"plan expired"}`) + before, ok := ResolveSources(dir, "", "").ByID("z-ai") + if !ok || before.Door.ID != "coding-plan" || before.Address != "https://api.z.ai/api/coding/paas/v4" { + t.Fatalf("reload silently rebound the door: %+v, found=%t", before, ok) + } + row = PersistedSources(dir)[0] + reconnectSource := before.Source + reconnectSource.Doors = source.Doors + if outcome, err := ConnectService(context.Background(), dir, row, reconnectSource, nil); err != nil || outcome.Door.ID != "metered" { + t.Fatalf("explicit reconnect = %+v, %v", outcome, err) + } + after, ok := ResolveSources(dir, "", "").ByID("z-ai") + if !ok || after.Door.ID != "metered" || after.Address != "https://api.z.ai/api/paas/v4" { + t.Fatalf("reconnected source = %+v, found=%t", after, ok) + } +} + +func TestAOneDoorServiceIsUnchanged(t *testing.T) { + t.Setenv("DEEPSEEK_API_KEY", "") + server := sourcestub.New("deepseek-chat") + defer server.Close() + source := vendoredSource(t, "deepseek") + source.Address = server.URL() + outcome, err := ConnectService(context.Background(), t.TempDir(), PersistedSource{ + ID: source.ID, Written: "deepseek-direct", Key: "sk-direct-1234567890", Order: 1, + }, source, nil) + if err != nil || outcome.Kind != modelsource.OutcomeConnected || outcome.Door.ID != "" || outcome.Models != 1 { + t.Fatalf("one-door outcome = %+v, %v", outcome, err) + } + requests := server.Requests() + if len(requests) != 1 || requests[0].Method != http.MethodGet { + t.Fatalf("one-door requests = %+v", requests) + } +} + func TestVendorWordsDropsOnlyACodeThatRepeatsReadableWords(t *testing.T) { if got := vendorWords([]byte(`{"code":"1113","message":"Please recharge."}`)); got != "Please recharge." { t.Fatalf("readable refusal = %q", got) @@ -235,21 +400,69 @@ func TestDisconnectServiceRemovesTheRowAndItsKey(t *testing.T) { } } -func TestACollidingServiceWritesNothing(t *testing.T) { +func TestACollidingServiceConnectsUnderTheSuggestedName(t *testing.T) { + t.Setenv("DEEPSEEK_API_KEY", "") dir := t.TempDir() - if err := WriteAPIKey(dir, "sk-default-1234567890"); err != nil { - t.Fatal(err) - } - before, _ := os.ReadFile(BudgetConfigPath(dir)) + server := sourcestub.New("deepseek-chat", "deepseek-reasoner") + defer server.Close() source := vendoredSource(t, "deepseek") + source.Address = server.URL() row := PersistedSource{ID: source.ID, Written: "deepseek", Key: "sk-direct-1234567890", Order: 1} outcome, err := ConnectService(context.Background(), dir, row, source, []string{"deepseek"}) - if err != nil || outcome.Kind != modelsource.OutcomeCollides || outcome.Suggestion != "deepseek-direct" { - t.Fatalf("collision = %+v, %v", outcome, err) + if err != nil || outcome.Kind != modelsource.OutcomeConnected { + t.Fatalf("connection = %+v, %v", outcome, err) } - after, _ := os.ReadFile(BudgetConfigPath(dir)) - if string(after) != string(before) { - t.Fatal("a collision changed the profile") + rows := PersistedSources(dir) + if len(rows) != 1 || rows[0].Written != "deepseek-direct" { + t.Fatalf("connected rows = %+v", rows) + } +} + +func TestACollidingServicesModelsUseTheSuggestedName(t *testing.T) { + t.Setenv("DEEPSEEK_API_KEY", "") + dir := t.TempDir() + server := sourcestub.New("deepseek-chat", "deepseek-reasoner") + defer server.Close() + source := vendoredSource(t, "deepseek") + source.Address = server.URL() + outcome, err := ConnectService(context.Background(), dir, PersistedSource{ + ID: source.ID, Written: "deepseek", Key: "sk-direct-1234567890", Order: 1, + }, source, []string{"deepseek"}) + if err != nil || outcome.Kind != modelsource.OutcomeConnected { + t.Fatalf("connection = %+v, %v", outcome, err) + } + connected, ok := ResolveSources(dir, "", DefaultBaseURL).ByID("deepseek") + if !ok { + t.Fatal("the connected service was not resolved") + } + got := make([]string, 0, len(outcome.ModelIDs)) + for _, id := range outcome.ModelIDs { + got = append(got, connected.Qualify(id)) + } + want := []string{"deepseek-direct/deepseek-chat", "deepseek-direct/deepseek-reasoner"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("qualified models = %v, want %v", got, want) + } +} + +func TestReconnectingACollidingServiceKeepsTheFirstSuggestedName(t *testing.T) { + t.Setenv("DEEPSEEK_API_KEY", "") + dir := t.TempDir() + server := sourcestub.New("deepseek-chat") + defer server.Close() + source := vendoredSource(t, "deepseek") + source.Address = server.URL() + row := PersistedSource{ID: source.ID, Written: "deepseek", Key: "sk-direct-1234567890", Order: 1} + if outcome, err := ConnectService(context.Background(), dir, row, source, []string{"deepseek"}); err != nil || outcome.Kind != modelsource.OutcomeConnected { + t.Fatalf("first connection = %+v, %v", outcome, err) + } + row = PersistedSources(dir)[0] + if outcome, err := ConnectService(context.Background(), dir, row, source, []string{"deepseek"}); err != nil || outcome.Kind != modelsource.OutcomeConnected { + t.Fatalf("reconnection = %+v, %v", outcome, err) + } + rows := PersistedSources(dir) + if len(rows) != 1 || rows[0].Written != "deepseek-direct" { + t.Fatalf("reconnected rows = %+v", rows) } } @@ -333,6 +546,7 @@ func TestNoServiceKeyReachesTheRecordWhateverItsShape(t *testing.T) { server.Listingless() source := vendoredSource(t, "z-ai") source.Address = server.URL() + source.Doors = nil row := PersistedSource{ID: "z-ai", Written: "z-ai", Order: 1} outcome, err := ConnectService(context.Background(), dir, row, source, nil) if err != nil || outcome.Kind != modelsource.OutcomeConnected { @@ -356,6 +570,7 @@ func TestARefusalCannotEchoTheSubmittedKey(t *testing.T) { server.Refuse(401, `{"error":{"message":"account `+key+` has no credit"}}`) source := vendoredSource(t, "z-ai") source.Address = server.URL() + source.Doors = nil outcome, err := ConnectService(context.Background(), t.TempDir(), PersistedSource{ ID: "z-ai", Written: "z-ai", Key: key, Order: 1, }, source, nil) diff --git a/internal/manual/chat/getting-started.md b/internal/manual/chat/getting-started.md index a4f31d79d7..ea095ab126 100644 --- a/internal/manual/chat/getting-started.md +++ b/internal/manual/chat/getting-started.md @@ -29,9 +29,11 @@ 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 -aforge may spend · /model and /crew pick the models`. If the default OpenRouter service is still not -connected, its one-step screen returns on the next local interactive launch because the -model cannot work without it. +aforge may spend · /model and /crew pick the models`. If the default OpenRouter service 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 +step stays away. The header reads `aforge` on the left and `setup · 2 of 2` on the right; with only one screen to show there is no count at all. The foot names the keys that work on the row you @@ -95,16 +97,22 @@ 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 -`esc` on the idle step skips setup. The conversation then says one dim line: +`esc` on the idle step skips setup. When the conversation is using the default service, 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 +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. + This default-service 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, and neither the shell nor the profile holds a key. A custom `AFORGE_BASE_URL`, a +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 `AFORGE_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 `aforge` once to connect in a terminal, or export `OPENROUTER_API_KEY` (or `OPENAI_API_KEY`) before running it. diff --git a/internal/manual/chat/services.md b/internal/manual/chat/services.md index c989682f12..b40ed41285 100644 --- a/internal/manual/chat/services.md +++ b/internal/manual/chat/services.md @@ -3,22 +3,47 @@ ## Add a key — connect a service, add a provider, use a different model service Open `/connect` or `/connections`. The `models` group lists DeepSeek, Z.ai, Moonshot, -Ollama and **Something else**, followed by any service already connected. Pick a row and -answer its fields. A successful listed service says `deepseek is connected · 6 models`; -one without a list says only `deepseek is connected`. The Providers tab in `/settings` -then shows the service, the safe spelling of its key, its region and its order. +MiniMax, Alibaba Qwen, Ollama and **Something else**, followed by any service already +connected. Pick a row and answer its fields. A successful listed service 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 +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. 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. -## When a newly connected model service starts working in this conversation - -On a plain launch on this machine, a successful connection with a pasted key is live in -the conversation that is already open. The surface writes the service to the engine's -profile; the engine re-reads that profile through the model-setting door before it applies -the chosen model. Pick one of the new service's models in `/model`, and the very next -request uses that service's address and pasted key. Opening another conversation is not -required. +## Using aforge with only a direct service — no OpenRouter key at all + +Yes. When the conversation is on a model from a connected service, that service can carry +the turn without an OpenRouter key. Pressing `enter` sends the message; the setup screen +does not open, and aforge 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. + +The crew follows the same road. Its small background calls — naming a session, titling a +task, the reflex and the judges — normally use the configured crew models. 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 +from that turn inherit the same rule, so none of them makes an OpenRouter request. If the +default service does have a key, the crew keeps using its configured models as usual. + +## What model do I get after connecting a service — 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 +first documented model wins. Otherwise aforge uses the vendor's preferred model when the +service listed it or published no list, then the first model the service listed. With no +preferred or listed model there is no move and no extra sentence. + +For example, the connection line +`z-ai-direct is connected · coding plan · 4 models` is followed by +`this conversation was on ~deepseek/deepseek-v4-flash-latest · it is now on z-ai-direct/glm-5.3`. +The status line and `/model` show the new model at once. The previous model is named so +opening `/model` and choosing it once takes you back; `/model` is also how to go somewhere +else. If a turn is answering, the connection lands immediately but the model move waits +until that answer ends, so the model does not change under a sentence already streaming. When the key is a variable, the receipt adds, for example, `the engine process reads $DEEPSEEK_API_KEY from its own environment`. The daemon keeps @@ -32,36 +57,102 @@ not the local profile the panel could write. ## Use my own DeepSeek key — connecting DeepSeek, GLM, Kimi, Qwen or MiniMax directly -Open `/connect`, choose the vendor in the `models` group, and enter the key. Z.ai asks -for a region before its key and is the direct door for GLM; Moonshot asks for a region -before its key and is the direct door for Kimi. Qwen or MiniMax can be added through -**Something else** when you have an OpenAI-compatible address and key for them. +Open `/connect` and choose the vendor in the `models` group. DeepSeek and MiniMax open +`your key` directly. Z.ai, Moonshot and Alibaba Qwen first open `your region` as a +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 **Something else** are single-door services. MiniMax makes no plan +claim because its plan and metered traffic currently have no wire-level difference +aforge 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. For example, `deepseek` collides, so aforge says -`deepseek is a model author on openrouter · connect this as deepseek-direct`. Connect it with that written name; its -models then read `deepseek-direct/`. +service. When `deepseek` is already an author there, aforge connects the direct service +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 + +A service may not be written with a name the default service already uses for a model +author. Aforge 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 aforge checks before it saves anything -Open `/connect` and choose a row in `models`. DeepSeek asks for a key. Z.ai and Moonshot -ask for a region and then a key. Ollama asks for nothing. **Something else** asks for a -base URL and a key. A key may also be the name of an environment variable, such as -`$DEEPSEEK_API_KEY`. +Open `/connect` and choose a row in `models`. 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; +enter takes the row under the cursor and then opens `your key`; esc backs out with +nothing saved. The region is a choice and cannot be typed. Ollama asks for nothing. +**Something else** asks for `your base url` and then `your key`. A key may also be the +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: -`deepseek refused that key — Authentication Fails, Your api key is invalid`. No answer is different: -`deepseek did not answer · nothing was saved`. Aforge asks `GET /models` first even when the vendor does not document -that address; undocumented is not the same as absent. If it is absent, aforge uses the row's current one-token check where -one is known, or believes the key until the first turn rather than guessing a billable model. - -A payment refusal proves the key authenticated, so the service is connected and stored. It says, for example, +`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: +`deepseek refused that key — Authentication Fails, Your api key is invalid`. No answer is +different: `deepseek did not answer · nothing was saved`. + +Aforge checks each billing door in order with a one-token completion and binds the first +one that answers. A no-plan or payment answer on one door moves the connection check to +the next; a bad-key answer stops immediately in the vendor's words. A missing answer also +moves to the next door. If no door answers, aforge saves nothing. Key-prefix hints may +change which door is tried first, but never skip a door. + +A spent plan window proves that plan door works: aforge binds it, stops before the +pay-as-you-go door, and says `plan paused`. It does not make a paid probe or change the +saved billing door. When the vendor supplies a reset time, the connected line carries +the same sentence used during a turn, for example +`plan paused · resets at 18:30 UTC · /connect can switch to pay-as-you-go`. + +The bound door is saved and every later request uses it. Aforge 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` +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, aforge 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.` -Fix the balance or package on that account; its own words appear again on the first turn, which is not retried. A plain -`429` with no payment explanation still means the service is busy and is waited out. Every saved key lives in the profile -`config.json`, owner-readable only. +A plain `429` with no recognised payment or plan code still means the service 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 + +Z.ai's coding endpoint publishes a wider model listing than its DevPack documentation +says the plan serves. Aforge therefore shows only the documented plan catalog: +`glm-5.3`, `glm-5.3-flash`, `glm-5.3[1m]`, and `glm-5.3-flash[1m]`. The +pay-as-you-go door keeps the model listing returned by that door. + +## Plan paused — what happens when my plan runs out, reset times, and pay-as-you-go overflow + +When a plan reaches a documented usage window, the turn is paced and the account is not +described as unable to pay. The live line says `plan paused`; when the vendor supplies a +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`. +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. +During overflow the status line names it, for example +`writing · 4s · pay-as-you-go 61 t/s`. The setting is per service. + +## Is aforge supported by Zhipu for the coding plan + +Zhipu lists the tools its plan covers. Aforge is not currently listed; a request has been +drafted but has not been sent. Aforge identifies itself as aforge and does not pretend to +be another supported client. ## What a service without a model list can and cannot do @@ -109,12 +200,18 @@ same model name. `via ` belongs only to a default-service model with ro lanes. A direct-service row and status line draw no `via` at all and open no lane sheet; that service has one road, not a choice of serving machines. -## Why there is no price on a direct service yet +## 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 spend page and show no invented `$0.00`. This does not mean the vendor charged nothing; consult that account for its bill and limits. +When a direct stream ends before its usage block arrives, aforge asks for no OpenRouter +receipt and writes no ledger row for that unmeasured call. `/cost` stays silent about it +rather than saying a subscription call was charged but could not be priced. Direct calls +whose usage block does arrive still record their model call and token counts without an +invented price. + A direct service has one lane, so there is no serving-machine picker and nothing to choose between. That is not a fault. Price caps, privacy negotiation and lane routing belong to the default routed service and are not applied to a direct call. diff --git a/internal/manual/chat/starting-aforge.md b/internal/manual/chat/starting-aforge.md index cc36b04067..0c79c24e40 100644 --- a/internal/manual/chat/starting-aforge.md +++ b/internal/manual/chat/starting-aforge.md @@ -43,13 +43,16 @@ prose three rows under it. There is one name now, and `openaf` is on no screen. instead — connect OpenRouter in your browser, choose the crew, set the spending rails — and then on the empty conversation. The preference questions are shown once. The OpenRouter step returns on any later local interactive launch while no key exists, -including a named or resumed conversation, and `enter` on an unsent message brings it back -without clearing the draft. The getting-started page has the whole flow. - -A `--once` or piped run cannot open a browser and still stops at the door with -`aforge chat needs a model to talk with.` Its next line says to run bare `aforge` in a -terminal to connect OpenRouter, or to export `OPENROUTER_API_KEY` (or `OPENAI_API_KEY`). A -custom `AFORGE_BASE_URL` is never offered the OpenRouter connection. +including a named or resumed conversation using the default service, and `enter` on an +unsent message brings it back without clearing the draft. A conversation on a connected +direct service's model sends without an OpenRouter key and does not open that step. The +getting-started page has the whole flow. + +A `--once` or piped run cannot open a browser. When its model uses the keyless default +service it stops at the door with `aforge chat needs a model to talk with.` Its next line +says to run bare `aforge` in a terminal to connect OpenRouter, or to export +`OPENROUTER_API_KEY` (or `OPENAI_API_KEY`). A connected direct service can carry that run +instead. A custom `AFORGE_BASE_URL` is never offered the OpenRouter connection. That variable still changes only the default service. To add a supported second place models come from, connect a service through `/connect`; the [services page](services.md) explains the checked connection and picker names. diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index c6642d833f..abe1f8ed56 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -35,6 +35,17 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"why can't it make pictures any more", "services"}, {"how do I remove a key", "services"}, {"can I use ollama", "services"}, + {"does z-ai use my coding plan first", "services"}, + {"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 aforge use after I connect a service", "services"}, + {"I only have a zhipu key can I use aforge", "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 aforge supported by zhipu", "services"}, + {"how do I reconnect a model service", "services"}, {"I exported the model service key after the engine started", "services"}, {"why does /connect say connections are unavailable", "accounts"}, {"connect says unavailable on my own machine", "accounts"}, @@ -2595,6 +2606,10 @@ func TestTheServicesPageNamesCustomListingDiscoveryAndDisconnectConfirmation(t * "a **Something else** service must provide the compatible chat path", "tries `GET /models` first", "When that address is absent, aforge connects the service 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", + "Aforge is not currently listed; a request has been\ndrafted but has not been sent.", } { if !strings.Contains(page, sentence) { t.Errorf("services page does not state %q", sentence) diff --git a/internal/modelsource/modelsource.go b/internal/modelsource/modelsource.go index 2697ca5723..a9ec976e9b 100644 --- a/internal/modelsource/modelsource.go +++ b/internal/modelsource/modelsource.go @@ -44,6 +44,27 @@ type Region struct { Address string } +// Door is one way of paying for the same vendor's models: a base URL, and the +// word a person reads for it. ORDER IS THE POLICY — the subsidised door is +// first, because a person who has paid a subscription meant to use it. +type Door struct { + ID string + Name string + Address string + // Metered marks the road whose use can create a charge outside a fixed + // plan. The fact lives on the door because an ID is persistence vocabulary, + // not a billing policy for callers to reinterpret. + Metered bool + // KeyPrefix is a shortcut hint for ordering, never a claim. Every door is + // still tried because a vendor may change its key convention. + KeyPrefix string + // Models is the catalog to trust when this door's own listing is wider than + // the models the billing product actually serves. Empty believes the listing. + Models []string + // Observed says somebody has watched this door answer. It never gates use. + Observed bool +} + // Probe is the cheap read that proves a key works. It is a description only; // internal/config performs the request beside the key it needs. type Probe struct { @@ -63,6 +84,7 @@ type Source struct { Written string Name string Address string + Doors []Door Regions []Region KeyEnv string // KeyShape validates a supplied key. Nil accepts any non-blank value; @@ -74,8 +96,10 @@ type Source struct { Probe Probe Listing Listing ProbeModel string - Plan bool - KeyPrefix string + // A ROW'S PREFERRED MUST BE ANSWERABLE ON EVERY DOOR IT CAN BIND. The door + // is chosen by what the key proves, so this is the vendor's best model both + // its subscription and metered roads serve, not simply its flagship. + Preferred string } // Connected is one service with the two facts only a caller that may read the @@ -84,6 +108,13 @@ type Connected struct { Source Source Key string Address string + // Door is the bound billing road. It is zero for a one-door service, so all + // older status and runtime behaviour remains byte-identical. + Door Door + // Overflow is the metered road a bound plan may use only when the person has + // explicitly chosen it. Nil means there is no such road. + Overflow *Door + PlanPaused string } // Set is the services this profile talks to, in the person's own order, the @@ -156,7 +187,7 @@ func (s Set) OrDefault(key, address string) Set { return s } source := DefaultSource(address) - return NewSet(Connected{source, key, address}) + return NewSet(Connected{Source: source, Key: key, Address: address}) } // WithDefaultKey returns the same ordered set with its default member's key @@ -221,16 +252,27 @@ func LooksLikeAPIKey(key string) bool { return strings.HasPrefix(key, "sk-") && len(key) >= 20 && !strings.ContainsAny(key, " \t\r\n") } -// Vendored returns the five service descriptions shipped by this phase. +const ( + zaiPreferredModel = "glm-5.3" + moonshotPreferredModel = "kimi-k2.7-code" + minimaxPreferredModel = "MiniMax-M3" +) + +// Vendored returns the seven service descriptions shipped by this phase. func Vendored() []Source { return []Source{ { ID: "deepseek", Written: "deepseek", Name: "DeepSeek", Address: "https://api.deepseek.com/v1", KeyEnv: "DEEPSEEK_API_KEY", KeyShape: LooksLikeAPIKey, Listing: ListingModels, Probe: listingProbe(), + Preferred: "deepseek-v4-pro", }, { ID: "z-ai", Written: "z-ai", Name: "Z.ai", KeyEnv: "ZHIPU_API_KEY", + Doors: []Door{ + {ID: "coding-plan", Name: "coding plan", Address: "https://api.z.ai/api/coding/paas/v4", Models: []string{zaiPreferredModel, "glm-5.3-flash", "glm-5.3[1m]", "glm-5.3-flash[1m]"}, Observed: true}, + {ID: "metered", Name: "pay-as-you-go", Address: "https://api.z.ai/api/paas/v4", Metered: true, Observed: true}, + }, Regions: []Region{ {ID: "intl", Name: "International", Address: "https://api.z.ai/api/paas/v4"}, {ID: "cn", Name: "China", Address: "https://open.bigmodel.cn/api/paas/v4"}, @@ -242,27 +284,110 @@ func Vendored() []Source { // endpoint actually does. The fallback model stays for the regions // or the day it stops. Listing: ListingModels, ProbeModel: "glm-5.3-flash", Probe: listingProbe(), + Preferred: zaiPreferredModel, }, { ID: "moonshot", Written: "moonshot", Name: "Moonshot", KeyEnv: "MOONSHOT_API_KEY", + Doors: []Door{ + {ID: "kimi-code", Name: "kimi code", Address: "https://api.kimi.com/coding/v1"}, + {ID: "metered", Name: "pay-as-you-go", Address: "https://api.moonshot.ai/v1", Metered: true}, + }, Regions: []Region{ {ID: "intl", Name: "International", Address: "https://api.moonshot.ai/v1"}, {ID: "cn", Name: "China", Address: "https://api.moonshot.cn/v1"}, }, - Listing: ListingNone, Probe: listingProbe(), + Listing: ListingNone, ProbeModel: moonshotPreferredModel, Probe: listingProbe(), + Preferred: moonshotPreferredModel, + }, + { + ID: "minimax", Written: "minimax", Name: "MiniMax", KeyEnv: "MINIMAX_API_KEY", + Address: "https://api.minimax.io/v1", KeyShape: LooksLikeAPIKey, + // MiniMax's plan and metered calls currently have no wire-level + // distinction: the same host, bearer, model and request can spend either + // balance. Two doors return only when an observed response field, header + // or error can prove which billing product answered. + Listing: ListingNone, ProbeModel: minimaxPreferredModel, Probe: listingProbe(), + Preferred: minimaxPreferredModel, + }, + { + ID: "qwen", Written: "qwen", Name: "Alibaba Qwen", KeyEnv: "DASHSCOPE_API_KEY", + Doors: []Door{ + {ID: "coding-plan", Name: "coding plan", Address: "https://coding-intl.dashscope.aliyuncs.com/v1", KeyPrefix: "sk-sp-"}, + {ID: "metered", Name: "pay-as-you-go", Address: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", Metered: true}, + }, + Regions: []Region{ + {ID: "intl", Name: "International", Address: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"}, + {ID: "cn", Name: "China", Address: "https://dashscope.aliyuncs.com/compatible-mode/v1"}, + }, + Listing: ListingNone, ProbeModel: "qwen3.8-flash", Probe: listingProbe(), + Preferred: "qwen3.7-plus", }, { ID: "ollama", Written: "ollama", Name: "Ollama", Address: "http://localhost:11434/v1", KeyOptional: true, - Listing: ListingModels, Probe: listingProbe(), + Listing: ListingModels, Probe: listingProbe(), Preferred: "", }, { ID: "custom", Written: "custom", Name: "Something else", - Listing: ListingModels, Probe: listingProbe(), + Listing: ListingModels, Probe: listingProbe(), Preferred: "", }, } } +// PreferredModel answers the model a newly connected service should put the +// conversation on. THE ORDER IS THE POLICY: a door's documented catalog is +// narrower than the vendor row, then a row preference is trusted only when the +// service did not list models or listed that id, and only then may the first +// listed id stand in. Empty means there is no honest move to make. +func (s Source) PreferredModel(door Door, listed []string) string { + if len(door.Models) > 0 { + return strings.TrimSpace(door.Models[0]) + } + preferred := strings.TrimSpace(s.Preferred) + if preferred != "" { + if len(listed) == 0 { + return preferred + } + for _, id := range listed { + if strings.TrimSpace(id) == preferred { + return preferred + } + } + } + for _, id := range listed { + if id = strings.TrimSpace(id); id != "" { + return id + } + } + return "" +} + +// MeteredDoor returns the one separately billed road described by this +// service. A missing answer means the service has no honest overflow road. +func (s Source) MeteredDoor() (Door, bool) { + for _, door := range s.Doors { + if door.Metered { + return door, true + } + } + return Door{}, false +} + +// OrderedDoors returns every billing road exactly once. A matching prefix moves +// its likely door to the front and never removes any alternative. +func (s Source) OrderedDoors(key string) []Door { + doors := append([]Door(nil), s.Doors...) + key = strings.TrimSpace(key) + for index, door := range doors { + if door.KeyPrefix != "" && strings.HasPrefix(key, door.KeyPrefix) { + copy(doors[1:index+1], doors[0:index]) + doors[0] = door + break + } + } + return doors +} + func listingProbe() Probe { return Probe{Address: "/models", Method: "GET", Accepts: []int{200}, Timeout: ProbeTimeout} } @@ -285,6 +410,16 @@ func (s Source) FallbackProbe() Probe { return completionProbe(s.ProbeModel) } +// DoorProbe is the one-token request that proves which billing road this key +// can use. A multi-door row must name a current model rather than guess at the +// call site. +func (s Source) DoorProbe() Probe { + if strings.TrimSpace(s.ProbeModel) == "" { + return Probe{} + } + return completionProbe(s.ProbeModel) +} + // Collision says why a proposed Written may not be used, and what to use // instead. Author segments arrive as an argument because this package may not // reach internal/catalog. @@ -318,7 +453,6 @@ const ( OutcomeAccountCannotPay OutcomeUnanswered OutcomeWrongShape - OutcomeCollides ) // Outcome is what a connect attempt learned, in facts rather than a sentence: @@ -330,7 +464,14 @@ type Outcome struct { // ModelIDs are the non-empty ids carried by an answered listing. Keeping // them lets the surface retain the list it already paid for instead of // making a second catalog-shaped response the only road to the picker. - ModelIDs []string - Listed bool - Suggestion string + ModelIDs []string + Listed bool + Door Door + // PlanPaused records that the selected door proved the plan exists but its + // current usage window is spent. PlanReset is the vendor's readable reset + // time when it supplied one, and Overflow is the separately billed road the + // person may explicitly choose later. + PlanPaused bool + PlanReset string + Overflow *Door } diff --git a/internal/modelsource/modelsource_test.go b/internal/modelsource/modelsource_test.go index 5d111c5651..b71dd817e8 100644 --- a/internal/modelsource/modelsource_test.go +++ b/internal/modelsource/modelsource_test.go @@ -6,10 +6,11 @@ import ( "go/token" "path/filepath" "runtime" + "strings" "testing" ) -func TestAServiceNameThatCollidesWithAModelAuthorIsRefusedWithASuggestion(t *testing.T) { +func TestAServiceNameThatCollidesWithAModelAuthorGetsASuggestion(t *testing.T) { suggestion, collided := Collides(" DeepSeek ", []string{"openrouter"}, []string{"deepseek", "qwen"}) if !collided || suggestion != "DeepSeek-direct" { t.Fatalf("collision = %t, suggestion = %q", collided, suggestion) @@ -37,9 +38,9 @@ func TestUnqualifiedIdsStayOnTheDefaultService(t *testing.T) { } } -func TestVendoredRowsAreTheDecidedFive(t *testing.T) { +func TestVendoredRowsAreTheDecidedSeven(t *testing.T) { rows := Vendored() - want := []string{"deepseek", "z-ai", "moonshot", "ollama", "custom"} + want := []string{"deepseek", "z-ai", "moonshot", "minimax", "qwen", "ollama", "custom"} if len(rows) != len(want) { t.Fatalf("vendored rows = %d, want %d", len(rows), len(want)) } @@ -47,16 +48,18 @@ func TestVendoredRowsAreTheDecidedFive(t *testing.T) { if rows[i].ID != want[i] { t.Errorf("row %d = %q, want %q", i, rows[i].ID, want[i]) } - if rows[i].Plan || rows[i].KeyPrefix != "" { - t.Errorf("row %s set phase-five facts", rows[i].ID) - } if rows[i].Probe.Timeout != ProbeTimeout { t.Errorf("row %s probe timeout = %s, want %s", rows[i].ID, rows[i].Probe.Timeout, ProbeTimeout) } } - if !rows[3].KeyOptional || rows[0].KeyOptional || rows[1].KeyOptional || rows[2].KeyOptional || rows[4].KeyOptional { + if !rows[5].KeyOptional { t.Fatal("only Ollama may omit its key") } + for index, row := range rows { + if index != 5 && row.KeyOptional { + t.Fatalf("%s unexpectedly accepts a blank key", row.ID) + } + } } // THE ROWS RECORD THE BEST KNOWN TRUTH, AND OBSERVATION OUTRANKS THE SURVEY. @@ -71,30 +74,34 @@ func TestVendoredListingHintsAndProbeModelsMatchTheProviderSurvey(t *testing.T) id string listing Listing probeModel string + preferred string }{ - {"deepseek", ListingModels, ""}, + {"deepseek", ListingModels, "", "deepseek-v4-pro"}, // OBSERVED on 2026-09-10 against api.z.ai: 200 and ten models. The row // says so rather than repeating the survey's "undocumented", so the // hint and the behaviour cannot disagree. glm-5.3-flash stays as the // fallback for a region that does not answer, and is the current cheap // model rather than the superseded glm-4.6 the first brief named. - {"z-ai", ListingModels, "glm-5.3-flash"}, + {"z-ai", ListingModels, "glm-5.3-flash", "glm-5.3"}, // UNOBSERVED. The survey says undocumented, which after Z.ai is known to // be weak evidence — but nobody has watched this endpoint, so the hint // stays what the survey says and the connect door asks anyway. The // survey's old K2 preview is gone and no replacement is guessed: with no // unambiguous cheapest current model, deferring proof beats spending on // an invented id, which is the mistake this whole law exists about. - {"moonshot", ListingNone, ""}, - {"ollama", ListingModels, ""}, - {"custom", ListingModels, ""}, + {"moonshot", ListingNone, "kimi-k2.7-code", "kimi-k2.7-code"}, + {"minimax", ListingNone, "MiniMax-M3", "MiniMax-M3"}, + {"qwen", ListingNone, "qwen3.8-flash", "qwen3.7-plus"}, + {"ollama", ListingModels, "", ""}, + {"custom", ListingModels, "", ""}, } rows := Vendored() for index, expected := range want { row := rows[index] - if row.ID != expected.id || row.Listing != expected.listing || row.ProbeModel != expected.probeModel { - t.Errorf("row %d = id %q listing %v probe %q, want %q %v %q", - index, row.ID, row.Listing, row.ProbeModel, expected.id, expected.listing, expected.probeModel) + if row.ID != expected.id || row.Listing != expected.listing || row.ProbeModel != expected.probeModel || row.Preferred != expected.preferred { + t.Errorf("row %d = id %q listing %v probe %q preferred %q, want %q %v %q %q", + index, row.ID, row.Listing, row.ProbeModel, row.Preferred, + expected.id, expected.listing, expected.probeModel, expected.preferred) } if row.Probe.Method != "GET" || row.Probe.Address != "/models" { t.Errorf("row %s does not try the listing first: %+v", row.ID, row.Probe) @@ -102,6 +109,81 @@ func TestVendoredListingHintsAndProbeModelsMatchTheProviderSurvey(t *testing.T) } } +func TestThePreferredModelIsThePlanDoorsFirstDocumentedId(t *testing.T) { + source := Source{Preferred: "vendor-wide"} + door := Door{Models: []string{"plan-first", "plan-second"}} + if got := source.PreferredModel(door, []string{"listed-first", "vendor-wide"}); got != "plan-first" { + t.Fatalf("preferred model = %q, want the plan door's first documented id", got) + } +} + +func TestTheRowsPreferredModelWinsWhenTheServiceDoesNotContradictIt(t *testing.T) { + source := Source{Preferred: "vendor-best"} + for _, listed := range [][]string{nil, {"listed-first", "vendor-best"}} { + if got := source.PreferredModel(Door{}, listed); got != "vendor-best" { + t.Errorf("PreferredModel(%v) = %q, want the row preference", listed, got) + } + } +} + +func TestAPreferredIdTheServiceDoesNotListFallsToTheFirstListed(t *testing.T) { + source := Source{Preferred: "vendor-best"} + if got := source.PreferredModel(Door{}, []string{"served-first", "served-second"}); got != "served-first" { + t.Fatalf("preferred model = %q, want the service's first listed id", got) + } +} + +func TestAServiceWithoutAPreferredModelUsesItsFirstListedModel(t *testing.T) { + if got := (Source{}).PreferredModel(Door{}, []string{"installed-first", "installed-second"}); got != "installed-first" { + t.Fatalf("preferred model = %q, want the first installed id", got) + } +} + +func TestAServiceWithNothingListedAndNoPreferredMovesNothing(t *testing.T) { + if got := (Source{}).PreferredModel(Door{}, nil); got != "" { + t.Fatalf("an empty service invented %q", got) + } +} + +func TestAKeyPrefixOrdersDoorsAndNeverSkipsOne(t *testing.T) { + source := Source{Doors: []Door{ + {ID: "metered", Metered: true}, + {ID: "plan", KeyPrefix: "sk-plan-"}, + {ID: "other"}, + }} + got := source.OrderedDoors("sk-plan-example") + if len(got) != 3 || got[0].ID != "plan" || got[1].ID != "metered" || got[2].ID != "other" { + t.Fatalf("ordered doors = %+v", got) + } + if ordinary := source.OrderedDoors("another-shape"); len(ordinary) != 3 || ordinary[0].ID != "metered" { + t.Fatalf("an unmatched key changed policy order: %+v", ordinary) + } +} + +func TestOnlyWireDistinctBillingProductsShipAsSeparateDoors(t *testing.T) { + rows := Vendored() + byID := make(map[string]Source, len(rows)) + for _, row := range rows { + byID[row.ID] = row + } + if minimax := byID["minimax"]; len(minimax.Doors) != 0 || minimax.Address == "" { + t.Fatalf("MiniMax claims distinguishable billing doors without wire evidence: %+v", minimax.Doors) + } + for _, id := range []string{"moonshot", "qwen"} { + source := byID[id] + if len(source.Doors) != 2 { + t.Fatalf("%s doors = %+v, want the documented plan and metered hosts", id, source.Doors) + } + if strings.TrimRight(source.Doors[0].Address, "/") == strings.TrimRight(source.Doors[1].Address, "/") { + t.Fatalf("%s labels one wire as two billing products: %+v", id, source.Doors) + } + metered, ok := source.MeteredDoor() + if !ok || metered.ID != source.Doors[1].ID { + t.Fatalf("%s metered identity = %+v, found=%t", id, metered, ok) + } + } +} + func TestEveryVendoredProbeNamesTheSharedTimeout(t *testing.T) { _, here, _, ok := runtime.Caller(0) if !ok { diff --git a/internal/modelsource/sourcestub/sourcestub.go b/internal/modelsource/sourcestub/sourcestub.go index 8acc004222..aab33833e5 100644 --- a/internal/modelsource/sourcestub/sourcestub.go +++ b/internal/modelsource/sourcestub/sourcestub.go @@ -19,6 +19,7 @@ type Request struct { Path string Host string Bearer string + Agent string Body []byte } @@ -103,7 +104,7 @@ func (s *Server) stage(request *http.Request) (int, string, time.Duration) { defer s.mu.Unlock() s.requests = append(s.requests, Request{ Method: request.Method, Path: request.URL.Path, Host: request.Host, - Bearer: request.Header.Get("Authorization"), Body: body, + Bearer: request.Header.Get("Authorization"), Agent: request.Header.Get("User-Agent"), Body: body, }) if request.Method == http.MethodPost && s.completionStatus != 0 { return s.completionStatus, s.completionBody, s.hang diff --git a/internal/paymentrefusal/paymentrefusal.go b/internal/paymentrefusal/paymentrefusal.go index 3b98028c3f..59a277d3a6 100644 --- a/internal/paymentrefusal/paymentrefusal.go +++ b/internal/paymentrefusal/paymentrefusal.go @@ -3,34 +3,112 @@ package paymentrefusal import ( + "encoding/json" "net/http" + "regexp" + "strconv" "strings" ) +// Kind is the meaning of a payment-shaped vendor response. The numeric code +// decides before prose because a plan-window message may also say that the +// account has insufficient balance for EXTRA usage. +type Kind int + +const ( + Unknown Kind = iota + Payment + WindowExhausted + NoPlan + Pacing +) + // Matches reports whether status and body describe an account that cannot pay. // A bare 429 is pacing and stays false; only payment-bearing words or a known // payment code turn that otherwise transient status into a terminal answer. func Matches(status int, body []byte) bool { + return Classify(status, body) == Payment +} + +// Classify separates an empty wallet, an exhausted subscription window, a +// door the key cannot use, and ordinary pacing. A vendor code is the stronger +// fact and a body carrying one never falls through to ambiguous prose. +func Classify(status int, body []byte) Kind { if status == http.StatusPaymentRequired { - return true + return Payment } if status != http.StatusTooManyRequests { - return false + return Unknown } - lower := strings.ToLower(string(body)) - compact := strings.NewReplacer(" ", "", "\t", "", "\r", "", "\n", "").Replace(lower) - for _, code := range []string{`"code":"1113"`, `"code":1113`} { - if strings.Contains(compact, code) { - return true + if code, ok := responseCode(body); ok { + switch code { + case "1113": + return Payment + case "1308", "1310", "1316", "1317", "1318", "1319", "1320", "1321": + return WindowExhausted + case "1309", "1311", "1315": + return NoPlan + case "1302", "1305": + return Pacing + default: + return Unknown } } + lower := strings.ToLower(string(body)) for _, phrase := range []string{ "insufficient balance", "insufficient quota", "no resource package", "recharge", "payment required", "billing", "out of credit", "no credit", "quota", } { if strings.Contains(lower, phrase) { - return true + return Payment + } + } + return Unknown +} + +func responseCode(body []byte) (string, bool) { + var value any + if json.Unmarshal(body, &value) != nil { + return "", false + } + return codeIn(value) +} + +func codeIn(value any) (string, bool) { + switch typed := value.(type) { + case map[string]any: + if code, ok := typed["code"]; ok { + switch scalar := code.(type) { + case string: + return strings.TrimSpace(scalar), true + case float64: + return strconv.FormatFloat(scalar, 'f', -1, 64), true + } + } + for _, child := range typed { + if code, ok := codeIn(child); ok { + return code, true + } } + case []any: + for _, child := range typed { + if code, ok := codeIn(child); ok { + return code, true + } + } + } + return "", false +} + +var resetPattern = regexp.MustCompile(`(?i)(?:will\s+)?reset\s+at\s+([^.;}\"]+)`) + +// ResetAt returns the vendor's reset value when a window refusal names one. +// It returns only the value after "reset at" so a surface can compose its own +// sentence without copying an entire error envelope onto the status line. +func ResetAt(body []byte) string { + match := resetPattern.FindSubmatch(body) + if len(match) != 2 { + return "" } - return false + return strings.TrimSpace(string(match[1])) } diff --git a/internal/paymentrefusal/paymentrefusal_test.go b/internal/paymentrefusal/paymentrefusal_test.go index ffd501ef6b..198be9df3a 100644 --- a/internal/paymentrefusal/paymentrefusal_test.go +++ b/internal/paymentrefusal/paymentrefusal_test.go @@ -13,8 +13,11 @@ func TestOnlyAPaymentShaped429IsTerminal(t *testing.T) { }{ {http.StatusPaymentRequired, "", true}, {http.StatusTooManyRequests, `{"code":"1113","message":"try later"}`, true}, + {http.StatusTooManyRequests, `{"error":{"code":1113,"message":"try later"}}`, true}, {http.StatusTooManyRequests, `{"message":"Insufficient balance. Please recharge."}`, true}, {http.StatusTooManyRequests, `{"message":"quota exhausted"}`, true}, + {http.StatusTooManyRequests, `{"code":"1316","message":"Insufficient balance for extra usage"}`, false}, + {http.StatusTooManyRequests, `{"code":"9999","message":"Insufficient balance"}`, false}, {http.StatusTooManyRequests, `{"message":"too many requests"}`, false}, {http.StatusTooManyRequests, "", false}, {http.StatusUnauthorized, `{"code":"1113","message":"Insufficient balance"}`, false}, @@ -25,3 +28,31 @@ func TestOnlyAPaymentShaped429IsTerminal(t *testing.T) { } } } + +func TestZAIResponseCodesDecideBeforeTheirWords(t *testing.T) { + tests := []struct { + code string + want Kind + }{ + {"1113", Payment}, + {"1302", Pacing}, + {"1305", Pacing}, + {"1308", WindowExhausted}, + {"1310", WindowExhausted}, + {"1316", WindowExhausted}, + {"1317", WindowExhausted}, + {"1318", WindowExhausted}, + {"1319", WindowExhausted}, + {"1320", WindowExhausted}, + {"1321", WindowExhausted}, + {"1309", NoPlan}, + {"1311", NoPlan}, + {"1315", NoPlan}, + } + for _, testCase := range tests { + body := []byte(`{"error":{"code":"` + testCase.code + `","message":"Insufficient balance for extra usage"}}`) + if got := Classify(http.StatusTooManyRequests, body); got != testCase.want { + t.Errorf("code %s = %v, want %v", testCase.code, got, testCase.want) + } + } +} diff --git a/internal/provider/attribution.go b/internal/provider/attribution.go index 6c32310d1c..2d31e51ee3 100644 --- a/internal/provider/attribution.go +++ b/internal/provider/attribution.go @@ -26,6 +26,11 @@ import ( // one. const ( + // DirectUserAgent is the identity aforge gives a service it reaches itself. + // The historical name distinguishes it from pretending to be a vendor's + // supported client; the same product name also belongs on routed requests. + DirectUserAgent = "aforge" + // AppURL is the HTTP-Referer OpenRouter groups this binary's usage under, // and it is the app's identity: a request without it is attributed to // nobody, whatever else it carries. diff --git a/internal/provider/client.go b/internal/provider/client.go index 175d14de7f..c586181c94 100644 --- a/internal/provider/client.go +++ b/internal/provider/client.go @@ -45,6 +45,14 @@ type Config struct { // explicitly accepts an empty key. The ordinary keyless client remains the // first-run state and refuses before the wire. KeyOptional bool + // BillingDoor is the person-facing name of a bound road. Empty is a service + // with one road and preserves every older status line. + BillingDoor string + // PlanOverflow is the separately billed road a paused subscription may use. + // It is inert unless OverflowOnPlanPause is true, which is never the default. + PlanOverflow string + PlanOverflowDoor string + OverflowOnPlanPause bool // Effort is the operator's own pin carried by the model value this client // was built from, such as `vendor/model:high`. It belongs to this client // rather than a context because one run holds several differently pinned @@ -123,12 +131,11 @@ type Client struct { // out of config so that a key handed over after construction reaches the // very next call. Empty is a client that cannot send yet ([ErrNoAPIKey]). apiKey string - // base is the pinned AgentField client, retained for the one surface this - // adapter does not implement for itself: the tool-call loop against a plain - // OpenAI-compatible endpoint. It never sees an OpenRouter request and never - // sees a request the adapter has shaped — see ExecuteToolCallLoop for where - // that boundary is drawn and why it is where it is. Nil while there is no - // key, because the SDK refuses to be built without one. + // base is the pinned AgentField client retained for an operator's custom + // non-direct OpenAI-compatible endpoint. Connected direct services use this + // adapter's transport even through ExecuteToolCallLoop, because that is where + // their billing-door policy and aforge attribution live. Nil while there is + // no key, because the SDK refuses to be built without one. base *ai.Client // wait is the retry backoff, seamed exactly like the media client's video // poll: production sleeps, tests record what would have been slept and @@ -257,13 +264,17 @@ func (c *Client) SetAPIKey(key string) error { key = strings.TrimSpace(key) var base *ai.Client if key != "" { + siteName := AppName + if c.config.Direct { + siteName = DirectUserAgent + } built, err := ai.NewClient(&ai.Config{ APIKey: key, BaseURL: c.config.BaseURL, Model: c.config.Model, Timeout: c.config.Timeout, SiteURL: AppURL, - SiteName: AppName, + SiteName: siteName, }) if err != nil { return err @@ -314,11 +325,10 @@ func (c *Client) OwnsToolLoop() bool { return true } // it — and the only way a refused belt reaches the endpoint-refusal ladder // instead of ending the turn on a 404. // -// c.base is for everything else: an operator pointed at a plain OpenAI- -// compatible endpoint, where the SDK's loop is a working implementation this -// package has no reason to duplicate. It never sees an OpenRouter request, and -// it never sees a request this adapter shaped. The SDK module itself is -// read-only and is not edited to make any of this true. +// c.base is only for an operator's non-direct plain OpenAI-compatible endpoint. +// A connected service is direct even when its wire happens to be compatible: +// sending that loop through the SDK would bypass both its billing-door answer +// and the User-Agent that identifies aforge honestly. func (c *Client) ExecuteToolCallLoop( ctx context.Context, messages []ai.Message, @@ -331,7 +341,7 @@ func (c *Client) ExecuteToolCallLoop( // the loop is a fact about the SHIPPED ROUTER's own dialect — the // categories header, the refusal ladder — and not about whether some base // carries a `provider` object (prefcarry.go). - if c.shippedRouterHint() || c.config.KeyOptional { + if c.shippedRouterHint() || c.config.Direct || c.config.KeyOptional { return c.executeOwnToolCallLoop(ctx, messages, tools, config, call, options...) } base := c.sdkClient() @@ -902,6 +912,7 @@ func (c *Client) newRequest(messages []ai.Message, options []ai.Option) (*ai.Req // and answers one whole JSON completion — and [Client.unstreamable] remembers it // from what actually happened, so the fallback is a memo rather than a guess. func (c *Client) CompleteWithMessages(ctx context.Context, messages []ai.Message, options ...ai.Option) (*ai.Response, error) { + ctx = WithPlanOverflowGuard(ctx) observer := streamObserverFrom(ctx) response, relearned, err := c.completeWithMessagesStreaming(ctx, observer, messages, options...) if err != nil { @@ -1395,6 +1406,7 @@ func (c *Client) completeWithMessagesStreaming( ctx = withPhaseClock(ctx, phase) defer phase.done() } + phase.useDoor(c.config.BillingDoor) // ONLY THE ARM THE PERSON IS HEARING NARRATES. An arm of a race runs this // same function on a child context and inherits the same clock; if it told // its own story the surface would be shown "connecting" by the rescue while @@ -2186,6 +2198,7 @@ func observeToolCallForming(observer StreamObserver, session string, tools *tool // It mirrors the SDK's channel contract exactly so the harness's stream pump is // unchanged. func (c *Client) StreamComplete(ctx context.Context, prompt string, options ...ai.Option) (<-chan ai.StreamChunk, <-chan error) { + ctx = WithPlanOverflowGuard(ctx) chunks := make(chan ai.StreamChunk) errs := make(chan error, 1) @@ -2251,7 +2264,11 @@ func (c *Client) StreamComplete(ctx context.Context, prompt string, options ...a } func (c *Client) newHTTPRequest(ctx context.Context, request *ai.Request, body []byte, stream bool) (*http.Request, error) { - endpoint := strings.TrimSuffix(strings.TrimSpace(c.config.BaseURL), "/") + "/chat/completions" + return c.newHTTPRequestAt(ctx, request, body, stream, c.config.BaseURL) +} + +func (c *Client) newHTTPRequestAt(ctx context.Context, request *ai.Request, body []byte, stream bool, baseURL string) (*http.Request, error) { + endpoint := strings.TrimSuffix(strings.TrimSpace(baseURL), "/") + "/chat/completions" httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("create request: %w", err) @@ -2264,18 +2281,13 @@ func (c *Client) newHTTPRequest(ctx context.Context, request *ai.Request, body [ return nil, err } httpRequest.Header.Set("Content-Type", "application/json") + c.applyRequestIdentity(httpRequest) if apiKey != "" { httpRequest.Header.Set("Authorization", "Bearer "+apiKey) } if stream { httpRequest.Header.Set("Accept", "text/event-stream") } - // THE HINT AND NOT THE PREFERENCE ANSWER. These headers are read by one - // machine's ranking page and by nothing else, so the question really is - // "is this that machine" (prefcarry.go says why every other site moved). - if c.shippedRouterHint() { - ApplyAttribution(httpRequest.Header) - } // The header half of cache affinity. Routers that ignore the body field // still honour a session header, and a router that honours neither is // unharmed by an extra one. @@ -2289,6 +2301,24 @@ func (c *Client) newHTTPRequest(ctx context.Context, request *ai.Request, body [ return httpRequest, nil } +// applyRequestIdentity gives every request this client builds the product's +// own name and adds the router's attribution only on the router. Keeping both +// decisions together matters because a late receipt is still this client's +// request: if it has a second header path, it can silently identify as Go or +// carry one service's ranking headers and bearer to a different service. +func (c *Client) applyRequestIdentity(request *http.Request) { + if request == nil { + return + } + request.Header.Set("User-Agent", DirectUserAgent) + // THE HINT AND NOT THE PREFERENCE ANSWER. These headers are read by one + // machine's ranking page and by nothing else, so the question really is + // "is this that machine" (prefcarry.go says why every other site moved). + if c.shippedRouterHint() { + ApplyAttribution(request.Header) + } +} + // shippedRouterHint reports whether this client is talking to THE SHIPPED // ROUTER, recognised by its hostname or by a model spelled `openrouter/…`. // @@ -2415,6 +2445,12 @@ type APIError struct { // as a number, as a string, and sometimes omits it, so it is normalised here // once rather than decoded at each reader. Code string + // Payment, PlanPaused and PlanUnavailable are the three billing facts the + // refusal door classified from the vendor's numeric code. They travel on the + // refusal so the response boundary does not classify the same body again. + Payment bool + PlanPaused bool + PlanUnavailable bool } // Error keeps the SDK's exact error phrasing, and names the upstream when the diff --git a/internal/provider/dispatch.go b/internal/provider/dispatch.go index ab7801dc88..1a87e720d4 100644 --- a/internal/provider/dispatch.go +++ b/internal/provider/dispatch.go @@ -18,7 +18,6 @@ import ( "github.com/Agent-Field/aforge-v2/internal/calllog" lanes "github.com/Agent-Field/aforge-v2/internal/lane" "github.com/Agent-Field/aforge-v2/internal/lane/control" - "github.com/Agent-Field/aforge-v2/internal/paymentrefusal" "github.com/Agent-Field/aforge-v2/internal/trace" "github.com/Agent-Field/agentfield/sdk/go/ai" ) @@ -249,6 +248,13 @@ func (c *Client) send(ctx context.Context, request *ai.Request, knobs callKnobs, // came back as a timeout. ceiling, _ := c.ceilingFor(request, knobs) httpClient := c.clientFor(c.modelFor(request), stream, ceiling) + // The address is part of the bound billing door. It changes only for the + // one separately authorised plan overflow below; every ordinary retry and + // endpoint move stays on the address the service was connected to. + currentBase := c.config.BaseURL + currentDoor := c.config.BillingDoor + usingOverflow := false + switchedDoor := false // providerWait is the provider's own comeback instruction from the last // 429 (Retry-After); it outranks our computed backoff for the one attempt // it was issued for, and is then spent. @@ -394,17 +400,26 @@ func (c *Client) send(ctx context.Context, request *ai.Request, knobs callKnobs, if plan.Spent(spentAt()) { break } - // A reachability check that answered after a pre-send failure names no - // machine and earns no routing move. The next send stays on this road; - // after the first recovered pass it pays the ordinary fault backoff - // below. Everything else asks the one move generator as before. - if !reconnected { + // TWO PASSES ALREADY OWE THE MOVE THEY ARE MAKING, and neither may ask + // the generator for a second one. + // + // A billing-door switch is one: it must reach the authorised address + // before the endpoint controller is asked for another move, or a plan + // pause could be turned into a lane walk or an early end without ever + // trying the metered door. + // + // A reachability check that answered after a pre-send failure is the + // other: it names no machine and earns no routing move. The next send + // stays on this road; after the first recovered pass it pays the + // ordinary fault backoff below. Everything else asks the one move + // generator as before. + if !switchedDoor && !reconnected { // What the last refusal asked us to wait, and whether it was the - // account's own ceiling rather than a machine's: the two inputs - // the move generator needs that change between moves. A ceiling - // over the whole key leaves nothing to put on the next body, - // which is what stops an open set pretending it has somewhere to - // go ([control.Plan.AccountRefused]). + // account's own ceiling rather than a machine's: the two inputs the + // move generator needs that change between moves. A ceiling over the + // whole key leaves nothing to put on the next body, which is what + // stops an open set pretending it has somewhere to go + // ([control.Plan.AccountRefused]). plan.Comeback = providerWait plan.AccountRefused = accountPaced move = control.Next(plan, plan.Moves.List()) @@ -428,7 +443,15 @@ func (c *Client) send(ctx context.Context, request *ai.Request, knobs callKnobs, // here because a call that is repaired or relaxed comes back through // this loop with a new body and the same trace. knobs.trace.begin() - if attempt > 0 && reconnected && connectionPasses > 1 { + if attempt > 0 && switchedDoor { + // A BILLING-DOOR SWITCH IS NEITHER A RECONNECTION NOR A RETRY. The + // person's opt-in already chose the next address, so it goes out at once + // and the phase clock's door is the only status change owed here. It is + // asked BEFORE the recovered-pass branch below for exactly that reason: + // a door switch that fell through to it would be narrated as recovering + // from a dropped connection and would sit out a backoff it does not owe. + providerWait = 0 + } else if attempt > 0 && reconnected && connectionPasses > 1 { // THE FIRST RECOVERED PASS IS IMMEDIATE. Every later one means the // check answered while the request itself still could not leave, which // is another fault and pays the same growing pause as every other one. @@ -530,8 +553,8 @@ func (c *Client) send(ctx context.Context, request *ai.Request, knobs callKnobs, owed += delay - took } } - reconnected, moved = false, false - if waited, err := c.waitConnection(ctx, c.modelFor(request), c.config.BaseURL, false); err != nil { + reconnected, moved, switchedDoor = false, false, false + if waited, err := c.waitConnection(ctx, c.modelFor(request), currentBase, false); err != nil { return nil, err } else if waited && knobs.trace != nil { knobs.trace.connectionRecovered = true @@ -570,7 +593,7 @@ func (c *Client) send(ctx context.Context, request *ai.Request, knobs callKnobs, knobs.trace.body = body } } - httpRequest, err := c.newHTTPRequest(attemptCtx, request, body, stream) + httpRequest, err := c.newHTTPRequestAt(attemptCtx, request, body, stream, currentBase) if err != nil { cancelAttempt() return nil, err @@ -687,23 +710,6 @@ func (c *Client) send(ctx context.Context, request *ai.Request, knobs callKnobs, if potentialRateLimit { named = retryAfter(response) } - var peek []byte - // A 429 IS CLASSIFIED BEFORE THE SHARED WINDOW IS NARROWED. Status alone - // cannot separate a busy queue from an authenticated account that cannot - // fund the request — both wear 429 — and only the queue is pacing. So the - // bounded prefix this loop reads anyway is read here instead of below, and - // an account that cannot pay narrows nothing and is told to come back at - // no time at all. - payment := false - if retryableStatus(response.StatusCode) { - peek, _ = io.ReadAll(io.LimitReader(response.Body, maxErrorPeek)) - payment = paymentrefusal.Matches(response.StatusCode, peek) - } - rateLimited := potentialRateLimit && !payment - if !rateLimited { - named = 0 - } - sharedLimiter.release(rateLimited, named) // ── A REFUSAL THAT NAMED A MACHINE IS THAT MACHINE'S, AND THE OTHERS // HAVE SAID NOTHING ───────────────────────────────────────────────── // @@ -724,29 +730,21 @@ func (c *Client) send(ctx context.Context, request *ai.Request, knobs callKnobs, // adapter can repair by itself — a knob it guessed wrong about reaches // [Client.sendRepaired] a layer up, and walking it would spend a machine // to discover a fact the memo already answers. + var peek []byte if !retryableStatus(response.StatusCode) { if !relayedByAMachine(response.StatusCode) || c.repairable(c.modelFor(request), knobs) || len(knobs.reasoning) > 0 { + sharedLimiter.release(false, 0) return response, nil } read, _ := io.ReadAll(io.LimitReader(response.Body, maxErrorPeek)) if upstream, ok := routerErrorEnvelope(read); !ok || upstream == "" { response.Body = rewound(read, response.Body) + sharedLimiter.release(false, 0) return response, nil } peek = read } - if rateLimited { - providerWait = named - if pacedSince.IsZero() { - pacedSince = time.Now() - } - // The park begins on the FIRST 429 this call draws, not on the - // first one it decides to wait out: by the time the backoff is - // computed the call is already not moving, and that is the fact - // anybody watching wants. - park(true) - } // Drain a bounded prefix before closing so the connection can be reused // and the eventual error still says what the provider complained about. // A relayed refusal above has already read it and must not read it twice. @@ -763,9 +761,8 @@ func (c *Client) send(ctx context.Context, request *ai.Request, knobs callKnobs, // instead. A wait we cannot see is a wait we replace with our own // doubling, which is how a pool that asked for three seconds was asked // again after seven hundred milliseconds. - if rateLimited && named <= 0 { + if potentialRateLimit && named <= 0 { named = retryAfterIn(peek) - providerWait = named } // EVERY REFUSAL THIS LOOP DRAWS GOES THROUGH THE ONE DOOR, and what is // done about it is decided there from what the refusal says rather than @@ -776,6 +773,26 @@ func (c *Client) send(ctx context.Context, request *ai.Request, knobs callKnobs, // is handed over rather than left in this loop; a 429 naming nobody is // this account's own ceiling and writes nothing at all. refusal := c.refuseUpstream(request, knobs, lastErr, "", named) + // ONLY ORDINARY PACING NARROWS THE SHARED LIMITER. A payment refusal, a + // plan pause and a door this key cannot use all arrive under 429 too, but + // none says the request rate is too high. + rateLimited := refusal.Kind == refusalPaced + if !rateLimited { + named = 0 + } + sharedLimiter.release(rateLimited, named) + if rateLimited { + providerWait = named + if pacedSince.IsZero() { + pacedSince = time.Now() + } + // The park begins on the FIRST ordinary pacing refusal this call + // draws — and it is announced through [park], which is THE ONE SITE + // that tells both readers of that fact at once. Saying it by hand + // here would leave the call-watch seam believing this call never + // waited (#868). + park(true) + } // AND THE MACHINE THAT REFUSED IS OFF THE NEXT BODY. This is the whole of // "never repeat" (see THE BODY IS WRITTEN PER ATTEMPT above): the door // above has already decided what the refusal MEANS for the process, and @@ -841,13 +858,32 @@ func (c *Client) send(ctx context.Context, request *ai.Request, knobs callKnobs, status: response.StatusCode, err: lastErr, responseBody: peek, served: refusal.Lane, retryAfter: named, }) - // AND AN ACCOUNT THAT CANNOT PAY IS TERMINAL FOR THE WHOLE CALL. It is not - // one machine's fault and not a wait: every other endpoint and every other - // model on this account will say the same thing, so walking the roster - // would spend the deadline discovering one fact the refusal already - // carries. The reader above turns it into the one sentence a person can - // act on. - if payment { + // MONEY MOVES ONLY THROUGH THIS EXACT OPT-IN. A paused fixed-price window + // never reaches the endpoint walk or model chain: it either takes the one + // configured metered door, once, or returns its typed pause immediately. + if refusal.Kind == refusalPlanPaused { + useOverflow := c.config.OverflowOnPlanPause && + strings.TrimSpace(c.config.PlanOverflow) != "" && !usingOverflow && + claimPlanOverflow(ctx) + if useOverflow { + currentBase = strings.TrimRight(strings.TrimSpace(c.config.PlanOverflow), "/") + currentDoor = strings.TrimSpace(c.config.PlanOverflowDoor) + if phase := phaseClockFrom(ctx); phase != nil { + phase.useDoor(currentDoor) + } + usingOverflow = true + switchedDoor = true + pacedSince = time.Time{} + continue + } + detail := planPauseDetail(refusal.Reset, c.config.PlanOverflowDoor) + now := c.clock() + notePhase(ctx, c.modelFor(request), PhasePlanPaused, detail, now, now, "") + return nil, &PlanPauseError{ + Reset: refusal.Reset, OverflowDoor: c.config.PlanOverflowDoor, Cause: lastErr, + } + } + if refusal.Kind == refusalPayment || refusal.Kind == refusalNoPlan { return nil, lastErr } // ── "NOT YET" IS ANSWERED BY GOING SOMEWHERE ELSE ─────────────────── @@ -942,6 +978,86 @@ func (c *Client) send(ctx context.Context, request *ai.Request, knobs callKnobs, return nil, fmt.Errorf("after %d attempts: %w", attempts, lastErr) } +// PlanPauseError is the typed end of a request whose fixed-price window is +// temporarily unavailable. Cause preserves the vendor refusal for the journal; +// surfaces read this type so the person sees only the actionable pause sentence +// rather than a generic API error after it. +type PlanPauseError struct { + Reset string + OverflowDoor string + Cause error +} + +type planOverflowGuard struct{ used atomic.Bool } +type planOverflowGuardKey struct{} + +// WithPlanOverflowGuard gives every request belonging to one turn the same +// one-shot billing decision. Repair, relaxation, hedge and model-hop re-entry +// all derive contexts from this one, so none can buy a second metered attempt +// after another road has already taken the separately authorised overflow. +func WithPlanOverflowGuard(ctx context.Context) context.Context { + if ctx == nil { + ctx = context.Background() + } + if _, held := ctx.Value(planOverflowGuardKey{}).(*planOverflowGuard); held { + return ctx + } + return context.WithValue(ctx, planOverflowGuardKey{}, &planOverflowGuard{}) +} + +func claimPlanOverflow(ctx context.Context) bool { + guard, _ := ctx.Value(planOverflowGuardKey{}).(*planOverflowGuard) + // send is package-private and its shipped callers install the guard before + // entering. Keeping the absent case usable preserves focused dispatcher rigs + // without turning a missing test harness value into a billing refusal. + return guard == nil || guard.used.CompareAndSwap(false, true) +} + +func (e *PlanPauseError) Error() string { + if e == nil { + return "" + } + return PlanPauseSentence(e.Reset, e.OverflowDoor) +} + +func (e *PlanPauseError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} + +// PlanPauseFrom recovers a plan-pause ending through the wrappers added by the +// provider and session loops. +func PlanPauseFrom(err error) (*PlanPauseError, bool) { + var paused *PlanPauseError + if errors.As(err, &paused) && paused != nil { + return paused, true + } + return nil, false +} + +// PlanPauseSentence is the one person-facing sentence for a temporarily spent +// subscription window, shared by live status, the final row, and connection. +func PlanPauseSentence(reset, overflowDoor string) string { + detail := planPauseDetail(reset, overflowDoor) + if detail == "" { + return string(PhasePlanPaused) + } + return string(PhasePlanPaused) + " · " + detail +} + +func planPauseDetail(reset, overflowDoor string) string { + parts := make([]string, 0, 2) + if reset = strings.TrimSpace(reset); reset != "" { + parts = append(parts, "resets at "+reset) + } + if overflowDoor = strings.TrimSpace(overflowDoor); overflowDoor != "" { + parts = append(parts, "/connect can switch to "+overflowDoor) + } + return strings.Join(parts, " · ") +} + // demandedThisOne reports that this request DEMANDED this one machine and // nothing else — a rescue's arm, or a person's strict pin. // diff --git a/internal/provider/payment_refusal_test.go b/internal/provider/payment_refusal_test.go index b818106d9d..c6bf9a0b4d 100644 --- a/internal/provider/payment_refusal_test.go +++ b/internal/provider/payment_refusal_test.go @@ -66,6 +66,9 @@ func TestAPayment429IsTerminalAndAPlain429StillPaces(t *testing.T) { } classified := client.laneRefusalFor(client.config.Model, "some-lane", err) if testCase.wantPayment { + if evidence := Evidence(err); !evidence.Unserved || evidence.PlanPaused { + t.Fatalf("payment evidence = %+v, want an unserved account", evidence) + } if classified.Kind != refusalPayment || !classified.Terminal || classified.paced() || classified.struck() { t.Fatalf("payment classification = %+v", classified) } @@ -74,6 +77,8 @@ func TestAPayment429IsTerminalAndAPlain429StillPaces(t *testing.T) { } } else if classified.Kind != refusalPaced || classified.Terminal { t.Fatalf("plain 429 classification = %+v", classified) + } else if evidence := Evidence(err); evidence.Unserved || evidence.PlanPaused { + t.Fatalf("ordinary pacing evidence = %+v", evidence) } }) } diff --git a/internal/provider/phase.go b/internal/provider/phase.go index 14c510cdc1..ee55c5da46 100644 --- a/internal/provider/phase.go +++ b/internal/provider/phase.go @@ -74,6 +74,9 @@ const ( // PhasePaced is a rate-limit wait. Its deadline is the router's own // `Retry-After` and is therefore real. PhasePaced Phase = "paced" + // PhasePlanPaused is a subscription window that will reset. It remains + // pacing, never a terminal account verdict. + PhasePlanPaused Phase = "plan paused" // PhaseRetrying is the relax ladder: the same question asked again with // something dropped from it. Detail carries "2 of 6". PhaseRetrying Phase = "trying again" @@ -200,6 +203,9 @@ type PhaseNews struct { // measured", never "nothing". Lane string Rate float64 + // Door is the billing road in use. It is distinct from Lane, which is the + // serving machine behind a router, and empty for every one-road service. + Door string // Detail is the phase's own noun, already in a person's words: the tool // being run, the rung of the ladder, how long a stall had gone on. Detail string @@ -269,7 +275,7 @@ type PhaseNews struct { // kept here so that two surfaces cannot disagree about it. func (n PhaseNews) Waiting() bool { switch n.Phase { - case PhaseConnecting, PhaseConnectionLost, PhaseFirstWord, PhasePaced, PhaseRetrying, PhaseSwitching, PhaseSwitchingModel, PhaseAsking, PhaseAllSlow: + case PhaseConnecting, PhaseConnectionLost, PhaseFirstWord, PhasePaced, PhasePlanPaused, PhaseRetrying, PhaseSwitching, PhaseSwitchingModel, PhaseAsking, PhaseAllSlow: return true } return false @@ -359,6 +365,7 @@ type phaseClock struct { // lane is who is answering, tokens how many deltas this phase has carried, // and deadline/then the consequence in force. lane string + door string tokens int deadline time.Time then string @@ -485,6 +492,25 @@ func (p *phaseClock) serve(lane string) { p.say("", p.now()) } +// useDoor keeps the billing road on every later phase of this request. A plan +// overflow changes it before the metered request goes out, so the spend is on +// screen while it happens rather than reported after the fact. +func (p *phaseClock) useDoor(door string) { + if p == nil || strings.TrimSpace(door) == "" { + return + } + p.mu.Lock() + defer p.mu.Unlock() + door = strings.TrimSpace(door) + if p.door == door { + return + } + p.door = door + if p.phase != "" { + p.say("", p.now()) + } +} + // wrote is one delta of progress in the current phase. It is what turns into a // rate, and it is the reason a phase says itself again while it lasts. func (p *phaseClock) wrote() { @@ -526,6 +552,7 @@ func (p *phaseClock) say(detail string, now time.Time) { Then: p.then, Ask: p.ask, Lane: p.lane, + Door: p.door, Detail: detail, Model: p.model, Role: p.role, diff --git a/internal/provider/plan_doors_test.go b/internal/provider/plan_doors_test.go new file mode 100644 index 0000000000..ac505c3999 --- /dev/null +++ b/internal/provider/plan_doors_test.go @@ -0,0 +1,237 @@ +package provider + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/aforge-v2/internal/modelsource/sourcestub" + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +func TestAnExhaustedPlanWaitsAndSpendsNothing(t *testing.T) { + oldLimiter := sharedLimiter + sharedLimiter = newAdaptiveLimiter() + defer func() { sharedLimiter = oldLimiter }() + + plan, metered := sourcestub.New(), sourcestub.New() + defer plan.Close() + defer metered.Close() + plan.RefuseCompletion(http.StatusTooManyRequests, `{"error":{"code":"1316","message":"Usage limit reached for the past 5 hours. Insufficient balance for extra usage. Your limit will reset at 18:30 UTC"}}`) + + told := listen(t) + client, err := NewClient(Config{ + APIKey: "test-key", BaseURL: plan.URL(), Model: "glm-5.3-flash", Direct: true, + BillingDoor: "coding plan", PlanOverflow: metered.URL(), PlanOverflowDoor: "pay-as-you-go", + }) + if err != nil { + t.Fatal(err) + } + client.wait = func(context.Context, time.Duration) error { return nil } + response, err := client.CompleteWithMessages(context.Background(), userMessages("hello")) + if err == nil || response != nil { + t.Fatalf("an exhausted window became an answer: response=%v error=%v", response, err) + } + if len(metered.Requests()) != 0 { + t.Fatalf("wait mode sent %d requests to the metered door", len(metered.Requests())) + } + if len(plan.Requests()) != 1 { + t.Fatalf("wait mode retried the paused plan door %d times; want one request", len(plan.Requests())) + } + refusal, ok := RefusalFrom(err) + if !ok || refusal.AccountCannotPay() { + t.Fatalf("window refusal = %+v, terminal=%t", refusal, ok && refusal.AccountCannotPay()) + } + ending, ok := PlanPauseFrom(err) + if !ok || ending.Error() != "plan paused · resets at 18:30 UTC · /connect can switch to pay-as-you-go" { + t.Fatalf("plan-pause ending = %+v, found=%t", ending, ok) + } + if evidence := Evidence(err); !evidence.PlanPaused || evidence.Unserved { + t.Fatalf("plan-pause evidence = %+v, want a distinct temporary pause", evidence) + } + paused, ok := told.find(PhasePlanPaused) + if !ok || paused.Detail != "resets at 18:30 UTC · /connect can switch to pay-as-you-go" { + t.Fatalf("paused phase = %+v, found %t", paused, ok) + } +} + +func TestADirectToolLoopUsesTheDoorAwareAttributedTransport(t *testing.T) { + oldLimiter := sharedLimiter + sharedLimiter = newAdaptiveLimiter() + defer func() { sharedLimiter = oldLimiter }() + + plan, metered := sourcestub.New(), sourcestub.New() + defer plan.Close() + defer metered.Close() + plan.RefuseCompletion(http.StatusTooManyRequests, `{"code":"1316","message":"plan window exhausted"}`) + client, err := NewClient(Config{ + APIKey: "tool-loop-test-key", BaseURL: plan.URL(), Model: "glm-5.3-flash", Direct: true, + BillingDoor: "coding plan", PlanOverflow: metered.URL(), PlanOverflowDoor: "pay-as-you-go", + OverflowOnPlanPause: true, + }) + if err != nil { + t.Fatal(err) + } + response, _, err := client.ExecuteToolCallLoop(context.Background(), userMessages("hello"), nil, + ai.ToolCallConfig{MaxTurns: 1, MaxToolCalls: 1}, nil) + if err != nil || response == nil || response.Text() != "done" { + t.Fatalf("door-aware tool loop = %v, %v", response, err) + } + if len(plan.Requests()) != 1 || len(metered.Requests()) != 1 { + t.Fatalf("tool-loop requests: plan=%d metered=%d", len(plan.Requests()), len(metered.Requests())) + } + for _, request := range append(plan.Requests(), metered.Requests()...) { + if request.Agent != DirectUserAgent || request.Bearer != "Bearer tool-loop-test-key" { + t.Fatalf("direct tool-loop request omitted its identity or bearer: %+v", request) + } + } +} + +func TestOverflowGoesToTheMeteredDoorAndSaysSo(t *testing.T) { + oldLimiter := sharedLimiter + sharedLimiter = newAdaptiveLimiter() + defer func() { sharedLimiter = oldLimiter }() + + plan, metered := sourcestub.New(), sourcestub.New() + defer plan.Close() + defer metered.Close() + plan.RefuseCompletion(http.StatusTooManyRequests, `{"code":"1316","message":"Insufficient balance for extra usage"}`) + + told := listen(t) + client, err := NewClient(Config{ + APIKey: "test-key", BaseURL: plan.URL(), Model: "glm-5.3-flash", Direct: true, + BillingDoor: "coding plan", PlanOverflow: metered.URL(), PlanOverflowDoor: "pay-as-you-go", + OverflowOnPlanPause: true, + }) + if err != nil { + t.Fatal(err) + } + response, err := client.CompleteWithMessages(context.Background(), userMessages("hello")) + if err != nil || response == nil { + t.Fatalf("metered overflow = %v, %v", response, err) + } + if len(plan.Requests()) != 1 || len(metered.Requests()) != 1 || + plan.Requests()[0].Agent != DirectUserAgent || metered.Requests()[0].Agent != DirectUserAgent { + t.Fatalf("requests: plan=%d metered=%d", len(plan.Requests()), len(metered.Requests())) + } + writing, ok := told.find(PhaseWriting) + if !ok || writing.Door != "pay-as-you-go" { + t.Fatalf("writing phase = %+v, found %t", writing, ok) + } + for _, news := range told.all() { + if news.Phase == PhaseRetrying || news.Phase == PhaseConnectionLost { + t.Fatalf("billing-door switch was narrated as recovery: %+v", news) + } + } +} + +func TestOverflowCanNameASecondDoorOnTheSameHost(t *testing.T) { + oldLimiter := sharedLimiter + sharedLimiter = newAdaptiveLimiter() + defer func() { sharedLimiter = oldLimiter }() + + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + if requests.Add(1) == 1 { + writer.WriteHeader(http.StatusTooManyRequests) + _, _ = io.WriteString(writer, `{"code":"1316","message":"plan window exhausted"}`) + return + } + writer.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(writer, "data: {\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"done\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n") + })) + defer server.Close() + base := server.URL + "/v1" + told := listen(t) + client, err := NewClient(Config{ + APIKey: "test-key", BaseURL: base, Model: "sim/model", Direct: true, + BillingDoor: "fixed plan", PlanOverflow: base, PlanOverflowDoor: "pay-as-you-go", + OverflowOnPlanPause: true, + }) + if err != nil { + t.Fatal(err) + } + response, err := client.CompleteWithMessages(context.Background(), userMessages("hello")) + if err != nil || response == nil || requests.Load() != 2 { + t.Fatalf("same-host overflow: response=%v error=%v requests=%d", response, err, requests.Load()) + } + writing, ok := told.find(PhaseWriting) + if !ok || writing.Door != "pay-as-you-go" { + t.Fatalf("same-host writing phase = %+v, found %t", writing, ok) + } +} + +func TestPlanOverflowHappensAtMostOnce(t *testing.T) { + oldLimiter := sharedLimiter + sharedLimiter = newAdaptiveLimiter() + defer func() { sharedLimiter = oldLimiter }() + + plan, metered := sourcestub.New(), sourcestub.New() + defer plan.Close() + defer metered.Close() + plan.RefuseCompletion(http.StatusTooManyRequests, `{"code":"1316","message":"plan window exhausted"}`) + metered.RefuseCompletion(http.StatusTooManyRequests, `{"code":"1316","message":"plan window exhausted"}`) + client, err := NewClient(Config{ + APIKey: "test-key", BaseURL: plan.URL(), Model: "glm-5.3-flash", Direct: true, + BillingDoor: "coding plan", PlanOverflow: metered.URL(), PlanOverflowDoor: "pay-as-you-go", + OverflowOnPlanPause: true, + }) + if err != nil { + t.Fatal(err) + } + response, err := client.CompleteWithMessages(context.Background(), userMessages("hello")) + if response != nil { + t.Fatalf("twice-paused request became an answer: %+v", response) + } + if _, ok := PlanPauseFrom(err); !ok { + t.Fatalf("twice-paused request ended as %T %v, want PlanPauseError", err, err) + } + if len(plan.Requests()) != 1 || len(metered.Requests()) != 1 { + t.Fatalf("overflow was not one-shot: plan=%d metered=%d", len(plan.Requests()), len(metered.Requests())) + } +} + +func TestPlanOverflowCannotRepeatAcrossARepair(t *testing.T) { + oldLimiter := sharedLimiter + sharedLimiter = newAdaptiveLimiter() + defer func() { sharedLimiter = oldLimiter }() + + var planCalls, meteredCalls atomic.Int32 + plan := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + planCalls.Add(1) + writer.WriteHeader(http.StatusTooManyRequests) + _, _ = io.WriteString(writer, `{"code":"1316","message":"plan window exhausted"}`) + })) + defer plan.Close() + metered := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + meteredCalls.Add(1) + writer.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(writer, `{"error":{"message":"Reasoning is mandatory for this endpoint; do not send effort: none","code":400}}`) + })) + defer metered.Close() + + client, err := NewClient(Config{ + APIKey: "test-key", BaseURL: plan.URL, Model: "plan-repair-overflow/model", Direct: true, + BillingDoor: "coding plan", PlanOverflow: metered.URL, PlanOverflowDoor: "pay-as-you-go", + OverflowOnPlanPause: true, + SupportsParameter: func(string, string) (bool, bool) { return true, true }, + }) + if err != nil { + t.Fatal(err) + } + ctx := WithRequiredReasoningEffort(context.Background(), EffortOff) + response, err := client.CompleteWithMessages(ctx, userMessages("hello"), ai.WithMaxTokens(1_000)) + if response != nil { + t.Fatalf("a second overflow became an answer: %+v", response) + } + if _, ok := PlanPauseFrom(err); !ok { + t.Fatalf("repair re-entry ended as %T %v, want PlanPauseError", err, err) + } + if planCalls.Load() != 2 || meteredCalls.Load() != 1 { + t.Fatalf("repair repeated overflow: plan=%d metered=%d, want plan=2 metered=1", planCalls.Load(), meteredCalls.Load()) + } +} diff --git a/internal/provider/receipt.go b/internal/provider/receipt.go index e37c13e3ce..fb65236c18 100644 --- a/internal/provider/receipt.go +++ b/internal/provider/receipt.go @@ -16,8 +16,8 @@ import ( // THE RECEIPT: MONEY A CUT STREAM LEFT OFF THE WIRE. // -// A provider can accept a request, begin a streamed answer and charge for the -// work, then lose the connection or be cut before the terminal usage block. +// A routed provider can accept a request, begin a streamed answer and charge +// for the work, then lose the connection or be cut before the terminal usage block. // When the stream named its generation id, the provider's generation route is // the only honest source for the missing figures. Text without an id is still // charged work but cannot name a receipt; neither text nor an id is nothing to @@ -105,18 +105,26 @@ func (m *receiptRouteMemo) heard(base string, now time.Time) { } // settle closes one of the four streamed endings that may still hold provider -// money. A usage block stays on the ordinary billing door. Without one, a -// generation id proves the provider got far enough for its receipt to be -// asked for, while text already written without an id is reported unpriced -// without making an unanswerable request. A call with neither is not reported -// at all: the measured first-frame in-band refusal is an upstream breaking -// before it produced a generation, not money the provider charged. Those -// mutually exclusive doors keep one call from ever being counted twice. +// money. A usage block stays on the ordinary billing door. A direct service +// stops there because it has no routed receipt. Otherwise a generation id +// proves the provider got far enough for its receipt to be asked for, while +// text already written without an id is reported unpriced without making an +// unanswerable request. A call with neither is not reported at all: the +// measured first-frame in-band refusal is an upstream breaking before it +// produced a generation, not money the provider charged. Those mutually +// exclusive doors keep one call from ever being counted twice. func (c *Client) settle(ctx context.Context, model string, response *ai.Response, reason string, answerBytes int) { if response != nil && response.Usage != nil { c.bill(ctx, model, response) return } + // A DIRECT SERVICE HAS NO OPENROUTER GENERATION ROUTE TO ASK. Its missing + // usage block supplies no measured figures to record, and calling the + // router's route on the vendor would send that vendor a request and bearer + // for a fact this client already knows cannot be there. + if c.config.Direct { + return + } sink := reconcileFrom(ctx) if sink == nil { return @@ -265,7 +273,7 @@ func (c *Client) fetchReceipt(ctx context.Context, ref string) (Billed, bool, bo } request.Header.Set("Accept", "application/json") request.Header.Set("Authorization", "Bearer "+key) - ApplyAttribution(request.Header) + c.applyRequestIdentity(request) response, err := c.http.Do(request) if err != nil { return Billed{}, false, false diff --git a/internal/provider/receipt_test.go b/internal/provider/receipt_test.go index 111c22211b..7fa97fedbb 100644 --- a/internal/provider/receipt_test.go +++ b/internal/provider/receipt_test.go @@ -41,6 +41,158 @@ func receiptResult(t *testing.T, results <-chan Reconciled) Reconciled { } } +// writeReceiptCut writes one identified answer fragment and then leaves the +// stream open. The shortened silence guard closes it, reproducing the ending +// whose absent usage block makes receipt reconciliation relevant. +func writeReceiptCut(w http.ResponseWriter, r *http.Request, id string) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `data: {"id":%q,"choices":[{"index":0,"delta":{"content":"begun"}}]}`+"\n\n", id) + w.(http.Flusher).Flush() + <-r.Context().Done() +} + +// TestContracts1And3ADirectCutStopsAtItsCompletion is validation contract +// items 1 and 3: after a direct stream ends without usage, either kind of +// direct billing door receives only the completion and no missing-price result +// reaches the sink that would book an unbilled marker. +func TestContracts1And3ADirectCutStopsAtItsCompletion(t *testing.T) { + defer shortenStallBounds(t, 30*time.Millisecond, 30*time.Millisecond)() + for _, door := range []string{"coding plan", "pay-as-you-go"} { + t.Run(door, func(t *testing.T) { + var mu sync.Mutex + releaseUnexpected := make(chan struct{}) + var releaseOnce sync.Once + var requests []struct { + method string + path string + header http.Header + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requests = append(requests, struct { + method string + path string + header http.Header + }{method: r.Method, path: r.URL.Path, header: r.Header.Clone()}) + mu.Unlock() + if r.URL.Path != "/chat/completions" { + <-releaseUnexpected + http.NotFound(w, r) + return + } + writeReceiptCut(w, r, "direct-cut") + })) + t.Cleanup(func() { + releaseOnce.Do(func() { close(releaseUnexpected) }) + server.Close() + }) + client, err := NewClient(Config{ + APIKey: "direct-key", BaseURL: server.URL, Model: "direct/model", + Direct: true, BillingDoor: door, HTTPClient: server.Client(), + }) + if err != nil { + t.Fatal(err) + } + var reconciled atomic.Int64 + ctx := WithStreamObserver(t.Context(), func(StreamEvent) {}) + ctx = WithReconcile(ctx, func(Reconciled) { reconciled.Add(1) }) + if _, err := client.CompleteWithMessages(ctx, userMessages("hello")); err == nil { + t.Fatal("the cut direct stream returned no error") + } + mu.Lock() + got := append([]struct { + method string + path string + header http.Header + }(nil), requests...) + mu.Unlock() + if len(got) != 1 || got[0].method != http.MethodPost || got[0].path != "/chat/completions" { + t.Fatalf("direct service requests = %+v, want only POST /chat/completions", got) + } + if agent := got[0].header.Get("User-Agent"); agent != DirectUserAgent { + t.Fatalf("direct completion User-Agent = %q, want %q", agent, DirectUserAgent) + } + for _, name := range []string{"HTTP-Referer", "X-Title", "X-OpenRouter-Title", "X-OpenRouter-Categories"} { + if value := got[0].header.Get(name); value != "" { + t.Fatalf("direct completion %s = %q, want no OpenRouter attribution", name, value) + } + } + client.receiptMu.Lock() + receiptWorkers := client.receiptRunning + client.receiptMu.Unlock() + if receiptWorkers != 0 { + t.Fatalf("the direct cut started %d receipt workers, want none", receiptWorkers) + } + if reconciled.Load() != 0 { + t.Fatalf("the direct cut entered receipt reconciliation %d times, want none", reconciled.Load()) + } + releaseOnce.Do(func() { close(releaseUnexpected) }) + }) + } +} + +// TestContracts2And4AnOpenRouterCutChasesAnIdentifiedReceipt is validation +// contract items 2 and 4: the real default-service client still asks for the +// missing receipt on a 404, and both that GET and its completion identify as +// aforge while the GET keeps OpenRouter's attribution. +func TestContracts2And4AnOpenRouterCutChasesAnIdentifiedReceipt(t *testing.T) { + defer shortenStallBounds(t, 30*time.Millisecond, 30*time.Millisecond)() + var requests atomic.Int64 + completionHeaders := make(chan http.Header, 1) + receiptRequests := make(chan *http.Request, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + switch r.URL.Path { + case "/chat/completions": + completionHeaders <- r.Header.Clone() + writeReceiptCut(w, r, "openrouter-cut") + case "/generation": + receiptRequests <- r.Clone(r.Context()) + http.NotFound(w, r) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + client, err := NewClient(Config{ + APIKey: "router-key", BaseURL: server.URL, Model: "openrouter/test-model", + HTTPClient: server.Client(), + }) + if err != nil { + t.Fatal(err) + } + client.wait = func(context.Context, time.Duration) error { return nil } + results := make(chan Reconciled, 1) + ctx := WithStreamObserver(t.Context(), func(StreamEvent) {}) + ctx = WithReconcile(ctx, func(result Reconciled) { results <- result }) + if _, err := client.CompleteWithMessages(ctx, userMessages("hello")); err == nil { + t.Fatal("the cut OpenRouter stream returned no error") + } + result := receiptResult(t, results) + if result.Found || result.Ref != "openrouter-cut" || !result.Billed.Empty() { + t.Fatalf("404 receipt result = %+v, want the named call reported without invented figures", result) + } + if got := requests.Load(); got != 2 { + t.Fatalf("OpenRouter requests = %d, want the completion and one receipt GET", got) + } + completion := <-completionHeaders + if got := completion.Get("User-Agent"); got != DirectUserAgent { + t.Fatalf("completion User-Agent = %q, want %q", got, DirectUserAgent) + } + receipt := <-receiptRequests + if receipt.Method != http.MethodGet || receipt.URL.Path != "/generation" || receipt.URL.Query().Get("id") != "openrouter-cut" { + t.Fatalf("receipt request = %s %s, want GET /generation?id=openrouter-cut", receipt.Method, receipt.URL.String()) + } + if got := receipt.Header.Get("User-Agent"); got != DirectUserAgent { + t.Fatalf("receipt User-Agent = %q, want %q", got, DirectUserAgent) + } + if got := receipt.Header.Get("Authorization"); got != "Bearer router-key" { + t.Fatalf("receipt Authorization = %q", got) + } + assertAttributed(t, receipt.Header, "receipt GET") +} + // TestACutStreamIsBilledFromTheProvidersReceipt is C1: the stream names its // generation twice and then stalls, and only the provider's later figures are // delivered. Native counts are deliberately different so the normalised pair diff --git a/internal/provider/refusalobject.go b/internal/provider/refusalobject.go index c9f02f3ae7..9ae6d63bdd 100644 --- a/internal/provider/refusalobject.go +++ b/internal/provider/refusalobject.go @@ -77,6 +77,13 @@ const ( // refusalPayment is an authenticated account that cannot fund the request. // It is terminal for the whole account, not a wait and not one lane's fault. refusalPayment + // refusalPlanPaused is a fixed-price window that will become usable again. + // It is neither ordinary pacing nor terminal account failure, and it must + // never earn an endpoint walk or a model hop. + refusalPlanPaused + // refusalNoPlan is a bound plan door that this key or model cannot use. It is + // a fact about the door, not about one endpoint behind it. + refusalNoPlan ) // laneRefusal is one refusal as every reader of it needs it. @@ -133,6 +140,10 @@ type laneRefusal struct { // verdict downstream needs it to hop at once rather than spend a transport // budget on three more identical 404s ([Client.withdrawnModel], #838). Withdrawn bool + // Reset is the vendor's stated reset value for a paused plan window. It is + // carried from the one classification so the dispatcher and surface never + // have to parse the refusal a second time. + Reset string } // struck reports whether there is a lane here for the ledger to WRITE OFF — a @@ -256,6 +267,9 @@ func markRefusal(err error, refusal laneRefusal) { if refusal.Withdrawn { marked.Withdrawn = true } + marked.Payment = refusal.Kind == refusalPayment + marked.PlanPaused = refusal.Kind == refusalPlanPaused + marked.PlanUnavailable = refusal.Kind == refusalNoPlan } // refusalObject is THE classifier. Everything this process does about a refusal @@ -284,8 +298,14 @@ func (c *Client) laneRefusalFor(model, demanded string, err error) laneRefusal { if !ok { return laneRefusal{} } - if paymentrefusal.Matches(refusal.Status, []byte(refusal.Body)) { + body := []byte(refusal.Body) + switch paymentrefusal.Classify(refusal.Status, body) { + case paymentrefusal.Payment: return laneRefusal{Kind: refusalPayment, Terminal: true} + case paymentrefusal.WindowExhausted: + return laneRefusal{Kind: refusalPlanPaused, Reset: paymentrefusal.ResetAt(body)} + case paymentrefusal.NoPlan: + return laneRefusal{Kind: refusalNoPlan, Terminal: true} } demanded = strings.TrimSpace(demanded) if refusal.Status == http.StatusTooManyRequests { @@ -320,7 +340,6 @@ func (c *Client) laneRefusalFor(model, demanded string, err error) laneRefusal { if refusal.FromUpstream() { return laneRefusal{Kind: refusalUpstream, Lane: refusal.Provider} } - body := []byte(refusal.Body) // A MODEL THE ROUTER HAS PUT DOWN IS ASKED BEFORE AN EMPTIED SET, because // the two arrive wearing the same 404 and very often the same sentence, and // only one of them has a machine anywhere behind it. Nothing is struck and @@ -485,7 +504,8 @@ func Evidence(err error) taxonomy.Evidence { evidence.Spent = errors.As(err, &ladder) evidence.Account = evidence.Routing && refusal.Account evidence.Withdrawn = refusal.Withdrawn - evidence.Unserved = accountUnserved(refusal.Status) + evidence.PlanPaused = refusal.PlanPaused + evidence.Unserved = accountUnserved(refusal.Status) || refusal.Payment || refusal.PlanUnavailable // OUR OWN BYTES ARE WHAT IS LEFT OVER, and the transport is the only layer // that can say so: it is the 4xx that named no upstream, is not a list, is // not an absent model and is not a key — which is [APIError.OurRequest]'s @@ -494,8 +514,10 @@ func Evidence(err error) taxonomy.Evidence { return evidence } -// accountUnserved reports the three statuses that mean THE ACCOUNT could not be -// served: no key, a key without permission, a balance that ran out. +// accountUnserved reports the three generic statuses that mean THE ACCOUNT +// could not be served: no key, a key without permission, a balance that ran +// out. The vendor-specific payment and no-plan facts join it in [Evidence] +// through the marks from [markRefusal]. // // IT IS THE ONE STATUS COMPARISON LEFT IN THIS BUILD OUTSIDE internal/taxonomy, // and it is here rather than at a reader for exactly that reason: the law test diff --git a/internal/remote/news.go b/internal/remote/news.go index fe06c8d89f..33525d1fd5 100644 --- a/internal/remote/news.go +++ b/internal/remote/news.go @@ -400,6 +400,7 @@ func phaseWireOf(news session.PhaseNews) PhaseWire { Subject: news.Subject, Session: news.Session, Lane: news.Lane, + Door: news.Door, Rate: news.Rate, Detail: news.Detail, Then: news.Then, @@ -428,6 +429,7 @@ func phaseNewsOf(wire PhaseWire, at time.Time) session.PhaseNews { Subject: wire.Subject, Session: wire.Session, Lane: wire.Lane, + Door: wire.Door, Rate: wire.Rate, Detail: wire.Detail, Then: wire.Then, diff --git a/internal/remote/news_test.go b/internal/remote/news_test.go index 1f1c732f53..ca04f2f2b4 100644 --- a/internal/remote/news_test.go +++ b/internal/remote/news_test.go @@ -249,7 +249,7 @@ func TestNewsGoesOnlyToTheConnectionOfTheConversationItNames(t *testing.T) { session.TellPhase(session.PhaseNews{ Phase: provider.PhaseWriting, Model: "openai/gpt-5", Role: lane.RoleTalk, - Lane: "friendli", Rate: 38, Session: "conversation-one", At: time.Now(), + Lane: "friendli", Door: "pay-as-you-go", Rate: 38, Session: "conversation-one", At: time.Now(), }) frame := here.await(func(f Frame) bool { return f.Kind == "phase" }) @@ -257,9 +257,12 @@ func TestNewsGoesOnlyToTheConnectionOfTheConversationItNames(t *testing.T) { if err := json.Unmarshal(frame.Payload, &wire); err != nil { t.Fatalf("the phase frame did not parse: %v", err) } - if wire.Lane != "friendli" || wire.Rate != 38 { + if wire.Lane != "friendli" || wire.Door != "pay-as-you-go" || wire.Rate != 38 { t.Fatalf("the phase crossed as %+v, want friendli at 38 tok/s", wire) } + if got := phaseNewsOf(wire, time.Now()); got.Door != "pay-as-you-go" { + t.Fatalf("the billing door did not survive the read back: %+v", got) + } // AND THE OTHER CONVERSATION HEARD NOTHING. Its own turn is what its row is // about, and a clock from somebody else's window is worse than no clock. if frame, sent := nextFrameWithin(there, 200*time.Millisecond); sent { diff --git a/internal/remote/wire.go b/internal/remote/wire.go index d36d15bf4e..9af4290009 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -1507,6 +1507,9 @@ type PhaseWire struct { // never "nothing" — the emptiness law, carried across the wire intact. Lane string `json:"lane,omitempty"` Rate float64 `json:"rate,omitempty"` + // Door is the billing road in use. It stays separate from Lane so a direct + // service is never presented as a router's serving machine. + Door string `json:"door,omitempty"` // Detail is the phase's own noun, already in a person's words, and Then // what will be done about the wait when a deadline is real. Detail string `json:"detail,omitempty"` diff --git a/internal/session/agent.go b/internal/session/agent.go index 9c0c773f7e..fb83393032 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -623,6 +623,9 @@ func (a *Agent) SetModel(model string) { } a.mu.Lock() a.model = model + if a.clientPool != nil { + a.clientPool.setSeat(model) + } if !a.running { a.rebindClientLocked(model) } diff --git a/internal/session/auxiliary.go b/internal/session/auxiliary.go index cb437fd82e..abac65ac6f 100644 --- a/internal/session/auxiliary.go +++ b/internal/session/auxiliary.go @@ -275,7 +275,10 @@ func (a *Agent) callRoleChecked(ctx context.Context, role roles.Role, sessionDef served := &provider.ServedEndpoint{} callCtx = provider.WithServedEndpoint(callCtx, served) callCtx, releaseRung := errandRungContext(callCtx, len(rungs)-attempt-1) - response, callErr := a.completeWithModel(callCtx, messages, rung.Model, options...) + response, called, callErr := a.completeWithNamedModel(callCtx, messages, rung.Model, options...) + if strings.TrimSpace(called) == "" { + called = rung.Model + } releaseRung() if callErr == nil && response != nil { // AND THE ERRAND WRITES ITS OWN CALL LINE, exactly as a step of the @@ -285,9 +288,9 @@ func (a *Agent) callRoleChecked(ctx context.Context, role roles.Role, sessionDef // — the whole of the difference being three side-calls to a // mastermind. See [journalCall] for why that is a record worth // nothing and why the role rides the line. - a.journalRoleCall(response, role, rung.Model, served.Name()) - if accept == nil || accept(response, rung.Model) { - return response, rung.Model, nil + a.journalRoleCall(response, role, called, served.Name()) + if accept == nil || accept(response, called) { + return response, called, nil } // AN ANSWER THE CALLER CANNOT USE IS NOT A TRANSPORT FAILURE and is // never asked for again from the same rung: the endpoint did its job @@ -297,7 +300,7 @@ func (a *Agent) callRoleChecked(ctx context.Context, role roles.Role, sessionDef tell(errandNews{Model: rung.Model, Rung: attempt + 1, Rungs: len(rungs), Failed: true, Why: errandUnusableWords}) if ctx.Err() != nil { - return nil, rung.Model, ctx.Err() + return nil, called, ctx.Err() } continue } @@ -336,7 +339,7 @@ func (a *Agent) callRoleChecked(ctx context.Context, role roles.Role, sessionDef // bare sentence, and the journal held no error row, no call row and no // word of why the worker started blind. A deadline is a failure like any // other and it is now recorded like one. - a.journalFailedCall(callCtx, rung.Model, string(role), lastErr, attempt+1, 0) + a.journalFailedCall(callCtx, called, string(role), lastErr, attempt+1, 0) // AND THE BOUNDARY READS IT, on the same row shape and for the same // reason the turn's own failures are read: an errand cut by a deadline // and an errand refused by an upstream are two different pieces of news @@ -358,25 +361,25 @@ func (a *Agent) callRoleChecked(ctx context.Context, role roles.Role, sessionDef // whole of a rung, so by the time its failure is read there is // nothing left to ask of THIS model — which is what turns the // verdict into the hop the ladder below is for. - verdict, evidence := a.readErrandFailure(lastErr, role, rung.Model, + verdict, evidence := a.readErrandFailure(lastErr, role, called, transportLadder{attempt: 1, outOfTime: true, fallback: attempt+1 < len(rungs)}) - tell(errandNews{Model: rung.Model, Rung: attempt + 1, Rungs: len(rungs), + tell(errandNews{Model: called, Rung: attempt + 1, Rungs: len(rungs), Failed: true, Why: errandFailureWords(lastErr)}) // The person's own interrupt ends the errand where it stands, and the // caller is handed the context's own error so it can tell "nobody // answered in time" from "the provider refused" without reading the row // this just wrote. if ctx.Err() != nil { - return nil, rung.Model, ctx.Err() + return nil, called, ctx.Err() } // AND SO DOES THE ERRAND'S OWN PATIENCE, for the same reason: walking a // ladder on a budget that is already spent is more requests that cannot // land. if errandCtx.Err() != nil { - return nil, rung.Model, errandCtx.Err() + return nil, called, errandCtx.Err() } if !errandWalksOn(verdict, evidence, attempt+1 < len(rungs)) { - return nil, rung.Model, lastErr + return nil, called, lastErr } } return nil, "", lastErr diff --git a/internal/session/callwindow_law_test.go b/internal/session/callwindow_law_test.go index abe54c8ece..f9efde9997 100644 --- a/internal/session/callwindow_law_test.go +++ b/internal/session/callwindow_law_test.go @@ -89,7 +89,7 @@ func TestEveryCheckerCallIsSentUnderAToldWindow(t *testing.T) { toldAtTheDoor := false for _, decl := range door.Decls { fn, ok := decl.(*ast.FuncDecl) - if !ok || fn.Name.Name != "completeWithModel" || fn.Body == nil { + if !ok || fn.Body == nil { continue } ast.Inspect(fn.Body, func(n ast.Node) bool { @@ -97,14 +97,22 @@ func TestEveryCheckerCallIsSentUnderAToldWindow(t *testing.T) { if !ok || calledName(call.Fun) != "CompleteWithMessages" || len(call.Args) == 0 { return true } + // EVERY COMPLETION THE DOOR MAKES IS TOLD ITS WINDOW, whichever function + // makes it. completeWithModel delegates to completeWithNamedModel so an + // errand receipt can name the model that answered, and the window has to + // ride along through that delegation rather than stop at the first name. if told, ok := call.Args[0].(*ast.CallExpr); ok && calledName(told.Fun) == "toldItsWindow" { toldAtTheDoor = true + return true } + t.Errorf("%s: %s hands the completer a context it was not told its window on; "+ + "an opened window would then bound the call and tell the model nothing", + fset.Position(call.Pos()), fn.Name.Name) return true }) } if !toldAtTheDoor { - t.Error("clientdoor.go's completeWithModel no longer hands the completer toldItsWindow(ctx); " + + t.Error("clientdoor.go no longer hands any completer toldItsWindow(ctx); " + "an opened window would then bound the call and tell the model nothing") } diff --git a/internal/session/clientdoor.go b/internal/session/clientdoor.go index eae54a096e..aa99c122d9 100644 --- a/internal/session/clientdoor.go +++ b/internal/session/clientdoor.go @@ -13,50 +13,102 @@ import ( ) type modelAccount struct { - id string - key string - address string - optional bool + id string + key string + address string + door string + overflow string + planPaused string + optional bool } // modelClientPool is the account boundary shared by a conversation and every // production child it creates. The whole account is the key: source identity, -// bearer, address, and whether a blank bearer is an explicit capability. +// bearer, bound billing door, overflow answer, and whether a blank bearer is +// an explicit capability. The policy fields make a live settings change mint +// a client with the new spending answer instead of reusing the old adapter. // Sharing the pool keeps a task worker from falling back to whichever adapter // its parent happened to be using when the worker was constructed. type modelClientPool struct { mu sync.Mutex config Config + seat string clients map[modelAccount]Completer } func newModelClientPool(config Config, model string, client Completer) *modelClientPool { - pool := &modelClientPool{config: config, clients: make(map[modelAccount]Completer)} + pool := &modelClientPool{config: config, seat: strings.TrimSpace(model), clients: make(map[modelAccount]Completer)} pool.clients[accountFor(config, model)] = client return pool } +// seatedModel is THE ONE PLACE A MODEL BECOMES A DIFFERENT MODEL, and it +// exists for one state: a profile with no default-provider key and a connected +// service carrying the conversation. ANY PART A SERVICE CANNOT FILL FALLS TO +// THE MODEL ALREADY IN THE SEAT. The default service must be the set's first +// member, because an unqualified model falls to that member; resolving both +// names before changing either keeps the address, bearer and wire slug together +// in one call. +// The caller holds p.mu so the live seat and service set are one snapshot. +func (p *modelClientPool) seatedModel(model string) string { + model = strings.TrimSpace(model) + seat := strings.TrimSpace(p.seat) + if seat == "" || seat == model { + return model + } + services := p.config.Sources.OrDefault(p.config.APIKey, p.config.BaseURL) + if !strings.EqualFold(services.Default().Source.ID, modelsource.DefaultID) { + return model + } + service, _ := services.For(model) + if !strings.EqualFold(service.Source.ID, modelsource.DefaultID) || modelServiceCanAnswer(service) { + return model + } + seated, _ := services.For(seat) + if seated.Source.ID == "" || strings.EqualFold(seated.Source.ID, modelsource.DefaultID) || !modelServiceCanAnswer(seated) { + return model + } + return seat +} + +func modelServiceCanAnswer(service modelsource.Connected) bool { + return strings.TrimSpace(service.Key) != "" || service.Source.KeyOptional +} + +// setSeat moves the one live fallback beside the source snapshot. A model +// chosen after launch must carry the next turn; construction-time config is a +// receipt of how the conversation opened, not an answer about where it sits. +func (p *modelClientPool) setSeat(model string) { + if p == nil { + return + } + p.mu.Lock() + p.seat = strings.TrimSpace(model) + p.mu.Unlock() +} + // clientFor resolves model and its wire slug from one snapshot, then reuses or // constructs the adapter for that complete account. Construction opens no // connection, so holding the lock prevents two simultaneous children from // minting two adapters and, more importantly, two competing lane-prober seams. -func (p *modelClientPool) clientFor(model string) (Completer, string, modelAccount, error) { +func (p *modelClientPool) clientFor(model string) (Completer, string, string, modelAccount, error) { if p == nil { - return nil, "", modelAccount{}, errNoCompleter + return nil, "", "", modelAccount{}, errNoCompleter } p.mu.Lock() defer p.mu.Unlock() + model = p.seatedModel(model) configured := p.config.clientConfig(model, providerTimeout) want := accountFor(p.config, model) if client := p.clients[want]; client != nil { - return client, configured.Model, want, nil + return client, configured.Model, model, want, nil } client, err := newProviderClient(p.config, model) if err != nil { - return nil, "", modelAccount{}, err + return nil, "", model, modelAccount{}, err } p.clients[want] = client - return client, configured.Model, want, nil + return client, configured.Model, model, want, nil } // setSources moves the pool to a freshly resolved profile and forgets every @@ -123,9 +175,14 @@ func accountFor(config Config, model string) modelAccount { } func accountForService(service modelsource.Connected) modelAccount { + overflow := "" + if service.Overflow != nil { + overflow = strings.TrimSpace(service.Overflow.Address) + } return modelAccount{ id: strings.ToLower(strings.TrimSpace(service.Source.ID)), key: service.Key, - address: strings.TrimSpace(service.Address), optional: service.Source.KeyOptional, + address: strings.TrimSpace(service.Address), door: strings.TrimSpace(service.Door.ID), + overflow: overflow, planPaused: strings.TrimSpace(service.PlanPaused), optional: service.Source.KeyOptional, } } @@ -191,7 +248,7 @@ func (a *Agent) childClient(config Config) (Completer, modelAccount, *modelClien if !managed || pool == nil { return unwrapCompleter(current), modelAccount{}, nil, false, nil } - client, _, resolved, err := pool.clientFor(config.Model) + client, _, _, resolved, err := pool.clientFor(config.Model) return client, resolved, pool, true, err } @@ -259,17 +316,26 @@ func (a *Agent) routedCompleter() Completer { // fallbackModels reads the adapter's own ordered chain without exposing that // adapter to the caller. func (a *Agent) fallbackModels(model string) []string { + models, _ := a.modelFallbackChain(model) + return models +} + +// modelFallbackChain reads the optional model-chain capability behind the +// client door. The bool preserves #858's distinction between a completer that +// offers an empty bounded chain and one that has no chain capability at all, +// without letting the turn loop bypass account-aware client routing. +func (a *Agent) modelFallbackChain(model string) ([]string, bool) { if a == nil { - return nil + return nil, false } a.mu.Lock() client := a.client a.mu.Unlock() chain, ok := client.(modelChain) if !ok { - return nil + return nil, false } - return chain.FallbackModels(model) + return chain.FallbackModels(model), true } // probeClientLanes asks the optional prober without exposing the conversation @@ -304,7 +370,7 @@ func (a *Agent) rebindClientLocked(model string) { if !a.managedClient { return } - inner, _, want, err := a.clientPool.clientFor(model) + inner, _, _, want, err := a.clientPool.clientFor(model) if want == a.clientAccount { return } @@ -328,44 +394,54 @@ func (a *Agent) rebindClientLocked(model string) { // THIS IS THE ONLY DOOR THAT MAY PUT A MODEL OVERRIDE ON A SESSION REQUEST. An // override changes only the slug in the request; choosing the client here first // is what changes the address and bearer with it. -func (a *Agent) completerFor(model string) (Completer, string, error) { +func (a *Agent) completerFor(model string) (Completer, string, string, error) { if a == nil { - return nil, "", errNoCompleter + return nil, "", "", errNoCompleter } a.mu.Lock() defer a.mu.Unlock() if a.client == nil { - return nil, "", errNoCompleter + return nil, "", "", errNoCompleter } if !a.managedClient { configured := a.config.clientConfig(model, providerTimeout) - return a.client, configured.Model, nil + return a.client, configured.Model, model, nil } - inner, wire, want, err := a.clientPool.clientFor(model) + inner, wire, called, want, err := a.clientPool.clientFor(model) if err != nil { - return nil, "", err + return nil, "", called, err } if want == a.clientAccount { - return a.client, wire, nil + return a.client, wire, called, nil } if wrapper, ok := a.client.(sessionCompleter); ok { wrapper.inner = inner - return wrapper, wire, nil + return wrapper, wire, called, nil } - return inner, wire, nil + return inner, wire, called, nil } // completeWithModel is [Agent.completerFor] joined to the one wire-model // option. Keeping the two operations inseparable makes it impossible to change // a slug while accidentally retaining another service's address and bearer. func (a *Agent) completeWithModel(ctx context.Context, messages []ai.Message, model string, options ...ai.Option) (*ai.Response, error) { - client, wire, err := a.completerFor(model) + response, _, err := a.completeWithNamedModel(ctx, messages, model, options...) + return response, err +} + +// completeWithNamedModel keeps the model that answered attached to the same +// resolution that chose its account. Errand receipts need that identity when a +// missing default-service key moved the call onto the live seat; asking the pool +// a second time afterwards could observe a different model or source set. +func (a *Agent) completeWithNamedModel(ctx context.Context, messages []ai.Message, model string, options ...ai.Option) (*ai.Response, string, error) { + client, wire, called, err := a.completerFor(model) if err != nil { - return nil, err + return nil, called, err } // A CALL UNDER A TOLD WINDOW IS TOLD IT HERE, at the last moment the context // is this package's to change (callwindow.go says why it cannot be earlier). - return client.CompleteWithMessages(toldItsWindow(ctx), messages, append(options, ai.WithModel(wire))...) + response, err := client.CompleteWithMessages(toldItsWindow(ctx), messages, append(options, ai.WithModel(wire))...) + return response, called, err } type unavailableCompleter struct{ err error } diff --git a/internal/session/clientdoor_wire_test.go b/internal/session/clientdoor_wire_test.go index 21e2247a72..8e7673363b 100644 --- a/internal/session/clientdoor_wire_test.go +++ b/internal/session/clientdoor_wire_test.go @@ -10,6 +10,7 @@ import ( "net/http/httptest" "path/filepath" "sync" + "sync/atomic" "testing" "time" @@ -45,6 +46,26 @@ type clientDoorServer struct { content string } +// forbiddenClientDoorServer makes an accidental default-service request fail +// at the real HTTP boundary, while retaining a count for the final assertion. +// A mock completer could only prove that the test had bypassed clientFor too. +type forbiddenClientDoorServer struct { + *httptest.Server + calls atomic.Int64 +} + +func newForbiddenClientDoorServer(t *testing.T) *forbiddenClientDoorServer { + t.Helper() + server := &forbiddenClientDoorServer{} + server.Server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + server.calls.Add(1) + t.Errorf("default service received %s %s", request.Method, request.URL.Path) + writer.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(server.Close) + return server +} + func newClientDoorServer(t *testing.T, content string) *clientDoorServer { t.Helper() server := &clientDoorServer{content: content} @@ -253,6 +274,145 @@ func TestTheCrewReachesItsOwnServiceWhileTheChatIsElsewhere(t *testing.T) { } } +func TestACrewSeatFallsToTheSeatedModelWhenTheDefaultServiceHasNoKey(t *testing.T) { + defaultServer := newForbiddenClientDoorServer(t) + directServer := newClientDoorServer(t, "done") + defaultSource := modelsource.DefaultSource(defaultServer.URL) + directSource := modelsource.Source{ID: "direct", Written: "direct", Address: directServer.URL} + agent, err := New(Config{ + Workspace: t.TempDir(), Model: "direct/stub/old-seat", + Sources: modelsource.NewSet( + modelsource.Connected{Source: defaultSource, Address: defaultServer.URL}, + modelsource.Connected{Source: directSource, Key: "direct-seat-key", Address: directServer.URL}, + ), + RolesSource: tierSettings(map[string]string{ + roles.TierKey(roles.TierReflex): "crew/reflex", + }), + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = agent.Close() }) + + // The construction-time seat is deliberately made stale. The model picked + // live is the one the unavailable crew rung must fall to. + agent.SetModel("direct/stub/conversation") + _, answered, err := agent.callRole(t.Context(), roles.RoleReflex, agent.Model(), []ai.Message{textMessage("user", "answer")}) + if err != nil { + t.Fatal(err) + } + if answered != "direct/stub/conversation" { + t.Fatalf("answer was recorded on %q, want the seated model", answered) + } + if got := directServer.allCalls(); len(got) != 1 || got[0].authorization != "Bearer direct-seat-key" || clientDoorModel(t, got[0]) != "stub/conversation" { + t.Fatalf("connected service calls = %+v, want the seated model's bare slug", got) + } + if got := defaultServer.calls.Load(); got != 0 { + t.Fatalf("default service received %d requests", got) + } +} + +func TestACrewSeatFallsToAKeyOptionalSeatedService(t *testing.T) { + defaultServer := newForbiddenClientDoorServer(t) + localServer := newClientDoorServer(t, "done") + defaultSource := modelsource.DefaultSource(defaultServer.URL) + localSource := modelsource.Source{ + ID: "ollama", Written: "ollama", Address: localServer.URL, KeyOptional: true, + } + agent, err := New(Config{ + Workspace: t.TempDir(), Model: "ollama/llama3.2", + Sources: modelsource.NewSet( + modelsource.Connected{Source: defaultSource, Address: defaultServer.URL}, + modelsource.Connected{Source: localSource, Address: localServer.URL}, + ), + RolesSource: tierSettings(map[string]string{ + roles.TierKey(roles.TierReflex): "crew/reflex", + }), + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = agent.Close() }) + _, answered, err := agent.callRole(t.Context(), roles.RoleReflex, agent.Model(), []ai.Message{textMessage("user", "answer")}) + if err != nil { + t.Fatal(err) + } + if answered != "ollama/llama3.2" { + t.Fatalf("answer was recorded on %q, want the key-optional seat", answered) + } + if got := localServer.allCalls(); len(got) != 1 || got[0].authorization != "" || clientDoorModel(t, got[0]) != "llama3.2" { + t.Fatalf("key-optional service calls = %+v", got) + } + if got := defaultServer.calls.Load(); got != 0 { + t.Fatalf("default service received %d requests", got) + } +} + +func TestAChildAgentFallsToTheSeatedModelWhenTheDefaultServiceHasNoKey(t *testing.T) { + defaultServer := newForbiddenClientDoorServer(t) + directServer := newClientDoorServer(t, "child done") + defaultSource := modelsource.DefaultSource(defaultServer.URL) + directSource := modelsource.Source{ID: "direct", Written: "direct", Address: directServer.URL} + agent, err := New(Config{ + Workspace: t.TempDir(), Model: "direct/stub/conversation", + Sources: modelsource.NewSet( + modelsource.Connected{Source: defaultSource, Address: defaultServer.URL}, + modelsource.Connected{Source: directSource, Key: "direct-seat-key", Address: directServer.URL}, + ), + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = agent.Close() }) + child, err := agent.newChildAgent(Config{Workspace: t.TempDir(), Model: "crew/worker"}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = child.Close() }) + + drainTurn(t, child, "work") + if got := directServer.allCalls(); len(got) != 1 || got[0].authorization != "Bearer direct-seat-key" || clientDoorModel(t, got[0]) != "stub/conversation" { + t.Fatalf("connected service calls = %+v, want the parent's seated model", got) + } + if got := defaultServer.calls.Load(); got != 0 { + t.Fatalf("default service received %d requests", got) + } +} + +func TestACrewSeatKeepsTheDefaultServiceWhenItsKeyIsPresent(t *testing.T) { + defaultServer := newClientDoorServer(t, "crew done") + directServer := newClientDoorServer(t, "direct done") + defaultSource := modelsource.DefaultSource(defaultServer.URL) + directSource := modelsource.Source{ID: "direct", Written: "direct", Address: directServer.URL} + agent, err := New(Config{ + Workspace: t.TempDir(), Model: "direct/stub/conversation", + Sources: modelsource.NewSet( + modelsource.Connected{Source: defaultSource, Key: "default-crew-key", Address: defaultServer.URL}, + modelsource.Connected{Source: directSource, Key: "direct-seat-key", Address: directServer.URL}, + ), + RolesSource: tierSettings(map[string]string{ + roles.TierKey(roles.TierReflex): "crew/reflex", + }), + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = agent.Close() }) + _, answered, err := agent.callRole(t.Context(), roles.RoleReflex, agent.Model(), []ai.Message{textMessage("user", "answer")}) + if err != nil { + t.Fatal(err) + } + if answered != "crew/reflex" { + t.Fatalf("answer was recorded on %q, want the crew seat", answered) + } + if got := defaultServer.allCalls(); len(got) != 1 || got[0].authorization != "Bearer default-crew-key" || clientDoorModel(t, got[0]) != "crew/reflex" { + t.Fatalf("default service calls = %+v", got) + } + if got := directServer.allCalls(); len(got) != 0 { + t.Fatalf("connected service received the crew call: %+v", got) + } +} + // A production child is a request-carrying Agent in its own right. Repeating // the crossing catches the old split identity, where a role call happened to // use the account pool but a task-shaped child inherited the conversation's diff --git a/internal/session/loop.go b/internal/session/loop.go index 8652edd66e..f5659e4659 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -1594,6 +1594,10 @@ func (a *Agent) completeWithRetryReasoning(ctx context.Context, hub *eventHub, m // deliberation salvaged into its place would start work off a sentence the // model was still arguing with itself about. ctx = provider.WithProseAnswer(ctx) + // MONEY MOVES AT MOST ONCE FOR ONE TURN. The provider keeps this guard across + // its own repairs, rungs and hedge arms; the session adds it outside the + // model ladder so a later model cannot buy the same metered overflow again. + ctx = provider.WithPlanOverflowGuard(ctx) var lastErr error // cuts counts the attempts the STREAM GUARD ended — a stall, or a reply that // stopped being language. They are counted apart from the transport attempts @@ -2195,7 +2199,10 @@ func (a *Agent) nextFallback(ctx context.Context, origin string, hopped []string if a.config.OneModel { return "", false } - options := a.fallbackModels(origin) + options, ok := a.modelFallbackChain(origin) + if !ok { + return "", false + } // AND THE CHAIN IS STILL BOUNDED BY ITS OWN LENGTH. A turn may move as many // times as the chain is long and no further — the cap is the adapter's // (internal/provider's maxFallbackModels) and is not re-decided here — so a diff --git a/internal/session/plan_doors_test.go b/internal/session/plan_doors_test.go new file mode 100644 index 0000000000..0624da970b --- /dev/null +++ b/internal/session/plan_doors_test.go @@ -0,0 +1,279 @@ +package session + +import ( + "context" + "net/http" + "testing" + "time" + + account "github.com/Agent-Field/aforge-v2/internal/config" + "github.com/Agent-Field/aforge-v2/internal/modelsource" + "github.com/Agent-Field/aforge-v2/internal/modelsource/sourcestub" + "github.com/Agent-Field/aforge-v2/internal/provider" +) + +func planDoorSource(plan, metered *sourcestub.Server) modelsource.Source { + return modelsource.Source{ + ID: "z-ai", Written: "z-ai", Name: "Z.ai", KeyEnv: "ZHIPU_API_KEY", + Doors: []modelsource.Door{ + {ID: "coding-plan", Name: "coding plan", Address: plan.URL(), Models: []string{"glm-5.3-flash"}}, + {ID: "metered", Name: "pay-as-you-go", Address: metered.URL(), Metered: true}, + }, + Listing: modelsource.ListingModels, ProbeModel: "glm-5.3-flash", + Probe: modelsource.Probe{Address: "/models", Method: http.MethodGet, Accepts: []int{http.StatusOK}}, + } +} + +func connectedPlanDoor(source modelsource.Source, outcome modelsource.Outcome, key string) modelsource.Connected { + connected := modelsource.Connected{ + Source: source, Key: key, Address: outcome.Door.Address, Door: outcome.Door, + PlanPaused: account.PlanPausedWait, + } + if !outcome.Door.Metered { + for _, door := range source.Doors { + if door.Metered { + copy := door + connected.Overflow = © + break + } + } + } + return connected +} + +func connectThenRunPlanDoor(t *testing.T, source modelsource.Source) modelsource.Outcome { + t.Helper() + outcome, err := account.ConnectService(context.Background(), t.TempDir(), account.PersistedSource{ + ID: source.ID, Written: source.Written, Key: "plan-door-test-key", Order: 1, + }, source, nil) + if err != nil || outcome.Kind != modelsource.OutcomeConnected { + t.Fatalf("connect outcome = %v, error=%v", outcome.Kind, err) + } + agent, err := New(Config{ + Workspace: t.TempDir(), Model: "z-ai/glm-5.3-flash", + Sources: modelsource.NewSet( + modelsource.Connected{Source: modelsource.DefaultSource("http://127.0.0.1:1"), Address: "http://127.0.0.1:1"}, + connectedPlanDoor(source, outcome, "plan-door-test-key"), + ), + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = agent.Close() }) + drainTurn(t, agent, "answer from the bound billing door") + return outcome +} + +func TestAPlanKeyBindsToThePlanDoor(t *testing.T) { + t.Setenv("ZHIPU_API_KEY", "") + plan, metered := sourcestub.New(), sourcestub.New() + defer plan.Close() + defer metered.Close() + metered.RefuseCompletion(http.StatusTooManyRequests, `{"code":"1113","message":"empty"}`) + source := planDoorSource(plan, metered) + outcome := connectThenRunPlanDoor(t, source) + if outcome.Door.ID != "coding-plan" || len(plan.Requests()) != 2 || len(metered.Requests()) != 0 { + t.Fatalf("bound=%s requests: plan=%d metered=%d", outcome.Door.ID, len(plan.Requests()), len(metered.Requests())) + } + assertRequestsCarry(t, plan.Requests(), "plan-door-test-key") +} + +func TestAKeyWithNoPlanFallsBackToPayAsYouGo(t *testing.T) { + t.Setenv("ZHIPU_API_KEY", "") + plan, metered := sourcestub.New(), sourcestub.New("glm-5.3-flash") + defer plan.Close() + defer metered.Close() + plan.RefuseCompletion(http.StatusTooManyRequests, `{"code":"1309","message":"plan expired"}`) + source := planDoorSource(plan, metered) + outcome := connectThenRunPlanDoor(t, source) + if outcome.Door.ID != "metered" || len(plan.Requests()) != 1 || len(metered.Requests()) != 3 { + t.Fatalf("bound=%s requests: plan=%d metered=%d", outcome.Door.ID, len(plan.Requests()), len(metered.Requests())) + } + assertRequestsCarry(t, plan.Requests(), "plan-door-test-key") + assertRequestsCarry(t, metered.Requests(), "plan-door-test-key") +} + +func TestAnExhaustedPlanWaitsAndSpendsNothing(t *testing.T) { + plan, metered := sourcestub.New(), sourcestub.New() + defer plan.Close() + defer metered.Close() + plan.RefuseCompletion(http.StatusTooManyRequests, `{"code":"1316","message":"plan window exhausted"}`) + connected := connectedPlanDoor(planDoorSource(plan, metered), modelsource.Outcome{ + Door: planDoorSource(plan, metered).Doors[0], + }, "wait-mode-test-key") + // A real fallback chain is present on purpose: the typed pause must end the + // turn before #858's model hop can carry it to another model on this door. + agent := planDoorAgentWithFallbacks(t, connected, []string{"z-ai/glm-5.3"}) + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + events, err := agent.Submit(ctx, "wait on the plan I already paid for") + if err != nil { + t.Fatal(err) + } + for range events { + } + if got := metered.Requests(); len(got) != 0 { + t.Fatalf("wait mode sent %d requests to the metered host: %+v", len(got), got) + } + if got := completionRequestsOf(plan); len(got) != 1 { + t.Fatalf("paused turn sent %d plan requests across retry, hedge, endpoint, or fallback roads; want one", len(got)) + } else { + assertRequestsCarry(t, got, "wait-mode-test-key") + } +} + +func TestOverflowGoesToTheMeteredDoorAndSaysSo(t *testing.T) { + plan, metered := sourcestub.New(), sourcestub.New() + defer plan.Close() + defer metered.Close() + plan.RefuseCompletion(http.StatusTooManyRequests, `{"code":"1316","message":"plan window exhausted"}`) + source := planDoorSource(plan, metered) + connected := connectedPlanDoor(source, modelsource.Outcome{Door: source.Doors[0]}, "overflow-test-key") + connected.PlanPaused = account.PlanPausedUseMeter + agent := planDoorAgent(t, connected) + drainTurn(t, agent, "use the metered door I opted into") + planCalls, meteredCalls := completionRequestsOf(plan), completionRequestsOf(metered) + if len(planCalls) != 1 || len(meteredCalls) != 1 { + t.Fatalf("overflow requests: plan=%d metered=%d", len(planCalls), len(meteredCalls)) + } + assertRequestsCarry(t, planCalls, "overflow-test-key") + assertRequestsCarry(t, meteredCalls, "overflow-test-key") +} + +func TestOnlyAnExplicitReconnectRebindsTheDoor(t *testing.T) { + plan, metered := sourcestub.New(), sourcestub.New("glm-5.3-flash") + defer plan.Close() + defer metered.Close() + source := planDoorSource(plan, metered) + key := "rebind-test-key" + connected := connectedPlanDoor(source, modelsource.Outcome{Door: source.Doors[0]}, key) + agent := planDoorAgent(t, connected) + drainTurn(t, agent, "first turn stays on the bound plan") + planBeforeReconnect := len(plan.Requests()) + + plan.RefuseCompletion(http.StatusTooManyRequests, `{"code":"1309","message":"plan expired"}`) + outcome, err := account.ConnectService(context.Background(), t.TempDir(), account.PersistedSource{ + ID: source.ID, Written: source.Written, Key: key, Order: 1, + }, source, nil) + if err != nil || outcome.Door.ID != "metered" { + t.Fatalf("explicit reconnect = %+v, %v", outcome, err) + } + connected = connectedPlanDoor(source, outcome, key) + agent.SetSources(modelsource.NewSet( + modelsource.Connected{Source: modelsource.DefaultSource("http://127.0.0.1:1"), Address: "http://127.0.0.1:1"}, + connected, + )) + planAfterReconnect, meteredAfterReconnect := len(plan.Requests()), len(metered.Requests()) + drainTurn(t, agent, "second turn uses the explicitly rebound door") + if len(plan.Requests()) != planAfterReconnect || len(metered.Requests()) != meteredAfterReconnect+1 { + t.Fatalf("turn after reconnect went to the wrong host: plan %d->%d->%d metered %d->%d", + planBeforeReconnect, planAfterReconnect, len(plan.Requests()), meteredAfterReconnect, len(metered.Requests())) + } + assertRequestsCarry(t, plan.Requests(), key) + assertRequestsCarry(t, metered.Requests(), key) +} + +func TestEveryOneDoorServiceKeepsItsHostAndBearer(t *testing.T) { + for _, testCase := range []struct { + id, written, key string + optional bool + }{ + {id: "deepseek", written: "deepseek-direct", key: "deepseek-test-key"}, + {id: "ollama", written: "ollama", optional: true}, + {id: "custom", written: "something-local", key: "custom-test-key"}, + } { + t.Run(testCase.id, func(t *testing.T) { + host := sourcestub.New("one-model") + defer host.Close() + service := modelsource.Connected{ + Source: modelsource.Source{ + ID: testCase.id, Written: testCase.written, Address: host.URL(), + KeyOptional: testCase.optional, Listing: modelsource.ListingModels, + }, + Key: testCase.key, Address: host.URL(), + } + agent := planDoorAgent(t, service) + drainTurn(t, agent, "use this one-door service") + calls := completionRequestsOf(host) + if len(calls) != 1 { + t.Fatalf("one-door turn requests = %+v", calls) + } + assertRequestsCarry(t, calls, testCase.key) + }) + } +} + +func TestChangingThePlanPauseSettingReplacesTheLiveClient(t *testing.T) { + plan, metered := sourcestub.New(), sourcestub.New() + defer plan.Close() + defer metered.Close() + source := planDoorSource(plan, metered) + connected := connectedPlanDoor(source, modelsource.Outcome{Door: source.Doors[0]}, "plan-door-test-key") + sources := modelsource.NewSet( + modelsource.Connected{Source: modelsource.DefaultSource("http://127.0.0.1:1"), Address: "http://127.0.0.1:1"}, + connected, + ) + agent, err := New(Config{Workspace: t.TempDir(), Model: "z-ai/glm-5.3-flash", Sources: sources}) + if err != nil { + t.Fatal(err) + } + defer agent.Close() + before, _, _, _, err := agent.clientPool.clientFor("z-ai/glm-5.3-flash") + if err != nil { + t.Fatal(err) + } + connected.PlanPaused = account.PlanPausedUseMeter + agent.SetSources(modelsource.NewSet(sources.Default(), connected)) + after, _, _, _, err := agent.clientPool.clientFor("z-ai/glm-5.3-flash") + if err != nil { + t.Fatal(err) + } + if before == after { + t.Fatal("the live client retained the old plan-pause spending answer") + } +} + +func planDoorAgent(t *testing.T, service modelsource.Connected) *Agent { + return planDoorAgentWithFallbacks(t, service, nil) +} + +func planDoorAgentWithFallbacks(t *testing.T, service modelsource.Connected, fallbacks []string) *Agent { + t.Helper() + agent, err := New(Config{ + Workspace: t.TempDir(), Model: service.Qualify("glm-5.3-flash"), + ModelFallbacks: fallbacks, + Sources: modelsource.NewSet( + modelsource.Connected{Source: modelsource.DefaultSource("http://127.0.0.1:1"), Address: "http://127.0.0.1:1"}, + service, + ), + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = agent.Close() }) + return agent +} + +func completionRequestsOf(server *sourcestub.Server) []sourcestub.Request { + var calls []sourcestub.Request + for _, request := range server.Requests() { + if request.Method == http.MethodPost { + calls = append(calls, request) + } + } + return calls +} + +func assertRequestsCarry(t *testing.T, requests []sourcestub.Request, key string) { + t.Helper() + wantBearer := "" + if key != "" { + wantBearer = "Bearer " + key + } + for _, request := range requests { + if request.Bearer != wantBearer || request.Agent != provider.DirectUserAgent { + t.Fatalf("request reached %s with bearer %q and agent %q; want bearer %q and agent %q", + request.Path, request.Bearer, request.Agent, wantBearer, provider.DirectUserAgent) + } + } +} diff --git a/internal/taxonomy/policy.go b/internal/taxonomy/policy.go index bfc7aae25c..4c7c02529d 100644 --- a/internal/taxonomy/policy.go +++ b/internal/taxonomy/policy.go @@ -99,6 +99,12 @@ func (transportPolicy) Class() Class { return Transport } // and a model is not the last thing there is. func (transportPolicy) Decide(e Evidence, l Limits) Verdict { spent, allowed := transportBudget(e, l) + // A PAUSED PLAN HAS NO AUTOMATIC MOVE. The dispatcher either uses the + // separately authorised metered door itself or returns the typed pause; an + // endpoint walk or model hop here would evade that billing decision. + if e.PlanPaused { + return Verdict{Action: ActionReport, Reason: ReasonPlanPaused, Attempts: spent} + } // A MODEL THE ROUTER NO LONGER CARRIES HAS NO BUDGET TO SPEND. Every attempt // buys the identical 404 — there is no machine to rotate to, because the id // itself is gone — so the budget is declared spent at once and the move is @@ -336,6 +342,7 @@ const ( ReasonRefused = "the endpoint refused" ReasonPaced = "the account is being asked to slow down" ReasonUnserved = "the provider could not serve it" + ReasonPlanPaused = "the plan is paused" ReasonUnreached = "the request did not reach anybody" ReasonWithdrawn = "the model is no longer carried" ReasonUnauthorized = "this account could not be served" diff --git a/internal/taxonomy/taxonomy.go b/internal/taxonomy/taxonomy.go index 72eb2da4c8..37d4ce7d2b 100644 --- a/internal/taxonomy/taxonomy.go +++ b/internal/taxonomy/taxonomy.go @@ -184,6 +184,11 @@ type Evidence struct { // reached one. Status int + // PlanPaused says a fixed-price subscription window is temporarily spent. + // It is a transport fact with no recovery move: retrying, rotating endpoints + // or hopping models would turn a spending boundary into ordinary pacing. + PlanPaused bool + // Upstream is the provider the router NAMED as the one that refused, empty // when the router refused on its own account. The emptiness is the fact: a // 4xx that named nobody is our own bytes being read and rejected, and every @@ -289,8 +294,9 @@ type Evidence struct { OutOfTime bool // Unserved says THE ACCOUNT could not be served at all: no key, a key this - // model is not permitted, a balance that ran out. It arrives as a 401, a 402 - // or a 403. + // model or bound plan door is not permitted, or a balance that ran out. It + // arrives as a 401, 402 or 403, or as a vendor's structured payment/no-plan + // refusal. // // IT IS THE ONE REFUSAL WITH NO MOVE IN IT. Another machine, another shape // and another model all lead to the identical answer, so there is nothing to diff --git a/internal/taxonomy/taxonomy_test.go b/internal/taxonomy/taxonomy_test.go index 52146a08f9..d717da620f 100644 --- a/internal/taxonomy/taxonomy_test.go +++ b/internal/taxonomy/taxonomy_test.go @@ -379,6 +379,21 @@ func TestARoutingRefusalIsTransportAndAMalformedRequestIsWork(t *testing.T) { } } +// A PAUSED FIXED-PRICE WINDOW HAS NO RECOVERY MOVE. The dispatcher owns the +// only authorised metered-door switch; once it returns a pause, a fallback +// advertised by the caller must not turn that billing boundary into a model hop. +func TestAPlanPauseReportsEvenWhenAFallbackExists(t *testing.T) { + verdict := Classify(Evidence{ + Status: 429, PlanPaused: true, Attempt: 1, FallbackAvailable: true, + }, Limits{}) + if verdict.Class != Transport || verdict.Action != ActionReport || verdict.Reason != ReasonPlanPaused { + t.Fatalf("a paused plan read as %s, want transport reported as paused", verdict) + } + if !verdict.EndsTurn() || verdict.Hops() || verdict.Retries() { + t.Fatalf("a paused plan retained a recovery move: %s", verdict) + } +} + // ── A NAMED REFUSAL IS A MOVE, WHATEVER NUMBER IT WORE ────────────────────── // // THE LAW: when the router relays somebody else's no and says whose, the fact is diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 29f60621ba..de9b590fee 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -1627,11 +1627,15 @@ type app struct { // 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. - sources modelsource.Set - modelCatalog []modelsource.Source - sourceModels map[string][]Model - modelDraft *modelConnectDraft - modelSuggestions map[string]string + sources modelsource.Set + modelCatalog []modelsource.Source + sourceModels map[string][]Model + modelDraft *modelConnectDraft + // deferredModelServiceModel holds the move a connection earned while a turn + // was answering. A PERSON WATCHING A STREAM MUST NOT HAVE ITS MODEL CHANGED + // MID-SENTENCE, and dropping the move would leave them on the service they + // just replaced, so the one pending id lands at the turn boundary. + deferredModelServiceModel string // harn is the subharness registry (Options.Harnesses) and harnPanel the // list /harness opens over it (harnesspanel.go). A nil harn is a surface // that cannot show harnesses and says so; nothing about the OFFER depends on @@ -5471,6 +5475,7 @@ func (a *app) settle() tea.Cmd { if a.state == stateWorking { a.state = stateIdle } + a.applyDeferredModelServiceMove() // A turn that is over is a turn nothing is outstanding on: the clock stops // here rather than at the next turn's start, so a session left idle for an // hour cannot open its next turn holding an hour-old anchor. diff --git a/internal/tui3/connect.go b/internal/tui3/connect.go index f4e2a3329b..932828b129 100644 --- a/internal/tui3/connect.go +++ b/internal/tui3/connect.go @@ -3,6 +3,7 @@ package tui3 import ( "context" "strings" + "unicode" tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" @@ -442,8 +443,13 @@ func connectKeyHint(name, blank string) string { // inside a block whose height the session's question owns, and the two lines // this box can grow are two lines that block cannot spare. -// keyEntry is one typed answer being given: which service it is for, the word a -// person knows it by, what the service says about answering, and the box. +// entryChoice is one row of a CLOSED answer: the value that is saved, and the +// word a person reads and picks by. +type entryChoice struct{ ID, Name string } + +// keyEntry is one answer being given: which service it is for, the word a +// person knows it by, what the service says about answering, and either the +// closed choices or the box. // // ask and link are copied off the [connect.Service] at the moment the box opens // rather than looked up while it is drawn: a paint runs many times a second, and @@ -453,6 +459,11 @@ type keyEntry struct { name string // blank is the plain name of a visible answer. Empty means this is a key. blank string + // choices makes this a PICKED answer. While it is non-empty there is no + // box at all and nothing can be typed into one. + choices []entryChoice + // at is the choice carrying the cursor. + at int // answers is the service's closed list for blank. It is copied when the box // opens so the question and the accepted values cannot drift apart. answers []string @@ -491,8 +502,41 @@ func (e *keyEntry) typeInto(msg tea.KeyPressMsg) { listNavigate(msg, &e.box, func(int) {}, func() {}, 1) } -// value is what has been typed, trimmed — an answer or nothing at all. -func (e *keyEntry) value() string { return strings.TrimSpace(e.box.String()) } +func (e *keyEntry) choosing() bool { return len(e.choices) > 0 } + +// value is the picked id for a closed answer, or what has been typed and +// trimmed for an open one. +func (e *keyEntry) value() string { + if e.choosing() { + return e.choices[e.at].ID + } + return strings.TrimSpace(e.box.String()) +} + +// walk moves the choice cursor by the surface's one clamped-list rule. +func (e *keyEntry) walk(delta int) { + e.at = moveCursor(e.at, delta, len(e.choices)) +} + +// jumpTo moves to a choice whose name begins with one typed letter. The search +// starts after the cursor and wraps, so equal first letters cycle in catalog +// order instead of making every press land on the first one forever. +func (e *keyEntry) jumpTo(msg tea.KeyPressMsg) bool { + typed := []rune(msg.Key().Text) + if len(typed) != 1 || !unicode.IsPrint(typed[0]) { + return false + } + want := unicode.ToLower(typed[0]) + for offset := 1; offset <= len(e.choices); offset++ { + at := (e.at + offset) % len(e.choices) + name := []rune(strings.TrimSpace(e.choices[at].Name)) + if len(name) > 0 && unicode.ToLower(name[0]) == want { + e.at = at + return true + } + } + return false +} // keyHintLine is the one dim line under the box: where this key is to be found. // @@ -520,13 +564,14 @@ func keyHintLine(link string, pal palette, width int) string { return pal.dim(lead + linkify(fit(shown, width-ansi.StringWidth(lead)), link)) } -// keyBoxLines is the box as the lines it takes, and where the caret sits inside -// them: the instruction where the service has one, the answer box, and either -// the accepted values or the address where a key lives. +// keyBoxLines is the answer as the lines it takes, and where the caret sits +// inside them: either a closed choice, or the instruction, answer box, and +// accepted values or address where a key lives. // // It answers a caret ROW as well as a column because the instruction can stand // above the box, and a caller that assumed the box was the first line would put -// the caret on a sentence. +// the caret on a sentence. A choice answers -1 because it has no box and a +// caller that assumed every answer had a caret would park one on a name. // // indent is how far in the whole block sits, which is the one thing the two // surfaces disagree about: the panel's box takes the draft's own position at the @@ -542,6 +587,33 @@ func keyBoxLines(entry *keyEntry, pal palette, width, indent, maxRows int) ([]st } lead := strings.Repeat(" ", indent) width -= indent + if entry.choosing() { + question := lead + pal.dim(fit(" "+connectKeyHint(entry.name, entry.blank), width)) + choiceRows := maxRows + showQuestion := len(entry.choices)+1 <= maxRows + if showQuestion { + choiceRows-- + } + // THE QUESTION GIVES WAY FIRST, because these rows are what a person is + // answering. If even they do not fit, the ordinary list window keeps the + // cursor row drawn; two-region sources never need it, but the catalog is + // allowed to grow without turning the current answer invisible. + top := listTop(entry.at, 0, len(entry.choices), choiceRows) + bottom := min(len(entry.choices), top+choiceRows) + out := make([]string, 0, maxRows) + if showQuestion { + out = append(out, question) + } + for at := top; at < bottom; at++ { + name := entry.choices[at].Name + if at == entry.at { + out = append(out, lead+pal.accent(" › ")+pal.ink(fit(name, width-4))) + continue + } + out = append(out, lead+pal.dim(fit(" "+name, width))) + } + return out, 0, -1 + } askRows := make([]string, 0, 3) if entry.ask != "" { for _, line := range wrap(entry.ask, width-2) { diff --git a/internal/tui3/connectcaps.go b/internal/tui3/connectcaps.go index 3424e459e6..4b24b1225a 100644 --- a/internal/tui3/connectcaps.go +++ b/internal/tui3/connectcaps.go @@ -700,14 +700,7 @@ func (s *sheet) connRowLines(row *connRow, selected, hovered bool, width, boxRow // somebody is answering, or the one line saying what the account may do. // Never both — a box is open on a row that is not connected. if row.entry != nil { - // The box hangs at the same indent an open account's capabilities do, so - // a row that has opened into a question occupies the same interior column - // as a row that has opened into a list of answers. - lines, _, _ := keyBoxLines(row.entry, pal, width, overlayIndent, boxRows) - // AND ONE LINE OF AIR UNDER IT. A row that has opened into a question is - // a block, and the row after it is the catalog resuming; without the - // blank the next service reads as the fourth line of somebody's key. - return append(append(head, lines...), "") + return keyEntryRowLines(head, row.entry, pal, width, boxRows) } if row.summary != "" { return append(head, connUnder(row.summary, selected, hovered, width, pal)) @@ -715,6 +708,20 @@ func (s *sheet) connRowLines(row *connRow, selected, hovered bool, width, boxRow return head } +// keyEntryRowLines hangs the shared answer under one settings service row. +// The Connections catalog and the Providers reconnect row are two doors onto +// the SAME answer, so both spend this block rather than growing a second widget. +func keyEntryRowLines(head []string, entry *keyEntry, pal palette, width, boxRows int) []string { + // The answer hangs at the same indent an open account's capabilities do, so + // a row that has opened into a question occupies the same interior column + // as a row that has opened into a list of answers. + lines, _, _ := keyBoxLines(entry, pal, width, overlayIndent, boxRows) + // AND ONE LINE OF AIR UNDER IT. A row that has opened into a question is a + // block, and the row after it is the catalog resuming; without the blank the + // next service reads as the fourth line of somebody's key. + return append(append(head, lines...), "") +} + // connHeadInk is the held account's row, in the one weight this tab spends that // the settings list does not. // @@ -977,10 +984,13 @@ func (s *sheet) filterWorth() bool { // connKeysLine is the tab's key legend, in the sheet's own grammar. func (s *sheet) connKeysLine() string { - // A BOX HAS TAKEN THE KEYBOARD, so the legend is the box's two keys and not - // the list's five. A line still offering ↑↓ and the tabs would be offering - // keys that are going into a secret. + // AN ANSWER HAS TAKEN THE KEYBOARD, so the legend is its keys and not the + // sheet's five. A typed answer has only its two; a closed one is itself a + // list and says the walk it accepts. if s.conn.entry != nil { + if s.conn.entry.choosing() { + return "↑↓ move · enter choose · esc cancel" + } return "enter connect · esc cancel" } act := "enter act" @@ -1076,7 +1086,8 @@ func (a *app) newConnEntry(row *connRow) *keyEntry { return newKeyEntry(connect.Service{ID: row.service}, name) } -// connEntryKey drives the key box while it is open on a row of this tab. +// connEntryKey drives the answer while it is open on a Connections or Providers +// service row. // // It is the /connect panel's own two keys ([app.connectEntryKey]) with one // difference, and the difference is what the two surfaces ARE: the panel closes @@ -1090,17 +1101,17 @@ func (a *app) newConnEntry(row *connRow) *keyEntry { func (a *app) connEntryKey(msg tea.KeyPressMsg) tea.Cmd { s := &a.sheet entry := s.conn.entry - back := &connRow{kind: connService, service: entry.id} + back := entry.id switch msg.String() { case "esc": s.conn.entry = nil - s.rebuildConnAt(back) + s.rebuildEntryAt(back) case "enter": answer, id, name, secret := entry.value(), entry.id, entry.name, entry.secret s.conn.entry = nil if answer == "" { - s.rebuildConnAt(back) + s.rebuildEntryAt(back) return nil } if _, model := modelConnectionSource(id); model { @@ -1110,18 +1121,81 @@ func (a *app) connEntryKey(msg tea.KeyPressMsg) tea.Cmd { // [app.connTabSettled], which is the same bargain the sign-in makes with // the same two fields. s.conn.pending, s.conn.pendingKey, s.msg = id, secret, "" - s.rebuildConnAt(back) + s.rebuildEntryAt(back) if secret { return a.beginConnectKey(id, name, answer) } return a.beginConnect(id, name, answer) + case "up", "ctrl+p": + if entry.choosing() { + entry.walk(-1) + s.rebuildEntryAt(back) + return nil + } + entry.typeInto(msg) + + case "down", "ctrl+n": + if entry.choosing() { + entry.walk(1) + s.rebuildEntryAt(back) + return nil + } + entry.typeInto(msg) + + case "pgup": + if entry.choosing() { + entry.walk(-sheetRows) + s.rebuildEntryAt(back) + return nil + } + entry.typeInto(msg) + + case "pgdown": + if entry.choosing() { + entry.walk(sheetRows) + s.rebuildEntryAt(back) + return nil + } + entry.typeInto(msg) + default: + if entry.choosing() { + // A CLOSED CHOICE HAS NOTHING TO TYPE INTO. Rebuild only when a + // first-letter jump moved it; every other key is still swallowed. + if entry.jumpTo(msg) { + s.rebuildEntryAt(back) + } + return nil + } entry.typeInto(msg) } return nil } +// rebuildEntryAt redraws an answer and returns the cursor to the service row +// that opened it. The catalog stores the prefixed connection id while Providers +// stores the source id, so identity is translated once here rather than guessed +// by every key that moves the answer. +func (s *sheet) rebuildEntryAt(id string) { + if s.onConnections() { + s.rebuildConnAt(&connRow{kind: connService, service: id}) + return + } + s.build() + source, ok := modelConnectionSource(id) + if !ok { + return + } + for at, item := range s.items { + if item.service != nil && item.service.id == source { + s.cursor = at + return + } + } + s.cursor = s.clampCursor(s.cursor) +} + // cycleCapability walks yes → ask first → off → yes and WRITES AT ONCE. // // A refusal is shown and the word goes back: the state is read from the engine diff --git a/internal/tui3/connectpanel.go b/internal/tui3/connectpanel.go index 6925840b8e..087687631f 100644 --- a/internal/tui3/connectpanel.go +++ b/internal/tui3/connectpanel.go @@ -569,9 +569,9 @@ func (a *app) connectPanelKey(msg tea.KeyPressMsg) tea.Cmd { return cmd } -// connectEntryKey drives the typed-answer box open over the list. enter connects, an -// empty box is not an answer at all, and esc puts the person back on the row -// they pressed it from — which is the difference between this box and the +// connectEntryKey drives the answer open over the list. enter connects, an empty +// typed box is not an answer at all, and esc puts the person back on the row +// they pressed it from — which is the difference between this answer and the // offer's: nothing is waiting on it, so backing out of it declines nothing. func (a *app) connectEntryKey(msg tea.KeyPressMsg) tea.Cmd { p := &a.connPanel @@ -581,7 +581,7 @@ func (a *app) connectEntryKey(msg tea.KeyPressMsg) tea.Cmd { p.entry = nil case "enter": - answer := strings.TrimSpace(entry.box.String()) + answer := entry.value() p.entry = nil if answer == "" { break @@ -596,7 +596,42 @@ func (a *app) connectEntryKey(msg tea.KeyPressMsg) tea.Cmd { } return a.beginConnect(entry.id, entry.name, answer) + case "up", "ctrl+p": + if entry.choosing() { + entry.walk(-1) + break + } + entry.typeInto(msg) + + case "down", "ctrl+n": + if entry.choosing() { + entry.walk(1) + break + } + entry.typeInto(msg) + + case "pgup": + if entry.choosing() { + entry.walk(-connectRowsMax) + break + } + entry.typeInto(msg) + + case "pgdown": + if entry.choosing() { + entry.walk(connectRowsMax) + break + } + entry.typeInto(msg) + default: + if entry.choosing() { + // A CLOSED CHOICE HAS NOTHING TO TYPE INTO. A letter may move its + // cursor, and every other key is swallowed rather than reaching the + // editor that exists only for the next, open answer. + entry.jumpTo(msg) + break + } entry.typeInto(msg) } a.touch() diff --git a/internal/tui3/feed.go b/internal/tui3/feed.go index 7f5de391d6..d397e8f38f 100644 --- a/internal/tui3/feed.go +++ b/internal/tui3/feed.go @@ -1384,6 +1384,9 @@ func (f *feed) retry(ev session.Event) { // person's, and a status number is the one part of that answer nobody can act // on. func (f *feed) failureNote(err error, service string) string { + if paused, ok := provider.PlanPauseFrom(err); ok { + return provider.PlanPauseSentence(paused.Reset, paused.OverflowDoor) + } if said, ok := cannotPayWords(err); ok && strings.TrimSpace(service) != "" { return serviceCannotPayWord(service, said) } diff --git a/internal/tui3/firstrun.go b/internal/tui3/firstrun.go index fbb81d1b17..fbe56f3dcb 100644 --- a/internal/tui3/firstrun.go +++ b/internal/tui3/firstrun.go @@ -138,9 +138,11 @@ type setupFlow struct { skipped bool } -// setupStepsFor is which of the two questions this profile still needs -// answered, in the order they are asked. Each predicate is internal/config's -// own, so the screen cannot ask for a key Load would have found or a crew /crew +// setupSteps is which of the two questions this conversation still needs +// answered, in the order they are asked. The provider question uses the same +// answer as enter, so setup cannot ask for a default-service key a connected +// service carrying this conversation does not need. The controls keep using +// internal/config's own predicates, so the screen cannot ask for a crew /crew // would already report. // // THE CONTROLS SCREEN IS ONE STEP AND SO IT IS ASKED AS ONE. It carries three @@ -150,12 +152,12 @@ type setupFlow struct { // answered would read as a different screen every time it opened. The chat model // is not in the condition: it resolves from the build and from AFORGE_MODEL until // somebody chooses, so a profile is never MISSING one. -func setupStepsFor(profileDir string) []setupStep { +func (a *app) setupSteps() []setupStep { steps := make([]setupStep, 0, 2) - if !config.APIKeyConfigured(profileDir) { + if a.defaultProviderNeeded() { steps = append(steps, setupKey) } - if !config.CrewConfigured(profileDir) || !config.DailyBudgetConfigured(profileDir) { + if !config.CrewConfigured(a.profileDir) || !config.DailyBudgetConfigured(a.profileDir) { steps = append(steps, setupControls) } return steps @@ -190,7 +192,7 @@ func (a *app) openSetup(allowed bool) { return } dir := a.profileDir - providerMissing := a.routerConnect != nil && !config.APIKeyConfigured(dir) + providerMissing := a.defaultProviderNeeded() firstRun := allowed && !a.resumed && len(a.entries) == 0 && config.SetupSeenAt(dir).IsZero() if !providerMissing && !firstRun { return @@ -200,7 +202,7 @@ func (a *app) openSetup(allowed bool) { steps = append(steps, setupKey) } if firstRun { - for _, step := range setupStepsFor(dir) { + for _, step := range a.setupSteps() { if step == setupKey && providerMissing { continue } @@ -261,16 +263,12 @@ func (a *app) endSetup(skipped bool) tea.Cmd { if len(later) > 0 { a.noteFacts(setupLaterWord + " · " + strings.Join(later, " · ")) } - if !config.APIKeyConfigured(dir) { - word := setupNoKeyWord - facts := []string{config.APIKeyEnv} - if a.routerConnect != nil { - word = setupNoKeyConnectWord - facts = append([]string{"enter"}, facts...) - } else { - facts = append([]string{"/settings"}, facts...) - } - a.noteFacts(word, facts...) + if a.defaultProviderNeeded() { + a.noteFacts(setupNoKeyConnectWord, "enter", config.APIKeyEnv) + } else if a.routerConnect == nil && !config.APIKeyConfigured(dir) && !a.connectedServiceCarriesModel() { + // This is the older surface's settings pointer, not a second send gate. + // The local chat door always wires the browser seam above. + a.noteFacts(setupNoKeyWord, "/settings", config.APIKeyEnv) } // AND THE ONE LINE A MAC IS OWED BEFORE IT COSTS ANYBODY ANYTHING. Every // chord this surface binds is `⌥`, and most macOS terminals send Option as an @@ -294,10 +292,10 @@ func (a *app) endSetup(skipped bool) tea.Cmd { return nil } -// setupNoKeyWord is what the empty conversation says after a setup that ended -// with no key. It names the row and the variable, and nothing else: the -// conversation can be typed into, and the refusal on the first turn will say the -// rest in its own words. +// setupNoKeyWord is the older, non-browser setup seam's key line. Local chat +// launches wire the browser road and use [setupNoKeyConnectWord]; a surface +// without that road can still point at the settings row, unless a connected +// service already carries the conversation and the emptiness law says nothing. const setupNoKeyWord = "no openrouter key yet · paste one into /settings, or export " + config.APIKeyEnv // setupSkipKeysWord is what esc does, said the same way on every step of the @@ -316,8 +314,9 @@ const setupLaterWord = "still yours to set" // setupStepLater is the door onto ONE question esc walked past, said as the // thing a person would do rather than as the name of a step. The key step has -// none: a machine with no key says so in [setupNoKeyWord] already, and two lines -// about one absence is one too many. +// none: a conversation that still needs the default provider says so in +// [setupNoKeyConnectWord] or [setupNoKeyWord] already, and one carried by a +// connected service owes no line about OpenRouter at all. // // THE CONTROLS SCREEN NAMES ITS THREE DOORS AND NOT ITS OWN NAME. "the controls // screen" is a thing a person cannot go back to; /budget, /model and /crew are @@ -644,7 +643,7 @@ func (a *app) setupCommit() bool { // open /home/…/.aforge/config.json: permission denied`. It names a function, a // key, a path inside the program's own storage and an errno, and there is no act // in it. Three lines away this same file already had the right shape twice -// ([setupKeyShapeWord], [setupNoKeyWord]): the cause, and then what to do. +// ([setupKeyShapeWord], [setupNoKeyConnectWord]): the cause, and then what to do. // // THE THREE THAT ARE ALWAYS THE MACHINE'S are authored outright — nothing a // browser trip can return is a sentence for a person — and the four that write diff --git a/internal/tui3/firstrun_test.go b/internal/tui3/firstrun_test.go index f418b78aed..ac2a67b1fa 100644 --- a/internal/tui3/firstrun_test.go +++ b/internal/tui3/firstrun_test.go @@ -46,13 +46,22 @@ func setupApp(t *testing.T, seed func(dir string)) (*app, string, *[]string) { seed(dir) } handed := &[]string{} + var connect func(context.Context) (OpenRouterFlow, error) + if config.SetupSeenAt(dir).IsZero() { + connect = func(context.Context) (OpenRouterFlow, error) { return nil, nil } + } a := newApp(t.Context(), Options{ - Agent: &fakeAgent{model: "openai/gpt-4.1-mini"}, - Workspace: "/tmp/lab", - ProfileDir: dir, - Setup: true, - ApplyAPIKey: func(key string) error { *handed = append(*handed, key); return nil }, + Agent: &fakeAgent{model: "openai/gpt-4.1-mini"}, + Workspace: "/tmp/lab", + ProfileDir: dir, + Setup: true, + ConnectOpenRouter: connect, + ApplyAPIKey: func(key string) error { *handed = append(*handed, key); return nil }, }) + // The browser seam is present while the real launch decides which screens + // exist. Most tests below exercise the paste road, so it is taken back off + // before they press a key; the browser test installs its recording flow. + a.routerConnect = nil a.width, a.height = 90, 30 a.pal = newPalette(tokens.ANSI256, false) a.touch() diff --git a/internal/tui3/input.go b/internal/tui3/input.go index a22e942c6b..88fb25e3bb 100644 --- a/internal/tui3/input.go +++ b/internal/tui3/input.go @@ -7,7 +7,6 @@ import ( tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" - "github.com/Agent-Field/aforge-v2/internal/config" "github.com/Agent-Field/aforge-v2/internal/session" ) @@ -1333,15 +1332,15 @@ func (a *app) enterLine(marked bool) tea.Cmd { if windowsDroppedLineShape(line) && a.inputDroppedLine(line) { return a.edited() } - // A MODEL MESSAGE WITH NO DEFAULT-PROVIDER KEY OPENS THE CONNECTION BEFORE - // THE DRAFT IS CLEARED. This is the returning half of the key gate: a person - // who pressed esc to read an existing conversation can still type naturally, - // and enter gives them the browser door rather than spending their words on - // the provider's "no API key" refusal. Slash commands stay local and keep - // working — /help and /settings do not need a model — and a custom endpoint - // has no OpenRouter seam, so it keeps its own credential path. + // A MODEL MESSAGE THAT STILL NEEDS THE DEFAULT PROVIDER OPENS ITS CONNECTION + // BEFORE THE DRAFT IS CLEARED. This is the returning half of the key gate: a + // person who pressed esc to read an existing conversation can still type + // naturally, and enter gives them the browser door rather than spending their + // words on the provider's "no API key" refusal. Slash commands stay local and + // keep working — /help and /settings do not need a model — and a connected + // service carrying this model has its own working credential path. if !strings.HasPrefix(line, "/") && (line != "" || held) && - a.routerConnect != nil && !config.APIKeyConfigured(a.profileDir) { + a.defaultProviderNeeded() { a.openSetup(false) return nil } @@ -1541,7 +1540,15 @@ func (a *app) inputBlock(width int) ([]string, int, int) { // surface gives every box that has taken the keyboard. if a.connPanel.open { if entry := a.connPanel.entry; entry != nil { - return keyBoxLines(entry, a.pal, width, 0, rows) + lines, caretX, caretRow := keyBoxLines(entry, a.pal, width, 0, rows) + if caretRow < 0 { + // A CHOICE HAS NO BOX, so there is nowhere for the caret to live and + // it is hidden rather than parked on the first name — the same law + // home-at-rest and the job page follow (pages.go, view.go). + a.caret = false + return lines, 0, 0 + } + return lines, caretX, caretRow } if a.connPanel.filtering { return draftBlock(&a.connPanel.filter, a.pal, width, 1, connectFilterHint, "") diff --git a/internal/tui3/modelregion_test.go b/internal/tui3/modelregion_test.go new file mode 100644 index 0000000000..3877488f3f --- /dev/null +++ b/internal/tui3/modelregion_test.go @@ -0,0 +1,258 @@ +package tui3 + +import ( + "strings" + "testing" + + "github.com/Agent-Field/aforge-v2/internal/config" + "github.com/Agent-Field/aforge-v2/internal/modelsource" + "github.com/Agent-Field/aforge-v2/internal/modelsource/sourcestub" +) + +// modelRegionApp is the real model-service catalog over an empty profile. The +// shelf keeps every later listing on the same test-local seams as the existing +// connection tests; opening a region choice itself spends no network call. +func modelRegionApp(t *testing.T) (*app, string) { + t.Helper() + dir := t.TempDir() + a := modelServiceTestApp(t, dir, "openai/gpt-4.1-mini", + modelsource.NewSet(testDefaultService("sk-default-1234567890")), + []Model{{ID: "openai/gpt-4.1-mini"}}) + installModelServiceShelf(a, dir) + return a, dir +} + +// openModelRegion walks through the panel's own enter door on one catalog row. +func openModelRegion(t *testing.T, a *app, source string) int { + t.Helper() + a.openConnect() + want := modelConnectionID(source) + for at := range a.connPanel.hits { + row, ok := a.connPanel.at(at) + if !ok || row.ID != want { + continue + } + a.connPanel.cursor = at + drive(t, a, key("enter")) + return at + } + t.Fatalf("the models group did not contain %s", source) + return -1 +} + +func modelRegionBlock(a *app) string { + lines, _, _ := a.inputBlock(a.width) + plainLines := make([]string, 0, len(lines)) + for _, line := range lines { + plainLines = append(plainLines, plain(line)) + } + return strings.Join(plainLines, "\n") +} + +func regionUnderCursor(block, name string) bool { + for _, line := range strings.Split(block, "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "› "+name) { + return true + } + } + return false +} + +func TestARegionedServiceAsksWithAChoiceAndNotABox(t *testing.T) { + for _, source := range []string{"z-ai", "moonshot", "qwen"} { + t.Run(source, func(t *testing.T) { + a, _ := modelRegionApp(t) + openModelRegion(t, a, source) + + block := modelRegionBlock(a) + if !strings.Contains(block, "your region") { + t.Fatalf("the choice does not name its question:\n%s", block) + } + international, china := strings.Index(block, "International"), strings.Index(block, "China") + if international < 0 || china < international { + t.Fatalf("the regions are not drawn in catalog order:\n%s", block) + } + if !regionUnderCursor(block, "International") { + t.Fatalf("the first region is not under the cursor:\n%s", block) + } + for _, typedShape := range []string{"International, China", "pick one of"} { + if strings.Contains(block, typedShape) || strings.Contains(plain(frame(a)), typedShape) { + t.Fatalf("the region choice still draws %q:\n%s", typedShape, block) + } + } + if a.connPanel.entry == nil || !a.connPanel.entry.choosing() { + t.Fatal("the region question did not open as a closed choice") + } + _ = frame(a) + if a.caret { + t.Fatal("the region choice drew a caret with no box to hold it") + } + }) + } +} + +func TestTheRegionChoiceWalksTakesAndBacksOut(t *testing.T) { + a, _ := modelRegionApp(t) + openModelRegion(t, a, "z-ai") + + drive(t, a, key("down")) + if block := modelRegionBlock(a); !regionUnderCursor(block, "China") { + t.Fatalf("down did not put China under the cursor:\n%s", block) + } + drive(t, a, key("up")) + if block := modelRegionBlock(a); !regionUnderCursor(block, "International") { + t.Fatalf("up did not put International under the cursor:\n%s", block) + } + drive(t, a, key("ctrl+n")) + if block := modelRegionBlock(a); !regionUnderCursor(block, "China") { + t.Fatalf("ctrl+n did not put China under the cursor:\n%s", block) + } + drive(t, a, key("ctrl+p")) + if block := modelRegionBlock(a); !regionUnderCursor(block, "International") { + t.Fatalf("ctrl+p did not put International under the cursor:\n%s", block) + } + drive(t, a, key("down"), key("enter")) + if a.connPanel.entry == nil || !a.connPanel.entry.secret { + t.Fatal("taking a region did not open the secret key box") + } + if block := modelRegionBlock(a); !strings.Contains(block, "your key") { + t.Fatalf("taking a region did not ask for the key:\n%s", block) + } + + b, dir := modelRegionApp(t) + rowAt := openModelRegion(t, b, "z-ai") + drive(t, b, key("esc")) + if !b.connPanel.open || b.connPanel.entry != nil || b.connPanel.cursor != rowAt { + t.Fatal("esc did not return to the Z.ai row with the panel open") + } + if b.modelDraft != nil && b.modelDraft.row.Region != "" { + t.Fatalf("esc kept a region in the draft: %+v", b.modelDraft.row) + } + if rows := config.PersistedSources(dir); len(rows) != 0 { + t.Fatalf("esc saved a model service: %+v", rows) + } +} + +func TestALetterPicksTheRegionAndTypesNowhere(t *testing.T) { + a, _ := modelRegionApp(t) + openModelRegion(t, a, "z-ai") + + drive(t, a, key("c")) + if block := modelRegionBlock(a); !regionUnderCursor(block, "China") { + t.Fatalf("c did not jump to China:\n%s", block) + } + if got := a.connPanel.entry.box.String(); got != "" { + t.Fatalf("c typed into the hidden editor: %q", got) + } + drive(t, a, key("i")) + if block := modelRegionBlock(a); !regionUnderCursor(block, "International") { + t.Fatalf("i did not jump to International:\n%s", block) + } +} + +func TestTheChosenRegionIsTheOneTheConnectionUses(t *testing.T) { + a, _ := modelRegionApp(t) + openModelRegion(t, a, "z-ai") + drive(t, a, key("down"), key("enter")) + if a.modelDraft == nil || a.modelDraft.row.Region != "cn" { + t.Fatalf("China stored region %+v, want cn", a.modelDraft) + } + + b, _ := modelRegionApp(t) + openModelRegion(t, b, "z-ai") + drive(t, b, key("enter")) + if b.modelDraft == nil || b.modelDraft.row.Region != "intl" { + t.Fatalf("the preselected row stored region %+v, want intl", b.modelDraft) + } +} + +func TestASourceWithoutRegionsOpensItsOwnBoxAsBefore(t *testing.T) { + t.Run("DeepSeek key", func(t *testing.T) { + a, _ := modelRegionApp(t) + openModelRegion(t, a, "deepseek") + if a.connPanel.entry == nil || a.connPanel.entry.choosing() || !a.connPanel.entry.secret { + t.Fatal("DeepSeek did not open its secret key box") + } + if block := modelRegionBlock(a); !strings.Contains(block, "your key") { + t.Fatalf("DeepSeek did not ask for its key:\n%s", block) + } + }) + + t.Run("custom address", func(t *testing.T) { + a, _ := modelRegionApp(t) + openModelRegion(t, a, "custom") + if a.connPanel.entry == nil || a.connPanel.entry.choosing() || a.connPanel.entry.secret { + t.Fatal("Something else did not open its visible address box") + } + if block := modelRegionBlock(a); !strings.Contains(block, "your base url") { + t.Fatalf("Something else did not ask for its base URL:\n%s", block) + } + }) + + t.Run("Ollama starts", func(t *testing.T) { + server := sourcestub.New("llama3") + defer server.Close() + a, _ := modelRegionApp(t) + for at := range a.modelCatalog { + if a.modelCatalog[at].ID == "ollama" { + a.modelCatalog[at].Address = server.URL() + } + } + openModelRegion(t, a, "ollama") + if a.connPanel.entry != nil { + t.Fatal("Ollama opened an answer box") + } + }) +} + +func TestTheProvidersSheetAsksWithTheSameChoice(t *testing.T) { + a, dir := modelRegionApp(t) + var source modelsource.Source + for _, candidate := range modelsource.Vendored() { + if candidate.ID == "z-ai" { + source = candidate + break + } + } + row := config.PersistedSource{ + ID: "z-ai", Written: "z-ai", Key: "plan-test-key", Region: "intl", Door: "coding-plan", Order: 1, + } + if err := config.WriteSources(dir, []config.PersistedSource{row}); err != nil { + t.Fatal(err) + } + connected := modelsource.Connected{ + Source: source, Key: row.Key, Address: source.Regions[0].Address, Door: source.Doors[0], + } + a.sources = modelsource.NewSet(testDefaultService("sk-default-1234567890"), connected) + a.openSettings() + toProviders(t, a) + found := false + for at, item := range a.sheet.items { + if item.service != nil && item.service.id == "z-ai" { + a.sheet.cursor = at + found = true + break + } + } + if !found { + t.Fatal("Providers did not draw the connected Z.ai service") + } + drive(t, a, key("enter")) + + screen := strings.Join(sheetLabels(a), "\n") + if !strings.Contains(screen, "your region") || + !regionUnderCursor(screen, "International") || + strings.Index(screen, "China") < strings.Index(screen, "International") { + t.Fatalf("the sheet did not draw the shared region choice:\n%s", screen) + } + drive(t, a, key("down")) + screen = strings.Join(sheetLabels(a), "\n") + if !regionUnderCursor(screen, "China") { + t.Fatalf("the sheet did not redraw with China under the cursor:\n%s", screen) + } + drive(t, a, key("enter")) + screen = strings.Join(sheetLabels(a), "\n") + if a.sheet.conn.entry == nil || !a.sheet.conn.entry.secret || !strings.Contains(screen, "your key") { + t.Fatalf("the sheet did not open the key box on the same row:\n%s", screen) + } +} diff --git a/internal/tui3/modelservices.go b/internal/tui3/modelservices.go index 0fd668483e..d0664fc8ad 100644 --- a/internal/tui3/modelservices.go +++ b/internal/tui3/modelservices.go @@ -13,6 +13,7 @@ import ( "github.com/Agent-Field/aforge-v2/internal/config" "github.com/Agent-Field/aforge-v2/internal/connect" "github.com/Agent-Field/aforge-v2/internal/modelsource" + "github.com/Agent-Field/aforge-v2/internal/provider" ) // Model-service rows share the connection panel's row grammar without sharing @@ -59,12 +60,10 @@ func (a *app) prepareModelServices() { // Local v3 launches always pass the resolved set; hosted and older seams // pass nothing and must keep their connection surface byte-identical. a.sourceModels = make(map[string][]Model) - a.modelSuggestions = make(map[string]string) return } a.modelCatalog = modelsource.Vendored() a.sourceModels = make(map[string][]Model) - a.modelSuggestions = make(map[string]string) for _, service := range a.sources.All() { if strings.EqualFold(service.Source.ID, modelsource.DefaultID) || service.Source.Listing != modelsource.ListingModels { continue @@ -210,19 +209,18 @@ func (a *app) startModelConnect(row connect.Status, fromSheet bool) tea.Cmd { persisted := config.PersistedSource{ID: source.ID, Written: source.Written, Order: a.nextModelServiceOrder()} for _, existing := range config.PersistedSources(a.profileDir) { if strings.EqualFold(existing.ID, source.ID) { + // A reconnect starts with the row that actually landed, so an automatic + // name such as z-ai-direct stays put instead of being suggested again. persisted = existing break } } - if suggestion := a.modelSuggestions[strings.ToLower(source.ID)]; suggestion != "" { - persisted.Written = suggestion - } draft := &modelConnectDraft{source: source, row: persisted, sheet: fromSheet} a.modelDraft = draft switch { case len(source.Regions) > 0: draft.step = modelConnectRegion - a.showModelEntry(newModelEntry(row.ID, source.Name, "region", regionAnswers(source), false), fromSheet) + a.showModelEntry(newModelChoiceEntry(row.ID, source.Name, "region", regionChoices(source)), fromSheet) return nil case source.ID == "custom": draft.step = modelConnectAddress @@ -241,12 +239,16 @@ func newModelEntry(id, name, blank string, answers []string, secret bool) *keyEn return &keyEntry{id: id, name: strings.ToLower(name), blank: blank, answers: answers, secret: secret} } -func regionAnswers(source modelsource.Source) []string { - answers := make([]string, 0, len(source.Regions)) +func newModelChoiceEntry(id, name, blank string, choices []entryChoice) *keyEntry { + return &keyEntry{id: id, name: strings.ToLower(name), blank: blank, choices: choices} +} + +func regionChoices(source modelsource.Source) []entryChoice { + choices := make([]entryChoice, 0, len(source.Regions)) for _, region := range source.Regions { - answers = append(answers, region.Name) + choices = append(choices, entryChoice{ID: region.ID, Name: region.Name}) } - return answers + return choices } func (a *app) showModelEntry(entry *keyEntry, inSheet bool) { @@ -282,19 +284,9 @@ func (a *app) modelEntryAnswer(entry *keyEntry) tea.Cmd { } switch draft.step { case modelConnectRegion: - region := "" - for _, candidate := range draft.source.Regions { - if strings.EqualFold(answer, candidate.ID) || strings.EqualFold(answer, candidate.Name) { - region = candidate.ID - break - } - } - if region == "" { - a.modelServiceMessage("pick one of: " + strings.Join(regionAnswers(draft.source), ", ")) - a.showModelEntry(entry, draft.sheet) - return nil - } - draft.row.Region = region + // The answer is picked from this source's own region list, so there is + // nothing left to validate or refuse here. + draft.row.Region = answer draft.step = modelConnectKey a.showModelEntry(newModelEntry(modelConnectionID(draft.source.ID), draft.source.Name, "key", nil, true), draft.sheet) return nil @@ -378,7 +370,17 @@ func (a *app) beginModelConnect(draft modelConnectDraft) tea.Cmd { // a Connected here would create a second, subtly different account door. connected, found := config.ResolveSources(dir, "", "").ByID(draft.source.ID) if found { - if refresh != nil { + fixedDoorCatalog := len(outcome.Door.Models) > 0 + if fixedDoorCatalog { + seed := make([]modelcatalog.Model, 0, len(models)) + for _, model := range models { + seed = append(seed, modelcatalog.Model{ID: model.ID}) + } + _ = modelcatalog.Remember(modelcatalog.Options{ + Source: draft.source.ID, BaseURL: connected.Address, Dir: dir, + }, seed) + _ = WriteModelCacheFor(draft.source.ID, connected.Address, models) + } else if refresh != nil { if refreshed, refreshErr := refresh(ctx, connected, models); refreshErr == nil && len(refreshed) > 0 { models = refreshed } @@ -483,26 +485,41 @@ func (a *app) adoptModelConnectResult(msg modelConnectResultMsg) { } service = strings.ToLower(service) line := "" + nextModel := "" switch msg.outcome.Kind { case modelsource.OutcomeConnected, modelsource.OutcomeAccountCannotPay: a.reloadModelSources() - if connected, ok := a.sources.ByID(msg.service); ok { + connected, found := a.sources.ByID(msg.service) + if found { service = strings.ToLower(connected.Source.Written) } if msg.outcome.Kind == modelsource.OutcomeConnected { a.sourceModels[msg.service] = cleanModels(msg.models) + if found { + if modelUsesService(a.deferredModelServiceModel, connected.Source.Written) { + a.deferredModelServiceModel = "" + } + preferred := connected.Source.PreferredModel(connected.Door, listedModelIDs(a.sourceModels[msg.service])) + if next := connected.Qualify(preferred); next != "" && next != strings.TrimSpace(a.model) { + if a.state == stateWorking { + a.deferredModelServiceModel = next + } else { + nextModel = next + } + } + } } line = serviceOutcomeWord(service, msg.outcome) if msg.outcome.Kind == modelsource.OutcomeConnected && a.engineRoad && strings.TrimSpace(msg.keyEnv) != "" { line += " · " + engineVariableWord(msg.keyEnv) } - case modelsource.OutcomeCollides: - a.modelSuggestions[strings.ToLower(msg.service)] = msg.outcome.Suggestion - line = serviceOutcomeWord(service, msg.outcome) default: line = serviceOutcomeWord(service, msg.outcome) } a.modelServiceMessage(line) + if nextModel != "" { + a.moveConversationToConnectedModel(nextModel) + } if a.connPanel.open { a.connPanel.adopt(a.connectionRows()) } @@ -514,6 +531,39 @@ func (a *app) adoptModelConnectResult(msg modelConnectResultMsg) { a.touch() } +// listedModelIDs keeps the ids in the service's own order for modelsource's +// single preference rule. The surface metadata stays here; only ids cross the +// package boundary that owns the ordering. +func listedModelIDs(models []Model) []string { + ids := make([]string, 0, len(models)) + for _, model := range models { + ids = append(ids, model.ID) + } + return ids +} + +// moveConversationToConnectedModel takes the same model-change road a person +// takes through /model, then uses the disconnect receipt's sentence so the two +// automatic moves cannot drift into two accounts of what happened. +func (a *app) moveConversationToConnectedModel(next string) { + next = strings.TrimSpace(next) + if next == "" || next == strings.TrimSpace(a.model) { + return + } + was := a.model + a.switchModel(next, 0) + a.modelServiceFollowup(serviceMovedWord(was, next)) +} + +// applyDeferredModelServiceMove spends the one pending move only after the +// answering turn has settled. Clearing it first makes the second settle event +// free and prevents a failed later path from replaying an old connection. +func (a *app) applyDeferredModelServiceMove() { + next := a.deferredModelServiceModel + a.deferredModelServiceModel = "" + a.moveConversationToConnectedModel(next) +} + func serviceOutcomeWord(service string, outcome modelsource.Outcome) string { switch outcome.Kind { case modelsource.OutcomeConnected: @@ -530,18 +580,26 @@ func serviceOutcomeWord(service string, outcome modelsource.Outcome) string { return service + " did not answer · nothing was saved" case modelsource.OutcomeWrongShape: return "that is not the shape of a " + service + " key — they start with sk-" - case modelsource.OutcomeCollides: - return service + " is a model author on openrouter · connect this as " + outcome.Suggestion } return "" } func serviceConnectedWord(service string, outcome modelsource.Outcome) string { line := service + " is connected" - if !outcome.Listed || outcome.Models <= 0 { - return line + if door := strings.TrimSpace(outcome.Door.Name); door != "" { + line += " · " + door + } + if outcome.Listed && outcome.Models > 0 { + line += " · " + itoa(outcome.Models) + " " + plural("model", outcome.Models) + } + if outcome.PlanPaused { + overflow := "" + if outcome.Overflow != nil { + overflow = outcome.Overflow.Name + } + line += " · " + provider.PlanPauseSentence(outcome.PlanReset, overflow) } - return line + " · " + itoa(outcome.Models) + " " + plural("model", outcome.Models) + return line } func engineVariableWord(name string) string { @@ -652,6 +710,9 @@ func (a *app) disconnectModelService(id string) { a.modelServiceMessage(err.Error()) return } + if modelUsesService(a.deferredModelServiceModel, connected.Source.Written) { + a.deferredModelServiceModel = "" + } delete(a.sourceModels, id) a.reloadModelSources() a.modelServiceMessage(serviceDisconnectedWord(written)) @@ -680,6 +741,31 @@ func (a *app) modelIsDirect(model string) bool { return service.Source.ID != "" && !strings.EqualFold(service.Source.ID, modelsource.DefaultID) } +// defaultProviderNeeded is the ONE answer to "does this person still owe us an +// OpenRouter key before they can say anything". A CONNECTED SERVICE THAT CAN +// CARRY THE CONVERSATION IS THE PROVIDER: opening the default service's browser +// door over it would stop a working turn in order to collect a key that turn +// does not use. Blank is usable only when the service says so explicitly, which +// is how a local Ollama seat remains a real seat rather than a broken key row. +func (a *app) defaultProviderNeeded() bool { + if a.routerConnect == nil || config.APIKeyConfigured(a.profileDir) { + return false + } + return !a.connectedServiceCarriesModel() +} + +// connectedServiceCarriesModel is the service half of the prerequisite: the +// current model resolves away from the default service and that account can +// answer. Keeping it named lets the non-browser setup seam describe its own +// missing key without teaching that older seam a second version of this rule. +func (a *app) connectedServiceCarriesModel() bool { + service, _ := a.sources.For(a.model) + if service.Source.ID == "" || strings.EqualFold(service.Source.ID, modelsource.DefaultID) { + return false + } + return strings.TrimSpace(service.Key) != "" || service.Source.KeyOptional +} + func modelUsesService(model, written string) bool { model = strings.TrimSpace(model) written = strings.TrimSpace(written) @@ -722,7 +808,10 @@ func modelServiceRows(profileDir string, sources modelsource.Set) []*modelServic if !ok { continue } - parts := make([]string, 0, 3) + parts := make([]string, 0, 5) + if service.Door.Name != "" { + parts = append(parts, service.Door.Name) + } switch { case persisted.KeyEnv != "": parts = append(parts, "$"+persisted.KeyEnv) @@ -733,15 +822,59 @@ func modelServiceRows(profileDir string, sources modelsource.Set) []*modelServic parts = append(parts, persisted.Region) } parts = append(parts, "order "+itoa(persisted.Order)) + if persisted.ID == "z-ai" && service.Door.ID == "coding-plan" { + parts = append(parts, "Zhipu lists the tools its plan covers; aforge is not listed, and its request has been drafted but not sent.") + } out = append(out, &modelServiceRow{ id: persisted.ID, name: service.Source.Written, value: strings.Join(parts, " · "), }) + if service.Overflow != nil && !service.Door.Metered { + out = append(out, &modelServiceRow{ + id: persisted.ID, name: "when the plan is paused", value: service.PlanPaused, planPause: true, + }) + } } return out } type modelServiceRow struct { - id string - name string - value string + id string + name string + value string + planPause bool +} + +func (a *app) cyclePlanPause(id string) { + rows := config.PersistedSources(a.profileDir) + for index := range rows { + if !strings.EqualFold(rows[index].ID, id) { + continue + } + if strings.TrimSpace(rows[index].PlanPaused) == config.PlanPausedUseMeter { + rows[index].PlanPaused = config.PlanPausedWait + } else { + rows[index].PlanPaused = config.PlanPausedUseMeter + } + if err := config.WriteSources(a.profileDir, rows); err != nil { + a.modelServiceMessage(err.Error()) + return + } + a.reloadModelSources() + a.sheet.sources = a.sources + a.sheet.build() + return + } +} + +func (a *app) reconnectModelService(id string) tea.Cmd { + source, ok := a.modelSource(id) + if !ok { + return nil + } + for _, row := range config.PersistedSources(a.profileDir) { + if strings.EqualFold(row.ID, id) { + return a.beginModelConnect(modelConnectDraft{source: source, row: row, sheet: a.at(pageSettings)}) + } + } + return nil } diff --git a/internal/tui3/modelservices_test.go b/internal/tui3/modelservices_test.go index ff7d5f6a28..dd57b36a3a 100644 --- a/internal/tui3/modelservices_test.go +++ b/internal/tui3/modelservices_test.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "net/http" + "os" + "path/filepath" "reflect" "strings" "testing" @@ -52,7 +54,8 @@ func modelServiceTestAppWithAgent(t *testing.T, dir string, model string, source t.Setenv("AFORGE_HOME", t.TempDir()) options := Options{ Agent: agent, ProfileDir: dir, Sources: sources, - Models: func() []Model { return models }, + Models: func() []Model { return models }, + SaveModel: func(model string) error { return config.WriteChatModel(dir, model) }, } if live, ok := agent.(*session.Agent); ok { options.ApplyModelSources = live.SetSources @@ -79,6 +82,230 @@ func testDirectService(address string) modelsource.Connected { return modelsource.Connected{} } +func testModelSource(t *testing.T, id string) modelsource.Source { + t.Helper() + for _, source := range modelsource.Vendored() { + if source.ID == id { + return source + } + } + t.Fatalf("there is no vendored model service %q", id) + return modelsource.Source{} +} + +func installTestModelSource(t *testing.T, a *app, source modelsource.Source) { + t.Helper() + for index := range a.modelCatalog { + if a.modelCatalog[index].ID == source.ID { + a.modelCatalog[index] = source + return + } + } + t.Fatalf("the surface catalog has no model service %q", source.ID) +} + +func connectZAIFromPanel(t *testing.T, a *app, apiKey string) { + t.Helper() + a.openConnect() + rowAt := -1 + for at := range a.connPanel.hits { + row, ok := a.connPanel.at(at) + if ok && row.ID == modelConnectionID("z-ai") { + rowAt = at + break + } + } + if rowAt < 0 { + t.Fatal("the models group did not contain Z.ai") + } + a.connPanel.cursor = rowAt + if cmd := a.connectAct(rowAt); cmd != nil || a.connPanel.entry == nil || !a.connPanel.entry.choosing() { + t.Fatal("enter on Z.ai did not open the region choice") + } + if cmd := a.connectEntryKey(key("enter")); cmd != nil || a.connPanel.entry == nil || !a.connPanel.entry.secret { + t.Fatal("the chosen region did not open the key box") + } + a.connPanel.entry.box.setText(apiKey) + cmd := a.connectEntryKey(key("enter")) + if cmd == nil { + t.Fatal("the completed key did not start the connection") + } + if _, follow := a.Update(cmd()); follow != nil { + t.Fatal("the settled connection unexpectedly started another command") + } +} + +func newZAIConnectTestApp(t *testing.T, defaultKey string) (*app, string) { + t.Helper() + for _, pin := range []string{config.APIKeyEnv, "OPENAI_API_KEY", "ZHIPU_API_KEY"} { + t.Setenv(pin, "") + } + plan := sourcestub.New("plan-probe") + t.Cleanup(plan.Close) + metered := sourcestub.New("metered-probe") + t.Cleanup(metered.Close) + source := testModelSource(t, "z-ai") + source.Doors[0].Address = plan.URL() + source.Doors[1].Address = metered.URL() + dir := t.TempDir() + opening := "~deepseek/deepseek-v4-flash-latest" + a := modelServiceTestApp(t, dir, opening, + modelsource.NewSet(testDefaultService(defaultKey)), + []Model{{ID: opening}, {ID: "z-ai/existing-author"}}) + installTestModelSource(t, a, source) + return a, dir +} + +func TestConnectingAServiceMovesTheConversationOntoItsPreferredModel(t *testing.T) { + for _, testCase := range []struct { + name string + defaultKey string + }{ + {name: "with the default service connected", defaultKey: "sk-default-1234567890"}, + {name: "with no default service key"}, + } { + t.Run(testCase.name, func(t *testing.T) { + a, dir := newZAIConnectTestApp(t, testCase.defaultKey) + opening := a.model + connectZAIFromPanel(t, a, "plan-test-key") + + want := "z-ai-direct/glm-5.3" + if a.model != want || a.agent.Model() != want || a.modelWord() != want { + t.Fatalf("conversation model = %q/%q, status = %q, want %q", a.model, a.agent.Model(), a.modelWord(), want) + } + if got := noteSaying(t, a, "is connected"); got != "z-ai-direct is connected · coding plan · 4 models" { + t.Fatalf("connection note = %q", got) + } + if got := noteSaying(t, a, "this conversation was on"); got != "this conversation was on "+opening+" · it is now on "+want { + t.Fatalf("move note = %q", got) + } + if got := config.ChatModelAt(dir); got != want { + t.Fatalf("the profile holds %q, want the moved model %q", got, want) + } + }) + } +} + +func TestConnectingAServiceFromProvidersMovesTheConversationOntoItsPreferredModel(t *testing.T) { + a, dir := newZAIConnectTestApp(t, "") + source, ok := a.modelSource("z-ai") + if !ok { + t.Fatal("the test surface lost Z.ai") + } + row := config.PersistedSource{ + ID: source.ID, Written: "z-ai-direct", Region: "intl", Key: "old-plan-key", Door: source.Doors[0].ID, Order: 1, + } + if err := config.WriteSources(dir, []config.PersistedSource{row}); err != nil { + t.Fatal(err) + } + connectedSource := source + connectedSource.Written = row.Written + connectedSource.Address = source.Doors[0].Address + a.sources = modelsource.NewSet(a.sources.Default(), modelsource.Connected{ + Source: connectedSource, Key: row.Key, Address: source.Doors[0].Address, Door: source.Doors[0], + }) + a.openSettings() + toProviders(t, a) + found := false + for at, item := range a.sheet.items { + if item.service != nil && item.service.id == "z-ai" { + a.sheet.cursor = at + found = true + break + } + } + if !found { + t.Fatal("Providers did not draw the connected Z.ai service") + } + drive(t, a, key("enter")) + drive(t, a, key("enter")) + if a.sheet.conn.entry == nil || !a.sheet.conn.entry.secret { + t.Fatal("the Providers road did not reach the key box") + } + a.sheet.conn.entry.box.setText("new-plan-key") + drive(t, a, key("enter")) + + wantModel := "z-ai-direct/glm-5.3" + wantMessage := "z-ai-direct is connected · coding plan · 4 models\n" + + "this conversation was on ~deepseek/deepseek-v4-flash-latest · it is now on " + wantModel + if a.model != wantModel || a.modelWord() != wantModel { + t.Fatalf("Providers left the conversation on %q with status %q", a.model, a.modelWord()) + } + if a.sheet.msg != wantMessage { + t.Fatalf("Providers message = %q, want %q", a.sheet.msg, wantMessage) + } +} + +func TestAConnectDuringATurnMovesTheModelWhenTheTurnEnds(t *testing.T) { + a, _ := newZAIConnectTestApp(t, "") + opening := a.model + a.state = stateWorking + connectZAIFromPanel(t, a, "plan-test-key") + + if a.model != opening || a.modelWord() != opening { + t.Fatalf("a running turn moved from %q to %q before it ended", opening, a.model) + } + for _, note := range noteTexts(a) { + if strings.Contains(note, "this conversation was on") { + t.Fatalf("the running turn drew its deferred move early: %q", note) + } + } + if a.deferredModelServiceModel != "z-ai-direct/glm-5.3" { + t.Fatalf("deferred model = %q", a.deferredModelServiceModel) + } + a.settle() + if a.model != "z-ai-direct/glm-5.3" || a.state != stateIdle || a.deferredModelServiceModel != "" { + t.Fatalf("settled model/state/deferred = %q/%v/%q", a.model, a.state, a.deferredModelServiceModel) + } + if got := noteSaying(t, a, "this conversation was on"); got != "this conversation was on "+opening+" · it is now on z-ai-direct/glm-5.3" { + t.Fatalf("settled move note = %q", got) + } +} + +func TestDisconnectingAServiceBeforeTheTurnEndsClearsItsDeferredMove(t *testing.T) { + a, _ := newZAIConnectTestApp(t, "") + opening := a.model + a.state = stateWorking + connectZAIFromPanel(t, a, "plan-test-key") + a.disconnectModelService("z-ai") + a.settle() + + if a.model != opening || a.deferredModelServiceModel != "" { + t.Fatalf("the disconnected service left model/deferred %q/%q", a.model, a.deferredModelServiceModel) + } + for _, note := range noteTexts(a) { + if strings.Contains(note, "this conversation was on") { + t.Fatalf("the disconnected deferred service still moved the conversation: %q", note) + } + } +} + +func TestTheMovedModelIsTheOneTheNextLaunchOpensOn(t *testing.T) { + a, dir := newZAIConnectTestApp(t, "") + connectZAIFromPanel(t, a, "plan-test-key") + want := "z-ai-direct/glm-5.3" + if got := config.ChatModelAt(dir); got != want { + t.Fatalf("the profile holds %q, want %q", got, want) + } + + nextSources := config.ResolveSources(dir, "", config.DefaultBaseURL) + next := newApp(t.Context(), Options{ + Agent: &fakeAgent{model: config.ChatModelAt(dir)}, + Workspace: t.TempDir(), + ProfileDir: dir, + Sources: nextSources, + Models: func() []Model { return []Model{{ID: config.DefaultModel}} }, + SaveModel: func(model string) error { return config.WriteChatModel(dir, model) }, + }) + next.width, next.height = 100, 30 + next.pal = newPalette(tokens.ANSI256, false) + next.openPicker() + chosen, ok := next.pick.choice() + if next.model != want || !ok || chosen.ID != want { + t.Fatalf("next launch model/picker = %q/%q (found=%t), want %q", next.model, chosen.ID, ok, want) + } +} + func TestAConnectedServicesModelsAppearGroupedWithoutARestart(t *testing.T) { defaultServer := sourcestub.New("openai/gpt-4.1-mini") defer defaultServer.Close() @@ -174,7 +401,7 @@ func TestAnUndocumentedListingGetsAListingServicesPickerAndCacheImmediately(t *t a := modelServiceTestApp(t, dir, "openai/gpt-4.1-mini", modelsource.NewSet(testDefaultService("sk-default-1234567890")), []Model{{ID: "openai/gpt-4.1-mini"}}) installModelServiceShelf(a, dir) - source := modelsource.Vendored()[4] + source := modelsource.Vendored()[6] source.Listing = modelsource.ListingNone draft := modelConnectDraft{source: source, row: config.PersistedSource{ ID: "custom", Written: "localhost", Address: server.URL(), Key: "a-custom-key", Order: 1, @@ -209,7 +436,7 @@ func TestAPaymentRefusalConnectsTheAuthenticatedAccount(t *testing.T) { dir := t.TempDir() a := modelServiceTestApp(t, dir, "openai/gpt-4.1-mini", modelsource.NewSet(testDefaultService("sk-default-1234567890")), []Model{{ID: "openai/gpt-4.1-mini"}}) - source := modelsource.Vendored()[4] + source := modelsource.Vendored()[6] source.Listing = modelsource.ListingNone source.ProbeModel = "probe-model" draft := modelConnectDraft{source: source, row: config.PersistedSource{ @@ -342,18 +569,33 @@ func TestTheModelServiceWordsAreExactAndVendorWordsStopAtAWordBoundary(t *testin want string }{ {modelsource.Outcome{Kind: modelsource.OutcomeConnected, Listed: true, Models: 6}, "deepseek is connected · 6 models"}, + {modelsource.Outcome{Kind: modelsource.OutcomeConnected, Listed: true, Models: 1}, "deepseek is connected · 1 model"}, + {modelsource.Outcome{Kind: modelsource.OutcomeConnected, Listed: true, Models: 10, Door: modelsource.Door{Name: "coding plan"}}, "deepseek is connected · coding plan · 10 models"}, + {modelsource.Outcome{Kind: modelsource.OutcomeConnected, Listed: true, Models: 10, Door: modelsource.Door{Name: "pay-as-you-go"}}, "deepseek is connected · pay-as-you-go · 10 models"}, + {modelsource.Outcome{ + Kind: modelsource.OutcomeConnected, Listed: true, Models: 4, + Door: modelsource.Door{Name: "coding plan"}, PlanPaused: true, PlanReset: "18:30 UTC", + Overflow: &modelsource.Door{Name: "pay-as-you-go", Metered: true}, + }, "deepseek is connected · coding plan · 4 models · plan paused · resets at 18:30 UTC · /connect can switch to pay-as-you-go"}, {modelsource.Outcome{Kind: modelsource.OutcomeConnected}, "deepseek is connected"}, {modelsource.Outcome{Kind: modelsource.OutcomeRefused, VendorSaid: "Authentication Fails, Your api key is invalid"}, "deepseek refused that key — Authentication Fails, Your api key is invalid"}, {modelsource.Outcome{Kind: modelsource.OutcomeAccountCannotPay, VendorSaid: "Insufficient balance or no resource package. Please recharge."}, "deepseek accepted the key but the account cannot pay — Insufficient balance or no resource package. Please recharge."}, {modelsource.Outcome{Kind: modelsource.OutcomeUnanswered}, "deepseek did not answer · nothing was saved"}, {modelsource.Outcome{Kind: modelsource.OutcomeWrongShape}, "that is not the shape of a deepseek key — they start with sk-"}, - {modelsource.Outcome{Kind: modelsource.OutcomeCollides, Suggestion: "deepseek-direct"}, "deepseek is a model author on openrouter · connect this as deepseek-direct"}, } for _, testCase := range tests { if got := serviceOutcomeWord("deepseek", testCase.outcome); got != testCase.want { t.Errorf("word = %q, want %q", got, testCase.want) } } + for service, want := range map[string]string{ + "ollama": "ollama is connected · 1 model", + "something-local": "something-local is connected · 1 model", + } { + if got := serviceConnectedWord(service, modelsource.Outcome{Kind: modelsource.OutcomeConnected, Listed: true, Models: 1}); got != want { + t.Errorf("one-door word = %q, want %q", got, want) + } + } if got := serviceAnsweringWord("deepseek"); got != "deepseek is answering right now · try again in a moment" { t.Errorf("answering word = %q", got) } @@ -376,6 +618,92 @@ func TestTheModelServiceWordsAreExactAndVendorWordsStopAtAWordBoundary(t *testin } } +func TestACollidingServiceNameConnectsOnTheFirstAttemptUnderTheSuggestedName(t *testing.T) { + plan := sourcestub.New("too-wide") + metered := sourcestub.New("metered") + defer plan.Close() + defer metered.Close() + source := modelsource.Vendored()[1] + source.Doors[0].Address = plan.URL() + source.Doors[1].Address = metered.URL() + dir := t.TempDir() + a := modelServiceTestApp(t, dir, "openai/gpt-4.1-mini", + modelsource.NewSet(testDefaultService("default-test-key")), + []Model{{ID: "openai/gpt-4.1-mini"}, {ID: "z-ai/glm-5.3"}}) + message := a.beginModelConnect(modelConnectDraft{ + source: source, + row: config.PersistedSource{ + ID: source.ID, Written: source.Written, Region: "intl", Key: "plan-test-key", Order: 1, + }, + })() + if _, follow := a.Update(message); follow != nil { + t.Fatal("the settled connection unexpectedly started another command") + } + if got := noteSaying(t, a, "is connected"); got != "z-ai-direct is connected · coding plan · 4 models" { + t.Fatalf("connection note = %q", got) + } +} + +func TestNoSourceAsksForASecondAttemptAfterAServiceNameCollision(t *testing.T) { + err := filepath.WalkDir("../..", func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() || filepath.Ext(path) != ".go" || strings.HasSuffix(path, "_test.go") { + return nil + } + raw, err := os.ReadFile(path) + if err != nil { + return err + } + if strings.Contains(string(raw), "connect this as") { + t.Errorf("%s still asks the person to connect a colliding name twice", path) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestThePlanCatalogDoesNotBelieveTheWiderListing(t *testing.T) { + plan := sourcestub.New("glm-5.3", "glm-5.3-flash", "metered-only") + metered := sourcestub.New("metered-only") + defer plan.Close() + defer metered.Close() + var source modelsource.Source + for _, candidate := range modelsource.Vendored() { + if candidate.ID == "z-ai" { + source = candidate + break + } + } + source.Doors[0].Address = plan.URL() + source.Doors[1].Address = metered.URL() + a := modelServiceTestApp(t, t.TempDir(), "openai/gpt-4.1-mini", + modelsource.NewSet(testDefaultService("default-test-key")), nil) + refreshed := false + a.serviceModelRefresh = func(context.Context, modelsource.Connected, []Model) ([]Model, error) { + refreshed = true + return nil, nil + } + message, ok := a.beginModelConnect(modelConnectDraft{ + source: source, + row: config.PersistedSource{ + ID: source.ID, Written: source.Written, Key: "plan-test-key", Order: 1, + }, + })().(modelConnectResultMsg) + want := []string{"glm-5.3", "glm-5.3-flash", "glm-5.3[1m]", "glm-5.3-flash[1m]"} + if !ok || message.err != nil || message.outcome.Door.ID != "coding-plan" || + !reflect.DeepEqual(message.outcome.ModelIDs, want) || refreshed { + t.Fatalf("fixed plan catalog: message=%T door=%s models=%v refreshed=%t", + message, message.outcome.Door.ID, message.outcome.ModelIDs, refreshed) + } + if len(plan.Requests()) != 1 || len(metered.Requests()) != 0 { + t.Fatalf("catalog connection requests: plan=%d metered=%d", len(plan.Requests()), len(metered.Requests())) + } +} + func TestAnEngineRoadConnectionSaysWhoseEnvironmentReadsItsVariable(t *testing.T) { base := testDefaultService("default-key") engine := modelServiceTestApp(t, t.TempDir(), "openai/gpt-4.1-mini", modelsource.NewSet(base), []Model{{ID: "openai/gpt-4.1-mini"}}) @@ -519,7 +847,7 @@ func TestACustomServiceUsesItsWrittenNameOnRefusalAndSuccess(t *testing.T) { defer agent.Close() a := modelServiceTestAppWithAgent(t, t.TempDir(), agent.Model(), modelsource.NewSet(base), []Model{{ID: agent.Model()}}, agent) draft := modelConnectDraft{ - source: modelsource.Vendored()[4], + source: modelsource.Vendored()[6], row: config.PersistedSource{ID: "custom", Written: "localhost", Address: server.URL(), Key: "a-custom-key", Order: 1}, } msg := a.beginModelConnect(draft)().(modelConnectResultMsg) @@ -597,6 +925,132 @@ func TestADirectModelKeepsItsServiceInStatusAndCarriesNoLane(t *testing.T) { t.Fatal("the direct model was not in the picker") } +func TestATurnOnAConnectedServiceSendsWithNoDefaultProviderKey(t *testing.T) { + for _, pin := range []string{config.APIKeyEnv, "OPENAI_API_KEY"} { + t.Setenv(pin, "") + } + dir := t.TempDir() + base := testDefaultService("") + direct := testDirectService("https://direct.example/v1") + sources := modelsource.NewSet(base, direct) + agent := &fakeAgent{model: "deepseek-direct/deepseek-v4-pro"} + a := modelServiceTestAppWithAgent(t, dir, agent.model, sources, nil, agent) + a.routerConnect = func(context.Context) (OpenRouterFlow, error) { return nil, nil } + a.input.setText("hello") + runSubmit(t, a.enter()) + + if len(agent.sent) != 1 || agent.sent[0] != "hello" { + t.Fatalf("connected service was sent %v, want the turn", agent.sent) + } + if a.setup.open { + t.Fatal("enter opened the default provider over a connected service") + } + for _, line := range noteTexts(a) { + if strings.Contains(line, "openrouter is not connected") { + t.Fatalf("connected service drew the default-provider note: %q", line) + } + } +} + +func TestAConnectedServiceKeepsSetupSilentAboutTheDefaultProvider(t *testing.T) { + for _, pin := range []string{config.APIKeyEnv, "OPENAI_API_KEY", "AFORGE_DAILY_BUDGET"} { + t.Setenv(pin, "") + } + dir := t.TempDir() + base := testDefaultService("") + direct := testDirectService("https://direct.example/v1") + a := newApp(t.Context(), Options{ + Agent: &fakeAgent{model: "deepseek-direct/deepseek-v4-pro"}, + Workspace: t.TempDir(), + ProfileDir: dir, + Sources: modelsource.NewSet(base, direct), + Setup: true, + ConnectOpenRouter: func(context.Context) (OpenRouterFlow, error) { + return nil, nil + }, + }) + if !a.setup.open || len(a.setup.steps) != 1 || a.setup.step() != setupControls { + t.Fatalf("the direct-only setup asked %+v, want only controls", a.setup.steps) + } + pressSetup(a, key("esc")) + for _, line := range noteTexts(a) { + if strings.Contains(line, "openrouter") { + t.Fatalf("direct-only setup left an OpenRouter note: %q", line) + } + } +} + +func TestAKeyOptionalServiceCarriesATurnWithNoDefaultProviderKey(t *testing.T) { + for _, pin := range []string{config.APIKeyEnv, "OPENAI_API_KEY"} { + t.Setenv(pin, "") + } + base := testDefaultService("") + local := modelsource.Connected{ + Source: modelsource.Source{ID: "ollama", Written: "ollama", KeyOptional: true}, + Address: "http://localhost:11434/v1", + } + agent := &fakeAgent{model: "ollama/llama3.2"} + a := modelServiceTestAppWithAgent(t, t.TempDir(), agent.model, modelsource.NewSet(base, local), nil, agent) + a.routerConnect = func(context.Context) (OpenRouterFlow, error) { return nil, nil } + a.input.setText("hello locally") + runSubmit(t, a.enter()) + if len(agent.sent) != 1 || agent.sent[0] != "hello locally" || a.setup.open { + t.Fatalf("key-optional service sent %v with setup open=%v", agent.sent, a.setup.open) + } +} + +func TestATurnOnTheDefaultServiceStillOpensSetupWithNoDefaultProviderKey(t *testing.T) { + for _, pin := range []string{config.APIKeyEnv, "OPENAI_API_KEY"} { + t.Setenv(pin, "") + } + dir := t.TempDir() + base := testDefaultService("") + direct := testDirectService("https://direct.example/v1") + agent := &fakeAgent{model: config.DefaultModel} + a := modelServiceTestAppWithAgent(t, dir, agent.model, modelsource.NewSet(base, direct), nil, agent) + a.routerConnect = func(context.Context) (OpenRouterFlow, error) { return nil, nil } + a.input.setText("keep these words") + + if cmd := a.enter(); cmd != nil { + t.Fatal("the missing default provider submitted a turn") + } + if !a.setup.open || a.setup.step() != setupKey { + t.Fatalf("the default provider's setup did not open: %+v", a.setup) + } + if len(agent.sent) != 0 || a.input.String() != "keep these words" { + t.Fatalf("the gate sent %v and kept draft %q", agent.sent, a.input.String()) + } +} + +func TestDirectOnlyCommandsInventNoDefaultProviderUsage(t *testing.T) { + for _, pin := range []string{config.APIKeyEnv, "OPENAI_API_KEY"} { + t.Setenv(pin, "") + } + base := testDefaultService("") + direct := testDirectService("https://direct.example/v1") + a := modelServiceTestApp(t, t.TempDir(), "deepseek-direct/deepseek-v4-pro", + modelsource.NewSet(base, direct), []Model{{ID: config.DefaultModel}}) + a.sourceModels[direct.Source.ID] = []Model{{ID: "deepseek-v4-pro"}} + + for _, command := range []string{"/status", "/cost"} { + a.slash(command) + answer := strings.ToLower(lastNote(t, a)) + for _, invented := range []string{"$0.00", "0 tok", "openrouter"} { + if strings.Contains(answer, invented) { + t.Fatalf("%s invented %q in %q", command, invented, answer) + } + } + } + a.openPicker() + rendered := plain(strings.Join(a.pick.rows(100, a.pick.height(100), a.pal, -1, a.reasoningFor), "\n")) + if !strings.Contains(rendered, "deepseek-direct") || !strings.Contains(rendered, "deepseek-direct/deepseek-v4-pro") { + t.Fatalf("/model lost the connected service and its model:\n%s", rendered) + } + if !strings.Contains(rendered, "openrouter") || !strings.Contains(rendered, config.DefaultModel) { + t.Fatalf("/model lost the keyless default service's existing group or model:\n%s", rendered) + } +} + func TestDisconnectingReplacesTheLiveClientBeforeTheNextRequest(t *testing.T) { defaultServer := sourcestub.New(config.DefaultModel) defer defaultServer.Close() @@ -702,3 +1156,49 @@ func TestConnectedServicesAppearUnderProvidersAndEmptinessDrawsNothing(t *testin t.Fatalf("the services section is not immediately under the openrouter key: key=%d head=%d row=%d", keyAt, headAt, rowAt) } } + +func TestThePlanDoorSaysItsPositionAndDefaultsToWait(t *testing.T) { + dir := t.TempDir() + row := config.PersistedSource{ + ID: "z-ai", Written: "z-ai", Key: "plan-test-key", Door: "coding-plan", Order: 1, + } + if err := config.WriteSources(dir, []config.PersistedSource{row}); err != nil { + t.Fatal(err) + } + var source modelsource.Source + for _, candidate := range modelsource.Vendored() { + if candidate.ID == "z-ai" { + source = candidate + break + } + } + if len(source.Doors) != 2 { + t.Fatal("the z-ai billing doors are missing") + } + overflow := source.Doors[1] + connected := modelsource.Connected{ + Source: source, Key: row.Key, Address: source.Doors[0].Address, + Door: source.Doors[0], Overflow: &overflow, PlanPaused: config.PlanPausedWait, + } + sources := modelsource.NewSet(testDefaultService("default-test-key"), connected) + rows := modelServiceRows(dir, sources) + if len(rows) != 2 || rows[0].name != "z-ai" || + !strings.Contains(rows[0].value, "coding plan") || + !strings.Contains(rows[0].value, "Zhipu lists the tools its plan covers; aforge is not listed, and its request has been drafted but not sent.") || + rows[1].name != "when the plan is paused" || rows[1].value != config.PlanPausedWait { + t.Fatalf("the plan rows do not say their billing position: count=%d", len(rows)) + } + + a := modelServiceTestApp(t, dir, "openai/gpt-4.1-mini", sources, nil) + a.raiseSettings() + a.cyclePlanPause("z-ai") + written := config.PersistedSources(dir) + if len(written) != 1 || written[0].PlanPaused != config.PlanPausedUseMeter { + t.Fatal("the plan-pause setting did not opt in to metered overflow") + } + a.cyclePlanPause("z-ai") + written = config.PersistedSources(dir) + if len(written) != 1 || written[0].PlanPaused != "" { + t.Fatal("the default wait value was not stored as the default") + } +} diff --git a/internal/tui3/paymentnote_test.go b/internal/tui3/paymentnote_test.go index d0a9d36ff9..aa27724fc6 100644 --- a/internal/tui3/paymentnote_test.go +++ b/internal/tui3/paymentnote_test.go @@ -60,6 +60,21 @@ func TestAnOrdinaryRefusalKeepsItsPlainLine(t *testing.T) { } } +func TestAPlanPauseEndingDrawsOnlyThePauseSentence(t *testing.T) { + refusal := &provider.APIError{ + Status: 429, Message: "Usage limit reached", Body: `{"code":"1316","message":"Usage limit reached"}`, + } + ending := &provider.PlanPauseError{ + Reset: "18:30 UTC", OverflowDoor: "pay-as-you-go", Cause: refusal, + } + note := (&feed{}).failureNote(fmt.Errorf("LLM call failed: %w", ending), "z-ai") + const want = "plan paused · resets at 18:30 UTC · /connect can switch to pay-as-you-go" + if note != want { + t.Fatalf("pause ending = %q, want %q", note, want) + } + forbidden(t, note) +} + // forbidden is the vocabulary law for one line: a person-facing sentence may // not carry this program's words for its own machinery, nor a status number // that names nothing they can act on. diff --git a/internal/tui3/phase.go b/internal/tui3/phase.go index a9532975e6..17f43e3420 100644 --- a/internal/tui3/phase.go +++ b/internal/tui3/phase.go @@ -165,7 +165,7 @@ var phases = phaseDesk{latest: map[string]PhaseNews{}, waits: map[string]time.Ti // long the turn has. func phaseWaiting(phase provider.Phase) bool { switch phase { - case provider.PhaseConnecting, provider.PhaseConnectionLost, provider.PhaseFirstWord, provider.PhasePaced, + case provider.PhaseConnecting, provider.PhaseConnectionLost, provider.PhaseFirstWord, provider.PhasePaced, provider.PhasePlanPaused, provider.PhaseRetrying, provider.PhaseSwitching, provider.PhaseSwitchingModel, session.PhaseAsking, session.PhaseAllSlow: return true @@ -528,6 +528,12 @@ func phaseFields(news PhaseNews, now time.Time) []rowField { } } return []rowField{rowSay(word), rowSay(clock)} + case provider.PhasePlanPaused: + fields := []rowField{rowSay(word)} + if detail := strings.TrimSpace(news.Detail); detail != "" { + fields = append(fields, rowSay(detail)) + } + return fields case provider.PhaseRetrying: // The rung of the ladder is better than the clock when the ladder said // which rung it is on: "2 of 6" answers "is this going anywhere?" and a @@ -655,6 +661,12 @@ func phaseWaitFields(news PhaseNews, clock string, consequence bool) []rowField rowSay(phaseJoinWord(string(news.Phase), full), short, clock), } } + if door := strings.ToLower(strings.TrimSpace(news.Door)); door != "" { + return []rowField{ + rowSay(door), + rowSay(phaseJoinWord(string(news.Phase), full), short, clock), + } + } return []rowField{rowSay(string(news.Phase)), rowSay(full, short, clock)} } @@ -681,6 +693,9 @@ func phaseJoinWord(word, detail string) string { // and this row is a decision about a person's eye. func phaseServing(news PhaseNews) rowField { served := strings.ToLower(news.Lane) + if served == "" { + served = strings.ToLower(strings.TrimSpace(news.Door)) + } rate := laneRateWord(news.Rate) switch { case served != "" && rate != "": diff --git a/internal/tui3/phase_test.go b/internal/tui3/phase_test.go index b0198fb0c7..6837b75294 100644 --- a/internal/tui3/phase_test.go +++ b/internal/tui3/phase_test.go @@ -102,6 +102,14 @@ func TestThePhaseClockSpellsEveryStateItIsToldAbout(t *testing.T) { what: "a rate-limit wait nobody gave a moment for", news: PhaseNews{Phase: provider.PhasePaced, Since: ago(6 * time.Second)}, want: "paced · 6s", + }, { + what: "a plan window that will reset without metered spend", + news: PhaseNews{Phase: provider.PhasePlanPaused, Since: ago(2 * time.Second), Detail: "resets at 18:30 UTC · /connect can switch to pay-as-you-go"}, + want: "plan paused · resets at 18:30 UTC · /connect can switch to pay-as-you-go", + }, { + what: "a metered overflow while it is writing", + news: PhaseNews{Phase: provider.PhaseWriting, Since: ago(4 * time.Second), Door: "pay-as-you-go", Rate: 61}, + want: "writing · 4s · pay-as-you-go 61 t/s", }, { what: "the relax ladder saying which rung it is on", news: PhaseNews{Phase: provider.PhaseRetrying, Since: ago(4 * time.Second), Detail: "2 of 6"}, diff --git a/internal/tui3/place_settings.go b/internal/tui3/place_settings.go index c0287ce092..baea1c9d8a 100644 --- a/internal/tui3/place_settings.go +++ b/internal/tui3/place_settings.go @@ -193,7 +193,7 @@ func (placeSettings) owns(a *app, msg tea.KeyPressMsg) (tea.Cmd, bool) { a.sheetEditKey(msg) case s.sel != nil: a.sheetSelectKey(msg) - case s.conn.entry != nil && s.onConnections(): + case s.conn.entry != nil: return a.connEntryKey(msg), true default: return nil, false diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index 20dd64494b..bf67d2e0a3 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -1885,6 +1885,10 @@ func (a *app) sheetKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { case "enter", " ", "space": return a.activate(), true + case "ctrl+r": + if item, ok := s.current(); ok && item.service != nil && !item.service.planPause { + return a.reconnectModelService(item.service.id), true + } case "delete": // The one key on this sheet that only one kind of row answers, and it @@ -1943,6 +1947,10 @@ func (a *app) activate() tea.Cmd { return a.connAct(item.conn) } if item.service != nil { + if item.service.planPause { + a.cyclePlanPause(item.service.id) + return nil + } source, ok := a.modelSource(item.service.id) if !ok { return nil @@ -2755,12 +2763,25 @@ func (s *sheet) cursorLine(owner []int) int { } // cursorLastLine is the display row the cursor's ROW ends on — the wrapped -// value's line at [tierPhone], and the row itself everywhere else. +// value's line at [tierPhone], or the end of the shared answer block while a +// service row has one open. // // It stops at the row and does not walk the whole item: the selected setting's // description follows it under the same owner, and pinning that into the window // would scroll the list by two rows the moment somebody moved the cursor. func (s *sheet) cursorLastLine(owner []int, at, width int) int { + if item, ok := s.current(); ok { + entryOpen := item.conn != nil && item.conn.entry != nil + entryOpen = entryOpen || (item.service != nil && s.conn.entry != nil && + s.conn.entry.id == modelConnectionID(item.service.id)) + if entryOpen { + last := at + for last+1 < len(owner) && owner[last+1] == s.cursor { + last++ + } + return last + } + } if phoneList(width) && at+1 < len(owner) && owner[at+1] == s.cursor { return at + 1 } @@ -2790,7 +2811,12 @@ func (s *sheet) rowLinesWithin(item sheetItem, selected, hovered bool, width, bo return s.connRowLines(item.conn, selected, hovered, width, boxRows, pal) } if item.service != nil { - return overlayLines(item.service.name, item.service.value, selected, false, hovered, width, pal) + head := overlayLines(item.service.name, item.service.value, selected, false, hovered, width, pal) + entry := s.conn.entry + if entry != nil && entry.id == modelConnectionID(item.service.id) { + return keyEntryRowLines(head, entry, pal, width, boxRows) + } + return head } if item.role != nil { return s.roleRowLines(item.role, selected, hovered, width, pal) @@ -2967,6 +2993,8 @@ func (s *sheet) keysLine() string { return "↑↓ move · ← or tab back · enter choose · esc cancel · type to filter" } return "↑↓ move · → or tab lanes · enter choose · esc cancel · type to filter" + case s.conn.entry != nil: + return s.connKeysLine() case s.onConnections(): return s.connKeysLine() default: @@ -2989,7 +3017,7 @@ func (a *app) sheetLayerOwnsKeys() bool { return false } s := &a.sheet - return s.edit != nil || s.sel != nil || (s.conn.entry != nil && s.onConnections()) + return s.edit != nil || s.sel != nil || s.conn.entry != nil } // hoveredSheetRow is the item the pointer is over, or -1.