The loop now consults quota and host state before every dispatch and
DEFERS gated work with a logged reason instead of letting turns die at
the provider (the 2026-08-28 19:00 quota-wall failure mode, replayed as
a test). Adds internal/quota: 5h/weekly credit buckets (provider poll
when z.ai ships an endpoint - fake-server tested - else locally
estimated from the documented credit formula), TZ-aware peak window
(default 01:00-05:00 America/Chicago weekdays, matching the documented
z.ai peak Mon-Fri 14:00-18:00 Singapore), block/defer thresholds, a
read-only load/mem/disk/IO-PSI monitor, an optional redis shared-state
hop (stdlib RESP2 mini-client) so all instances of an account
coordinate, per-class token+credit accounting in loop.jsonl, and
`harness quota status|probe|gate`. Config: [quota] + [resources]
sections; README runbook covers the redis container and deploy-time
cgroup enforcement.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
162 lines
5.1 KiB
Go
162 lines
5.1 KiB
Go
// Package quota is the z.ai coding-plan quota gate (Redmine 490) plus the
|
|
// read-only system resource monitor (Redmine 491): it tracks plan credit
|
|
// buckets (5-hour + weekly), computes per-turn credit consumption from the
|
|
// documented z.ai formula, applies TZ-aware peak-hour scheduling and
|
|
// back-pressure thresholds, and shares quota state across harness instances
|
|
// through an optional redis container. The gate NEVER hard-fails the loop:
|
|
// unknown state is permissive, exhaustion defers work with a logged reason.
|
|
//
|
|
// z.ai credit model (docs.z.ai/devpack/overview, 2026-08-29):
|
|
//
|
|
// credits = (input*in_mult + cached_input*cache_mult + output*out_mult) / 10000
|
|
//
|
|
// GLM-5.3: 6.9/1.7/24; GLM-5.3-Flash: 2.3/0.56/8. Off-peak hours charge 50%.
|
|
// Plans: Lite 2,000/10,000, Pro 12,000/60,000, Max 28,000/140,000 credits
|
|
// (5-hour / weekly). LIVE VERIFICATION of the usage endpoint is OPEN: z.ai
|
|
// documents the buckets but no public usage REST route exists today
|
|
// (probed 2026-08-29, see REPORT-20260829-0500-quota); the parser targets
|
|
// the documented shape and runs against a fake server until z.ai ships it.
|
|
package quota
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// Bucket IDs for the z.ai coding-plan windows.
|
|
const (
|
|
Bucket5h = "5h"
|
|
BucketWeekly = "weekly"
|
|
)
|
|
|
|
// Bucket is one usage window: credits used against the plan limit and when
|
|
// the window resets. Used/Limit are in z.ai credits, not tokens or dollars.
|
|
type Bucket struct {
|
|
ID string `json:"id"`
|
|
Used float64 `json:"used"`
|
|
Limit float64 `json:"limit"`
|
|
WindowReset time.Time `json:"window_reset"`
|
|
}
|
|
|
|
// UsedPct is the bucket's consumption ratio in percent of the limit.
|
|
// A zero/negative limit reads as 0 (never blocks).
|
|
func (b Bucket) UsedPct() float64 {
|
|
if b.Limit <= 0 {
|
|
return 0
|
|
}
|
|
return b.Used / b.Limit * 100
|
|
}
|
|
|
|
// QuotaSnapshot is one account's quota state at a point in time, either
|
|
// polled from the provider (Source "usage_url") or synthesized from locally
|
|
// estimated consumption against the configured plan limits (Source
|
|
// "estimate").
|
|
type QuotaSnapshot struct {
|
|
Account string `json:"account"`
|
|
Source string `json:"source"`
|
|
FetchedAt time.Time `json:"fetched_at"`
|
|
Buckets []Bucket `json:"buckets"`
|
|
}
|
|
|
|
// Bucket returns the bucket with the given id, if present.
|
|
func (s *QuotaSnapshot) Bucket(id string) (Bucket, bool) {
|
|
for _, b := range s.Buckets {
|
|
if b.ID == id {
|
|
return b, true
|
|
}
|
|
}
|
|
return Bucket{}, false
|
|
}
|
|
|
|
// MaxUsedPct is the highest consumption ratio across all buckets — the
|
|
// number back-pressure thresholds compare against.
|
|
func (s *QuotaSnapshot) MaxUsedPct() float64 {
|
|
if s == nil {
|
|
return 0
|
|
}
|
|
max := 0.0
|
|
for _, b := range s.Buckets {
|
|
if p := b.UsedPct(); p > max {
|
|
max = p
|
|
}
|
|
}
|
|
return max
|
|
}
|
|
|
|
// providerUsage is the z.ai usage response shape this parser targets. z.ai
|
|
// has not published the route yet; field aliases (used_credits|credits_used,
|
|
// total_credits|limit_credits) keep the parser tolerant of either naming.
|
|
type providerUsage struct {
|
|
Usage struct {
|
|
FiveHour providerBucket `json:"five_hour"`
|
|
Weekly providerBucket `json:"weekly"`
|
|
} `json:"usage"`
|
|
}
|
|
|
|
type providerBucket struct {
|
|
Used any `json:"used_credits"`
|
|
UsedAlias any `json:"credits_used"`
|
|
Limit any `json:"total_credits"`
|
|
LimitAlias any `json:"limit_credits"`
|
|
ResetAt string `json:"reset_at"`
|
|
ResetAlias string `json:"reset_time"`
|
|
}
|
|
|
|
// ParseUsage decodes a provider usage response into a QuotaSnapshot for the
|
|
// account. It is strict about structure (unknown shapes error so a silent
|
|
// HTML error page never reads as "quota fine") and lenient about field
|
|
// naming within the documented buckets.
|
|
func ParseUsage(account string, fetchedAt time.Time, body []byte) (*QuotaSnapshot, error) {
|
|
var p providerUsage
|
|
if err := json.Unmarshal(body, &p); err != nil {
|
|
return nil, fmt.Errorf("usage decode: %w", err)
|
|
}
|
|
num := func(v any) (float64, bool) {
|
|
switch n := v.(type) {
|
|
case float64:
|
|
return n, true
|
|
case string:
|
|
var f float64
|
|
if _, err := fmt.Sscanf(n, "%g", &f); err == nil {
|
|
return f, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
snap := &QuotaSnapshot{Account: account, Source: "usage_url", FetchedAt: fetchedAt}
|
|
for id, pb := range map[string]providerBucket{Bucket5h: p.Usage.FiveHour, BucketWeekly: p.Usage.Weekly} {
|
|
used, ok := num(pb.Used)
|
|
if !ok {
|
|
used, ok = num(pb.UsedAlias)
|
|
}
|
|
if !ok {
|
|
return nil, fmt.Errorf("usage decode: bucket %q has no used_credits", id)
|
|
}
|
|
limit, ok := num(pb.Limit)
|
|
if !ok {
|
|
limit, ok = num(pb.LimitAlias)
|
|
}
|
|
if !ok {
|
|
return nil, fmt.Errorf("usage decode: bucket %q has no total_credits", id)
|
|
}
|
|
reset := pb.ResetAt
|
|
if reset == "" {
|
|
reset = pb.ResetAlias
|
|
}
|
|
rt := time.Time{}
|
|
if reset != "" {
|
|
var err error
|
|
rt, err = time.Parse(time.RFC3339, reset)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("usage decode: bucket %q reset_at: %w", id, err)
|
|
}
|
|
}
|
|
snap.Buckets = append(snap.Buckets, Bucket{ID: id, Used: used, Limit: limit, WindowReset: rt})
|
|
}
|
|
if len(snap.Buckets) != 2 {
|
|
return nil, fmt.Errorf("usage decode: want five_hour and weekly buckets, got %d", len(snap.Buckets))
|
|
}
|
|
return snap, nil
|
|
}
|