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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ func New(cfg providers.Config) (providers.Provider, error) {
|
||||
Authorize: openaicompat.BearerAuth(sec.APIKey),
|
||||
Headers: headers,
|
||||
IncludeUsage: true,
|
||||
IncludeCost: true,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user