Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package litellm_budgettrack

import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"testing"
"time"

fwd "github.com/rossoctl/cortex/authbridge/authlib/listener/forwardproxy"
"github.com/rossoctl/cortex/authbridge/authlib/pipeline"
"github.com/rossoctl/cortex/authbridge/authlib/session"
)

// TestForwardProxyStreamedSSEUpdatesLedger is the listener-level test the PR #815
// review asked for: stand up the real forward proxy with a streamed
// (text/event-stream) upstream and BudgetTrack in the outbound pipeline, drive a
// request through the proxy, and assert the ledger moved.
//
// This exercises the path the direct-call unit tests structurally cannot: whether
// the listener actually dispatches response frames to the plugin. On the
// header-only version (before this branch), a streamed response never reaches the
// plugin's cost accounting, so this test would fail — which is exactly the gap the
// reviewer flagged.
func TestForwardProxyStreamedSSEUpdatesLedger(t *testing.T) {
// Upstream emits Anthropic-style streamed usage and NO cost header — as
// LiteLLM does for streamed responses — so the plugin must price it from the
// parsed token usage.
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
f, _ := w.(http.Flusher)
io.WriteString(w, "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":100,\"output_tokens\":1}}}\n\n")
if f != nil {
f.Flush()
}
io.WriteString(w, "event: message_delta\ndata: {\"type\":\"message_delta\",\"usage\":{\"output_tokens\":40}}\n\n")
if f != nil {
f.Flush()
}
}))
t.Cleanup(upstream.Close)

spend := filepath.Join(t.TempDir(), "spend.json")
p := New()
raw, _ := json.Marshal(budgetTrackConfig{
SpendFile: spend, MaxBudget: 5, InputCostPerToken: 1e-6, OutputCostPerToken: 5e-6,
})
if err := p.Configure(raw); err != nil {
t.Fatalf("Configure: %v", err)
}
// WrapConfigured is what the real build applies; it preserves StreamingResponder.
wrapped := pipeline.WrapConfigured(p, raw)

pipe, err := pipeline.New([]pipeline.Plugin{wrapped})
if err != nil {
t.Fatalf("pipeline.New: %v", err)
}
if !pipe.HasStreamingResponders() {
t.Fatal("pipeline does not recognize BudgetTrack as a StreamingResponder")
}
store := session.New(5*time.Minute, 100, 0)
t.Cleanup(store.Close)
srv, err := fwd.NewServer(pipeline.NewHolder(pipe), store, nil)
if err != nil {
t.Fatalf("NewServer: %v", err)
}
proxy := httptest.NewServer(srv.Handler())
t.Cleanup(proxy.Close)

pu, _ := url.Parse(proxy.URL)
client := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(pu)}}
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, upstream.URL+"/v1/messages", nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request via proxy: %v", err)
}
_, _ = io.Copy(io.Discard, resp.Body)
if err := resp.Body.Close(); err != nil {
t.Errorf("close body: %v", err)
}

// The terminal last=true dispatch runs in the handler's defer after the
// stream is forwarded, so poll briefly for the ledger to settle.
want := 100*1e-6 + 40*5e-6 // 0.0003
for i := 0; i < 200; i++ {
p.mu.Lock()
got, calls := p.ledger.TotalSpend, p.ledger.TotalCalls
p.mu.Unlock()
if calls > 0 {
if got < want-1e-12 || got > want+1e-12 {
t.Fatalf("ledger TotalSpend = %v, want %v", got, want)
}
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("ledger never updated from a streamed SSE response — the forward proxy did not dispatch frames to the plugin")
}
210 changes: 193 additions & 17 deletions authbridge/authlib/plugins/litellm_budgettrack/plugin.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
// Package litellm_budgettrack provides a pipeline plugin that tracks
// per-request cost via the x-litellm-response-cost response header and
// enforces a daily spending budget, rejecting requests with HTTP 429
// when the budget is exceeded.
// per-request cost and enforces a daily spending budget, rejecting requests
// with HTTP 429 when the budget is exceeded.
//
// Cost is resolved in two ways:
//
// - Non-streaming responses carry the cost in a response header
// (x-litellm-response-cost, or the pre-discount -original variant), read
// on the terminal frame.
// - Streaming responses (text/event-stream — what Claude Code's
// /v1/messages uses) report cost 0 in the header because the total is not
// known when the headers are sent. For these, the plugin parses the token
// usage out of the terminal SSE events (Anthropic message_delta /
// message_stop, or OpenAI's final chunk usage) and prices it from the
// configured per-token rates. Streaming cost tracking is therefore active
// only when input_cost_per_token / output_cost_per_token are configured.
package litellm_budgettrack

import (
"bytes"
"context"
"encoding/json"
"fmt"
"math"
"os"
"strconv"
"sync"
Expand All @@ -33,6 +47,25 @@ const (
type budgetTrackConfig struct {
SpendFile string `json:"spend_file" required:"true" description:"Path to the JSON spend ledger file."`
MaxBudget float64 `json:"max_budget" required:"true" description:"Daily budget in USD."`
// InputCostPerToken / OutputCostPerToken price streamed responses whose
// header cost is 0 (the total is unknown when streaming headers are sent).
// USD per token; optional. When both are zero, streamed responses cannot be
// priced and contribute 0 to the ledger.
InputCostPerToken float64 `json:"input_cost_per_token" description:"USD per input/prompt token, for pricing streamed responses."`
OutputCostPerToken float64 `json:"output_cost_per_token" description:"USD per output/completion token, for pricing streamed responses."`
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

// stateKey names the per-request scratch holding token usage accumulated across
// streaming frames until the terminal frame prices it.
const stateKey = "litellm-budget-track"

// usageState accumulates the largest token counts seen across a stream's
// frames. Anthropic reports input_tokens in message_start and the cumulative
// output_tokens in the final message_delta, so taking the max of each yields
// the final totals; OpenAI reports both together in its terminal usage chunk.
type usageState struct {
inputTokens int
outputTokens int
}

type spendLedger struct {
Expand All @@ -59,7 +92,7 @@ func (p *BudgetTrack) Name() string { return "litellm-budget-track" }

func (p *BudgetTrack) Capabilities() pipeline.PluginCapabilities {
return pipeline.PluginCapabilities{
Description: "Track x-litellm-response-cost and enforce daily budget limit.",
Description: "Track LLM cost (response header or streamed usage) and enforce a daily budget.",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

must-fixCapabilities() declares neither ReadsBody nor WritesBody, but as of this PR the plugin does parse the response body. PluginCapabilities.Normalize() only derives ReadsBody from WritesBody, so Pipeline.NeedsBody() comes out false for any pipeline containing this plugin:

HasStreamingResponders = true
NeedsBody              = false

That matters on the extproc (envoy-sidecar) listener, because handleResponseHeaders requests a buffered response body via ModeOverride only when NeedsBody() is true. With it false it takes the header-only branch, dispatches a single RunResponseFrame(pctx, nil, true), and states in its own comment that "No body phase will run". Driving exactly that sequence with a streamed response (Content-Type: text/event-stream, cost header 0):

after extproc header-only dispatch: TotalSpend=0 TotalCalls=0

So the feature this PR adds records nothing on that listener. And if the deployed Envoy processing_mode sets response_body_mode: BUFFERED statically, then handleResponseBody runs as well — a second last=true via dispatchBufferedFrames — which double-charges the ledger (see the comment on line 171). I can't read the rendered Envoy config from this repo, so it's one or the other depending on deployment. ReadsBody: true fixes both, because the header phase then early-returns without dispatching at all.

inference-parser — the sibling plugin that parses response bodies — declares it (inferenceparser/plugin.go:29):

return pipeline.PluginCapabilities{
	ReadsBody:   true,
	Description: "Track LLM cost (response header or streamed usage) and enforce a daily budget.",
}

I ran the full package suite with that one line added, including your new TestForwardProxyStreamedSSEUpdatesLedger — all green. The proxy listeners gate on HasStreamingResponders() rather than NeedsBody(), so nothing there changes.

If extproc is deliberately out of scope for this plugin, that's a legitimate answer — but then the docs should say so, because nothing in the config surface hints at it.

}
}

Expand All @@ -73,6 +106,17 @@ func (p *BudgetTrack) Configure(raw json.RawMessage) error {
if p.cfg.MaxBudget <= 0 {
return fmt.Errorf("litellm-budget-track: max_budget must be > 0")
}
// Per-token rates must be finite and non-negative. A negative rate would make
// a streamed request's cost negative, which accumulate() drops — so the request
// would silently neither charge budget nor record a call. Reject at config time.
for name, rate := range map[string]float64{
"input_cost_per_token": p.cfg.InputCostPerToken,
"output_cost_per_token": p.cfg.OutputCostPerToken,
} {
if rate < 0 || math.IsNaN(rate) || math.IsInf(rate, 0) {
return fmt.Errorf("litellm-budget-track: %s must be finite and >= 0", name)
}
}
p.loadLedger()
return nil
}
Expand All @@ -91,31 +135,156 @@ func (p *BudgetTrack) OnRequest(_ context.Context, pctx *pipeline.Context) pipel
return pipeline.Action{Type: pipeline.Continue}
}

// OnResponse reads x-litellm-response-cost and accumulates the spend.
// OnResponse handles the buffered path on listeners that do not route through
// OnResponseFrame. On the proxy listeners this plugin is a StreamingResponder,
// so pipeline.RunResponse skips it and OnResponseFrame drives accumulation
// instead; this remains for listeners that only call OnResponse.
func (p *BudgetTrack) OnResponse(_ context.Context, pctx *pipeline.Context) pipeline.Action {
costStr := pctx.ResponseHeaders.Get(responseCostHeader)
if costStr == "" {
// Anthropic /v1/messages (and newer LiteLLM) omit the bare header.
costStr = pctx.ResponseHeaders.Get(responseCostOriginalHeader)
if cost := headerCost(pctx); cost > 0 {
p.accumulate(cost)
}
if costStr == "" {
return pipeline.Action{Type: pipeline.Continue}
return pipeline.Action{Type: pipeline.Continue}
}

// OnResponseFrame observes each response frame. It parses token usage out of
// streamed SSE frames and, on the terminal frame, prices the request: the
// response-header cost when present (non-streaming), otherwise the parsed
// usage times the configured per-token rates (streaming).
func (p *BudgetTrack) OnResponseFrame(_ context.Context, pctx *pipeline.Context, frame []byte, last bool) pipeline.Action {
if in, out, ok := parseFrameUsage(frame); ok {
st := pipeline.GetState[usageState](pctx, stateKey)
if st == nil {
st = &usageState{}
pipeline.SetState(pctx, stateKey, st)
}
if in > st.inputTokens {
st.inputTokens = in
}
if out > st.outputTokens {
st.outputTokens = out
}
}
cost, err := strconv.ParseFloat(costStr, 64)
if err != nil || cost <= 0 {
if !last {
return pipeline.Action{Type: pipeline.Continue}
}

// Terminal frame: settle the cost exactly once.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion — the comment says "settle the cost exactly once", but nothing enforces it, and accumulate is a +=. A second terminal dispatch charges twice:

one last=true : spend=0.0003 calls=1
two last=true : spend=0.0006 calls=2

This is the first plugin whose last=true handler has a non-idempotent side effect. inference-parser and a2a-parser finalize by overwriting fields, so a repeat dispatch is harmless for them — which means the listeners' exactly-once contract is now load-bearing for money in a way it wasn't before.

Today's listeners are disciplined about it (reverseproxy guards with b.finished, forwardproxy uses a single defer), so this is latent rather than a live bug. But a flag makes the comment true for free, and it also neutralizes the extproc double-dispatch branch described on line 95:

type usageState struct {
	inputTokens  int
	outputTokens int
	settled      bool
}

One wrinkle worth handling: on a header-cost-only response no usage frame ever arrives, so the scratch is still nil at the terminal frame. Materialize it unconditionally in the terminal block (or use a separate sentinel key) so the guard covers that case too — otherwise the header-only path stays unguarded, which is exactly the extproc shape.

cost := headerCost(pctx)
if cost <= 0 {
if st := pipeline.GetState[usageState](pctx, stateKey); st != nil {
cost = float64(st.inputTokens)*p.cfg.InputCostPerToken +

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

must-fix — this prices uncached input, cache writes and cache reads at one flat rate. inputTotal() (line 324) sums InputTokens + CacheCreationInputTokens + CacheReadInputTokens + PromptTokens, and all of it is multiplied by p.cfg.InputCostPerToken.

pipeline/extensions.go:179-185 — from #814, merged two days ago — states the problem directly:

a provider that prices prompt caching charges a premium to write an entry and a steep discount to read one, so two requests with an identical PromptTokens can differ by an order of magnitude in price

Using the real Claude Code turn captured in #811 (input_tokens: 9, cache_creation: 3755, cache_read: 30008) and the usual multipliers (uncached 1.0×, cache write 1.25×, cache read 0.1× — exact values vary by provider):

plugin charges (flat 1.0×):  33,772 token-equivalents
actual (1.0 / 1.25 / 0.1×):   7,703.55
overcharge:                   4.38×

And 4.38× is the mild case. As a session warms and cache reads come to dominate the prompt, the ratio approaches 10×. The consequence isn't just a wrong number in a ledger — OnRequest rejects with 429 budget.exceeded off that figure, so operators would get cut off four to ten times earlier than their real spend warrants, on exactly the traffic shape the docs name (Claude Code /v1/messages).

Blast radius is bounded, and worth stating: this only affects the usage-fallback path, since LiteLLM's x-litellm-response-cost already accounts for cache tiers correctly and wins when present. But that fallback path is what this PR adds, so it's in scope.

The encouraging part is that usageJSON already parses the three counts separately — only inputTotal() throws the split away. Two routes, both acceptable to me:

  1. Track the buckets — keep inputTokens / cacheWriteTokens / cacheReadTokens on usageState and add optional cache_write_cost_per_token / cache_read_cost_per_token, defaulting to input_cost_per_token when unset so existing config keeps working. Small change; the data is already there.
  2. Docs-only — if you'd rather not grow the config surface in this PR, say plainly in litellm-budgettrack-plugin.md that input_cost_per_token is applied to all prompt tokens including cached ones, so it must be set to a blended rate, and that cached traffic will otherwise overstate spend by up to ~10×. Then file (1) as a follow-up.

I'd rather not push scope creep on you, so option 2 clears this block as far as I'm concerned — the trap is an operator reading "USD per input/prompt token", setting the model's list input price, and getting a 4× overstatement with no warning.

float64(st.outputTokens)*p.cfg.OutputCostPerToken
}
}
if cost > 0 {
p.accumulate(cost)
}
return pipeline.Action{Type: pipeline.Continue}
}

// accumulate adds one priced call to today's ledger and persists it. A
// non-finite or non-positive cost is ignored: NaN/±Inf would poison
// TotalSpend (making the budget check meaningless) and break the JSON
// marshal, so this is the single chokepoint that guarantees the ledger
// only ever holds finite money.
func (p *BudgetTrack) accumulate(cost float64) {
if cost <= 0 || math.IsNaN(cost) || math.IsInf(cost, 0) {
return
}
p.mu.Lock()
p.resetIfNewDay()
p.ledger.TotalSpend += cost
p.ledger.TotalCalls++
p.saveLedger()
p.mu.Unlock()
}

return pipeline.Action{Type: pipeline.Continue}
// headerCost returns the cost reported in the response headers, or 0 when
// absent/zero/unparseable. Streamed responses report 0 here.
func headerCost(pctx *pipeline.Context) float64 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestionheaderCost returns 0 for "header absent", "header is 0", and "header is garbage" alike, and the caller treats all three identically as "fall back to per-token pricing". But x-litellm-response-cost: 0 is also what LiteLLM reports for a genuinely free call — cache hits and error responses, not only streams. So a non-streamed response that LiteLLM priced at zero gets charged from its own usage block:

cost header "0" + usage body => TotalSpend=0.0003 TotalCalls=1

Not reachable today on the forward proxy, since pctx.ResponseBody is empty without ReadsBody and there's no usage to find — but it becomes reachable the moment the Capabilities() finding on line 95 is addressed. The two interact, so they're worth fixing in the same pass rather than sequentially.

Tightening the fallback condition settles it: price from usage only when the header is absent, or when Content-Type is text/event-stream (the listeners already carry an isEventStream helper for precisely this test). Streaming is the case you want the fallback for, and it's exactly identifiable — no need to infer it from a zero.

costStr := pctx.ResponseHeaders.Get(responseCostHeader)
if costStr == "" {
// Anthropic /v1/messages (and newer LiteLLM) omit the bare header.
costStr = pctx.ResponseHeaders.Get(responseCostOriginalHeader)
}
if costStr == "" {
return 0
}
cost, err := strconv.ParseFloat(costStr, 64)
// strconv.ParseFloat accepts "NaN" / "Inf"; reject non-finite (and
// non-positive) so a garbage header falls through to the usage path
// rather than poisoning the ledger.
if err != nil || cost <= 0 || math.IsNaN(cost) || math.IsInf(cost, 0) {
return 0
}
return cost
}

// parseFrameUsage extracts token usage from a response frame, covering
// Anthropic (usage, or message.usage in message_start) and OpenAI
// (usage.prompt_tokens / completion_tokens). Returns the largest input/output
// token counts found.
//
// The listener's sseframe reader strips the "data:" prefix and returns the
// bare payload, so a streamed frame arrives as raw JSON. The buffered
// application/json path also delivers the whole body as one raw-JSON frame.
// We therefore try the frame as JSON directly, and also scan any "data:"
// lines for the case a frame still carries SSE framing.
func parseFrameUsage(frame []byte) (in, out int, found bool) {
consider := func(b []byte) {
b = bytes.TrimSpace(b)
if len(b) == 0 || b[0] != '{' {
return
}
var ev struct {
Usage *usageJSON `json:"usage"`
Message *struct {
Usage *usageJSON `json:"usage"`
} `json:"message"`
}
if json.Unmarshal(b, &ev) != nil {
return
}
u := ev.Usage
if u == nil && ev.Message != nil {
u = ev.Message.Usage // Anthropic message_start nests usage
}
if u == nil {
return
}
if i := u.inputTotal(); i > in {
in, found = i, true
}
if o := u.outputTotal(); o > out {
out, found = o, true
}
}

consider(frame) // bare-JSON frame (sseframe payload, or buffered body)
for _, line := range bytes.Split(frame, []byte("\n")) {
if line = bytes.TrimSpace(line); bytes.HasPrefix(line, []byte("data:")) {
consider(bytes.TrimPrefix(line, []byte("data:")))
}
}
return in, out, found
}

// usageJSON accepts both Anthropic and OpenAI usage shapes.
type usageJSON struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
CacheReadInputTokens int `json:"cache_read_input_tokens"`
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
}

func (u usageJSON) inputTotal() int {
return u.InputTokens + u.CacheCreationInputTokens + u.CacheReadInputTokens + u.PromptTokens
}

func (u usageJSON) outputTotal() int { return u.OutputTokens + u.CompletionTokens }

func (p *BudgetTrack) todayUTC() string {
return time.Now().UTC().Format("2006-01-02")
}
Expand All @@ -142,11 +311,18 @@ func (p *BudgetTrack) loadLedger() {
}

func (p *BudgetTrack) saveLedger() {
data, _ := json.MarshalIndent(p.ledger, "", " ")
data, err := json.MarshalIndent(p.ledger, "", " ")
if err != nil {
// Never overwrite a good ledger with a failed marshal (e.g. a
// non-finite TotalSpend that slipped through). accumulate already
// rejects non-finite costs; this is the belt-and-suspenders guard.
return
}
_ = os.WriteFile(p.cfg.SpendFile, data, 0644)
}

var (
_ pipeline.Plugin = (*BudgetTrack)(nil)
_ pipeline.Configurable = (*BudgetTrack)(nil)
_ pipeline.Plugin = (*BudgetTrack)(nil)
_ pipeline.Configurable = (*BudgetTrack)(nil)
_ pipeline.StreamingResponder = (*BudgetTrack)(nil)
)
Loading
Loading