on main help sign in
feat(ui): extract hypercredits from fantasy
commitf45712a32da871a962b05fbe9d93d4b444de9a17 authorKieran Klukas <kieran@dunkirk.sh> dateMon 1 Jun 2026, 14:22 (4 months ago) parent99a5fad
4 files changed+56−11
go.mod+3−3 go.sum+6−6 internal/agent/agent.go+23 internal/agent/hyper/provider.go+24−2
go.mod+3−3
@@ -7,7 +7,7 @@ require (
77 charm.land/bubbletea/v2 v2.0.7
88 charm.land/catwalk v0.45.0
99 charm.land/fang/v2 v2.0.1
10− charm.land/fantasy v0.32.0
10+ charm.land/fantasy v0.33.0
1111 charm.land/glamour/v2 v2.0.1
1212 charm.land/lipgloss/v2 v2.0.4
1313 charm.land/log/v2 v2.0.0
@@ -31,7 +31,7 @@ require (
3131 github.com/charmbracelet/x/exp/charmtone v0.0.0-20260527151214-009e6338d40d
3232 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
3333 github.com/charmbracelet/x/exp/ordered v0.1.0
34− github.com/charmbracelet/x/exp/slice v0.0.0-20260422141420-a6cbdff8a7e2
34+ github.com/charmbracelet/x/exp/slice v0.0.0-20260615092313-b57e5e6d29bb
3535 github.com/charmbracelet/x/exp/strings v0.1.0
3636 github.com/charmbracelet/x/powernap v0.1.6
3737 github.com/charmbracelet/x/term v0.2.2
@@ -148,7 +148,7 @@ require (
148148 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
149149 github.com/josharian/intern v1.0.0 // indirect
150150 github.com/kaptinlin/jsonpointer v0.4.26 // indirect
151− github.com/kaptinlin/jsonschema v0.8.0 // indirect
151+ github.com/kaptinlin/jsonschema v0.8.1 // indirect
152152 github.com/klauspost/compress v1.18.6 // indirect
153153 github.com/klauspost/cpuid/v2 v2.3.0 // indirect
154154 github.com/klauspost/pgzip v1.2.6 // indirect
go.sum+6−6
@@ -6,8 +6,8 @@ charm.land/catwalk v0.45.0 h1:pnaVxD34dYb/mt089+ZvXtmxyS6FbTF9J12wQ6wW3WU=
66charm.land/catwalk v0.45.0/go.mod h1:bw6/oiChsa9Fkr8LgYn84KrbbJY2z1+m7Jmlmmv3B2A=
77charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY=
88charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII=
9−charm.land/fantasy v0.32.0 h1:tlC1qlOdXi2CkF6KB0x8YAAm3hiarI2/69u6pZmOZk8=
10−charm.land/fantasy v0.32.0/go.mod h1:CWAFEOB21guhmt4qWN9sOnAHkZzVWjKbhxbPHG+oRs8=
9+charm.land/fantasy v0.33.0 h1:D141zBr/6IDo5xxZbyk8QZcMK5mE04DptfhgzsRae74=
10+charm.land/fantasy v0.33.0/go.mod h1:CWAFEOB21guhmt4qWN9sOnAHkZzVWjKbhxbPHG+oRs8=
1111charm.land/glamour/v2 v2.0.1 h1:xl+r00A4aJWU0z8fgwKd9fQQ4rsphqGUzuEiXZP5n+c=
1212charm.land/glamour/v2 v2.0.1/go.mod h1:jo9z8XqVKPeEFMVdvCRLGk++RyJ3CdUwgNr7EvXLw3k=
1313charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q=
@@ -122,8 +122,8 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6g
122122github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
123123github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
124124github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
125−github.com/charmbracelet/x/exp/slice v0.0.0-20260422141420-a6cbdff8a7e2 h1:EXQ7j9kQUGILoSIbxrr1osK9Ca3xqc6EJ7710FIiI3U=
126−github.com/charmbracelet/x/exp/slice v0.0.0-20260422141420-a6cbdff8a7e2/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
125+github.com/charmbracelet/x/exp/slice v0.0.0-20260615092313-b57e5e6d29bb h1:fr6DwrfJB2XQ3zM2fCwumXPE5G+hegnkEpl1KUuPsQI=
126+github.com/charmbracelet/x/exp/slice v0.0.0-20260615092313-b57e5e6d29bb/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
127127github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA=
128128github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
129129github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
@@ -266,8 +266,8 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
266266github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
267267github.com/kaptinlin/jsonpointer v0.4.26 h1:tw616yszHek+B3/GtDSia+uzBa3sLXGpmo4tYeMhBZw=
268268github.com/kaptinlin/jsonpointer v0.4.26/go.mod h1:wVOBaXGGnP42YsMb6zev/3W5POTvspdNfh8DXzf8XS8=
269−github.com/kaptinlin/jsonschema v0.8.0 h1:GhY966O2q3ZQsg1zkQj988KF2MADJ6EA7pKBMpGmb9A=
270−github.com/kaptinlin/jsonschema v0.8.0/go.mod h1:dxt7s98W5NEuWEwCnAwGrhYGQdaRLqXZImR28DuxcMU=
269+github.com/kaptinlin/jsonschema v0.8.1 h1:Krhuq1HpE+olHoPfcxkohqKKCnXfixUPv+aUYRegBBQ=
270+github.com/kaptinlin/jsonschema v0.8.1/go.mod h1:mCH2W5lXd29tdDjvoFfY32nedPORnlk7pCVrrcs/NkQ=
271271github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
272272github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
273273github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
internal/agent/agent.go+23
@@ -15,6 +15,7 @@ import (
1515 "errors"
1616 "fmt"
1717 "log/slog"
18+ "math"
1819 "net/http"
1920 "os"
2021 "regexp"
@@ -978,6 +979,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *
978979 }
979980 usage, estimated := fallbackStepUsage(stepMessages, stepResult)
980981 a.updateSessionUsage(largeModel, &updatedSession, usage, a.openrouterCost(stepResult.ProviderMetadata), estimated)
982+ extractHyperCredits(stepResult.ProviderMetadata)
981983 _, sessionErr := a.sessions.Save(ctx, updatedSession)
982984 if sessionErr != nil {
983985 return sessionErr
@@ -1386,6 +1388,7 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan
13861388 }
13871389 openrouterCost = &newCost
13881390 }
1391+ extractHyperCredits(step.ProviderMetadata)
13891392 }
13901393
13911394 a.updateSessionUsage(largeModel, &currentSession, resp.TotalUsage, openrouterCost, false)
@@ -1752,6 +1755,7 @@ func (a *sessionAgent) GenerateTitle(ctx context.Context, sessionID string, user
17521755 }
17531756 openrouterCost = &newCost
17541757 }
1758+ extractHyperCredits(step.ProviderMetadata)
17551759 }
17561760
17571761 modelConfig := model.CatwalkCfg
@@ -1796,6 +1800,25 @@ func (a *sessionAgent) openrouterCost(metadata fantasy.ProviderMetadata) *float6
17961800 return &opts.Usage.Cost
17971801}
17981802
1803+// extractHyperCredits reads usage.remaining.hypercredits from OpenAI
1804+// provider metadata and stores it for the next FetchCredits call.
1805+func extractHyperCredits(metadata fantasy.ProviderMetadata) {
1806+ openaiMeta, ok := metadata[openai.Name]
1807+ if !ok {
1808+ return
1809+ }
1810+ pm, ok := openaiMeta.(*openai.ProviderMetadata)
1811+ if !ok {
1812+ return
1813+ }
1814+ var remaining struct {
1815+ Hypercredits float64 `json:"hypercredits"`
1816+ }
1817+ if pm.ExtraField("remaining", &remaining) && remaining.Hypercredits > 0 {
1818+ hyper.SetBalance(int(math.Round(remaining.Hypercredits)))
1819+ }
1820+}
1821+
17991822func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session, usage fantasy.Usage, overrideCost *float64, estimated bool) {
18001823 if !usageIsZero(usage) {
18011824 session.EstimatedUsage = estimated
internal/agent/hyper/provider.go+24−2
@@ -11,6 +11,7 @@ import (
1111 "net/http"
1212 "os"
1313 "sync"
14+ "sync/atomic"
1415 "time"
1516
1617 "charm.land/catwalk/pkg/catwalk"
@@ -47,9 +48,30 @@ var BaseURL = sync.OnceValue(func() string {
4748 return cmp.Or(os.Getenv("HYPER_URL"), defaultBaseURL)
4849})
4950
50−// FetchCredits calls the Hyper /v1/credits endpoint and returns the remaining
51−// credits count.
51+// lastKnownBalance stores the most recently extracted hypercredit balance
52+// from API response metadata. FetchCredits checks this before making a
53+// separate HTTP call.
54+var lastKnownBalance atomic.Int64
55+
56+// hasBalance tracks whether lastKnownBalance has been set.
57+var hasBalance atomic.Bool
58+
59+// SetBalance stores a credit balance extracted from API response metadata.
60+func SetBalance(balance int) {
61+ lastKnownBalance.Store(int64(balance))
62+ hasBalance.Store(true)
63+}
64+
65+// FetchCredits returns the remaining hypercredit balance. It first checks
66+// for a balance extracted from the most recent API response's usage
67+// metadata. If none is available, it falls back to calling the /v1/credits
68+// endpoint directly.
5269func FetchCredits(ctx context.Context, apiKey string) (int, error) {
70+ if hasBalance.Load() {
71+ hasBalance.Store(false)
72+ return int(lastKnownBalance.Load()), nil
73+ }
74+
5375 req, err := http.NewRequestWithContext(
5476 ctx,
5577 http.MethodGet,