quota: z.ai credit-bucket back-pressure + resource gate + usage accounting (Redmine 490+491)
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
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user