From dac881399b0be1ca77a5468816f85bc899bb4efe Mon Sep 17 00:00:00 2001 From: Jacob Clayden Date: Tue, 28 Apr 2026 02:38:54 +0100 Subject: [PATCH] feat: add provider availability to JSON output --- README.md | 10 +++ internal/app/report.go | 159 ++++++++++++++++++++++++++++++++--- internal/app/report_test.go | 126 +++++++++++++++++++++++++++ internal/output/json_test.go | 41 +++++++++ 4 files changed, 326 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 6d1532a..2806585 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,16 @@ cq --version # Print version `check` accepts `claude`, `codex`, and `gemini` provider names. +### JSON availability + +`cq --json` includes a provider-level `availability` object for agents and other automated consumers. Use `availability.state` and `availability.guidance` to decide whether to send new work to a provider: + +- `available`: provider is available for normal work. +- `limited`: provider can route work, but quota is low; conserve it for small, necessary, or user-approved work. +- `exhausted`: provider is exhausted or unavailable for new work. + +Account-level `active` fields are retained for compatibility and mean credential default/current account. They are not proxy routing decisions and should not be used as provider availability signals; the proxy may route differently because of quota, manual pins, or failover. + ## Account Management Claude and Codex support stored account management: diff --git a/internal/app/report.go b/internal/app/report.go index ed30acb..fb70041 100644 --- a/internal/app/report.go +++ b/internal/app/report.go @@ -2,6 +2,7 @@ package app import ( "context" + "strconv" "time" "unicode" "unicode/utf8" @@ -53,19 +54,156 @@ type Report struct { } type ProviderReport struct { - ID provider.ID `json:"id"` - Name string `json:"name"` - Results []quota.Result `json:"results"` - Aggregate *AggregateReport `json:"aggregate,omitempty"` + ID provider.ID `json:"id"` + Name string `json:"name"` + Availability ProviderAvailability `json:"availability"` + Results []quota.Result `json:"results"` + Aggregate *AggregateReport `json:"aggregate,omitempty"` +} + +type ProviderAvailabilityState string + +const ( + ProviderAvailabilityAvailable ProviderAvailabilityState = "available" + ProviderAvailabilityLimited ProviderAvailabilityState = "limited" + ProviderAvailabilityExhausted ProviderAvailabilityState = "exhausted" +) + +type ProviderAvailability struct { + State ProviderAvailabilityState `json:"state"` + Guidance string `json:"guidance"` + Reason string `json:"reason"` + MinRemainingPct int `json:"min_remaining_pct"` + ResetsInSeconds int64 `json:"resets_in_s,omitempty"` } type AggregateReport struct { - ProviderID provider.ID `json:"provider_id"` - Kind string `json:"kind"` - Summary aggregate.AccountSummary `json:"summary"` + ProviderID provider.ID `json:"provider_id"` + Kind string `json:"kind"` + Summary aggregate.AccountSummary `json:"summary"` Windows map[quota.WindowName]quota.AggregateResult `json:"windows"` } +const providerLimitedThresholdPct = 5 + +func providerAvailability(results []quota.Result, now time.Time) ProviderAvailability { + best := ProviderAvailability{ + State: ProviderAvailabilityExhausted, + Guidance: "Provider cannot currently be assessed or used because all results are errors.", + Reason: "unavailable", + MinRemainingPct: -1, + } + foundUsable := false + for _, result := range results { + if !result.IsUsable() { + continue + } + minPct := result.MinRemainingPct() + if result.Status == quota.StatusExhausted { + minPct = 0 + } + resetIn := resetHorizonSeconds(result.Windows, minPct, now) + if result.Status == quota.StatusExhausted && resetIn == 0 { + resetIn = soonestResetHorizonSeconds(result.Windows, now) + } + availability := availabilityForMargin(minPct, resetIn) + if !foundUsable || availabilityRank(availability.State) > availabilityRank(best.State) { + best = availability + foundUsable = true + } + } + return best +} + +func availabilityForMargin(minPct int, resetIn int64) ProviderAvailability { + if minPct < 0 { + return ProviderAvailability{ + State: ProviderAvailabilityAvailable, + Guidance: "Provider is available for normal work. Quota margin is currently unknown.", + Reason: "unknown_quota", + MinRemainingPct: -1, + ResetsInSeconds: resetIn, + } + } + if minPct == 0 { + return ProviderAvailability{ + State: ProviderAvailabilityExhausted, + Guidance: guidanceWithReset("Provider is exhausted or unavailable for new work. Do not select it unless the user explicitly overrides this decision.", resetIn), + Reason: "exhausted_quota", + MinRemainingPct: 0, + ResetsInSeconds: resetIn, + } + } + if minPct <= providerLimitedThresholdPct { + return ProviderAvailability{ + State: ProviderAvailabilityLimited, + Guidance: guidanceWithReset("Provider is available but quota is low. Use only for small, necessary, or user-approved work; prefer another available provider for broad exploration or verification.", resetIn), + Reason: "low_remaining_quota", + MinRemainingPct: minPct, + ResetsInSeconds: resetIn, + } + } + return ProviderAvailability{ + State: ProviderAvailabilityAvailable, + Guidance: guidanceWithReset("Provider is available for normal work.", resetIn), + Reason: "healthy_quota", + MinRemainingPct: minPct, + ResetsInSeconds: resetIn, + } +} + +func availabilityRank(state ProviderAvailabilityState) int { + switch state { + case ProviderAvailabilityAvailable: + return 3 + case ProviderAvailabilityLimited: + return 2 + case ProviderAvailabilityExhausted: + return 1 + default: + return 0 + } +} + +func resetHorizonSeconds(windows map[quota.WindowName]quota.Window, minPct int, now time.Time) int64 { + if minPct < 0 { + return 0 + } + var soonest int64 + for _, window := range windows { + if window.RemainingPct != minPct || window.ResetAtUnix <= 0 { + continue + } + resetIn := max(window.ResetAtUnix-now.Unix(), 0) + if soonest == 0 || resetIn < soonest { + soonest = resetIn + } + } + return soonest +} + +func soonestResetHorizonSeconds(windows map[quota.WindowName]quota.Window, now time.Time) int64 { + var soonest int64 + for _, window := range windows { + if window.ResetAtUnix <= 0 { + continue + } + resetIn := max(window.ResetAtUnix-now.Unix(), 0) + if soonest == 0 || resetIn < soonest { + soonest = resetIn + } + } + return soonest +} + +func guidanceWithReset(guidance string, resetIn int64) string { + if resetIn <= 0 { + return guidance + } + minutes := (resetIn + 59) / 60 + return guidance + " The limiting window resets in about " + strconv.FormatInt(minutes, 10) + " minutes." +} + // flattenFetched returns all results from the fetched map in a single slice, // for the history store to process in one pass. func flattenFetched(fetched map[provider.ID][]quota.Result) []quota.Result { @@ -86,9 +224,10 @@ func buildReport(now time.Time, ordered []provider.ID, fetched map[provider.ID][ for _, id := range ordered { results := fetched[id] pr := ProviderReport{ - ID: id, - Name: capitalise(string(id)), - Results: results, + ID: id, + Name: capitalise(string(id)), + Availability: providerAvailability(results, now), + Results: results, } if windows, summary := aggregate.Compute(results, now.Unix(), burnRates); len(windows) > 0 && summary != nil { pr.Aggregate = &AggregateReport{ diff --git a/internal/app/report_test.go b/internal/app/report_test.go index 6e3542e..4c51eb6 100644 --- a/internal/app/report_test.go +++ b/internal/app/report_test.go @@ -1,6 +1,7 @@ package app import ( + "strings" "testing" "time" @@ -73,3 +74,128 @@ func TestBuildReportNoAggregateForSingleClaude(t *testing.T) { t.Fatal("single Claude account should not have aggregate") } } + +func TestBuildReportAddsProviderAvailability(t *testing.T) { + now := time.Unix(1000, 0) + tests := []struct { + name string + results []quota.Result + wantState ProviderAvailabilityState + wantReason string + wantMinPct int + wantResetIn int64 + wantGuidance string + }{ + { + name: "empty results", + results: nil, + wantState: ProviderAvailabilityExhausted, + wantReason: "unavailable", + wantMinPct: -1, + wantGuidance: "cannot currently be assessed or used", + }, + { + name: "available", + results: []quota.Result{{Status: quota.StatusOK, Windows: map[quota.WindowName]quota.Window{ + quota.Window5Hour: {RemainingPct: 80, ResetAtUnix: 1000 + 9000}, + }}}, + wantState: ProviderAvailabilityAvailable, + wantReason: "healthy_quota", + wantMinPct: 80, + wantResetIn: 9000, + wantGuidance: "available for normal work", + }, + { + name: "limited", + results: []quota.Result{{Status: quota.StatusOK, Windows: map[quota.WindowName]quota.Window{ + quota.WindowName("5h:opus"): {RemainingPct: 5, ResetAtUnix: 1000 + 1800}, + }}}, + wantState: ProviderAvailabilityLimited, + wantReason: "low_remaining_quota", + wantMinPct: 5, + wantResetIn: 1800, + wantGuidance: "Use only for small, necessary, or user-approved work", + }, + { + name: "exhausted", + results: []quota.Result{{Status: quota.StatusExhausted, Windows: map[quota.WindowName]quota.Window{ + quota.Window5Hour: {RemainingPct: 0, ResetAtUnix: 1000 + 600}, + }}}, + wantState: ProviderAvailabilityExhausted, + wantReason: "exhausted_quota", + wantMinPct: 0, + wantResetIn: 600, + wantGuidance: "Do not select it", + }, + { + name: "exhausted status overrides non-zero windows", + results: []quota.Result{{Status: quota.StatusExhausted, Windows: map[quota.WindowName]quota.Window{ + quota.Window5Hour: {RemainingPct: 3, ResetAtUnix: 1000 + 600}, + }}}, + wantState: ProviderAvailabilityExhausted, + wantReason: "exhausted_quota", + wantMinPct: 0, + wantResetIn: 600, + wantGuidance: "Do not select it", + }, + { + name: "error only", + results: []quota.Result{ + quota.ErrorResult("fetch_error", "boom", 0), + }, + wantState: ProviderAvailabilityExhausted, + wantReason: "unavailable", + wantMinPct: -1, + wantGuidance: "cannot currently be assessed or used", + }, + { + name: "unknown quota remains available", + results: []quota.Result{{Status: quota.StatusOK}}, + wantState: ProviderAvailabilityAvailable, + wantReason: "unknown_quota", + wantMinPct: -1, + wantGuidance: "available for normal work", + }, + { + name: "best routeable account beats active exhausted account", + results: []quota.Result{ + {Active: true, Status: quota.StatusExhausted, Windows: map[quota.WindowName]quota.Window{ + quota.Window5Hour: {RemainingPct: 0, ResetAtUnix: 1000 + 600}, + }}, + {Status: quota.StatusOK, Windows: map[quota.WindowName]quota.Window{ + quota.Window5Hour: {RemainingPct: 40, ResetAtUnix: 1000 + 3600}, + }}, + }, + wantState: ProviderAvailabilityAvailable, + wantReason: "healthy_quota", + wantMinPct: 40, + wantResetIn: 3600, + wantGuidance: "available for normal work", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fetched := map[provider.ID][]quota.Result{provider.Codex: tt.results} + + r := buildReport(now, []provider.ID{provider.Codex}, fetched, nil) + got := r.Providers[0].Availability + + if got.State != tt.wantState { + t.Fatalf("availability state = %q, want %q", got.State, tt.wantState) + } + if got.Reason != tt.wantReason { + t.Fatalf("availability reason = %q, want %q", got.Reason, tt.wantReason) + } + if got.MinRemainingPct != tt.wantMinPct { + t.Fatalf("min remaining pct = %v, want %v", got.MinRemainingPct, tt.wantMinPct) + } + if got.ResetsInSeconds != tt.wantResetIn { + t.Fatalf("resets in seconds = %d, want %d", got.ResetsInSeconds, tt.wantResetIn) + } + if !strings.Contains(got.Guidance, tt.wantGuidance) { + t.Fatalf("guidance = %q, want to contain %q", got.Guidance, tt.wantGuidance) + } + }) + } +} diff --git a/internal/output/json_test.go b/internal/output/json_test.go index f6b34e4..e49c92c 100644 --- a/internal/output/json_test.go +++ b/internal/output/json_test.go @@ -109,3 +109,44 @@ func TestJSONRendererScopedWindowKeys(t *testing.T) { t.Fatalf("expected scoped window key in JSON output, got %s", buf.String()) } } + +func TestJSONRendererIncludesAvailabilityAndAccountActive(t *testing.T) { + var buf bytes.Buffer + r := &JSONRenderer{W: &buf} + report := app.Report{ + GeneratedAt: time.Unix(1000, 0), + Providers: []app.ProviderReport{ + { + ID: provider.Claude, + Name: "claude", + Availability: app.ProviderAvailability{ + State: app.ProviderAvailabilityExhausted, + Guidance: "Provider is exhausted.", + Reason: "exhausted_quota", + MinRemainingPct: 0, + }, + Results: []quota.Result{{ + Active: true, + Status: quota.StatusExhausted, + }}, + }, + }, + } + + if err := r.Render(context.Background(), report); err != nil { + t.Fatalf("Render error: %v", err) + } + + if !strings.Contains(buf.String(), `"availability"`) { + t.Fatalf("expected availability in JSON output, got %s", buf.String()) + } + if !strings.Contains(buf.String(), `"state":"exhausted"`) { + t.Fatalf("expected exhausted availability in JSON output, got %s", buf.String()) + } + if !strings.Contains(buf.String(), `"min_remaining_pct":0`) { + t.Fatalf("expected zero min remaining in JSON output, got %s", buf.String()) + } + if !strings.Contains(buf.String(), `"active":true`) { + t.Fatalf("expected account active compatibility field in JSON output, got %s", buf.String()) + } +}