From dc11a8dde5f485e4817f4bb8a776fa50cedd2a69 Mon Sep 17 00:00:00 2001 From: LovroG05 Date: Wed, 1 Jul 2026 16:17:05 +0200 Subject: [PATCH] fix: bill OpenRouter requests using its own reported cost Cost was always estimated locally from catalog per-token prices, which drifts from what OpenRouter actually charges (dynamic routing, BYOK, promos, provider-specific caching). Request OpenRouter's usage.include extension and use the returned usage.cost as the authoritative figure when present, falling back to the per-token estimate for providers that don't report it. --- backend/internal/billing/billing.go | 12 +++++-- backend/internal/providers/chat.go | 35 +++++++++++++------ .../providers/openaicompat/transport.go | 7 ++-- .../providers/openrouter/openrouter.go | 1 + backend/internal/providers/providers_test.go | 2 +- 5 files changed, 41 insertions(+), 16 deletions(-) diff --git a/backend/internal/billing/billing.go b/backend/internal/billing/billing.go index e031f64..4308e0c 100644 --- a/backend/internal/billing/billing.go +++ b/backend/internal/billing/billing.go @@ -7,9 +7,17 @@ import ( ) // Cost returns the dollar cost of a request given the served catalog entry and -// token usage. Cached prompt tokens are billed at the cached rate when set, -// otherwise at the input rate. Prices are per 1M tokens. +// token usage. When the upstream reports its own billed cost (e.g. +// OpenRouter's usage.include extension), that authoritative figure is used +// as-is — it accounts for provider-specific pricing quirks (dynamic routing, +// BYOK, promos) that catalog per-token prices can't reproduce exactly. +// Otherwise cost is estimated from catalog prices: cached prompt tokens are +// billed at the cached rate when set, otherwise at the input rate. Prices are +// per 1M tokens. func Cost(e *catalog.Entry, u providers.Usage) float64 { + if u.Cost != nil { + return *u.Cost + } if e == nil { return 0 } diff --git a/backend/internal/providers/chat.go b/backend/internal/providers/chat.go index 2888125..5ac4781 100644 --- a/backend/internal/providers/chat.go +++ b/backend/internal/providers/chat.go @@ -42,20 +42,23 @@ func ParseChatRequest(body []byte) (*ChatRequest, error) { } // MarshalUpstream serializes the request body for a non-streaming call, with the -// model overridden to the upstream name. -func (r *ChatRequest) MarshalUpstream() ([]byte, error) { - return json.Marshal(r.upstreamPayload(false, false)) +// model overridden to the upstream name. When includeCost is set, OpenRouter's +// usage.include extension is added so the response reports its own billed +// dollar cost (needed for accurate billing — see Usage.Cost). +func (r *ChatRequest) MarshalUpstream(includeCost bool) ([]byte, error) { + return json.Marshal(r.upstreamPayload(false, false, includeCost)) } // MarshalUpstreamStream serializes the body for a streaming call. When // includeUsage is set, stream_options.include_usage is injected so the upstream -// reports token usage in the final chunk (needed for billing). -func (r *ChatRequest) MarshalUpstreamStream(includeUsage bool) ([]byte, error) { - return json.Marshal(r.upstreamPayload(true, includeUsage)) +// reports token usage in the final chunk (needed for billing). includeCost is +// as in MarshalUpstream. +func (r *ChatRequest) MarshalUpstreamStream(includeUsage, includeCost bool) ([]byte, error) { + return json.Marshal(r.upstreamPayload(true, includeUsage, includeCost)) } -func (r *ChatRequest) upstreamPayload(stream, includeUsage bool) map[string]any { - out := make(map[string]any, len(r.Payload)+2) +func (r *ChatRequest) upstreamPayload(stream, includeUsage, includeCost bool) map[string]any { + out := make(map[string]any, len(r.Payload)+3) maps.Copy(out, r.Payload) out["model"] = r.Model if stream { @@ -71,6 +74,9 @@ func (r *ChatRequest) upstreamPayload(stream, includeUsage bool) map[string]any } else { delete(out, "stream") } + if includeCost { + out["usage"] = map[string]any{"include": true} + } return out } @@ -88,6 +94,11 @@ type Usage struct { CompletionTokens int CachedTokens int TotalTokens int + // Cost is the upstream-reported dollar cost of the request, when the + // provider supports reporting it (e.g. OpenRouter's usage.include + // extension). Nil when not reported — billing then falls back to + // computing cost from catalog per-token prices. + Cost *float64 } // ChatStream yields OpenAI-style SSE chunks from the upstream. The dispatcher @@ -129,9 +140,10 @@ func ParseUsageChunk(data []byte) (Usage, bool) { } type usageJSON struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + Cost *float64 `json:"cost"` // OpenRouter usage.include extension PromptTokensDetails struct { CachedTokens int `json:"cached_tokens"` } `json:"prompt_tokens_details"` @@ -143,6 +155,7 @@ func (u *usageJSON) toUsage() Usage { CompletionTokens: u.CompletionTokens, CachedTokens: u.PromptTokensDetails.CachedTokens, TotalTokens: u.TotalTokens, + Cost: u.Cost, } if out.TotalTokens == 0 { out.TotalTokens = out.PromptTokens + out.CompletionTokens diff --git a/backend/internal/providers/openaicompat/transport.go b/backend/internal/providers/openaicompat/transport.go index b8fc389..b631346 100644 --- a/backend/internal/providers/openaicompat/transport.go +++ b/backend/internal/providers/openaicompat/transport.go @@ -31,6 +31,9 @@ type Transport struct { // IncludeUsage injects stream_options.include_usage so streamed responses // carry token usage for billing. IncludeUsage bool + // IncludeCost requests the upstream's own billed dollar cost via + // OpenRouter's usage.include extension, when supported. + IncludeCost bool } func (t *Transport) endpoint(path string) string { @@ -71,7 +74,7 @@ func (t *Transport) newRequest(ctx context.Context, method, path string, body [] // ChatCompletion performs a non-streaming chat completion. func (t *Transport) ChatCompletion(ctx context.Context, cr *providers.ChatRequest) (*providers.ChatResponse, error) { - body, err := cr.MarshalUpstream() + body, err := cr.MarshalUpstream(t.IncludeCost) if err != nil { return nil, err } @@ -99,7 +102,7 @@ func (t *Transport) ChatCompletion(ctx context.Context, cr *providers.ChatReques // ChatCompletionStream performs a streaming chat completion. A non-2xx upstream // status is returned as *providers.APIError so the caller can relay it. func (t *Transport) ChatCompletionStream(ctx context.Context, cr *providers.ChatRequest) (providers.ChatStream, error) { - body, err := cr.MarshalUpstreamStream(t.IncludeUsage) + body, err := cr.MarshalUpstreamStream(t.IncludeUsage, t.IncludeCost) if err != nil { return nil, err } diff --git a/backend/internal/providers/openrouter/openrouter.go b/backend/internal/providers/openrouter/openrouter.go index b1e0e92..54895f0 100644 --- a/backend/internal/providers/openrouter/openrouter.go +++ b/backend/internal/providers/openrouter/openrouter.go @@ -48,6 +48,7 @@ func New(cfg providers.Config) (providers.Provider, error) { Authorize: openaicompat.BearerAuth(sec.APIKey), Headers: headers, IncludeUsage: true, + IncludeCost: true, }, }, nil } diff --git a/backend/internal/providers/providers_test.go b/backend/internal/providers/providers_test.go index a04d144..1be2f03 100644 --- a/backend/internal/providers/providers_test.go +++ b/backend/internal/providers/providers_test.go @@ -51,7 +51,7 @@ func TestMarshalUpstreamStreamInjectsUsage(t *testing.T) { Model: "upstream-model", Payload: map[string]any{"model": "alias", "messages": []any{}}, } - body, err := req.MarshalUpstreamStream(true) + body, err := req.MarshalUpstreamStream(true, false) if err != nil { t.Fatalf("MarshalUpstreamStream: %v", err) }