blob: 4e97df3ef468f9a9f79a32afd06427d9099e87ed [file]
// Copyright 2026 The casbin Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package controllers
import (
"bytes"
"encoding/json"
"io"
"time"
"github.com/apache/casbin-gateway/object"
)
// usageTailBytes is how much of the end of a response is kept to read the token
// usage out of. Both APIs report it last, so the tail is enough.
const usageTailBytes = 16 * 1024
// finishLlmRecord queues the record of one relayed request. Every path out of
// forwardToProviders ends the client request, so this is the only place it is
// called from.
func (c *ApiController) finishLlmRecord(route *proxyRoute) {
if route.record == nil {
return
}
record := route.record
route.record = nil
record.DurationMs = time.Since(route.start).Milliseconds()
object.AddLlmRecord(record, route.body)
}
func (route *proxyRoute) recordAttempt(providerId string) {
if route.record == nil {
return
}
route.record.Attempts++
route.record.Provider = providerId
}
func (route *proxyRoute) recordOutcome(status int, message string) {
if route.record == nil {
return
}
route.record.Status = status
route.record.Error = message
}
func (route *proxyRoute) recordUsage(tail []byte) {
if route.record == nil {
return
}
usage := readUsage(tail)
record := route.record
record.CompletionTokens = higher(usage.CompletionTokens, usage.OutputTokens)
record.ReasoningTokens = usage.ReasoningTokens
record.CacheWriteTokens = usage.CacheWriteTokens
record.CacheReadTokens = higher(usage.CacheReadInputTokens, usage.CachedTokens)
// OpenAI counts cached input inside prompt_tokens, Anthropic reports it
// beside input_tokens. Taking it out where it is included leaves the four
// counters disjoint, so each can be priced at its own rate.
record.PromptTokens = higher(usage.PromptTokens, usage.InputTokens)
if usage.PromptTokens > 0 && usage.CachedTokens > 0 {
record.PromptTokens = higher(record.PromptTokens-usage.CachedTokens, 0)
}
record.TotalTokens = record.PromptTokens + record.CompletionTokens + record.CacheReadTokens + record.CacheWriteTokens
if record.TotalTokens == 0 {
record.TotalTokens = usage.TotalTokens
}
}
// usageTap keeps the tail of a relayed response. It never changes a byte of
// what the client receives, and never holds more than usageTailBytes.
type usageTap struct {
reader io.Reader
tail []byte
}
func (tap *usageTap) Read(p []byte) (int, error) {
n, err := tap.reader.Read(p)
if n > 0 {
tap.keep(p[:n])
}
return n, err
}
func (tap *usageTap) keep(chunk []byte) {
if len(chunk) >= usageTailBytes {
tap.tail = append(tap.tail[:0], chunk[len(chunk)-usageTailBytes:]...)
return
}
if len(tap.tail)+len(chunk) > usageTailBytes {
kept := usageTailBytes - len(chunk)
copy(tap.tail, tap.tail[len(tap.tail)-kept:])
tap.tail = tap.tail[:kept]
}
tap.tail = append(tap.tail, chunk...)
}
// llmUsage covers both spellings of the same counters: OpenAI nests the cached
// and reasoning parts in detail objects, Anthropic reports them flat.
type llmUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CacheWriteTokens int `json:"cache_creation_input_tokens"`
CacheReadInputTokens int `json:"cache_read_input_tokens"`
// CacheCreation is what cache_creation_input_tokens totals, so it is only
// read when that field is absent.
CacheCreation struct {
Ephemeral5m int `json:"ephemeral_5m_input_tokens"`
Ephemeral1h int `json:"ephemeral_1h_input_tokens"`
} `json:"cache_creation"`
PromptTokensDetails struct {
CachedTokens int `json:"cached_tokens"`
} `json:"prompt_tokens_details"`
CompletionTokensDetails struct {
ReasoningTokens int `json:"reasoning_tokens"`
} `json:"completion_tokens_details"`
// Flattened out of the detail objects above by normalize.
CachedTokens int `json:"-"`
ReasoningTokens int `json:"-"`
}
// normalize lifts the nested counters up onto one flat set of fields.
func (usage *llmUsage) normalize() {
usage.CachedTokens = usage.PromptTokensDetails.CachedTokens
usage.ReasoningTokens = usage.CompletionTokensDetails.ReasoningTokens
if usage.CacheWriteTokens == 0 {
usage.CacheWriteTokens = usage.CacheCreation.Ephemeral5m + usage.CacheCreation.Ephemeral1h
}
}
func (usage *llmUsage) merge(other llmUsage) {
usage.PromptTokens = higher(usage.PromptTokens, other.PromptTokens)
usage.CompletionTokens = higher(usage.CompletionTokens, other.CompletionTokens)
usage.TotalTokens = higher(usage.TotalTokens, other.TotalTokens)
usage.InputTokens = higher(usage.InputTokens, other.InputTokens)
usage.OutputTokens = higher(usage.OutputTokens, other.OutputTokens)
usage.CacheWriteTokens = higher(usage.CacheWriteTokens, other.CacheWriteTokens)
usage.CacheReadInputTokens = higher(usage.CacheReadInputTokens, other.CacheReadInputTokens)
usage.CachedTokens = higher(usage.CachedTokens, other.CachedTokens)
usage.ReasoningTokens = higher(usage.ReasoningTokens, other.ReasoningTokens)
}
// readUsage merges every usage object in the tail: a stream splits the counters
// across several events, and a plain response carries them once.
func readUsage(tail []byte) llmUsage {
usage := llmUsage{}
marker := []byte(`"usage"`)
for rest := tail; ; {
index := bytes.Index(rest, marker)
if index < 0 {
return usage
}
rest = rest[index+len(marker):]
var parsed llmUsage
if value := balancedObject(rest); value != nil && json.Unmarshal(value, &parsed) == nil {
parsed.normalize()
usage.merge(parsed)
}
}
}
// balancedObject returns the JSON object that directly follows a key, or nil
// when the value is not one. Usage objects hold only numbers and objects of
// numbers, so counting braces cannot be thrown off by one inside a string.
func balancedObject(data []byte) []byte {
start := 0
for start < len(data) && (data[start] == ':' || data[start] == ' ' || data[start] == '\t' || data[start] == '\r' || data[start] == '\n') {
start++
}
if start >= len(data) || data[start] != '{' {
return nil
}
depth := 0
for i := start; i < len(data); i++ {
switch data[i] {
case '{':
depth++
case '}':
depth--
if depth == 0 {
return data[start : i+1]
}
}
}
return nil
}
func higher(a int, b int) int {
if a > b {
return a
}
return b
}