206 lines
5.0 KiB
Go
206 lines
5.0 KiB
Go
package agents
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"log/slog"
|
||
|
|
"maps"
|
||
|
|
"os"
|
||
|
|
"os/exec"
|
||
|
|
"slices"
|
||
|
|
"strings"
|
||
|
|
"syscall"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"g.fc.run/ai/byre/internal/api"
|
||
|
|
)
|
||
|
|
|
||
|
|
const usageTimeout = 10 * time.Second
|
||
|
|
|
||
|
|
func (m *Manager) loadUsage() {
|
||
|
|
cmds, err := m.store.UsageCommands(context.Background())
|
||
|
|
if err != nil {
|
||
|
|
slog.Error("load usage commands", "err", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
m.mu.Lock()
|
||
|
|
m.usageCommands = cmds
|
||
|
|
m.mu.Unlock()
|
||
|
|
go m.readCodexRateLimit()
|
||
|
|
for _, v := range []api.AgentSessionVendor{api.Claude, api.Codex, api.Antigravity} {
|
||
|
|
go m.readUsage(v)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *Manager) UsageCommands() api.UsageCommands {
|
||
|
|
m.mu.Lock()
|
||
|
|
defer m.mu.Unlock()
|
||
|
|
return api.UsageCommands{
|
||
|
|
Claude: m.usageCommands[api.Claude],
|
||
|
|
Codex: m.usageCommands[api.Codex],
|
||
|
|
Antigravity: m.usageCommands[api.Antigravity],
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *Manager) SetUsageCommands(ctx context.Context, actor string, c api.UsageCommands) error {
|
||
|
|
cmds := map[api.AgentSessionVendor]string{
|
||
|
|
api.Claude: strings.TrimSpace(c.Claude),
|
||
|
|
api.Codex: strings.TrimSpace(c.Codex),
|
||
|
|
api.Antigravity: strings.TrimSpace(c.Antigravity),
|
||
|
|
}
|
||
|
|
err := m.store.SetUsageCommands(ctx, actor, cmds)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
changed := []api.AgentSessionVendor{}
|
||
|
|
m.mu.Lock()
|
||
|
|
for v, cmd := range cmds {
|
||
|
|
if m.usageCommands[v] != cmd {
|
||
|
|
changed = append(changed, v)
|
||
|
|
delete(m.rateLimits, v)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
m.usageCommands = cmds
|
||
|
|
m.mu.Unlock()
|
||
|
|
|
||
|
|
m.store.Broadcast(api.Event{Kind: "admin.health", Actor: "byre", Payload: map[string]any{}})
|
||
|
|
for _, v := range changed {
|
||
|
|
if v == api.Codex && cmds[v] == "" {
|
||
|
|
go m.readCodexRateLimit()
|
||
|
|
}
|
||
|
|
go m.readUsage(v)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *Manager) readUsage(vendor api.AgentSessionVendor) {
|
||
|
|
mu := m.usageMu[vendor]
|
||
|
|
mu.Lock()
|
||
|
|
defer mu.Unlock()
|
||
|
|
|
||
|
|
m.mu.Lock()
|
||
|
|
cmdline := m.usageCommands[vendor]
|
||
|
|
m.mu.Unlock()
|
||
|
|
if cmdline == "" {
|
||
|
|
if vendor == api.Antigravity {
|
||
|
|
m.readAgyRateLimit()
|
||
|
|
}
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
u, err := runUsageCommand(cmdline)
|
||
|
|
m.mu.Lock()
|
||
|
|
if m.usageCommands[vendor] != cmdline {
|
||
|
|
m.mu.Unlock()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
delete(m.rateLimits, vendor)
|
||
|
|
} else {
|
||
|
|
m.rateLimits[vendor] = u
|
||
|
|
}
|
||
|
|
m.mu.Unlock()
|
||
|
|
if err != nil {
|
||
|
|
slog.Error("usage command", "vendor", vendor, "err", err)
|
||
|
|
}
|
||
|
|
m.store.Broadcast(api.Event{Kind: "admin.health", Actor: "byre", Payload: map[string]any{}})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *Manager) setVendorUsage(vendor api.AgentSessionVendor, u api.Usage) {
|
||
|
|
m.mu.Lock()
|
||
|
|
if m.usageCommands[vendor] != "" {
|
||
|
|
m.mu.Unlock()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
m.rateLimits[vendor] = u
|
||
|
|
m.mu.Unlock()
|
||
|
|
m.store.Broadcast(api.Event{Kind: "admin.health", Actor: "byre", Payload: map[string]any{}})
|
||
|
|
}
|
||
|
|
|
||
|
|
func runUsageCommand(cmdline string) (api.Usage, error) {
|
||
|
|
shell := os.Getenv("SHELL")
|
||
|
|
if shell == "" {
|
||
|
|
return api.Usage{}, errors.New("SHELL is not set in byre's environment")
|
||
|
|
}
|
||
|
|
ctx, cancel := context.WithTimeout(context.Background(), usageTimeout)
|
||
|
|
defer cancel()
|
||
|
|
cmd := exec.CommandContext(ctx, shell, "-l", "-c", cmdline)
|
||
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||
|
|
cmd.Cancel = func() error {
|
||
|
|
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||
|
|
}
|
||
|
|
cmd.WaitDelay = time.Second
|
||
|
|
out, err := cmd.Output()
|
||
|
|
if err != nil {
|
||
|
|
return api.Usage{}, fmt.Errorf("%s: %w", cmdline, err)
|
||
|
|
}
|
||
|
|
return parseUsage(out)
|
||
|
|
}
|
||
|
|
|
||
|
|
func parseUsage(out []byte) (api.Usage, error) {
|
||
|
|
in := struct {
|
||
|
|
UpdatedAt *time.Time `json:"updated_at"`
|
||
|
|
Rejected *string `json:"rejected"`
|
||
|
|
Windows []struct {
|
||
|
|
api.UsageWindow
|
||
|
|
ResetsInSeconds *float64 `json:"resets_in_seconds"`
|
||
|
|
} `json:"windows"`
|
||
|
|
}{}
|
||
|
|
err := json.Unmarshal(out, &in)
|
||
|
|
if err != nil {
|
||
|
|
return api.Usage{}, fmt.Errorf("usage output: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
u := api.Usage{UpdatedAt: time.Now(), Rejected: in.Rejected, Windows: []api.UsageWindow{}}
|
||
|
|
if in.UpdatedAt != nil {
|
||
|
|
u.UpdatedAt = *in.UpdatedAt
|
||
|
|
}
|
||
|
|
for _, w := range in.Windows {
|
||
|
|
if w.ResetsAt == nil && w.ResetsInSeconds != nil {
|
||
|
|
w.ResetsAt = new(u.UpdatedAt.Add(time.Duration(*w.ResetsInSeconds * float64(time.Second))))
|
||
|
|
}
|
||
|
|
u.Windows = append(u.Windows, w.UsageWindow)
|
||
|
|
}
|
||
|
|
return u, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func percentWindow(label string, fraction float64, resetsAt int64, windowSeconds int64) api.UsageWindow {
|
||
|
|
w := api.UsageWindow{Label: label, Used: fraction * 100, Limit: new(100.0), Unit: "percent"}
|
||
|
|
if resetsAt != 0 {
|
||
|
|
w.ResetsAt = new(time.Unix(resetsAt, 0))
|
||
|
|
}
|
||
|
|
if windowSeconds != 0 {
|
||
|
|
w.WindowSeconds = &windowSeconds
|
||
|
|
}
|
||
|
|
return w
|
||
|
|
}
|
||
|
|
|
||
|
|
func claudeUsage(info map[string]any) api.Usage {
|
||
|
|
u := api.Usage{UpdatedAt: time.Now(), Windows: []api.UsageWindow{}}
|
||
|
|
if info["status"] == "rejected" {
|
||
|
|
reason, _ := info["rateLimitType"].(string)
|
||
|
|
u.Rejected = new(reason)
|
||
|
|
}
|
||
|
|
windows, _ := info["unifiedWindows"].(map[string]any)
|
||
|
|
for _, name := range slices.Sorted(maps.Keys(windows)) {
|
||
|
|
w, ok := windows[name].(map[string]any)
|
||
|
|
if !ok {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
used, _ := w["utilization"].(float64)
|
||
|
|
resets, _ := w["resetsAt"].(float64)
|
||
|
|
seconds := int64(0)
|
||
|
|
switch {
|
||
|
|
case strings.HasPrefix(name, "five_hour"):
|
||
|
|
seconds = 5 * 60 * 60
|
||
|
|
case strings.HasPrefix(name, "seven_day"):
|
||
|
|
seconds = 7 * 24 * 60 * 60
|
||
|
|
}
|
||
|
|
u.Windows = append(u.Windows, percentWindow(name, used, int64(resets), seconds))
|
||
|
|
}
|
||
|
|
return u
|
||
|
|
}
|