on main help sign in
feat(ui): extract hypercredits from fantasy
f45712aKieran Klukas
go.mod+3−3
@@ -7,7 +7,7 @@ require (
7 charm.land/bubbletea/v2 v2.0.7
7 charm.land/bubbletea/v2 v2.0.7
8 charm.land/catwalk v0.45.0
8 charm.land/catwalk v0.45.0
9 charm.land/fang/v2 v2.0.1
9 charm.land/fang/v2 v2.0.1
10− charm.land/fantasy v0.32.0
10+ charm.land/fantasy v0.33.0
11 charm.land/glamour/v2 v2.0.1
11 charm.land/glamour/v2 v2.0.1
12 charm.land/lipgloss/v2 v2.0.4
12 charm.land/lipgloss/v2 v2.0.4
13 charm.land/log/v2 v2.0.0
13 charm.land/log/v2 v2.0.0
@@ -31,7 +31,7 @@ require (
31 github.com/charmbracelet/x/exp/charmtone v0.0.0-20260527151214-009e6338d40d
31 github.com/charmbracelet/x/exp/charmtone v0.0.0-20260527151214-009e6338d40d
32 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
32 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
33 github.com/charmbracelet/x/exp/ordered v0.1.0
33 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
35 github.com/charmbracelet/x/exp/strings v0.1.0
35 github.com/charmbracelet/x/exp/strings v0.1.0
36 github.com/charmbracelet/x/powernap v0.1.6
36 github.com/charmbracelet/x/powernap v0.1.6
37 github.com/charmbracelet/x/term v0.2.2
37 github.com/charmbracelet/x/term v0.2.2
@@ -148,7 +148,7 @@ require (
148 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
148 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
149 github.com/josharian/intern v1.0.0 // indirect
149 github.com/josharian/intern v1.0.0 // indirect
150 github.com/kaptinlin/jsonpointer v0.4.26 // indirect
150 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
152 github.com/klauspost/compress v1.18.6 // indirect
152 github.com/klauspost/compress v1.18.6 // indirect
153 github.com/klauspost/cpuid/v2 v2.3.0 // indirect
153 github.com/klauspost/cpuid/v2 v2.3.0 // indirect
154 github.com/klauspost/pgzip v1.2.6 // indirect
154 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=
6charm.land/catwalk v0.45.0/go.mod h1:bw6/oiChsa9Fkr8LgYn84KrbbJY2z1+m7Jmlmmv3B2A=
6charm.land/catwalk v0.45.0/go.mod h1:bw6/oiChsa9Fkr8LgYn84KrbbJY2z1+m7Jmlmmv3B2A=
7charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY=
7charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY=
8charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII=
8charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII=
9−charm.land/fantasy v0.32.0 h1:tlC1qlOdXi2CkF6KB0x8YAAm3hiarI2/69u6pZmOZk8=
9+charm.land/fantasy v0.33.0 h1:D141zBr/6IDo5xxZbyk8QZcMK5mE04DptfhgzsRae74=
10−charm.land/fantasy v0.32.0/go.mod h1:CWAFEOB21guhmt4qWN9sOnAHkZzVWjKbhxbPHG+oRs8=
10+charm.land/fantasy v0.33.0/go.mod h1:CWAFEOB21guhmt4qWN9sOnAHkZzVWjKbhxbPHG+oRs8=
11charm.land/glamour/v2 v2.0.1 h1:xl+r00A4aJWU0z8fgwKd9fQQ4rsphqGUzuEiXZP5n+c=
11charm.land/glamour/v2 v2.0.1 h1:xl+r00A4aJWU0z8fgwKd9fQQ4rsphqGUzuEiXZP5n+c=
12charm.land/glamour/v2 v2.0.1/go.mod h1:jo9z8XqVKPeEFMVdvCRLGk++RyJ3CdUwgNr7EvXLw3k=
12charm.land/glamour/v2 v2.0.1/go.mod h1:jo9z8XqVKPeEFMVdvCRLGk++RyJ3CdUwgNr7EvXLw3k=
13charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q=
13charm.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
122github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
122github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
123github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
123github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
124github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
124github.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=
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-20260422141420-a6cbdff8a7e2/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
126+github.com/charmbracelet/x/exp/slice v0.0.0-20260615092313-b57e5e6d29bb/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
127github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA=
127github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA=
128github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
128github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
129github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
129github.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
266github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
266github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
267github.com/kaptinlin/jsonpointer v0.4.26 h1:tw616yszHek+B3/GtDSia+uzBa3sLXGpmo4tYeMhBZw=
267github.com/kaptinlin/jsonpointer v0.4.26 h1:tw616yszHek+B3/GtDSia+uzBa3sLXGpmo4tYeMhBZw=
268github.com/kaptinlin/jsonpointer v0.4.26/go.mod h1:wVOBaXGGnP42YsMb6zev/3W5POTvspdNfh8DXzf8XS8=
268github.com/kaptinlin/jsonpointer v0.4.26/go.mod h1:wVOBaXGGnP42YsMb6zev/3W5POTvspdNfh8DXzf8XS8=
269−github.com/kaptinlin/jsonschema v0.8.0 h1:GhY966O2q3ZQsg1zkQj988KF2MADJ6EA7pKBMpGmb9A=
269+github.com/kaptinlin/jsonschema v0.8.1 h1:Krhuq1HpE+olHoPfcxkohqKKCnXfixUPv+aUYRegBBQ=
270−github.com/kaptinlin/jsonschema v0.8.0/go.mod h1:dxt7s98W5NEuWEwCnAwGrhYGQdaRLqXZImR28DuxcMU=
270+github.com/kaptinlin/jsonschema v0.8.1/go.mod h1:mCH2W5lXd29tdDjvoFfY32nedPORnlk7pCVrrcs/NkQ=
271github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
271github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
272github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
272github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
273github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
273github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
internal/agent/agent.go+23
@@ -15,6 +15,7 @@ import (
15 "errors"
15 "errors"
16 "fmt"
16 "fmt"
17 "log/slog"
17 "log/slog"
18+ "math"
18 "net/http"
19 "net/http"
19 "os"
20 "os"
20 "regexp"
21 "regexp"
@@ -978,6 +979,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *
978 }
979 }
979 usage, estimated := fallbackStepUsage(stepMessages, stepResult)
980 usage, estimated := fallbackStepUsage(stepMessages, stepResult)
980 a.updateSessionUsage(largeModel, &updatedSession, usage, a.openrouterCost(stepResult.ProviderMetadata), estimated)
981 a.updateSessionUsage(largeModel, &updatedSession, usage, a.openrouterCost(stepResult.ProviderMetadata), estimated)
982+ extractHyperCredits(stepResult.ProviderMetadata)
981 _, sessionErr := a.sessions.Save(ctx, updatedSession)
983 _, sessionErr := a.sessions.Save(ctx, updatedSession)
982 if sessionErr != nil {
984 if sessionErr != nil {
983 return sessionErr
985 return sessionErr
@@ -1386,6 +1388,7 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan
1386 }
1388 }
1387 openrouterCost = &newCost
1389 openrouterCost = &newCost
1388 }
1390 }
1391+ extractHyperCredits(step.ProviderMetadata)
1389 }
1392 }
1390
1393
1391 a.updateSessionUsage(largeModel, &currentSession, resp.TotalUsage, openrouterCost, false)
1394 a.updateSessionUsage(largeModel, &currentSession, resp.TotalUsage, openrouterCost, false)
@@ -1752,6 +1755,7 @@ func (a *sessionAgent) GenerateTitle(ctx context.Context, sessionID string, user
1752 }
1755 }
1753 openrouterCost = &newCost
1756 openrouterCost = &newCost
1754 }
1757 }
1758+ extractHyperCredits(step.ProviderMetadata)
1755 }
1759 }
1756
1760
1757 modelConfig := model.CatwalkCfg
1761 modelConfig := model.CatwalkCfg
@@ -1796,6 +1800,25 @@ func (a *sessionAgent) openrouterCost(metadata fantasy.ProviderMetadata) *float6
1796 return &opts.Usage.Cost
1800 return &opts.Usage.Cost
1797}
1801}
1798
1802
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+
1799func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session, usage fantasy.Usage, overrideCost *float64, estimated bool) {
1822func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session, usage fantasy.Usage, overrideCost *float64, estimated bool) {
1800 if !usageIsZero(usage) {
1823 if !usageIsZero(usage) {
1801 session.EstimatedUsage = estimated
1824 session.EstimatedUsage = estimated
internal/agent/hyper/provider.go+24−2
@@ -11,6 +11,7 @@ import (
11 "net/http"
11 "net/http"
12 "os"
12 "os"
13 "sync"
13 "sync"
14+ "sync/atomic"
14 "time"
15 "time"
15
16
16 "charm.land/catwalk/pkg/catwalk"
17 "charm.land/catwalk/pkg/catwalk"
@@ -47,9 +48,30 @@ var BaseURL = sync.OnceValue(func() string {
47 return cmp.Or(os.Getenv("HYPER_URL"), defaultBaseURL)
48 return cmp.Or(os.Getenv("HYPER_URL"), defaultBaseURL)
48})
49})
49
50
50−// FetchCredits calls the Hyper /v1/credits endpoint and returns the remaining
51+// lastKnownBalance stores the most recently extracted hypercredit balance
51−// credits count.
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.
52func FetchCredits(ctx context.Context, apiKey string) (int, error) {
69func FetchCredits(ctx context.Context, apiKey string) (int, error) {
70+ if hasBalance.Load() {
71+ hasBalance.Store(false)
72+ return int(lastKnownBalance.Load()), nil
73+ }
74+
53 req, err := http.NewRequestWithContext(
75 req, err := http.NewRequestWithContext(
54 ctx,
76 ctx,
55 http.MethodGet,
77 http.MethodGet,