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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
159 changes: 149 additions & 10 deletions internal/app/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package app

import (
"context"
"strconv"
"time"
"unicode"
"unicode/utf8"
Expand Down Expand Up @@ -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 {
Expand All @@ -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{
Expand Down
126 changes: 126 additions & 0 deletions internal/app/report_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package app

import (
"strings"
"testing"
"time"

Expand Down Expand Up @@ -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)
}
})
}
}
41 changes: 41 additions & 0 deletions internal/output/json_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
Loading