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
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2045,6 +2045,7 @@ deterministic PASS/FAIL verifier.
--web-control opt into browser session approvals/prompts (requires --web)
--web-port PORT local browser port (default: 0/automatic; requires --web)
--refresh DURATION refresh interval (default: 1m)
--quota-step-down PERCENT:MODEL:EFFORT[:SPEED] offer a confirmed session profile at a quota threshold (repeatable)
--reset-threshold PERCENT show available resets at this consumption level (0-100; default: 80)
--reset-warning-hours HOURS expiry warning lead time (default: 72; 0 disables)
-v, --version print the version and exit
Expand All @@ -2061,8 +2062,56 @@ codexometer --inline

# Use a separately installed Codex build
codexometer --codex ~/bin/codex

# Ask before lowering model, reasoning and speed as weekly quota is consumed
codexometer \
--quota-step-down 80:gpt-5.6-sol:medium:standard \
--quota-step-down 95:gpt-5.6-luna:medium:slow
```

### Quota step-down profiles

`--quota-step-down PERCENT:MODEL:EFFORT[:SPEED]` configures an opt-in profile
for the longest ordinary Codex quota window (normally the weekly window). The
flag is repeatable and thresholds must be unique. `SPEED` is optional and may
be `fast`, `standard`, `slow`, `priority` or `flex`. Names such as `fast` and
`slow` are resolved against the model's advertised service tiers; `slow` is
**not** assumed to mean Flex. Unsupported models, reasoning levels and speeds
are rejected before changing settings. `standard` clears an explicit tier;
omitting speed leaves the session's current tier intact.
Codexometer ships no enabled profile and does not infer which model is cheaper.

The quota/reset action area adds separate profile controls without replacing
the banked-reset button. Controls remain available when no reset credits exist.
At a reached threshold, `G` reviews one loaded session and its current and
proposed settings; repeating `G` within ten seconds approves that exact session.
`N` selects the next session, `D` skips it at this gate, and `Esc` cancels review.
`A` explicitly reviews/approves all listed sessions, only when the review fits
the pane. Newly discovered sessions are never covered by an earlier approval.
The shared app-server's experimental `thread/settings/update` changes subsequent
turns, not a turn already in progress. A queued acknowledgement alone is not
reported as a verified change. Changed settings since review are skipped.

Approved settings remain after Codexometer closes, crashes, or restarts, and
when the quota window changes. Codexometer keeps no model/reasoning/speed
history and never rolls settings back. Change them again in Codex when needed.
Persistence across a Codex/app-server restart is controlled by Codex itself.

On every launch and refresh, Codexometer reads current session settings and
compares them with the eligible target profile. Sessions already at that target
need no approval or update, even after restarting with the same switches. Speed
names are resolved through the model catalogue before comparison; omitted speed
accepts any current speed, while `standard` requires an unset explicit tier.
Other sessions still require approval. Skips and attempted-change tracking are
process-local; approval itself is never persisted. Refreshes do not reapply a
profile or overwrite manual changes. Shutdown cancels/drains outstanding work
without sending any restoration calls; an already queued change may still apply.

This feature does not edit `config.toml` or change global Codex defaults. It
requires a Codex version exposing the shared app-server control socket and
experimental `thread/settings/update`. Updates are not atomic with concurrent
settings changes in the Codex UI.

## Experimental browser interface

Keep the terminal experience, or opt into a local Svelte browser dashboard:
Expand Down
3 changes: 3 additions & 0 deletions internal/codex/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ const requestTimeout = 15 * time.Second
type Client struct {
Binary string
LiveUsage *LiveUsageReader
// QuotaSteps is an opt-in launch-time policy for lowering the model profile
// of loaded sessions as quota consumption crosses configured thresholds.
QuotaSteps []QuotaStep
// BenchmarkAPIKey is used only by isolated benchmark app-server sessions.
// Fetch and local monitoring continue to use the prevailing Codex login.
BenchmarkAPIKey string
Expand Down
26 changes: 14 additions & 12 deletions internal/codex/daemon_status_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,20 @@ type daemonStatusProvider struct {
contexts map[string]*daemonContextState
socketPath string

mu sync.Mutex
connection *websocket.Conn
nextRequestID int64
pending map[int64]chan daemonEnvelope
subscribed map[string]struct{}
reroutedTurns map[daemonTurnKey]string
observations []resolvedModelObservation
nextSequence uint64
lastStatusAt time.Time
statusThreads map[string]struct{}
statuses map[string]sessionRuntimeStatus
writeMu sync.Mutex
mu sync.Mutex
connection *websocket.Conn
nextRequestID int64
pending map[int64]chan daemonEnvelope
subscribed map[string]struct{}
reroutedTurns map[daemonTurnKey]string
observations []resolvedModelObservation
nextSequence uint64
lastStatusAt time.Time
statusThreads map[string]struct{}
statuses map[string]sessionRuntimeStatus
settingsMu sync.Mutex
settingsClosed bool
writeMu sync.Mutex
}

type daemonTurnKey struct {
Expand Down
75 changes: 75 additions & 0 deletions internal/codex/quota_step.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package codex

import (
"context"
"errors"
)

type QuotaStep struct {
Threshold int
Model string
Effort string
// An advertised tier name or ID; empty preserves speed.
ServiceTier string
}
type QuotaSession struct {
ID string
Model string
Effort string
Tier *string
}
type QuotaStepPolicyProvider interface{ QuotaStepPolicy() []QuotaStep }
type SessionSettingsClient interface {
QuotaSessions(context.Context) ([]QuotaSession, error)
ApplyQuotaProfile(context.Context, []QuotaSession, QuotaStep) (int, error)
ResolveQuotaStep(context.Context, QuotaStep) (QuotaStep, error)
CloseQuotaProfiles()
}

func (c Client) QuotaStepPolicy() []QuotaStep { return append([]QuotaStep(nil), c.QuotaSteps...) }
func (c Client) settingsClient() SessionSettingsClient {
if c.LiveUsage != nil {
p, _ := c.LiveUsage.statusProvider.(SessionSettingsClient)
return p
}
return nil
}
func (c Client) QuotaSessions(ctx context.Context) ([]QuotaSession, error) {
if p := c.settingsClient(); p != nil {
return p.QuotaSessions(ctx)
}
return nil, errors.New("shared session control unavailable")
}
func (c Client) ApplyQuotaProfile(ctx context.Context, targets []QuotaSession, step QuotaStep) (int, error) {
if p := c.settingsClient(); p != nil {
return p.ApplyQuotaProfile(ctx, targets, step)
}
return 0, errors.New("shared session control unavailable")
}
func (c Client) ResolveQuotaStep(ctx context.Context, step QuotaStep) (QuotaStep, error) {
if p := c.settingsClient(); p != nil {
return p.ResolveQuotaStep(ctx, step)
}
return step, errors.New("shared session control unavailable")
}
func (c Client) CloseQuotaProfiles() {
if p := c.settingsClient(); p != nil {
p.CloseQuotaProfiles()
}
}

// MatchesQuotaStep compares current state, not approval history. Resolve an
// advertised speed name to its ID first. Omitted speed imposes no constraint.
func (s QuotaSession) MatchesQuotaStep(step QuotaStep) bool {
if s.Model != step.Model || s.Effort != step.Effort {
return false
}
switch step.ServiceTier {
case "":
return true
case "default":
return s.Tier == nil
default:
return s.Tier != nil && *s.Tier == step.ServiceTier
}
}
27 changes: 27 additions & 0 deletions internal/codex/quota_step_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package codex

import "testing"

func TestQuotaTargetMatching(t *testing.T) {
priority, flex := "priority", "flex"
for _, tc := range []struct {
name string
session QuotaSession
step QuotaStep
want bool
}{
{"exact", QuotaSession{Model: "small", Effort: "medium", Tier: &priority}, QuotaStep{Model: "small", Effort: "medium", ServiceTier: "priority"}, true},
{"different model", QuotaSession{Model: "large", Effort: "medium", Tier: &priority}, QuotaStep{Model: "small", Effort: "medium", ServiceTier: "priority"}, false},
{"different reasoning", QuotaSession{Model: "small", Effort: "high", Tier: &priority}, QuotaStep{Model: "small", Effort: "medium", ServiceTier: "priority"}, false},
{"different speed", QuotaSession{Model: "small", Effort: "medium", Tier: &flex}, QuotaStep{Model: "small", Effort: "medium", ServiceTier: "priority"}, false},
{"omitted speed", QuotaSession{Model: "small", Effort: "medium", Tier: &flex}, QuotaStep{Model: "small", Effort: "medium"}, true},
{"standard", QuotaSession{Model: "small", Effort: "medium"}, QuotaStep{Model: "small", Effort: "medium", ServiceTier: "default"}, true},
{"standard differs", QuotaSession{Model: "small", Effort: "medium", Tier: &priority}, QuotaStep{Model: "small", Effort: "medium", ServiceTier: "default"}, false},
} {
t.Run(tc.name, func(t *testing.T) {
if got := tc.session.MatchesQuotaStep(tc.step); got != tc.want {
t.Fatalf("match=%v want=%v", got, tc.want)
}
})
}
}
Loading
Loading