Read Codex rate limits at startup so Status always shows them

This commit is contained in:
Ian Gulliver
2026-10-15 22:30:32 -07:00
parent a1a4b83f39
commit a2ab3a74fc
7 changed files with 216 additions and 101 deletions
+1 -1
View File
@@ -43,7 +43,7 @@ Closing stdin ends the process.
- `turn/started`, `turn/completed` (`turn.status` `completed`, `interrupted` or `failed` with `turn.error`). A failed request to the model also sends an `error` notification first, with `willRetry`. - `turn/started`, `turn/completed` (`turn.status` `completed`, `interrupted` or `failed` with `turn.error`). A failed request to the model also sends an `error` notification first, with `willRetry`.
- `item/started` and `item/completed` with a `ThreadItem`: `userMessage`, `agentMessage` (`text`), `reasoning`, `commandExecution` (`command`, `aggregatedOutput`, `exitCode`), `fileChange`, `mcpToolCall`, `contextCompaction` and others. - `item/started` and `item/completed` with a `ThreadItem`: `userMessage`, `agentMessage` (`text`), `reasoning`, `commandExecution` (`command`, `aggregatedOutput`, `exitCode`), `fileChange`, `mcpToolCall`, `contextCompaction` and others.
- `item/agentMessage/delta` (`itemId`, `delta`) streams an agent message; the deltas join to the completed item's `text`. `item/commandExecution/outputDelta` and the reasoning deltas stream the same way. - `item/agentMessage/delta` (`itemId`, `delta`) streams an agent message; the deltas join to the completed item's `text`. `item/commandExecution/outputDelta` and the reasoning deltas stream the same way.
- `thread/tokenUsage/updated` carries `last` and `total` token counts and `modelContextWindow`; `account/rateLimits/updated` the account's rate-limit windows; `thread/status/changed`, `mcpServer/startupStatus/updated`, `account/updated` and `remoteControl/status/changed` also arrive. - `thread/tokenUsage/updated` carries `last` and `total` token counts and `modelContextWindow`; `account/rateLimits/updated` the account's rate-limit windows (the same snapshot `account/rateLimits/read` returns on request, as `rateLimits`); `thread/status/changed`, `mcpServer/startupStatus/updated`, `account/updated` and `remoteControl/status/changed` also arrive.
- Lines that aren't a JSON object, don't parse, or carry a notification method outside `notificationParams` go to `Options.OnBadLine`; parsed notifications are still delivered as events. `Event.Decode` returns the notification's generated params type. - Lines that aren't a JSON object, don't parse, or carry a notification method outside `notificationParams` go to `Options.OnBadLine`; parsed notifications are still delivered as events. `Event.Decode` returns the notification's generated params type.
Besides `item/tool/call`, server requests (approvals, user input) shouldn't arrive with approvals off; if one does, command and file-change approvals are accepted and anything else gets a JSON-RPC error. Besides `item/tool/call`, server requests (approvals, user input) shouldn't arrive with approvals off; if one does, command and file-change approvals are accepted and anything else gets a JSON-RPC error.
+1 -1
View File
@@ -118,7 +118,7 @@ Codex runs with its own home, `codex` under the data directory (`CODEX_HOME`, se
## Status ## Status
`GET /api/admin/status` reports `running_turns` (task sessions now `running`, as session, task and vendor; lead turns aren't listed); `rate_limits`, the latest status each vendor sent since byre started (Claude's `rate_limit_info` unchanged; Codex's `account/rateLimits/updated` reshaped by `codexRateLimit` in `internal/agents/codex.go` into the same `status`, `rateLimitType` and `unifiedWindows` fields); `agent_processes`, `zombies`, `stuck_after_seconds` and `protocol_errors` (below); and server counts (start time, agent processes, projects, active tasks, SSE clients, database size including WAL, last event seq). `GET /api/admin/status` reports `running_turns` (task sessions now `running`, as session, task and vendor; lead turns aren't listed); `rate_limits`, the latest status each vendor sent since byre started (Claude's `rate_limit_info` unchanged; Codex's `account/rateLimits/updated` reshaped by `codexRateLimit` in `internal/agents/codex.go` into the same `status`, `rateLimitType` and `unifiedWindows` fields). Claude only reports during its turns; Codex is also asked once at startup (`readCodexRateLimit`, a short-lived `codex app-server` that calls `account/rateLimits/read` without opening a thread), so its entry is there before any Codex turn runs, and every Codex update broadcasts `admin.health`; `agent_processes`, `zombies`, `stuck_after_seconds` and `protocol_errors` (below); and server counts (start time, agent processes, projects, active tasks, SSE clients, database size including WAL, last event seq).
Agent health is `internal/agents/health.go`: Agent health is `internal/agents/health.go`:
+27 -3
View File
@@ -4,8 +4,11 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"log/slog" "log/slog"
"os"
"strings" "strings"
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/codex" "g.fc.run/misc/byre/internal/codex"
) )
@@ -66,9 +69,7 @@ func (m *Manager) handleCodex(ctx context.Context, l *live, s *codex.Session, ev
if err != nil { if err != nil {
return err return err
} }
m.mu.Lock() m.setCodexRateLimit(n.RateLimits)
m.rateLimits[l.vendor] = codexRateLimit(n.RateLimits)
m.mu.Unlock()
return nil return nil
case codex.MethodTurnCompleted: case codex.MethodTurnCompleted:
@@ -194,6 +195,29 @@ func codexToolResult(item codex.ThreadItem) (string, bool) {
return status, status != "completed" return status, status != "completed"
} }
func (m *Manager) readCodexRateLimit() {
err := os.MkdirAll(m.codexHome, 0o700)
if err != nil {
slog.Error("read codex rate limits", "err", err)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
r, err := codex.ReadRateLimits(ctx, m.codexHome, []string{"CODEX_HOME=" + m.codexHome})
if err != nil {
slog.Error("read codex rate limits", "err", err)
return
}
m.setCodexRateLimit(r)
}
func (m *Manager) setCodexRateLimit(r codex.RateLimitSnapshot) {
m.mu.Lock()
m.rateLimits[api.Codex] = codexRateLimit(r)
m.mu.Unlock()
m.store.Broadcast(api.Event{Kind: "admin.health", Actor: "byre", Payload: map[string]any{}})
}
func codexRateLimit(r codex.RateLimitSnapshot) map[string]any { func codexRateLimit(r codex.RateLimitSnapshot) map[string]any {
status := "allowed" status := "allowed"
if r.RateLimitReachedType != "" { if r.RateLimitReachedType != "" {
+1
View File
@@ -114,6 +114,7 @@ func New(st *store.Store, worktreeRoot, leadRoot, codexHome string) *Manager {
watchStop: make(chan struct{}), watchStop: make(chan struct{}),
} }
go m.signalLoop() go m.signalLoop()
go m.readCodexRateLimit()
return m return m
} }
+8 -2
View File
@@ -24,6 +24,7 @@ var clientMethods = []string{
"turn/start", "turn/start",
"turn/steer", "turn/steer",
"turn/interrupt", "turn/interrupt",
"account/rateLimits/read",
} }
type schema struct { type schema struct {
@@ -201,8 +202,13 @@ func (g *gen) variants(def string) []method {
log.Fatalf("%s method: %v", def, err) log.Fatalf("%s method: %v", def, err)
} }
params := "" params := ""
if p := v.Properties["params"]; p != nil && p.Ref != "" { if p := v.Properties["params"]; p != nil {
params = refName(p.Ref) if alts := nonNull(p.AnyOf); len(alts) == 1 {
p = alts[0]
}
if p.Ref != "" {
params = refName(p.Ref)
}
} }
response := "" response := ""
if strings.HasSuffix(params, "Params") { if strings.HasSuffix(params, "Params") {
+121 -74
View File
@@ -16,6 +16,7 @@ const (
MethodTurnStart = "turn/start" MethodTurnStart = "turn/start"
MethodTurnSteer = "turn/steer" MethodTurnSteer = "turn/steer"
MethodTurnInterrupt = "turn/interrupt" MethodTurnInterrupt = "turn/interrupt"
MethodAccountRateLimitsRead = "account/rateLimits/read"
MethodError = "error" MethodError = "error"
MethodThreadStarted = "thread/started" MethodThreadStarted = "thread/started"
MethodThreadStatusChanged = "thread/status/changed" MethodThreadStatusChanged = "thread/status/changed"
@@ -113,15 +114,16 @@ const (
) )
var clientResults = map[string]func() any{ var clientResults = map[string]func() any{
MethodInitialize: func() any { return &InitializeResponse{} }, MethodInitialize: func() any { return &InitializeResponse{} },
MethodThreadStart: func() any { return &ThreadStartResponse{} }, MethodThreadStart: func() any { return &ThreadStartResponse{} },
MethodThreadResume: func() any { return &ThreadResumeResponse{} }, MethodThreadResume: func() any { return &ThreadResumeResponse{} },
MethodThreadFork: func() any { return &ThreadForkResponse{} }, MethodThreadFork: func() any { return &ThreadForkResponse{} },
MethodThreadCompactStart: func() any { return &ThreadCompactStartResponse{} }, MethodThreadCompactStart: func() any { return &ThreadCompactStartResponse{} },
MethodThreadRead: func() any { return &ThreadReadResponse{} }, MethodThreadRead: func() any { return &ThreadReadResponse{} },
MethodTurnStart: func() any { return &TurnStartResponse{} }, MethodTurnStart: func() any { return &TurnStartResponse{} },
MethodTurnSteer: func() any { return &TurnSteerResponse{} }, MethodTurnSteer: func() any { return &TurnSteerResponse{} },
MethodTurnInterrupt: func() any { return &TurnInterruptResponse{} }, MethodTurnInterrupt: func() any { return &TurnInterruptResponse{} },
MethodAccountRateLimitsRead: func() any { return &GetAccountRateLimitsResponse{} },
} }
var notificationParams = map[string]func() any{ var notificationParams = map[string]func() any{
@@ -534,6 +536,47 @@ const (
ReasoningSummaryNone ReasoningSummary = "none" ReasoningSummaryNone ReasoningSummary = "none"
) )
const (
RateLimitResetTypeCodexRateLimits RateLimitResetType = "codexRateLimits"
RateLimitResetTypeUnknown RateLimitResetType = "unknown"
)
const (
RateLimitResetCreditStatusAvailable RateLimitResetCreditStatus = "available"
RateLimitResetCreditStatusRedeeming RateLimitResetCreditStatus = "redeeming"
RateLimitResetCreditStatusRedeemed RateLimitResetCreditStatus = "redeemed"
RateLimitResetCreditStatusUnknown RateLimitResetCreditStatus = "unknown"
)
const (
PlanTypeFree PlanType = "free"
PlanTypeGo PlanType = "go"
PlanTypePlus PlanType = "plus"
PlanTypePro PlanType = "pro"
PlanTypeProlite PlanType = "prolite"
PlanTypePromax PlanType = "promax"
PlanTypeTeam PlanType = "team"
PlanTypeSelfServeBusinessProlite PlanType = "self_serve_business_prolite"
PlanTypeSelfServeBusinessUsageBased PlanType = "self_serve_business_usage_based"
PlanTypeBusiness PlanType = "business"
PlanTypeEnt26 PlanType = "ent26"
PlanTypeEnterpriseCbpAutomation PlanType = "enterprise_cbp_automation"
PlanTypeEnterpriseCbpUsageBased PlanType = "enterprise_cbp_usage_based"
PlanTypeEnterprise PlanType = "enterprise"
PlanTypeEdu PlanType = "edu"
PlanTypeEduPlus PlanType = "edu_plus"
PlanTypeEduPro PlanType = "edu_pro"
PlanTypeUnknown PlanType = "unknown"
)
const (
RateLimitReachedTypeRateLimitReached RateLimitReachedType = "rate_limit_reached"
RateLimitReachedTypeWorkspaceOwnerCreditsDepleted RateLimitReachedType = "workspace_owner_credits_depleted"
RateLimitReachedTypeWorkspaceMemberCreditsDepleted RateLimitReachedType = "workspace_member_credits_depleted"
RateLimitReachedTypeWorkspaceOwnerUsageLimitReached RateLimitReachedType = "workspace_owner_usage_limit_reached"
RateLimitReachedTypeWorkspaceMemberUsageLimitReached RateLimitReachedType = "workspace_member_usage_limit_reached"
)
const ( const (
ThreadAttachmentOperationCreated ThreadAttachmentOperation = "created" ThreadAttachmentOperationCreated ThreadAttachmentOperation = "created"
ThreadAttachmentOperationDeleted ThreadAttachmentOperation = "deleted" ThreadAttachmentOperationDeleted ThreadAttachmentOperation = "deleted"
@@ -714,27 +757,6 @@ const (
AuthModeBedrockAccessKeys AuthMode = "bedrockAccessKeys" AuthModeBedrockAccessKeys AuthMode = "bedrockAccessKeys"
) )
const (
PlanTypeFree PlanType = "free"
PlanTypeGo PlanType = "go"
PlanTypePlus PlanType = "plus"
PlanTypePro PlanType = "pro"
PlanTypeProlite PlanType = "prolite"
PlanTypePromax PlanType = "promax"
PlanTypeTeam PlanType = "team"
PlanTypeSelfServeBusinessProlite PlanType = "self_serve_business_prolite"
PlanTypeSelfServeBusinessUsageBased PlanType = "self_serve_business_usage_based"
PlanTypeBusiness PlanType = "business"
PlanTypeEnt26 PlanType = "ent26"
PlanTypeEnterpriseCbpAutomation PlanType = "enterprise_cbp_automation"
PlanTypeEnterpriseCbpUsageBased PlanType = "enterprise_cbp_usage_based"
PlanTypeEnterprise PlanType = "enterprise"
PlanTypeEdu PlanType = "edu"
PlanTypeEduPlus PlanType = "edu_plus"
PlanTypeEduPro PlanType = "edu_pro"
PlanTypeUnknown PlanType = "unknown"
)
const ( const (
GatewayOAuthStatusNotReady GatewayOAuthStatus = "notReady" GatewayOAuthStatusNotReady GatewayOAuthStatus = "notReady"
GatewayOAuthStatusStarted GatewayOAuthStatus = "started" GatewayOAuthStatusStarted GatewayOAuthStatus = "started"
@@ -742,14 +764,6 @@ const (
GatewayOAuthStatusFailed GatewayOAuthStatus = "failed" GatewayOAuthStatusFailed GatewayOAuthStatus = "failed"
) )
const (
RateLimitReachedTypeRateLimitReached RateLimitReachedType = "rate_limit_reached"
RateLimitReachedTypeWorkspaceOwnerCreditsDepleted RateLimitReachedType = "workspace_owner_credits_depleted"
RateLimitReachedTypeWorkspaceMemberCreditsDepleted RateLimitReachedType = "workspace_member_credits_depleted"
RateLimitReachedTypeWorkspaceOwnerUsageLimitReached RateLimitReachedType = "workspace_owner_usage_limit_reached"
RateLimitReachedTypeWorkspaceMemberUsageLimitReached RateLimitReachedType = "workspace_member_usage_limit_reached"
)
const ( const (
RemoteControlConnectionStatusDisabled RemoteControlConnectionStatus = "disabled" RemoteControlConnectionStatusDisabled RemoteControlConnectionStatus = "disabled"
RemoteControlConnectionStatusConnecting RemoteControlConnectionStatus = "connecting" RemoteControlConnectionStatusConnecting RemoteControlConnectionStatus = "connecting"
@@ -1611,6 +1625,75 @@ type TurnInterruptParams struct {
type TurnInterruptResponse = map[string]any type TurnInterruptResponse = map[string]any
type GetAccountRateLimitsParams struct {
ExcludeResetCreditDetails bool `json:"excludeResetCreditDetails,omitempty"`
SupportsLunaReserve bool `json:"supportsLunaReserve,omitempty"`
}
type RateLimitResetType string
type RateLimitResetCreditStatus string
type RateLimitResetCredit struct {
Description string `json:"description,omitempty"`
ExpiresAt int64 `json:"expiresAt,omitempty"`
GrantedAt int64 `json:"grantedAt"`
ID string `json:"id"`
ResetType RateLimitResetType `json:"resetType"`
Status RateLimitResetCreditStatus `json:"status"`
Title string `json:"title,omitempty"`
}
type RateLimitResetCreditsSummary struct {
AvailableCount int64 `json:"availableCount"`
Credits []RateLimitResetCredit `json:"credits,omitempty"`
}
type CreditsSnapshot struct {
Balance string `json:"balance,omitempty"`
HasCredits bool `json:"hasCredits"`
Unlimited bool `json:"unlimited"`
}
type SpendControlLimitSnapshot struct {
Limit string `json:"limit"`
RemainingPercent int64 `json:"remainingPercent"`
ResetsAt int64 `json:"resetsAt"`
Used string `json:"used"`
}
type PlanType string
type RateLimitWindow struct {
ResetsAt int64 `json:"resetsAt,omitempty"`
UsedPercent int64 `json:"usedPercent"`
WindowDurationMins int64 `json:"windowDurationMins,omitempty"`
}
type RateLimitReachedType string
type RateLimitSnapshot struct {
Credits *CreditsSnapshot `json:"credits,omitempty"`
IndividualLimit *SpendControlLimitSnapshot `json:"individualLimit,omitempty"`
LimitID string `json:"limitId,omitempty"`
LimitName string `json:"limitName,omitempty"`
NormalModelSlug string `json:"normalModelSlug,omitempty"`
PlanType PlanType `json:"planType,omitempty"`
Primary *RateLimitWindow `json:"primary,omitempty"`
RateLimitReachedType RateLimitReachedType `json:"rateLimitReachedType,omitempty"`
Secondary *RateLimitWindow `json:"secondary,omitempty"`
SpendControlReached bool `json:"spendControlReached,omitempty"`
}
type GetAccountRateLimitsResponse struct {
AccountID string `json:"accountId,omitempty"`
OrdinaryUsageAllowed bool `json:"ordinaryUsageAllowed,omitempty"`
RateLimitResetCredits *RateLimitResetCreditsSummary `json:"rateLimitResetCredits,omitempty"`
RateLimitUpsell json.RawMessage `json:"rateLimitUpsell,omitempty"`
RateLimits RateLimitSnapshot `json:"rateLimits"`
RateLimitsByLimitID map[string]RateLimitSnapshot `json:"rateLimitsByLimitId,omitempty"`
}
type ErrorNotification struct { type ErrorNotification struct {
Error TurnError `json:"error"` Error TurnError `json:"error"`
ThreadID string `json:"threadId"` ThreadID string `json:"threadId"`
@@ -2065,8 +2148,6 @@ type McpServerEventStreamNotification struct {
type AuthMode string type AuthMode string
type PlanType string
type AccountUpdatedNotification struct { type AccountUpdatedNotification struct {
AuthMode AuthMode `json:"authMode,omitempty"` AuthMode AuthMode `json:"authMode,omitempty"`
PlanType PlanType `json:"planType,omitempty"` PlanType PlanType `json:"planType,omitempty"`
@@ -2081,40 +2162,6 @@ type GatewayOAuthChangedNotification struct {
Status GatewayOAuthStatus `json:"status"` Status GatewayOAuthStatus `json:"status"`
} }
type CreditsSnapshot struct {
Balance string `json:"balance,omitempty"`
HasCredits bool `json:"hasCredits"`
Unlimited bool `json:"unlimited"`
}
type SpendControlLimitSnapshot struct {
Limit string `json:"limit"`
RemainingPercent int64 `json:"remainingPercent"`
ResetsAt int64 `json:"resetsAt"`
Used string `json:"used"`
}
type RateLimitWindow struct {
ResetsAt int64 `json:"resetsAt,omitempty"`
UsedPercent int64 `json:"usedPercent"`
WindowDurationMins int64 `json:"windowDurationMins,omitempty"`
}
type RateLimitReachedType string
type RateLimitSnapshot struct {
Credits *CreditsSnapshot `json:"credits,omitempty"`
IndividualLimit *SpendControlLimitSnapshot `json:"individualLimit,omitempty"`
LimitID string `json:"limitId,omitempty"`
LimitName string `json:"limitName,omitempty"`
NormalModelSlug string `json:"normalModelSlug,omitempty"`
PlanType PlanType `json:"planType,omitempty"`
Primary *RateLimitWindow `json:"primary,omitempty"`
RateLimitReachedType RateLimitReachedType `json:"rateLimitReachedType,omitempty"`
Secondary *RateLimitWindow `json:"secondary,omitempty"`
SpendControlReached bool `json:"spendControlReached,omitempty"`
}
type AccountRateLimitsUpdatedNotification struct { type AccountRateLimitsUpdatedNotification struct {
RateLimits RateLimitSnapshot `json:"rateLimits"` RateLimits RateLimitSnapshot `json:"rateLimits"`
} }
+57 -20
View File
@@ -116,6 +116,45 @@ type Session struct {
} }
func Start(ctx context.Context, opts Options) (*Session, error) { func Start(ctx context.Context, opts Options) (*Session, error) {
s, err := spawn(opts)
if err != nil {
return nil, err
}
err = s.open(ctx, opts)
if err != nil {
s.Close()
return nil, err
}
return s, nil
}
func ReadRateLimits(ctx context.Context, cwd string, env []string) (RateLimitSnapshot, error) {
s, err := spawn(Options{Cwd: cwd, Env: env})
if err != nil {
return RateLimitSnapshot{}, err
}
defer s.Close()
go func() {
for range s.events {
}
}()
err = s.initialize(ctx)
if err != nil {
return RateLimitSnapshot{}, err
}
resp := GetAccountRateLimitsResponse{}
err = s.Call(ctx, MethodAccountRateLimitsRead, GetAccountRateLimitsParams{ExcludeResetCreditDetails: true}, &resp)
if err != nil {
return RateLimitSnapshot{}, fmt.Errorf("read rate limits: %w", err)
}
return resp.RateLimits, nil
}
func spawn(opts Options) (*Session, error) {
cmd := exec.Command("codex", "app-server", "-c", "check_for_update_on_startup=false") cmd := exec.Command("codex", "app-server", "-c", "check_for_update_on_startup=false")
cmd.Dir = opts.Cwd cmd.Dir = opts.Cwd
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
@@ -138,12 +177,6 @@ func Start(ctx context.Context, opts Options) (*Session, error) {
s := newSession(opts, stdin, stdout) s := newSession(opts, stdin, stdout)
s.cmd = cmd s.cmd = cmd
err = s.open(ctx, opts)
if err != nil {
s.Close()
return nil, err
}
return s, nil return s, nil
} }
@@ -167,20 +200,7 @@ func newSession(opts Options, stdin io.WriteCloser, stdout io.Reader) *Session {
} }
func (s *Session) open(ctx context.Context, opts Options) error { func (s *Session) open(ctx context.Context, opts Options) error {
init := InitializeResponse{} err := s.initialize(ctx)
err := s.Call(ctx, MethodInitialize, InitializeParams{
ClientInfo: ClientInfo{Name: "byre", Title: "byre", Version: "0.0.0"},
Capabilities: &InitializeCapabilities{ExperimentalApi: true},
}, &init)
if err != nil {
return fmt.Errorf("initialize: %w", err)
}
s.stateMu.Lock()
s.userAgent = init.UserAgent
s.stateMu.Unlock()
err = s.notify("initialized", nil)
if err != nil { if err != nil {
return err return err
} }
@@ -239,6 +259,23 @@ func (s *Session) open(ctx context.Context, opts Options) error {
return nil return nil
} }
func (s *Session) initialize(ctx context.Context) error {
init := InitializeResponse{}
err := s.Call(ctx, MethodInitialize, InitializeParams{
ClientInfo: ClientInfo{Name: "byre", Title: "byre", Version: "0.0.0"},
Capabilities: &InitializeCapabilities{ExperimentalApi: true},
}, &init)
if err != nil {
return fmt.Errorf("initialize: %w", err)
}
s.stateMu.Lock()
s.userAgent = init.UserAgent
s.stateMu.Unlock()
return s.notify("initialized", nil)
}
func stdioConfig(servers []mcp.Stdio) map[string]any { func stdioConfig(servers []mcp.Stdio) map[string]any {
if len(servers) == 0 { if len(servers) == 0 {
return nil return nil