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
9 changes: 9 additions & 0 deletions cmd/codeaf/chatv3_credits_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ import (
func TestStartedConversationResolvesCurrentCreditDefault(t *testing.T) {
t.Setenv(config.ModelEnv, "")
dir := t.TempDir()
if err := config.SetCrewFreeRoutes(dir, true); err != nil {
t.Fatal(err)
}
if err := config.WriteAPIKey(dir, "key"); err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -219,6 +222,9 @@ func TestProcessWatcherDropsAReadSupersededByAChangedKey(t *testing.T) {

func TestEngineStartUsesTheCurrentDefaultAndItsWindow(t *testing.T) {
proc := v3TestProcess(t)
if err := config.SetCrewFreeRoutes(proc.ProfileDir, true); err != nil {
t.Fatal(err)
}
opts := v3Options{Workspace: t.TempDir(), Interactive: true}
boot, err := openV3Launch(proc, opts)
if err != nil {
Expand Down Expand Up @@ -307,6 +313,9 @@ func TestLocalCreditDoorReadsOnlyDefaultServiceAndStoresNoBalance(t *testing.T)
func TestAFreshConversationOpensOnTheDefaultTheBalanceAnswersNow(t *testing.T) {
t.Setenv(config.ModelEnv, "")
dir := t.TempDir()
if err := config.SetCrewFreeRoutes(dir, true); err != nil {
t.Fatal(err)
}
if err := config.WriteAPIKey(dir, "key"); err != nil {
t.Fatal(err)
}
Expand Down
106 changes: 67 additions & 39 deletions cmd/codeaf/chatv3_crew_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,45 +176,73 @@ func TestTheCrewSourceIsSafeUnderConcurrentTurns(t *testing.T) {
wait.Wait()
}

// C1 on the ordinary launch road (#1439): the surface reads the balance and
// writes the record, and the ENGINE process seats the crew. A record written by
// another process never moves this process's settings generation, so the crew
// has to notice the record itself — or the engine keeps seating the paid table
// its snapshot was built on, and the reflex call on a $0 account is refused.
// The surface writes credit records while the engine seats the crew. External
// writes do not move this process's settings generation. The live source must
// notice the threshold record, but only explicit free-route opt-in may change
// its model; low means <= $0.50, not proof that the account is empty.
func TestACreditRecordWrittenByAnotherProcessReseatsTheLiveCrew(t *testing.T) {
dir := t.TempDir()
t.Setenv(config.APIKeyEnv, "sk-or-v1-crewtest")
source, err := v3RolesSource(t.TempDir(), dir)
if err != nil {
t.Fatalf("v3RolesSource: %v", err)
}
reflex := roles.TierKey(roles.TierReflex)
paid, _ := source(reflex)
if paid != config.DefaultReflexModel {
t.Fatalf("a profile with no record seated reflex on %q, want the shipped %q", paid, config.DefaultReflexModel)
}
record, err := json.Marshal(map[string]any{
"low": true, "known": true, "key": config.CreditsKeyPrint("sk-or-v1-crewtest"), "read_at": "2026-09-24T21:44:22Z",
})
if err != nil {
t.Fatal(err)
}
// Written the way ANOTHER process writes it: straight to the file, with no
// call into this process's settings writer.
if err := os.WriteFile(filepath.Join(dir, "credits.json"), record, 0o600); err != nil {
t.Fatal(err)
}
free, _ := source(reflex)
if free == paid || free != config.TierModelAt(dir, config.ModelTierReflex) || !strings.HasSuffix(free, ":free") {
t.Fatalf("after another process recorded a low balance the live crew seated reflex on %q", free)
}
record, _ = json.Marshal(map[string]any{
"low": false, "known": true, "key": config.CreditsKeyPrint("sk-or-v1-crewtest"), "read_at": "2026-09-24T21:50:00Z",
})
if err := os.WriteFile(filepath.Join(dir, "credits.json"), record, 0o600); err != nil {
t.Fatal(err)
}
if back, _ := source(reflex); back != paid {
t.Fatalf("after a top-up recorded elsewhere the live crew kept reflex on %q", back)
for _, freeAllowed := range []bool{false, true} {
name := "free routes off keeps paid helpers"
if freeAllowed {
name = "explicit opt-in follows external balance"
}
t.Run(name, func(t *testing.T) {
dir := t.TempDir()
const key = "sk-or-v1-crewtest"
t.Setenv(config.APIKeyEnv, key)
if freeAllowed {
if err := config.SetCrewFreeRoutes(dir, true); err != nil {
t.Fatal(err)
}
}
source, err := v3RolesSource(t.TempDir(), dir)
if err != nil {
t.Fatalf("v3RolesSource: %v", err)
}
reflex := roles.TierKey(roles.TierReflex)
paid, _ := source(reflex)
if paid != config.DefaultReflexModel {
t.Fatalf("a profile with no record seated reflex on %q, want %q", paid, config.DefaultReflexModel)
}
generation := config.SettingsGeneration()
writeExternalRecord := func(low bool, readAt string) {
t.Helper()
record, err := json.Marshal(map[string]any{"low": low, "known": true, "key": config.CreditsKeyPrint(key), "read_at": readAt})
if err != nil {
t.Fatal(err)
}
// Use the external writer's file path, never this process's settings API.
if err := os.WriteFile(filepath.Join(dir, "credits.json"), record, 0o600); err != nil {
t.Fatal(err)
}
if config.SettingsGeneration() != generation {
t.Fatal("external credit record changed this process's settings generation")
}
}
writeExternalRecord(true, "2026-09-24T21:44:22Z")
if !config.CreditsLowAt(dir) {
t.Fatal("external low threshold record was not visible")
}
current, _ := source(reflex)
if freeAllowed {
if current == paid || current != config.TierModelAt(dir, config.ModelTierReflex) || !strings.HasSuffix(current, ":free") {
t.Fatalf("opted-in live crew ignored external low reading: %q", current)
}
} else {
if current != paid {
t.Fatalf("a low threshold record silently enabled free reflex: %q", current)
}
if low, _ := source(roles.TierKey(roles.TierLow)); strings.HasSuffix(low, ":free") {
t.Fatalf("a low threshold record silently enabled free small-work: %q", low)
}
}
writeExternalRecord(false, "2026-09-24T21:50:00Z")
if back, _ := source(reflex); back != paid {
t.Fatalf("after a top-up recorded elsewhere the live crew kept reflex on %q", back)
}
if config.CrewFreeRoutesAt(dir) != freeAllowed {
t.Fatal("external balance read changed the person's free-route choice")
}
})
}
}
15 changes: 15 additions & 0 deletions docs/changes/unreleased/1905-free-route-consent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
kind: fixed
title: Automatic credit recovery honors free-route consent
pr: 1905
surface: [chat, engine]
invalidates:
- "Low credit and payment refusals previously allowed automatic free routes even with the switch off. Every automatic default, recovery ladder and helper now honors opt-out."
- "A low-balance record previously blocked paid routes before an actual payment refusal. With free routes off, a small positive paid balance remains usable."
- "Resolved helper models previously counted as explicit consent. Helpers now require current explicit settings, and malformed settings fail closed."
---

A current explicit choice of a named free model permits that model without
enabling other automatic free fallbacks. When no allowed route remains, work
is preserved and the user is guided to add credit, connect a provider, or
explicitly enable free routes. Saved preferences and pins are not rewritten.
4 changes: 3 additions & 1 deletion internal/config/credits.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,9 @@ func ChatDefaultAt(profileDir string) string {

// useFreeDefaultsAt is the single balance check for implicit chat and crew
// bottom rungs. Both callers resolve explicit choices before asking it.
func useFreeDefaultsAt(profileDir string) bool { return CreditsLowAt(profileDir) }
func useFreeDefaultsAt(profileDir string) bool {
return CrewFreeRoutesAt(profileDir) && CreditsLowAt(profileDir)
}

// IsFreeModel accepts either the published free suffix or a catalog row whose
// three charges are known zeros. An unknown price never claims to be free.
Expand Down
12 changes: 12 additions & 0 deletions internal/config/credits_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ func creditsProfile(t *testing.T) string {

func TestCreditsRecordChangesImplicitDefaultsWithoutSavingModels(t *testing.T) {
dir := creditsProfile(t)
if err := SetCrewFreeRoutes(dir, true); err != nil {
t.Fatal(err)
}
if err := WriteAPIKey(dir, "secret-key"); err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -190,6 +193,9 @@ func TestFreeModelNeedsASuffixOrAKnownZeroTariff(t *testing.T) {
// paid OpenRouter route is tried first. A healthy reading routes as usual.
func TestALowBalanceRoutesTheCrewToFreePools(t *testing.T) {
dir := crewProfile(t)
if err := SetCrewFreeRoutes(dir, true); err != nil {
t.Fatal(err)
}
rows := CrewCatalog()
rows = append(rows, catalog.Model{ID: "z-ai/glm-5.3-flash:free", OpenWeights: true, IntelligenceIndex: 41.8, CodingIndex: 71.5,
AgenticIndex: 50.9, ArenaElo: 1348, ContextLength: 1310720, Parameters: []string{"tools"}})
Expand All @@ -211,6 +217,9 @@ func TestALowBalanceRoutesTheCrewToFreePools(t *testing.T) {
if strings.Count(d.Note, "free routes in use (may log prompts)") != 1 {
t.Errorf("the decision note is %q, want the free-routes notice once", d.Note)
}
if err := SetCrewFreeRoutes(dir, false); err != nil {
t.Fatal(err)
}
if err := WriteCreditsReading(dir, APIKeyAt(dir), credits.Reading{Known: true}); err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -238,6 +247,9 @@ func TestLowCreditsFollowTheEffectiveEnvironmentKey(t *testing.T) {
t.Run(row.name, func(t *testing.T) {
t.Setenv("CODEAF_BASE_URL", "")
dir := creditsProfile(t)
if err := SetCrewFreeRoutes(dir, true); err != nil {
t.Fatal(err)
}
if err := WriteAPIKey(dir, "sk-or-v1-test-profile"); err != nil {
t.Fatal(err)
}
Expand Down
71 changes: 39 additions & 32 deletions internal/config/crewhealth.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,9 @@ func crewHealthAt(profileDir string) crewHealth {
history = CrewRouteHistory(profileDir)
}
h := crewHealthOf(history, time.Now())
// AN OPENROUTER BALANCE KNOWN TO BE LOW ([CreditsLowAt]) is an account out
// of credit before any call is made: the seats reach for free routes and
// the crew line says so, instead of a first paid call being refused.
// With free-route opt-in, a low OpenRouter balance steers automatic seats
// toward free routes. Without it, a small positive balance remains usable
// until a real payment refusal or known-zero account says otherwise.
if useFreeDefaultsAt(profileDir) {
h.unaffordable[modelsource.DefaultID] = true
h.lowBalance = map[string]bool{modelsource.DefaultID: true}
Expand Down Expand Up @@ -306,39 +306,47 @@ func (h crewHealth) usable(routes []crewroute.Route) []crewroute.Route {
return out
}

// crewCandidatesNoticed is [CrewCandidatesAt] with the one notice a decision
// carries when it had to reach for free routes.
//
// WHEN NOTHING PAID CAN BE REACHED, THE FREE POOLS ARE USED — whatever the
// free-routes row says — because a task on a free pool is better than a task
// that cannot start, and the line says so: a free pool may log what it is
// sent. A paid route coming back (a paid call answering, a provider reporting
// credit) puts the next task back on normal routing on its own.
// crewCandidatesNoticed keeps the automatic candidate set within the person's
// free-route choice, even when every paid account is out of credit.
func crewCandidatesNoticed(profileDir string, health crewHealth) ([]crewroute.Candidate, string) {
rule, providers, off := CrewAllowedAt(profileDir), CrewProvidersAt(profileDir), CrewProvidersOffAt(profileDir)
facts := crewRouteFacts{free: CrewFreeRoutesAt(profileDir), health: health}
candidates := off.Candidates(crewCandidatesWith(rule, providers, facts))
if facts.free || len(health.unaffordable) == 0 || anyPaidRoute(candidates) {
return candidates, ""
}
facts.free = true
withFree := off.Candidates(crewCandidatesWith(rule, providers, facts))
if len(withFree) == len(candidates) {
return candidates, ""
if facts.free && len(health.unaffordable) > 0 && onlyFreeRoutes(candidates) {
return candidates, "free routes in use (may log prompts) · credit unavailable on " + strings.Join(crewSet(health.unaffordable), ", ")
}
return withFree, "free routes in use (may log prompts) · credit unavailable on " + strings.Join(crewSet(health.unaffordable), ", ")
return candidates, ""
}

// anyPaidRoute is whether any candidate is reachable on a route that bills.
func anyPaidRoute(candidates []crewroute.Candidate) bool {
// A notice names free routes only when every available route is free; an
// empty candidate set or a subscription/local alternative is not free use.
func onlyFreeRoutes(candidates []crewroute.Candidate) bool {
found := false
for _, c := range candidates {
for _, r := range c.Routes {
if r.Kind == crewroute.Metered {
return true
if r.Kind != crewroute.Free {
return false
}
found = true
}
}
return false
return found
}

// AutomaticCrewRungs excludes free pools from automatic substitution while the
// switch is off. An explicit pin is resolved separately and is never rewritten.
// Check again when selecting a saved ladder: a task can outlive a settings edit.
func AutomaticCrewRungs(profileDir string, rungs []crewroute.Pick) []crewroute.Pick {
if CrewFreeRoutesAt(profileDir) {
return rungs
}
out := make([]crewroute.Pick, 0, len(rungs))
for _, rung := range rungs {
if rung.Kind != crewroute.Free && !crewroute.IsFree(rung.Send) {
out = append(out, rung)
}
}
return out
}

// crewSet is a set's members in order.
Expand Down Expand Up @@ -429,6 +437,9 @@ func CrewRescue(profileDir string, class crewroute.Class, seat crewroute.Seat, c
// NOT EVEN THE PERSON'S OWN MODEL rides an account that just said it
// is out of credit, or a key that was just refused: the conversation
// running on it proves the model, not the account.
if pin.Kind == crewroute.Free && !CrewFreeRoutesAt(profileDir) {
return
}
if health.disconnected[pin.Provider] || (pin.Kind == crewroute.Metered && health.unaffordable[pin.Provider]) {
return
}
Expand All @@ -445,17 +456,13 @@ func CrewRescue(profileDir string, class crewroute.Class, seat crewroute.Seat, c
return out
}

// crewFreeRescue is the seat on a free pool when EVERY PAID ROUTE IS OUT OF
// REACH — the free-routes switch off or on. With the switch on the pools are
// already routes on the seat's ladder; with it off they are used only here,
// and the crew line says so ([crewCandidatesNoticed]'s notice). The best few
// free picks for the seat, each a different model, so a pool at its limit
// leaves the next.
// crewFreeRescue offers the best few allowed free pools only after explicit
// free-route opt-in. An empty paid balance never grants that permission.
func crewFreeRescue(profileDir string, class crewroute.Class, seat crewroute.Seat, health crewHealth) []crewroute.Pick {
candidates, notice := crewCandidatesNoticed(profileDir, health)
if notice == "" {
if !CrewFreeRoutesAt(profileDir) {
return nil
}
candidates, _ := crewCandidatesNoticed(profileDir, health)
var free []crewroute.Candidate
for _, c := range candidates {
var routes []crewroute.Route
Expand Down
19 changes: 17 additions & 2 deletions internal/config/crewhealth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,9 @@ func TestAZeroCreditAccountWalksTheLadder(t *testing.T) {
if got := probe.Seat(crewroute.Worker); got.Kind != crewroute.Metered {
t.Fatalf("the next task's worker is %+v, want the paid route, probed", got)
}
if err := SetCrewFreeRoutes(dir, true); err != nil {
t.Fatal(err)
}
rescue := CrewRescue(dir, crewroute.Bugfix, crewroute.Worker, "somelab/the-chat-model")
if len(rescue) == 0 || rescue[0].Kind != crewroute.Free {
t.Fatalf("the rescue is %+v, want the free pool", rescue)
Expand All @@ -186,6 +189,9 @@ func TestAZeroCreditAccountWalksTheLadder(t *testing.T) {
t.Errorf("the one action is %q", got)
}

if err := SetCrewFreeRoutes(dir, false); err != nil {
t.Fatal(err)
}
history = append(history, router.CrewRouteOutcome{At: time.Now(), Seat: "worker", Send: w.Send, Provider: w.Provider, Paid: true})
for _, r := range CrewRescue(dir, crewroute.Bugfix, crewroute.Worker, "somelab/the-chat-model") {
if r.Kind == crewroute.Free {
Expand Down Expand Up @@ -341,14 +347,17 @@ func TestCrewOffersNoSpeechOrImageModels(t *testing.T) {
// person's own model on the account that said no is not.
func TestTheRescueFallsToAFreePoolWhenNothingPaidIsReachable(t *testing.T) {
dir := crewProfile(t)
if err := SetCrewFreeRoutes(dir, true); err != nil {
t.Fatal(err)
}
rows := CrewCatalog()
rows = append(rows, catalog.Model{ID: "z-ai/glm-5.3-flash:free", OpenWeights: true,
IntelligenceIndex: 41.8, CodingIndex: 71.5, AgenticIndex: 50.9, ContextLength: 1310720, Parameters: []string{"tools"}})
CrewCatalog = func() []catalog.Model { return rows }
history := []router.CrewRouteOutcome{{At: time.Now(), Seat: "worker", Send: "z-ai/glm-5.3-flash", Provider: "openrouter", Kind: "payment"}}
withRouteHistory(t, &history)
if CrewFreeRoutesAt(dir) {
t.Fatal("the switch should be off")
if !CrewFreeRoutesAt(dir) {
t.Fatal("the switch should be on")
}
rescue := CrewRescue(dir, crewroute.Bugfix, crewroute.Worker, "moonshotai/kimi-k3")
if len(rescue) == 0 || rescue[0].Kind != crewroute.Free {
Expand Down Expand Up @@ -467,6 +476,9 @@ func TestADecisionRowNamesOnlySeatableCandidatesAndItsTopThree(t *testing.T) {
// says free routes once.
func TestTheFreeRescuePrefersGeneralModels(t *testing.T) {
dir := crewProfile(t)
if err := SetCrewFreeRoutes(dir, true); err != nil {
t.Fatal(err)
}
rows := CrewCatalog()
rows = append(rows,
catalog.Model{ID: "inclusionai/ling-3.0-flash-fin:free", ContextLength: 262144, IntelligenceIndex: 60, CodingIndex: 80, AgenticIndex: 60, Parameters: []string{"tools"}},
Expand Down Expand Up @@ -573,6 +585,9 @@ func TestTheSpendLinesASeatCallIsHeldTo(t *testing.T) {
// rescue — while an explicit-zero `:free` pool still is.
func TestAnUnpricedModelIsNeverPicked(t *testing.T) {
dir := crewProfile(t)
if err := SetCrewFreeRoutes(dir, true); err != nil {
t.Fatal(err)
}
rows := CrewCatalog()
rows = append(rows,
catalog.Model{ID: "stealth/space-bunny-alpha", ContextLength: 1048576,
Expand Down
Loading
Loading