From 9018146b95eec6900bd459ce0a58b385e6cb65ce Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Mon, 1 Jun 2026 14:22:52 -0400 Subject: [PATCH] feat(ui): extract hypercredits from fantasy resolve: hypercredits extraction conflict --- go.mod | 2 +- go.sum | 12 ++++++------ internal/agent/agent.go | 23 +++++++++++++++++++++++ internal/agent/hyper/provider.go | 26 ++++++++++++++++++++++++-- 4 files changed, 54 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 31c12e7e9b..1628ae18d6 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( charm.land/bubbletea/v2 v2.0.7 charm.land/catwalk v0.44.14 charm.land/fang/v2 v2.0.1 - charm.land/fantasy v0.31.1 + charm.land/fantasy v0.31.1-0.20260608134953-2993ed09b192 charm.land/glamour/v2 v2.0.1 charm.land/lipgloss/v2 v2.0.4 charm.land/log/v2 v2.0.0 diff --git a/go.sum b/go.sum index aaf0b5d8ee..8ce730d3c4 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,8 @@ charm.land/catwalk v0.44.14 h1:VMTLK9L2qVHHv8cBLiBq4Wd6lfAGo8rTp8hxNHwYKHs= charm.land/catwalk v0.44.14/go.mod h1:bw6/oiChsa9Fkr8LgYn84KrbbJY2z1+m7Jmlmmv3B2A= charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY= charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII= -charm.land/fantasy v0.31.1 h1:QypVgCd1zlBW1hqhss4sjTB4qk0cdBtEF8EvA+vnc3g= -charm.land/fantasy v0.31.1/go.mod h1:2Eqwxsoh5rIoAcfd52dEHBnkhMo53X9tNyonZscuP6k= +charm.land/fantasy v0.31.1-0.20260608134953-2993ed09b192 h1:8Y6TKUJhhbyfufQC/FBS8mw3pkGNU8dCdeh5lwSGVLM= +charm.land/fantasy v0.31.1-0.20260608134953-2993ed09b192/go.mod h1:dv80d73Fbrp5lfOepomqGwgg/ZgUu/uGoB46s2hsjBw= charm.land/glamour/v2 v2.0.1 h1:xl+r00A4aJWU0z8fgwKd9fQQ4rsphqGUzuEiXZP5n+c= charm.land/glamour/v2 v2.0.1/go.mod h1:jo9z8XqVKPeEFMVdvCRLGk++RyJ3CdUwgNr7EvXLw3k= charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q= @@ -553,10 +553,10 @@ google.golang.org/api v0.284.0 h1:i+cKTgeQRcRySkP7QTl5PDO7/pAm8EcMFIUMlNbk4Vc= google.golang.org/api v0.284.0/go.mod h1:AU44fU+XVZOCcd8uLaBIa/ZgzgPf/0qqY3+m7lQaado= google.golang.org/genai v1.60.0 h1:uAkea4tYhCz1LlUmxdiOFAmlrLFaLs8PbXucgZHqHVo= google.golang.org/genai v1.60.0/go.mod h1:mDdPDFXo1Ats7f1WXVyZgWb/CkMzFWTWJruIMy7hGIU= -google.golang.org/genproto v0.0.0-20260610212136-7ab31c22f7ad h1:cYL1DPJAQr4JMvhfGao0PDXoaf03ifMljAuDyrbMBd0= -google.golang.org/genproto v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:cVHIikDNAdx8ISZeW+2rYkEMf3xn0GSaBYmVnWXQBUo= -google.golang.org/genproto/googleapis/api v0.0.0-20260610212136-7ab31c22f7ad h1:3iLyITS/sySRwbUKoC7ogfj2Yr1Cjs0pfaRKj5U5HEw= -google.golang.org/genproto/googleapis/api v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:KdNqO+rCIWgFumrNBSEDlDNrkrQnpkax7Tv1WxNY8V4= +google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa h1:mfj8IS4EA4VAR9a6QDVxTQkLY64iBybb5QI1B4pXrpE= +google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:fuT7yonGw1Iq2oa+YC0fyqPPQJkgo/54gPNC6VitOkI= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad h1:45WmJvIV6C2+O/jjLkPUH+F3aOj/1miDoU2DD0+NWbg= google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index f4972b181a..9e376ee44a 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -15,6 +15,7 @@ import ( "errors" "fmt" "log/slog" + "math" "net/http" "os" "regexp" @@ -977,6 +978,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result * } usage, estimated := fallbackStepUsage(stepMessages, stepResult) a.updateSessionUsage(largeModel, &updatedSession, usage, a.openrouterCost(stepResult.ProviderMetadata), estimated) + extractHyperCredits(stepResult.ProviderMetadata) _, sessionErr := a.sessions.Save(ctx, updatedSession) if sessionErr != nil { return sessionErr @@ -1385,6 +1387,7 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan } openrouterCost = &newCost } + extractHyperCredits(step.ProviderMetadata) } a.updateSessionUsage(largeModel, ¤tSession, resp.TotalUsage, openrouterCost, false) @@ -1728,6 +1731,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user } openrouterCost = &newCost } + extractHyperCredits(step.ProviderMetadata) } modelConfig := model.CatwalkCfg @@ -1771,6 +1775,25 @@ func (a *sessionAgent) openrouterCost(metadata fantasy.ProviderMetadata) *float6 return &opts.Usage.Cost } +// extractHyperCredits reads usage.remaining.hypercredits from OpenAI +// provider metadata and stores it for the next FetchCredits call. +func extractHyperCredits(metadata fantasy.ProviderMetadata) { + openaiMeta, ok := metadata[openai.Name] + if !ok { + return + } + pm, ok := openaiMeta.(*openai.ProviderMetadata) + if !ok { + return + } + var remaining struct { + Hypercredits float64 `json:"hypercredits"` + } + if pm.ExtraField("remaining", &remaining) && remaining.Hypercredits > 0 { + hyper.SetBalance(int(math.Round(remaining.Hypercredits))) + } +} + func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session, usage fantasy.Usage, overrideCost *float64, estimated bool) { if !usageIsZero(usage) { session.EstimatedUsage = estimated diff --git a/internal/agent/hyper/provider.go b/internal/agent/hyper/provider.go index c8acf132db..29a65e7b8e 100644 --- a/internal/agent/hyper/provider.go +++ b/internal/agent/hyper/provider.go @@ -11,6 +11,7 @@ import ( "net/http" "os" "sync" + "sync/atomic" "time" "charm.land/catwalk/pkg/catwalk" @@ -47,9 +48,30 @@ var BaseURL = sync.OnceValue(func() string { return cmp.Or(os.Getenv("HYPER_URL"), defaultBaseURL) }) -// FetchCredits calls the Hyper /v1/credits endpoint and returns the remaining -// credits count. +// lastKnownBalance stores the most recently extracted hypercredit balance +// from API response metadata. FetchCredits checks this before making a +// separate HTTP call. +var lastKnownBalance atomic.Int64 + +// hasBalance tracks whether lastKnownBalance has been set. +var hasBalance atomic.Bool + +// SetBalance stores a credit balance extracted from API response metadata. +func SetBalance(balance int) { + lastKnownBalance.Store(int64(balance)) + hasBalance.Store(true) +} + +// FetchCredits returns the remaining hypercredit balance. It first checks +// for a balance extracted from the most recent API response's usage +// metadata. If none is available, it falls back to calling the /v1/credits +// endpoint directly. func FetchCredits(ctx context.Context, apiKey string) (int, error) { + if hasBalance.Load() { + hasBalance.Store(false) + return int(lastKnownBalance.Load()), nil + } + req, err := http.NewRequestWithContext( ctx, http.MethodGet,