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.
This commit is contained in:
2026-07-01 16:17:05 +02:00
parent 58d9c0a9c0
commit dc11a8dde5
5 changed files with 41 additions and 16 deletions

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -48,6 +48,7 @@ func New(cfg providers.Config) (providers.Provider, error) {
Authorize: openaicompat.BearerAuth(sec.APIKey),
Headers: headers,
IncludeUsage: true,
IncludeCost: true,
},
}, nil
}

View File

@@ -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)
}