From 3c71d2c2fdb73a659562ccab0211ea9bff86125c Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 10:17:16 -0400 Subject: [PATCH] auto lends the routing to OpenRouter and takes it back when its answers turn bad The lane row's default ran aforge's own chooser on every request, paying aforge's measurement budget to second-guess a router that balances well most of the time. Now auto sends the legacy preferences and lets the router route; a per-model gate counts refusals and unusable answers, and twice in a short while arms the chooser for a window. The belief ledger learns from every named lane's answers either way, so a takeover is never a cold guess. openrouter keeps its meaning as auto without the safety. --- .../unreleased/1003-auto-lends-the-router.md | 15 + internal/config/settings.go | 10 +- internal/manual/chat/lanes.md | 49 +-- internal/manual/chat/models-and-cost.md | 6 +- internal/provider/client.go | 5 + internal/provider/gate_e2e_test.go | 59 ++++ internal/provider/hedge_test.go | 12 + internal/provider/lanes.go | 32 ++ internal/provider/lanes_test.go | 9 + internal/provider/learners_test.go | 4 + internal/provider/onechoice_test.go | 11 +- internal/provider/routefirst.go | 285 ++++++++++++++++++ internal/provider/routefirst_test.go | 148 +++++++++ internal/provider/velocity.go | 16 + internal/tui3/lanes_test.go | 4 +- internal/tui3/palette.go | 2 +- 16 files changed, 639 insertions(+), 28 deletions(-) create mode 100644 docs/changes/unreleased/1003-auto-lends-the-router.md create mode 100644 internal/provider/gate_e2e_test.go create mode 100644 internal/provider/routefirst.go create mode 100644 internal/provider/routefirst_test.go diff --git a/docs/changes/unreleased/1003-auto-lends-the-router.md b/docs/changes/unreleased/1003-auto-lends-the-router.md new file mode 100644 index 0000000000..8321a1d112 --- /dev/null +++ b/docs/changes/unreleased/1003-auto-lends-the-router.md @@ -0,0 +1,15 @@ +--- +kind: changed +title: "`auto` lends the routing to OpenRouter and takes it back when its answers turn bad" +pr: 1003 +surface: [chat, engine] +invalidates: + - "`lane.talk: auto` was believed to run aforge's own lane chooser on every request, ranking the machines behind the model and demanding the admitted set on the wire. It does not: `auto` now sends the legacy preferences — the sort word, the strike ledger's order, the price ceiling — and lets OpenRouter route. aforge's chooser takes over only after a model's answers come back refused or unusable twice in a short while, and hands the choice back after about half an hour of good answers." + - "The picker's `auto` row said it 'weighs speed against price each answer'. It says 'the router routes; aforge takes over if its answers turn bad'." + - "`lane.talk: openrouter` was the only spelling of 'the router routes'. It is now `auto` without the takeover: the same router routing, and aforge never intervenes." +--- + +The chooser is not demoted, it is deferred. Every answer names the machine that +served it, so the belief ledger learns the router's picks exactly as it learned +its own; when the takeover comes the chooser ranks lanes it has been watching, +not strangers. Pins are untouched: a person's own word still outranks both. diff --git a/internal/config/settings.go b/internal/config/settings.go index 7647c110ca..9c2920e2fd 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -1963,11 +1963,13 @@ func (s *Settings) build() []Setting { Label: "lane", EmptyLabel: LaneAuto, Hint: "which machine behind your model answers requests from this home. One model id is served by " + "a dozen endpoints that differ by seven times on the wait before the first " + - "word, so this is often a bigger change than switching model. auto lets aforge " + - "pick the fastest one each answer; a name — `cloudflare` — pins it and nothing " + - "else is asked; `pinned: cloudflare, borrow when slow` keeps the pin but lets " + + "word, so this is often a bigger change than switching model. auto lets the router " + + "route — and aforge takes over choosing the machine when its answers start coming " + + "back refused or unusable, handing it back once it has been well for a while; " + + "a name — `cloudflare` — pins it and nothing else is asked; " + + "`pinned: cloudflare, borrow when slow` keeps the pin but lets " + "a slow answer be rescued elsewhere; openrouter asks for no endpoint at all and " + - "lets the router balance on price. enter on this row opens them with what " + + "lets the router balance on price, with no takeover. enter on this row opens them with what " + "has been measured of each, and so does → on a model row in the picker — " + "under /model and under `your model` in the settings panel alike.", read: func() string { return LaneRowWord(dir, LaneSlotTalk) }, diff --git a/internal/manual/chat/lanes.md b/internal/manual/chat/lanes.md index a338aeb54c..c4b4f51876 100644 --- a/internal/manual/chat/lanes.md +++ b/internal/manual/chat/lanes.md @@ -43,23 +43,38 @@ the router publishes for it, and the older build's record stays where it is inst being spent on a model nobody has measured. That is the same rule as everywhere else here: a measured thing is about the thing that was measured. -## Auto, and which lanes it is choosing between — how it picks a provider on the very first message, and whether aforge do routes too - -Left alone, aforge is on **auto**. Before each request it drops every endpoint -that cannot do the job at all — too small an answer for what you asked for, not -enough room for the conversation, weights served at a coarser precision than the -model is meant to run at, a share of usable answers below what this kind of work -needs — and then ranks what is left by the only thing you actually feel: how long -you will be sitting there, plus what it costs, with the money converted into -seconds by how much your waiting is worth. - -**The machines that survive all that are asked for by name, and the provider may -not go outside them.** It is not a ranking the provider is free to put aside; it -is a closed set with the ranking applied inside it. The section below says what -that costs when every machine in the set is busy at once. - -Nothing is waiting on this when nobody is waiting on you. A background errand is -ranked on price, because a second saved for a machine is a second nobody spends. +## Auto, and who is actually choosing — the router first, and when aforge takes over + +Left alone, aforge is on **auto**, and `auto` means the router routes. OpenRouter +balances the machines behind your model on its own queues and prices, and aforge +watches: every answer names the machine that served it, so the speed and the +quality of what the router hands you are learned exactly as if aforge had asked +for them. You see the machine in the status line — `via cloudflare · 0.6s · 61 t/s` +— and the picker's `auto` row tells you what aforge would choose if it were +choosing. + +**Why did it pick that provider on the very first message?** Because on the first +message nobody has chosen anything: no pin, no takeover earned yet, so the pick +is the router's own — whichever machine its balance landed on. The machine is +named in the status line so the choice is never invisible, and from that first +answer on it is being learned like any other. + +**aforge takes over when the router lets go.** If a model's answers start coming +back refused (a 429, a machine that cannot serve the shape) or unusable (the +thread lost, tool markup, a stream that had to be cut) — twice in a short while — +aforge stops lending the router the choice and picks the machine itself, from the +lanes it has been watching all along. The conversation says so once, in one +sentence, and after about half an hour of good answers the choice is the +router's again. Pinning a machine yourself in `/model` ends it there and then: +your word outranks either of them. + +**`openrouter` is `auto` without the safety.** It is the same router routing, and +aforge never takes over no matter what comes back. Choose it when you would +rather have the router's price balance than be rescued from its bad minute. + +The rest of this page — the closed set, the refusal walk, the probe — describes +what happens while aforge is choosing: during a takeover, and whenever you have +pinned a lane yourself. **A run started from a terminal is routed on the same terms.** `aforge do`, `aforge run` and `aforge plan run` open no conversation and draw no status line, and they used to diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index f96a1dda10..04214221f9 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -2664,7 +2664,7 @@ lane in force (`auto` when nothing is pinned): ``` deepseek-v4-flash via cloudflare · ▲0.8s · $0.09/$0.18 per M · 1M · 58t/s - ● auto weighs speed against price each answer — cloudflare now · recommended + ● auto router routes; aforge takes over if answers turn bad — cloudflare now · recommended cloudflare 0.8s · 58 t/s · $1.3/M · no tools · 100% · ▁▂▁▃▁▂ coreweave 0.4s · 24 t/s · $0.28/M · tail 12s · 99% · ▁▁▇▁▂▁ deepinfra 0.8s · 27 t/s · $0.18/M · out ≤ 65k · 99% @@ -2922,10 +2922,10 @@ Settings → Providers has two rows under **routing**: | Value | What it does | |---|---| -| `auto` | aforge weighs speed against price on each answer and picks the lane that wins | +| `auto` | the router routes, and aforge takes over choosing the machine if its answers start coming back refused or unusable — handing it back once it has been well for a while | | `pinned: cloudflare` | every request goes to that lane and nowhere else, until the router says that lane cannot serve this model — then this model routes on auto for the rest of the run and aforge says so once | | `pinned: cloudflare, borrow when slow` | it goes there, but a slow answer may still be rescued elsewhere | -| `openrouter` | no lane is asked for; the router balances on price | +| `openrouter` | no lane is asked for; the router balances on price, and aforge never takes over | The pinned rungs are missing until aforge has measured something — there is no honest lane to name yet, so the walk is `auto` ↔ `openrouter`. diff --git a/internal/provider/client.go b/internal/provider/client.go index c586181c94..bad5424b75 100644 --- a/internal/provider/client.go +++ b/internal/provider/client.go @@ -514,6 +514,11 @@ func (c *Client) sendShaped(ctx context.Context, request *ai.Request, knobs call // on every call after the sentence has been said. tellRetiredPins(ctx) tellUncarriedPins(ctx) + // AND THE TAKEOVER SAYS ITSELF THROUGH THE SAME DOOR (routefirst.go). One + // more parked sentence, one more nil check; the three share the two + // conditions above because they are the same promise about three different + // rows. + tellTakeover(ctx) // AND THE MODEL IS WRITTEN DOWN BEFORE THE REQUEST LEAVES. This is the one // door every send passes through, and since the adapter stopped changing the // model (endpoints.go) every attempt below this line is on the model named diff --git a/internal/provider/gate_e2e_test.go b/internal/provider/gate_e2e_test.go new file mode 100644 index 0000000000..713043ba70 --- /dev/null +++ b/internal/provider/gate_e2e_test.go @@ -0,0 +1,59 @@ +package provider + +import ( + "context" + "testing" + "time" + + lanes "github.com/Agent-Field/aforge-v2/internal/lane" + "github.com/Agent-Field/aforge-v2/internal/lane/lanestub" +) + +// The whole new `auto`, proved over the wire: OpenRouter routes until two +// refusals reach this client, and then the chooser's closed set goes out. +// +// THE ROUTER HERE DELIVERS ITS REFUSALS rather than absorbing them, because +// every lane it may fall back to is full — which is the only shape a refusal +// ever reaches a person under, and the shape the live wire produced on +// 2026-09-12: three full pools, one turn, and the takeover armed mid-turn. +func TestTwoPacedAnswersHandTheRoadToTheChooser(t *testing.T) { + forgetRouterGates() + t.Cleanup(forgetRouterGates) + const model = "openrouter/gate-e2e" + full := lanestub.Profile{TTFT: time.Millisecond, Rate: 400, Tokens: 8, Tools: true, Paced: true, PacedFor: time.Second} + server := lanestub.New(model, + lanestub.Lane{Name: "fulla", Profile: full}, + lanestub.Lane{Name: "fullb", Profile: full}, + ) + t.Cleanup(server.Close) + client, err := NewClient(Config{APIKey: "k", BaseURL: server.URL(), Model: model, Routing: StaticRouting(RoutingLatency)}) + if err != nil { + t.Fatal(err) + } + lanes.HeardPrefsCarried(server.URL()) + client.velocity = newVelocityLedger() + client.wait = func(context.Context, time.Duration) error { return nil } + primed(t, model, + laneBelief(model, "fulla", 400, 70, 0.25), + laneBelief(model, "fullb", 900, 60, 0.20), + ) + forgetRouterGates() + + // Every lane is full, so the router's own road ends in refusals this + // client hears — two of them, which is the takeover. + _, _ = client.CompleteWithMessages(context.Background(), userMessages("hello")) + if asks := server.Asks(); len(asks) == 0 || len(asks[0].Only) != 0 { + t.Fatalf("the first call demanded a set on OpenRouter's road: %+v", asks[0].Only) + } + if !gateFor(model).TakenOver(time.Now()) { + t.Fatal("two paced refusals arrived and the gate still lends the router the road") + } + + // The very next call is the chooser's: the wire carries its admitted set. + _, _ = client.CompleteWithMessages(context.Background(), userMessages("again")) + asks := server.Asks() + last := asks[len(asks)-1] + if len(last.Only) < 2 { + t.Fatalf("after the takeover the wire carried only=%v, want the chooser's admitted set", last.Only) + } +} diff --git a/internal/provider/hedge_test.go b/internal/provider/hedge_test.go index 54649dd033..4e3c172ecc 100644 --- a/internal/provider/hedge_test.go +++ b/internal/provider/hedge_test.go @@ -122,6 +122,12 @@ func newLaneRigWithPrice( // suite that saved its scripted beliefs into somebody's ledger would be a // suite that cost them their afternoon. t.Setenv(home.EnvVar, t.TempDir()) + // AND NO TEST READS ANOTHER'S SENTENCES. The takeover gate is reset at the + // previous rig's cleanup, but its parked lines flush into the first + // WATCHED call that runs after — which, in one process, can be this rig's + // own first call. Forgetting them here is the setup half of the same + // promise the cleanup half makes. + forgetRouterGates() model := "openrouter/" + name server := lanestub.New(model, lanesOffered...) t.Cleanup(server.Close) @@ -155,6 +161,12 @@ func newLaneRigWithPrice( // send no `provider` object at all until something was pinned, which is // exactly right for a plain endpoint and wrong for a router. lanes.HeardPrefsCarried(server.URL()) + // AND THE CHOOSER HOLDS THE ROAD FOR THE RIG'S WHOLE LIFE. Every rescue + // staged below is the chooser's move, and on the new `auto` the chooser + // moves only after a takeover (routefirst.go) — armed here rather than + // earned, because what is being proved is the walk and not the gate. The + // rig's own cleanup forgets it beside the other learners. + armTakeover(model) rig := &laneRig{server: server, client: client, ledger: ledger, model: model} shipped := lanes.SetController(func(plan control.Plan) control.Controller { return ridePolicy(rig.scaled(plan)) diff --git a/internal/provider/lanes.go b/internal/provider/lanes.go index 3576aec9ad..d3770899a2 100644 --- a/internal/provider/lanes.go +++ b/internal/provider/lanes.go @@ -488,6 +488,23 @@ func (c *Client) drawLaneChoice(knobs callKnobs, model string, request *ai.Reque if strategy == RoutingOff { return lanes.Choice{}, false } + // `auto` IS OPENROUTER'S ROAD UNTIL THE ROUTER LETS GO OF IT (routefirst.go). + // A gate per model counts the refusals and the answers that came back + // unusable; while it says the router is serving this model, there is no + // choice to draw — the request goes out with the legacy preferences (the + // sort word, the strike ledger's order, the price ceiling), which is the + // shape the wire carried before this package held an opinion. The belief + // ledger learns from every named lane's answers all the same, so when the + // gate says the router has let go, the chooser below ranks lanes it has + // been watching rather than strangers. A pin — strict or borrowable — + // skips the gate entirely: it is a person's own instruction, and the + // router's record is not theirs to answer for. A pin the wire has already + // retired for this model is `auto` again (the retirement block below), so + // it answers to the gate like any other unpinned call. + pinned := pin.pinned() != "" && !retired + if !pinned && !c.routingGateTakenOver(model) { + return lanes.Choice{}, false + } lambda := c.laneValueOfTime(knobs) ask := c.laneRequest(model, knobs, request, lambda) choice := lanes.Default().Chooser().Choose(ask) @@ -1013,6 +1030,21 @@ func (c *Client) noteLaneOutcome(model, served, reason string, accepted bool) { Reason: reason, At: laneNow(), }) + // AND THE ROUTER'S OWN RECORD MOVES WITH THE SAME ANSWER (routefirst.go). + // `auto` lends the model to OpenRouter until this says it has let go; an + // answer that could not be used counts toward the takeover, and one that + // served whole counts it back. The sentence is parked, not emitted: the + // call that earned the takeover is often one nobody is watching, and the + // next watched request carries it — the same promise lanepin.go's + // retired-pin line keeps. + // + // THE ONE REASON THIS DOOR DOES NOT HEAR IS `error`: an answer that ended + // with finish_reason:error is ALSO a refusal, and it already reached the + // gate through refuseLane in the same breath (terminal_error.go). Counting + // it here would be one event striking the router twice. + if reason != "error" && c.noteRouterChoice(model, served, reason, accepted) { + parkTakeoverLine(model) + } } // coverMargin is the room left above the dearest named lane's tariff, so that diff --git a/internal/provider/lanes_test.go b/internal/provider/lanes_test.go index e38870cddd..b2c41daac7 100644 --- a/internal/provider/lanes_test.go +++ b/internal/provider/lanes_test.go @@ -155,9 +155,18 @@ func primed(t *testing.T, model string, beliefs ...lanes.Belief) *recordingLedge ledger := &recordingLedger{beliefs: beliefs} lanes.Default().SetLedger(ledger) lanes.ForgetPrefixes() + // A PRIMED LEDGER IS THE CHOOSER HOLDING THE ROAD. On the new `auto` + // (routefirst.go) the chooser ranks only after a takeover; a fixture that + // goes to the trouble of seeding beliefs is a fixture about what the + // chooser does with them, so the gate is armed for this model beside the + // ledger. The one fixture that measures a cold start arms nothing — it is + // the reason the map is reset here rather than only emptied on cleanup. + forgetRouterGates() + armTakeover(model) t.Cleanup(func() { lanes.Default().Reset() lanes.ForgetPrefixes() + forgetRouterGates() }) return ledger } diff --git a/internal/provider/learners_test.go b/internal/provider/learners_test.go index dfb69327d4..f98a04e776 100644 --- a/internal/provider/learners_test.go +++ b/internal/provider/learners_test.go @@ -66,4 +66,8 @@ func resetSharedLearners() { sharedLimiter = newAdaptiveLimiter() quirks.resetForTests() lanes.ForgetRefusals() + // AND THE SIXTH, BESIDE THEM: the router gate is process-wide state of + // exactly the same kind, and a takeover one scenario earns is not a road + // the next scenario is driving on. + forgetRouterGates() } diff --git a/internal/provider/onechoice_test.go b/internal/provider/onechoice_test.go index 39e8d262f1..728ee49c34 100644 --- a/internal/provider/onechoice_test.go +++ b/internal/provider/onechoice_test.go @@ -139,9 +139,14 @@ func refusingRouter(t *testing.T) (*Client, *lanestub.Server, *movingLedger, str client.wait = func(context.Context, time.Duration) error { return nil } lanes.Default().SetLedger(ledger) lanes.ForgetPrefixes() + // NO SCENARIO HERE INHERITS ANOTHER'S TAKEOVER. The one that measures a + // warm belief arms the gate for itself; the cold one must not find it + // armed, or "no opinion at the start" is staged rather than measured. + forgetRouterGates() t.Cleanup(func() { lanes.Default().Reset() lanes.ForgetPrefixes() + forgetRouterGates() }) return client, server, ledger, model } @@ -235,8 +240,12 @@ func TestOneCallAsksForOneSetOfMachinesHoweverOftenItIsEncoded(t *testing.T) { // first body — and on the tenth, unchanged, however many times the request has // been re-encoded underneath. func TestACallThatHadABeliefAsksForItOnEveryBody(t *testing.T) { - client, server, ledger, _ := refusingRouter(t) + client, server, ledger, model := refusingRouter(t) ledger.warmUp() + // A warm belief reaches the wire while the chooser holds the road — on the + // new `auto`, after a takeover (routefirst.go), armed here because the + // encoder is the thing being measured and not the gate. + armTakeover(model) if _, err := client.CompleteWithMessages(context.Background(), userMessages("hello")); err == nil { t.Fatal("a router answering 500 to everything produced an answer") diff --git a/internal/provider/routefirst.go b/internal/provider/routefirst.go new file mode 100644 index 0000000000..e2919b4f5c --- /dev/null +++ b/internal/provider/routefirst.go @@ -0,0 +1,285 @@ +package provider + +import ( + "context" + "strings" + "sync" + "time" +) + +// ── `auto` FIRST, THE BELIEF WHEN THE ROUTER LETS GO ──────────────────────── +// +// The `lane` row's default is `auto`, and what `auto` means changed here: +// OpenRouter routes, and this build watches. Every answer names the machine +// that served it, and that machine's waits and outcomes land in the belief +// ledger ([Client.noteLane], [Client.noteLaneOutcome]) whether or not this +// build asked for it — so a takeover, when one comes, is never a cold guess. +// +// A takeover is earned, never assumed: two refusals, or two answers that lost +// their thread, inside the window that remembers them. One bad minute is a +// queue draining somewhere; two of them is the router sending the next call to +// the same machine. When the window opens the chooser ranks the lanes it has +// been learning from all along and the request carries its set — the same +// object a pin would send — and when the window lapses the row is `auto` +// again, which is the same thing said in reverse: OpenRouter routes, and this +// build watches. +// +// THE FLOOR IS THE SAME FLOOR THE STRIKE LEDGER ALREADY KEEPS. A refusal is +// forgotten after five minutes (velocity.go's ignoreCooldown, mirrored in +// internal/lane's AvailabilityHalfLife) and a takeover after thirty — long +// enough for a person to feel the difference, short enough that one bad +// evening does not re-route the week. Neither figure is tuned here: the window +// is the strike ledger's cooldown counted twice over, and the threshold is the +// number of bad answers a person has already been offered by the time anybody +// would reach for `/model`. + +const ( + // takeoverWindow is how long the chooser holds the road after the router + // earned a takeover. It is the strike ledger's own five-minute cooldown + // counted six times — the horizon over which a person reading their status + // line can tell the difference, and no longer, because the row on their + // screen still says `auto` and `auto` owes them the router's balance back. + takeoverWindow = 30 * time.Minute + + // takeoverStrikes is how many refusals, inside their own five-minute + // cooldown, earn a takeover. Two: one is a queue draining, two is the + // router sending the next call to the same machine — and it is the same + // count internal/lane's availability prior already prices as halving the + // belief a lane will answer at all. + takeoverStrikes = 2 + + // takeoverBads is how many answers that came back unusable — the thread + // lost, tool markup, a cut — earn a takeover, on the same window. The + // quality gate the frontier applies needs six sightings to drop a lane + // (assessment, 2026-09-11); a person has felt the answer by the second. + takeoverBads = 2 +) + +// routerGate is the per-model record of what the router has been doing with a +// model this build asked it to route. It is per model for the reason the +// retired pin is per pair: "the router is serving this model badly" is a fact +// about one model, and a second model's traffic is a different question. +type routerGate struct { + mu sync.Mutex + // strikes are the moments of the last few routing refusals and the last + // few unusable answers, pruned to their own half-lives on every read. Two + // slices rather than one because the two triggers are different promises: + // a refusal is about whether an answer ARRIVES, a bad answer is about + // whether the one that arrived could be used, and internal/lane keeps the + // two on different axes for the same reason. + strikes []time.Time + bads []time.Time + // until is when the current takeover lapses. A zero value is "the router + // is routing" — which is most of the time, and is the whole point of the + // row being called `auto` and not `advisory`. + until time.Time +} + +// routerGates is the process-wide map of them. Process-wide for the reason +// the pin knob is: the conversation, the errands beside it and every task node +// it starts are one process, and a router that is failing a model is failing +// it for all of them at once. Keyed by the ledger's own folded model id, so +// the dated slug and the pointer land on the same gate. +var routerGates sync.Map // string → *routerGate + +// gateFor returns the gate one model's traffic is judged against, creating it +// on first sight. +func gateFor(model string) *routerGate { + model = laneModel(model) + if model == "" { + return &routerGate{} + } + gate, _ := routerGates.LoadOrStore(model, &routerGate{}) + return gate.(*routerGate) +} + +// prune forgets every moment older than its own half-life. It is called with +// the lock held, on every read and every write, so the slices stay short and +// the arithmetic below is over the window a person would call "lately". +func (g *routerGate) prune(now time.Time) { + g.strikes = pruneTo(g.strikes, now, 5*time.Minute) + g.bads = pruneTo(g.bads, now, 30*time.Minute) +} + +func pruneTo(list []time.Time, now time.Time, age time.Duration) []time.Time { + cut := now.Add(-age) + kept := list[:0] + for _, at := range list { + if at.After(cut) { + kept = append(kept, at) + } + } + return kept +} + +// TakenOver reports whether the chooser holds this model's road right now. +func (g *routerGate) TakenOver(now time.Time) bool { + g.mu.Lock() + defer g.mu.Unlock() + g.prune(now) + return now.Before(g.until) +} + +// NoteRefused records that a routing refusal named this model. It answers +// whether this call is the one that earned the takeover, so a person is told +// once and not once per retry. +func (g *routerGate) NoteRefused(now time.Time) bool { + g.mu.Lock() + defer g.mu.Unlock() + g.prune(now) + g.strikes = append(g.strikes, now) + if len(g.strikes) < takeoverStrikes || now.Before(g.until) { + return false + } + g.until = now.Add(takeoverWindow) + return true +} + +// NoteBad records that an answer the router chose came back unusable. Same +// contract as [NoteRefused]. +func (g *routerGate) NoteBad(now time.Time) bool { + g.mu.Lock() + defer g.mu.Unlock() + g.prune(now) + g.bads = append(g.bads, now) + if len(g.bads) < takeoverBads || now.Before(g.until) { + return false + } + g.until = now.Add(takeoverWindow) + return true +} + +// NoteHealthy records an answer that arrived whole. It does not shorten a +// takeover that is already armed — the window is the promise, and a single +// good answer inside it is the router having one good minute, the exact thing +// the threshold is there to disregard — but it keeps a router that is serving +// well from ever earning one. +func (g *routerGate) NoteHealthy(now time.Time) { + g.mu.Lock() + defer g.mu.Unlock() + g.prune(now) + if now.Before(g.until) { + return + } + // HALF THE EVIDENCE DRAINS ON A GOOD ANSWER. A refusal an hour ago beside + // a refusal a minute ago reads as a lane having an afternoon; the same two + // with a healthy answer between them read as a queue that drained. One + // good answer takes the freshest mark off each axis, which is the shape of + // "back to normal" a person would draw on the same record. + if n := len(g.strikes); n > 0 { + g.strikes = g.strikes[:n-1] + } + if n := len(g.bads); n > 0 { + g.bads = g.bads[:n-1] + } +} + +// ── THE TWO DOORS THE GATE IS READ AND WRITTEN THROUGH ────────────────────── + +// routingGateTakenOver is the read [drawLaneChoice] makes: is the chooser +// holding this model's road? It is its own function for the reason every +// other belief site is one — a law test counts the doors, and a second door is +// a second answer to one question. +func (c *Client) routingGateTakenOver(model string) bool { + if !c.carriesPreferences() { + return false + } + return gateFor(model).TakenOver(laneNow()) +} + +// noteRouterChoice is the write side, called where an answer's lane is known. +// Refused and bad move their own axes; a healthy answer moves both. It answers +// whether THIS call armed the takeover, so the sentence a person reads is said +// once, by the call that earned it, and never by the retry that follows. +func (c *Client) noteRouterChoice(model, served, reason string, accepted bool) (armed bool) { + model = laneModel(model) + if !c.carriesPreferences() || model == "" { + return false + } + // THE ATTRIBUTION LAW IS [Client.noteLaneOutcome]'s: an answer that named + // no machine is evidence about no machine, and a takeover earned on an + // anonymous answer is a takeover against nobody. + if strings.TrimSpace(served) == "" { + return false + } + gate := gateFor(model) + now := laneNow() + switch { + case reason == "refused": + return gate.NoteRefused(now) + case !accepted: + return gate.NoteBad(now) + default: + gate.NoteHealthy(now) + return false + } +} + +// armTakeover puts the gate over for one model, and forgetRouterGates empties +// the map. They are for tests: a fixture about the chooser's wire object needs +// the chooser to be holding the road, and no fixture may inherit another's +// takeovers — the shipped path arms the gate through [routerGate.NoteRefused] +// and [routerGate.NoteBad] and forgets it on the window's own clock. +func armTakeover(model string) { + gate := gateFor(model) + gate.mu.Lock() + defer gate.mu.Unlock() + gate.until = laneNow().Add(takeoverWindow) +} + +func forgetRouterGates() { + routerGates = sync.Map{} + parkedTakeover.mu.Lock() + defer parkedTakeover.mu.Unlock() + parkedTakeover.lines = nil +} + +// takeoverLine is the whole sentence a person reads when the chooser takes +// over, spelled once. Two surfaces say it — the stream while the answer is in +// flight and the conversation that keeps it — and a sentence spelled twice is +// a sentence that drifts. It names the model rather than the machine, because +// the machine is the thing that changed and the model is the thing a person +// goes to change. +func takeoverLine(model string) string { + return model + " has come back refused or unusable twice lately, so aforge is choosing its machine for a while · pin one in /model to choose it yourself" +} + +// ── THE SENTENCE, PARKED UNTIL SOMEBODY IS READING ────────────────────────── +// +// The call that earns a takeover is often one nobody is watching — a naming +// errand, a memory reflex, a task node — and a sentence emitted into a stream +// nobody reads is a sentence never said. So it parks here, beside the retired +// pin's parked lines, and the next request a person is actually watching +// carries it ([Client.sendShaped] calls [tellTakeover]). The mechanism is the +// retired pin's own (lanepin.go), for the same reason and under the same two +// conditions: a stream to say it on, and a role a person reads. + +var parkedTakeover struct { + mu sync.Mutex + lines []string +} + +// parkTakeoverLine queues the sentence once per takeover. It is called only by +// a door that answered "this call armed it", so the parked lines never repeat +// one fact twice — a second refusal inside the window earns no second line. +func parkTakeoverLine(model string) { + parkedTakeover.mu.Lock() + defer parkedTakeover.mu.Unlock() + parkedTakeover.lines = append(parkedTakeover.lines, takeoverLine(model)) +} + +// tellTakeover hands whatever is parked to a call somebody is reading. The two +// conditions are lanepin.go's tellRetiredPins', word for word: a stream to say +// it on, and a role a person is watching. +func tellTakeover(ctx context.Context) { + if ctx == nil || streamObserverFrom(ctx) == nil || !RoleFrom(ctx).Visible() { + return + } + parkedTakeover.mu.Lock() + owed := parkedTakeover.lines + parkedTakeover.lines = nil + parkedTakeover.mu.Unlock() + for _, line := range owed { + Emit(ctx, StreamNotice, line) + } +} diff --git a/internal/provider/routefirst_test.go b/internal/provider/routefirst_test.go new file mode 100644 index 0000000000..77494756c9 --- /dev/null +++ b/internal/provider/routefirst_test.go @@ -0,0 +1,148 @@ +package provider + +import ( + "testing" + "time" +) + +// ── THE GATE, MEASURED ────────────────────────────────────────────────────── +// +// The gate is the whole of the new `auto`: when it says the router is serving, +// no chooser runs and the router routes. These scenarios pin the three numbers +// a person would argue about — two strikes, not one; half an hour, not forever; +// a good answer counts back down, not up — because they are the difference +// between insurance and a second opinion nobody asked for. + +func TestOneRefusalIsAQueueDrainingTwoIsATakeover(t *testing.T) { + forgetRouterGates() + t.Cleanup(forgetRouterGates) + now := time.Now() + + gate := gateFor("openrouter/any-model") + if gate.NoteRefused(now) { + t.Fatal("one refusal armed the takeover; one is a queue draining somewhere") + } + if gate.TakenOver(now) { + t.Fatal("one refusal and the gate reads as taken over") + } + if !gate.NoteRefused(now.Add(time.Minute)) { + t.Fatal("the second refusal inside the window did not arm the takeover") + } + if !gate.TakenOver(now.Add(time.Minute)) { + t.Fatal("two refusals and the gate still lends the router the road") + } +} + +func TestATakeoverLapsesOnItsOwnClock(t *testing.T) { + forgetRouterGates() + t.Cleanup(forgetRouterGates) + now := time.Now() + + gate := gateFor("openrouter/any-model") + gate.NoteRefused(now) + gate.NoteRefused(now.Add(time.Minute)) + armed := now.Add(time.Minute) + if !gate.TakenOver(armed) { + t.Fatal("the takeover never armed") + } + if gate.TakenOver(armed.Add(takeoverWindow + time.Minute)) { + t.Fatal("the window lapsed and the chooser still holds the road") + } +} + +func TestAHealthyAnswerCountsBackDown(t *testing.T) { + forgetRouterGates() + t.Cleanup(forgetRouterGates) + now := time.Now() + + gate := gateFor("openrouter/any-model") + gate.NoteRefused(now) + // A refusal, then a whole answer: the queue drained. A second refusal + // after it is the first of a new pair, not the second of the old one. + gate.NoteHealthy(now.Add(time.Minute)) + if gate.NoteRefused(now.Add(2 * time.Minute)) { + t.Fatal("a healthy answer between the refusals still armed the takeover") + } +} + +func TestTwoUnusableAnswersArmItToo(t *testing.T) { + forgetRouterGates() + t.Cleanup(forgetRouterGates) + now := time.Now() + + gate := gateFor("openrouter/any-model") + if gate.NoteBad(now) { + t.Fatal("one bad answer armed the takeover") + } + if !gate.NoteBad(now.Add(time.Minute)) { + t.Fatal("two answers that could not be used did not arm the takeover") + } +} + +func TestAnArmedGateEarnsItsSentenceOnce(t *testing.T) { + forgetRouterGates() + t.Cleanup(forgetRouterGates) + now := time.Now() + + gate := gateFor("openrouter/any-model") + gate.NoteRefused(now) + if !gate.NoteRefused(now.Add(time.Minute)) { + t.Fatal("the arming refusal answered false") + } + // Every later refusal inside the window is the same fact: the sentence is + // owed once, and the retry that follows the arming call must not say it + // again. + if gate.NoteRefused(now.Add(2 * time.Minute)) { + t.Fatal("a second refusal inside the window parked a second sentence") + } +} + +func TestAStaleStrikeIsForgotten(t *testing.T) { + forgetRouterGates() + t.Cleanup(forgetRouterGates) + now := time.Now() + + gate := gateFor("openrouter/any-model") + gate.NoteRefused(now) + // A refusal an hour ago is not evidence about this afternoon. + if gate.NoteRefused(now.Add(time.Hour)) { + t.Fatal("a refusal beside an hour-old one armed the takeover; the window is not being pruned") + } +} + +func TestTheGateHandsBackACleanSlate(t *testing.T) { + forgetRouterGates() + t.Cleanup(forgetRouterGates) + now := time.Now() + + gate := gateFor("openrouter/any-model") + gate.NoteRefused(now) + gate.NoteRefused(now.Add(time.Minute)) + // A takeover is thirty minutes; a strike is remembered for five. So the + // strikes that armed it — and any the chooser collected while it held the + // road — are all pruned before the window lapses, and the router's first + // fresh refusal afterwards is one strike, not the second of a pair. The + // 6× ratio between the two windows is what keeps the hand-back from + // flapping; this scenario is why it may never shrink. + lapse := now.Add(time.Minute).Add(takeoverWindow + time.Minute) + if gate.TakenOver(lapse) { + t.Fatal("the window lapsed and the gate still holds the road") + } + if gate.NoteRefused(lapse) { + t.Fatal("the router's first refusal after a lapse re-armed the takeover; the old strikes were not pruned") + } +} + +func TestTheTwoModelsKeepTwoGates(t *testing.T) { + forgetRouterGates() + t.Cleanup(forgetRouterGates) + now := time.Now() + + one := gateFor("openrouter/one-model") + two := gateFor("openrouter/another-model") + one.NoteRefused(now) + one.NoteRefused(now.Add(time.Minute)) + if two.TakenOver(now.Add(time.Minute)) { + t.Fatal("one model's bad afternoon took the other model's road with it") + } +} diff --git a/internal/provider/velocity.go b/internal/provider/velocity.go index 640c945fe0..799964a6c0 100644 --- a/internal/provider/velocity.go +++ b/internal/provider/velocity.go @@ -1257,6 +1257,14 @@ func (c *Client) refuseLane(model string, refusal laneRefusal, wait time.Duratio // remaining attempts of this very call included (retry.go). c.notePacedProvider(model, refusal.Lane, wait) c.noteLaneRefused(model, refusal.Lane, "paced") + // AND THE ROUTER'S GATE HEARS THE SAME REFUSAL ONCE (routefirst.go). + // This is the one door every refusal of a named machine passes, so it + // is where the gate's "twice lately" is kept true: a 429 here, a 404 + // below, an upstream fault beside them — one event, one strike, however + // the recovery above it goes. + if c.noteRouterChoice(model, refusal.Lane, "refused", false) { + parkTakeoverLine(model) + } return true } // AN ACCOUNT'S EXCLUSION IS WRITTEN FOR EVERY MODEL, and it is written here @@ -1276,6 +1284,14 @@ func (c *Client) refuseLane(model string, refusal laneRefusal, wait time.Duratio // count one refusal three times. The paced branch above is the one that // was silent, and the one the 429 loop lived in. c.refuseServing(model, refusal) + // AND THE GATE HEARS IT HERE, BESIDE THE STRIKE (routefirst.go) — the same + // door as the paced branch, for the same reason: one refusal of a named + // machine is one strike on the router's record, whether the machine said + // its queue was full or the pairing was impossible. An unasked machine + // (refusal.Unasked) teaches the gate nothing and never reaches this line. + if c.noteRouterChoice(model, refusal.Lane, "refused", false) { + parkTakeoverLine(model) + } return c.velocity.pace(model, refusal.Lane, 0) } diff --git a/internal/tui3/lanes_test.go b/internal/tui3/lanes_test.go index 07bf4b8cd3..a96877e623 100644 --- a/internal/tui3/lanes_test.go +++ b/internal/tui3/lanes_test.go @@ -123,7 +123,7 @@ func TestArrowUnfoldsTheLanesTheLedgerBelievesIn(t *testing.T) { } screen := plain(frame(a)) for _, want := range []string{ - "● auto", "weighs speed against price each answer", "recommended", + "● auto", "router routes; aforge takes over if answers turn bad", "recommended", "cloudflare", "0.8s", "58 t/s", "100%", "no tools", "coreweave", "0.4s", "tail", "deepinfra", "out ≤ 65k", @@ -553,7 +553,7 @@ func TestTheSettingsModelRowUnfoldsItsLanes(t *testing.T) { } screen := strings.Join(sheetLabels(a), "\n") for _, want := range []string{ - "auto", "weighs speed against price each answer", + "auto", "router routes; aforge takes over if answers turn bad", "cloudflare", "0.8s", "58 t/s", "no tools", "coreweave", "0.4s", "deepinfra", "out ≤ 65k", "openrouter", "let the router balance on price", diff --git a/internal/tui3/palette.go b/internal/tui3/palette.go index b1cf2e1bef..153cd3704c 100644 --- a/internal/tui3/palette.go +++ b/internal/tui3/palette.go @@ -1493,7 +1493,7 @@ func (p *picker) mark(filled bool) string { // laneAutoNote is what the auto row says it does. It is a sentence and not a // word because it is the row a person will land on first and the one they will // leave alone: what it is FOR has to be on it. -const laneAutoNote = "weighs speed against price each answer" +const laneAutoNote = "router routes; aforge takes over if answers turn bad" // laneUnmeasured is the one line a fold draws in the machines' place when // nothing behind the model has been measured. It is a sentence a person would