iop/apps/node/internal/adapters/cli/status/quota.go
toki deea59d035 feat: task routing, orchestration, quota probe, and skill updates
- add finalize-task-routing skill with policy and test scripts
- add execution target selection scripts and tests for orchestrate-agent-task-loop
- add quota probe adapter and CLI status for node app
- update plan, finalize-task-routing, and orchestration skill files
- add agent-task archive for runtime target selector
- update stream-evidence-gate-core milestone
2026-07-25 19:33:17 +09:00

171 lines
5 KiB
Go

package status
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
)
const quotaSnapshotSchemaVersion = "1.0"
// QuotaCapView is the normalized, non-sensitive evidence for one required
// usage cap. It deliberately excludes UsageStatus.RawOutput.
type QuotaCapView struct {
Name string `json:"name"`
Status string `json:"status"`
RemainingPercent *float64 `json:"remaining_percent"`
}
// QuotaTargetView is the selector-facing availability result for one target.
type QuotaTargetView struct {
Adapter string `json:"adapter"`
Target string `json:"target"`
Status string `json:"status"`
}
// QuotaSnapshot is a narrow JSON bridge from the Go usage checker to the
// execution selector. It never serializes provider output or checker errors.
type QuotaSnapshot struct {
SchemaVersion string `json:"schema_version"`
SnapshotID string `json:"snapshot_id"`
Source string `json:"source"`
CheckedAt string `json:"checked_at"`
Targets []QuotaTargetView `json:"targets"`
RequiredCaps []QuotaCapView `json:"required_caps"`
ReasonCodes []string `json:"reason_codes"`
}
// NormalizeQuotaSnapshot resolves the declared cap set against a parsed usage
// status. A confirmed exhausted cap wins over missing or malformed evidence so
// callers do not admit a target known to be exhausted.
func NormalizeQuotaSnapshot(adapter, target string, requiredCaps []string, checkedAt time.Time, usage *UsageStatus, checkErr error) QuotaSnapshot {
views := make([]QuotaCapView, 0, len(requiredCaps))
reasons := make([]string, 0, 1)
if checkErr != nil || usage == nil {
for _, cap := range requiredCaps {
views = append(views, QuotaCapView{Name: cap, Status: "unknown"})
}
reasons = append(reasons, "checker_error")
} else {
for _, cap := range requiredCaps {
views = append(views, resolveQuotaCap(usage, cap))
}
}
status := "available"
for _, view := range views {
if view.Status == "exhausted" {
status = "exhausted"
break
}
if view.Status != "available" {
status = "unknown"
}
}
if len(views) == 0 {
status = "unknown"
reasons = append(reasons, "required_cap_missing")
}
if status == "unknown" && len(reasons) == 0 {
reasons = append(reasons, "cap_evidence_unknown")
}
checked := checkedAt.UTC().Format(time.RFC3339Nano)
snapshot := QuotaSnapshot{
SchemaVersion: quotaSnapshotSchemaVersion,
Source: "iop-node quota-probe",
CheckedAt: checked,
Targets: []QuotaTargetView{{Adapter: adapter, Target: target, Status: status}},
RequiredCaps: views,
ReasonCodes: reasons,
}
snapshot.SnapshotID = quotaSnapshotID(snapshot)
return snapshot
}
func resolveQuotaCap(usage *UsageStatus, required string) QuotaCapView {
view := QuotaCapView{Name: required, Status: "unknown"}
if usage == nil {
return view
}
if required == "overall" {
return quotaCapFromRemaining(required, usage.DailyLimit)
}
model, ok := strings.CutPrefix(required, "model:")
if !ok || strings.TrimSpace(model) == "" {
return view
}
count, err := strconv.Atoi(usage.Metadata["model_usage_count"])
if err != nil || count < 0 {
return view
}
matches := make([]string, 0, 1)
for index := 0; index < count; index++ {
prefix := fmt.Sprintf("model_usage_%d", index)
if strings.EqualFold(strings.TrimSpace(usage.Metadata[prefix+"_name"]), strings.TrimSpace(model)) {
matches = append(matches, usage.Metadata[prefix+"_used_percent"])
}
}
if len(matches) != 1 {
return view
}
used, ok := parsePercent(matches[0])
if !ok {
return view
}
remaining := 100 - used
return quotaCapFromNumber(required, remaining)
}
func quotaCapFromRemaining(name, value string) QuotaCapView {
remaining, ok := parsePercent(value)
if !ok {
return QuotaCapView{Name: name, Status: "unknown"}
}
return quotaCapFromNumber(name, remaining)
}
func quotaCapFromNumber(name string, remaining float64) QuotaCapView {
view := QuotaCapView{Name: name, RemainingPercent: &remaining}
if remaining == 0 {
view.Status = "exhausted"
} else {
view.Status = "available"
}
return view
}
func parsePercent(value string) (float64, bool) {
value = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(value), "%"))
if value == "" {
return 0, false
}
parsed, err := strconv.ParseFloat(value, 64)
if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) || parsed < 0 || parsed > 100 {
return 0, false
}
return parsed, true
}
func quotaSnapshotID(snapshot QuotaSnapshot) string {
caps := append([]QuotaCapView(nil), snapshot.RequiredCaps...)
sort.Slice(caps, func(i, j int) bool { return caps[i].Name < caps[j].Name })
payload := struct {
CheckedAt string `json:"checked_at"`
Target QuotaTargetView `json:"target"`
Caps []QuotaCapView `json:"caps"`
}{
CheckedAt: snapshot.CheckedAt,
Target: snapshot.Targets[0],
Caps: caps,
}
encoded, _ := json.Marshal(payload)
digest := sha256.Sum256(encoded)
return "quota-" + hex.EncodeToString(digest[:])
}