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