Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/changes/unreleased/1003-auto-lends-the-router.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 6 additions & 4 deletions internal/config/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
Expand Down
49 changes: 32 additions & 17 deletions internal/manual/chat/lanes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions internal/manual/chat/models-and-cost.md
Original file line number Diff line number Diff line change
Expand Up @@ -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%
Expand Down Expand Up @@ -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`.
Expand Down
5 changes: 5 additions & 0 deletions internal/provider/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions internal/provider/gate_e2e_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
12 changes: 12 additions & 0 deletions internal/provider/hedge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand Down
32 changes: 32 additions & 0 deletions internal/provider/lanes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions internal/provider/lanes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
4 changes: 4 additions & 0 deletions internal/provider/learners_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
11 changes: 10 additions & 1 deletion internal/provider/onechoice_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading