Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions cmd/computing-provider/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,20 @@ func runDaemon(cctx *cli.Context) error {
providerStats := computing.NewProviderStatsClient(
conf.GetConfig().Inference.ServiceURL, conf.GetConfig().Inference.ApiKey)

// Record the platform's lifetime earnings with every metrics snapshot, so
// the earnings series can be differenced from the ledger that governs
// rather than recomputed from token counts at published rates. The client
// caches, so this costs one upstream call every couple of minutes at most.
inferenceService.SetPlatformEarningsProvider(func() (float64, bool) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stats, err := providerStats.Stats(ctx)
if err != nil || stats == nil {
return 0, false
}
return stats.TotalEarningsUSDC, true
})

gin.SetMode(gin.ReleaseMode)
r := gin.Default()
configureEncodedPathParameters(r)
Expand Down
23 changes: 23 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,29 @@ overloaded GPU. Both can also be changed at runtime, globally or per model,
through the REST API — see the endpoint table in the
[main README](../README.md#rest-api).

### Earnings history

`GET /inference/earnings/history` prefers **Swan Inference's own earnings
figure**. The platform's lifetime total is recorded with each metrics snapshot,
and consecutive samples are differenced to give what the platform says was
earned in each interval — no local rate arithmetic is involved, so it accounts
for however the platform actually settles.

Where no platform figure was recorded — intervals stored before this existed, or
samples taken while the API was unreachable — the interval falls back to pricing
this node's own token counts at current published rates. Each point reports
which it is, and the dashboard says so rather than presenting the two as the
same number.

Two consequences worth knowing:

- **The split by model stays local.** The platform reports no per-model
breakdown, so the division of a bar is this node's share of served tokens
rescaled onto the authoritative total. The bar's height is the platform's
number; how it is divided is an estimate.
- **A decrease contributes zero.** The lifetime total going down is a
correction or a payout on the platform side, not negative earnings.

### Request history

```toml
Expand Down
61 changes: 58 additions & 3 deletions internal/computing/earnings_history.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ type EarningsPoint struct {
Models map[string]ModelEarningsPoint `json:"models,omitempty"`
// Unattributed is the part of USD this bucket cannot assign to any model.
Unattributed float64 `json:"unattributed,omitempty"`
// Authoritative marks a bucket whose total came from differencing the
// platform's own lifetime figure rather than from local token counts
// priced at published rates. The two are not interchangeable: only the
// platform's ledger accounts for how it actually settles, so the UI must
// be able to say which one a bar is.
Authoritative bool `json:"authoritative,omitempty"`
}

// ModelEarningsPoint is one model's contribution to a bucket.
Expand All @@ -41,6 +47,9 @@ type EarningsSeries struct {
// days of a database that holds 7 should not silently look like a month of
// near-zero earnings.
Covers string `json:"covers,omitempty"`
// AuthoritativePoints is how many points were priced from the platform's
// own figure. Zero means the whole series is this node's estimate.
AuthoritativePoints int `json:"authoritative_points"`
// BucketSeconds is the interval each point spans. Sent so the UI can label
// and describe points from what was actually aggregated, rather than
// re-deriving the rule from the requested duration and drifting from it.
Expand Down Expand Up @@ -111,6 +120,7 @@ func CalculateEarningsHistory(ctx context.Context, snapshots []HistoricalDataPoi
inRate, outRate := blendedRate(metrics, rates)

var prevIn, prevOut int64
var prevPlatform *float64
prevModels := map[string]ModelTokenCounts{}
first := true
for _, s := range snapshots {
Expand All @@ -132,7 +142,28 @@ func CalculateEarningsHistory(ctx context.Context, snapshots []HistoricalDataPoi
prevIn, prevOut = s.TotalTokensIn, s.TotalTokensOut

// Rates are per million tokens, and the deltas are raw token counts.
// This is the fallback: it cannot know how the platform settles, so it
// is only used where the platform's own figure is unavailable.
usd := float64(dIn)/tokensPerPriceUnit*inRate + float64(dOut)/tokensPerPriceUnit*outRate

// Prefer the ledger. The platform's lifetime total is cumulative and
// never resets, so differencing consecutive samples gives what it says
// was earned in between — no local rate arithmetic involved.
authoritative := false
if s.PlatformEarningsUSD != nil {
if prevPlatform != nil {
delta := *s.PlatformEarningsUSD - *prevPlatform
// A decrease is a correction or a payout on the platform side,
// not negative earnings; it contributes nothing rather than
// subtracting from the window.
if delta < 0 {
delta = 0
}
usd = delta
authoritative = true
}
prevPlatform = s.PlatformEarningsUSD
}
out.TotalUSD += usd

// Split the same delta by model where the sample carries one. Each
Expand All @@ -142,9 +173,25 @@ func CalculateEarningsHistory(ctx context.Context, snapshots []HistoricalDataPoi
if s.ModelTokens != nil {
prevModels = s.ModelTokens
}
// When the bucket's total came from the platform, the per-model figures
// did not: the platform reports no per-model breakdown, so the split is
// this node's own share of served tokens. Rescale it onto the
// authoritative total so the segments sum to the bar they are drawn in.
//
// The consequence is worth being explicit about: the bar's height is
// the platform's number, the division of it is this node's estimate.
if authoritative && attributed > 0 {
scale := usd / attributed
for id, m := range perModel {
m.USD *= scale
perModel[id] = m
}
attributed = usd
}

// Only what the split could not account for is unattributed. Comparing
// against the bucket's own priced total keeps the segments summing to
// the bar rather than to a separately-rounded figure.
// against the bucket's own total keeps the segments summing to the bar
// rather than to a separately-rounded figure.
unattributed := usd - attributed
if unattributed < 0 {
unattributed = 0
Expand All @@ -160,17 +207,25 @@ func CalculateEarningsHistory(ctx context.Context, snapshots []HistoricalDataPoi
p.TokensOut += dOut
p.USD += usd
p.Unattributed += unattributed
// A bucket is only authoritative if every sample in it was.
p.Authoritative = p.Authoritative && authoritative
mergeModelPoints(p, perModel)
continue
}
point := EarningsPoint{
Timestamp: key, TokensIn: dIn, TokensOut: dOut, USD: usd,
Unattributed: unattributed,
Unattributed: unattributed, Authoritative: authoritative,
}
mergeModelPoints(&point, perModel)
out.Points = append(out.Points, point)
}

for _, p := range out.Points {
if p.Authoritative {
out.AuthoritativePoints++
}
}

if n := len(snapshots); n > 0 {
out.Covers = snapshots[n-1].Timestamp.Sub(snapshots[0].Timestamp).Round(time.Hour).String()
}
Expand Down
117 changes: 117 additions & 0 deletions internal/computing/earnings_split_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,120 @@ func TestEarningsHistoryReportsItsBucket(t *testing.T) {
t.Errorf("daily gave %d points, want 1 — all three samples are the same day", len(daily.Points))
}
}

func usd(v float64) *float64 { return &v }

func snapPlatform(t time.Time, in, out int64, models map[string]ModelTokenCounts, platform *float64) HistoricalDataPoint {
p := snap(t, in, out, models)
p.PlatformEarningsUSD = platform
return p
}

// The platform's lifetime figure is the number that governs. Differencing it
// gives what it says was earned in an interval, with no local rate arithmetic.
func TestEarningsHistoryPrefersThePlatformLedger(t *testing.T) {
t0 := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC)
models := map[string]ModelTokenCounts{"a": {In: 1_000_000}}
// A local rate that would give a wildly different answer, to prove it is
// not the one being used.
prices := fixedPrices{"a": {ProviderInputPrice: 999, ProviderOutputPrice: 999}}

series := CalculateEarningsHistory(context.Background(),
[]HistoricalDataPoint{
snapPlatform(t0, 0, 0, map[string]ModelTokenCounts{"a": {}}, usd(10)),
snapPlatform(t0.Add(time.Minute), 1_000_000, 0, models, usd(12.5)),
},
metricsFor(models), prices, "24h", 0)

last := series.Points[len(series.Points)-1]
if !last.Authoritative {
t.Error("bucket should be marked authoritative when it came from the ledger")
}
if last.USD < 2.49 || last.USD > 2.51 {
t.Errorf("bucket = %.4f, want the ledger delta 2.50 — not the local rate", last.USD)
}
if series.AuthoritativePoints != 1 {
t.Errorf("authoritative points = %d, want 1", series.AuthoritativePoints)
}
}

// Samples with no platform figure keep the local estimate, and say so.
func TestEarningsHistoryFallsBackWhenLedgerAbsent(t *testing.T) {
t0 := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC)
models := map[string]ModelTokenCounts{"a": {In: 1_000_000}}
prices := fixedPrices{"a": {ProviderInputPrice: 2, ProviderOutputPrice: 0}}

series := CalculateEarningsHistory(context.Background(),
[]HistoricalDataPoint{
snap(t0, 0, 0, map[string]ModelTokenCounts{"a": {}}),
snap(t0.Add(time.Minute), 1_000_000, 0, models),
},
metricsFor(models), prices, "24h", 0)

last := series.Points[len(series.Points)-1]
if last.Authoritative {
t.Error("a bucket with no platform figure must not claim to be authoritative")
}
if last.USD < 1.99 || last.USD > 2.01 {
t.Errorf("fallback = %.4f, want the locally priced 2.00", last.USD)
}
if series.AuthoritativePoints != 0 {
t.Errorf("authoritative points = %d, want 0", series.AuthoritativePoints)
}
}

// The platform reports no per-model split, so the local share is rescaled onto
// the authoritative total. The bar's height is the platform's; its division is
// this node's estimate — but the segments must still sum to the bar.
func TestEarningsHistoryRescalesModelSplitOntoLedgerTotal(t *testing.T) {
t0 := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC)
models := map[string]ModelTokenCounts{"a": {In: 3_000_000}, "b": {In: 1_000_000}}
prices := fixedPrices{
"a": {ProviderInputPrice: 1},
"b": {ProviderInputPrice: 1},
}

series := CalculateEarningsHistory(context.Background(),
[]HistoricalDataPoint{
snapPlatform(t0, 0, 0, map[string]ModelTokenCounts{"a": {}, "b": {}}, usd(0)),
snapPlatform(t0.Add(time.Minute), 4_000_000, 0, models, usd(8)),
},
metricsFor(models), prices, "24h", 0)

last := series.Points[len(series.Points)-1]
if last.USD < 7.99 || last.USD > 8.01 {
t.Fatalf("bucket = %.4f, want the ledger's 8.00", last.USD)
}
sum := 0.0
for _, m := range last.Models {
sum += m.USD
}
if sum < 7.99 || sum > 8.01 {
t.Errorf("model segments sum to %.4f, want them to fill the 8.00 bar", sum)
}
// Local rates gave a:b = 3:1, so the rescaled split must keep that ratio.
if got := last.Models["a"].USD; got < 5.99 || got > 6.01 {
t.Errorf("model a = %.4f, want 6.00 (3:1 of the ledger total)", got)
}
if last.Unattributed > 0.01 {
t.Errorf("unattributed = %.4f, want ~0 once the split fills the bar", last.Unattributed)
}
}

// A lifetime total that goes down is a correction or a payout on the platform
// side, not negative earnings.
func TestEarningsHistoryTreatsLedgerDecreaseAsZero(t *testing.T) {
t0 := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC)
models := map[string]ModelTokenCounts{"a": {In: 10}}
series := CalculateEarningsHistory(context.Background(),
[]HistoricalDataPoint{
snapPlatform(t0, 0, 0, nil, usd(50)),
snapPlatform(t0.Add(time.Minute), 10, 0, nil, usd(40)),
},
metricsFor(models), nil, "24h", 0)

last := series.Points[len(series.Points)-1]
if last.USD != 0 {
t.Errorf("bucket = %.4f, want 0 — a decrease must not subtract from the window", last.USD)
}
}
9 changes: 9 additions & 0 deletions internal/computing/inference_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -1294,6 +1294,15 @@ func (s *InferenceService) GetRequestHistory(limit int, modelFilter string) []Re
return s.client.metrics.GetRequestHistory(limit, modelFilter)
}

// SetPlatformEarningsProvider installs the source of the platform's lifetime
// earnings figure, so each stored snapshot carries it and the earnings series
// can be differenced from the ledger rather than priced locally.
func (s *InferenceService) SetPlatformEarningsProvider(fn func() (float64, bool)) {
if s.metricsHistory != nil {
s.metricsHistory.SetPlatformEarningsProvider(fn)
}
}

// QueryRequestHistory returns one page of request history along with the total
// matching the filters.
//
Expand Down
Loading
Loading