Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
7 changes: 7 additions & 0 deletions telemetry/langfuse/attribute.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ const (
type usageDetails struct {
Input int64 `json:"input,omitempty"`
Output int64 `json:"output,omitempty"`
Total int64 `json:"total,omitempty"`
InputCached int64 `json:"input_cached,omitempty"`
InputCacheRead int64 `json:"input_cache_read,omitempty"`
InputCacheCreation int64 `json:"input_cache_creation,omitempty"`
Expand All @@ -73,6 +74,8 @@ func (u *usageDetails) empty() bool {
}

// normalized returns mutually exclusive usage buckets as required by Langfuse.
// Provider total semantics differ once cache usage is split into separate
// buckets, so omit total in that case and let Langfuse derive it.
//
// OpenAI-compatible and Gemini providers report cached tokens as a subset of
// input tokens. Anthropic and Bedrock report cache reads and cache creation as
Expand All @@ -82,11 +85,15 @@ func (u usageDetails) normalized() usageDetails {
if u.InputCacheRead != 0 || u.InputCacheCreation != 0 {
// Prefer provider-specific buckets and drop the duplicate compatibility alias.
u.InputCached = 0
u.Total = 0
return u
}

// Langfuse flat usage details must not overlap. Keep cached input in its own
// bucket and convert the inclusive provider input count to the uncached remainder.
if u.InputCached != 0 {
u.Total = 0
}
u.Input = max(u.Input-u.InputCached, 0)
return u
}
Expand Down
4 changes: 3 additions & 1 deletion telemetry/langfuse/attribute_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ func TestUsageDetailsNormalized(t *testing.T) {
},
{
name: "inclusive cached input",
usage: usageDetails{Input: 100, Output: 50, InputCached: 30},
usage: usageDetails{Input: 100, Output: 50, Total: 999, InputCached: 30},
want: usageDetails{Input: 70, Output: 50, InputCached: 30},
},
{
Expand All @@ -139,6 +139,7 @@ func TestUsageDetailsNormalized(t *testing.T) {
usage: usageDetails{
Input: 20,
Output: 10,
Total: 999,
InputCached: 80,
InputCacheRead: 80,
},
Expand All @@ -153,6 +154,7 @@ func TestUsageDetailsNormalized(t *testing.T) {
usage: usageDetails{
Input: 20,
Output: 10,
Total: 999,
InputCacheCreation: 80,
},
want: usageDetails{
Expand Down
3 changes: 3 additions & 0 deletions telemetry/langfuse/exporter.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ func transformInvokeAgent(span *tracepb.Span) {
// Keeping token attributes on InvokeAgent would make Langfuse double count tokens
// compared to the old behavior (Chat-only token accounting).
case semconvtrace.KeyGenAIUsageInputTokens, semconvtrace.KeyGenAIUsageOutputTokens,
semconvtrace.KeyGenAIUsageTotalTokens,
semconvtrace.KeyGenAIUsageInputTokensCached, semconvtrace.KeyGenAIUsageInputTokensCacheRead,
semconvtrace.KeyGenAIUsageInputTokensCacheCreation:
default:
Expand Down Expand Up @@ -241,6 +242,8 @@ func collectLLMSpanAttributes(attrs []*commonpb.KeyValue) llmSpanCollected {
c.usage.Input = attr.Value.GetIntValue()
case semconvtrace.KeyGenAIUsageOutputTokens:
c.usage.Output = attr.Value.GetIntValue()
case semconvtrace.KeyGenAIUsageTotalTokens:
c.usage.Total = attr.Value.GetIntValue()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Keeping total here undercounts Anthropic cached calls, because TotalTokens is input plus output while cache buckets are exported separately. Derive total after normalization, or omit it so Langfuse derives it.

中文 这里保留 `total` 会低估 Anthropic 缓存调用,因为 `TotalTokens` 是 input 加 output,而缓存桶会单独导出。请在归一化后计算 `total`,或省略它让 Langfuse 计算。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 0842588. Cached usage now omits total after normalization so Langfuse derives it from the mutually exclusive buckets; the provider-reported total is retained only when no cache-bucket normalization is needed. The regression table now covers cached, cache-read, and cache-creation usage with deliberately inconsistent provider totals.

case semconvtrace.KeyGenAIUsageInputTokensCached:
c.usage.InputCached = attr.Value.GetIntValue()
case semconvtrace.KeyGenAIUsageInputTokensCacheRead:
Expand Down
31 changes: 30 additions & 1 deletion telemetry/langfuse/exporter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,12 @@ func TestTransformInvokeAgent(t *testing.T) {
Value: &commonpb.AnyValue_IntValue{IntValue: 456},
},
},
{
Key: semconvtrace.KeyGenAIUsageTotalTokens,
Value: &commonpb.AnyValue{
Value: &commonpb.AnyValue_IntValue{IntValue: 579},
},
},
{
Key: "other.attribute",
Value: &commonpb.AnyValue{
Expand Down Expand Up @@ -318,6 +324,7 @@ func TestTransformInvokeAgent(t *testing.T) {
for _, attr := range span.Attributes {
assert.NotEqual(t, semconvtrace.KeyGenAIUsageInputTokens, attr.Key)
assert.NotEqual(t, semconvtrace.KeyGenAIUsageOutputTokens, attr.Key)
assert.NotEqual(t, semconvtrace.KeyGenAIUsageTotalTokens, attr.Key)
}
}

Expand Down Expand Up @@ -611,6 +618,7 @@ func TestTransformCallLLM_UsageDetails(t *testing.T) {
name string
inputTokens int64
outputTokens int64
totalTokens int64
cachedTokens int64
cacheReadTokens int64
cacheCreationTokens int64
Expand All @@ -620,26 +628,35 @@ func TestTransformCallLLM_UsageDetails(t *testing.T) {
name: "basic input/output tokens",
inputTokens: 100,
outputTokens: 50,
expectedUsage: map[string]int64{"input": 100, "output": 50},
totalTokens: 150,
expectedUsage: map[string]int64{"input": 100, "output": 50, "total": 150},
},
{
name: "provider total only",
totalTokens: 42,
expectedUsage: map[string]int64{"total": 42},
},
{
name: "with OpenAI cached tokens",
inputTokens: 100,
outputTokens: 50,
totalTokens: 999,
cachedTokens: 30,
expectedUsage: map[string]int64{"input": 70, "output": 50, "input_cached": 30},
},
{
name: "with Anthropic cache_read tokens",
inputTokens: 200,
outputTokens: 80,
totalTokens: 999,
cacheReadTokens: 60,
expectedUsage: map[string]int64{"input": 200, "output": 80, "input_cache_read": 60},
},
{
name: "with Anthropic cache_creation tokens",
inputTokens: 200,
outputTokens: 80,
totalTokens: 999,
cacheCreationTokens: 40,
expectedUsage: map[string]int64{"input": 200, "output": 80, "input_cache_creation": 40},
},
Expand Down Expand Up @@ -703,6 +720,12 @@ func TestTransformCallLLM_UsageDetails(t *testing.T) {
Value: &commonpb.AnyValue{Value: &commonpb.AnyValue_IntValue{IntValue: tt.outputTokens}},
})
}
if tt.totalTokens != 0 {
attrs = append(attrs, &commonpb.KeyValue{
Key: semconvtrace.KeyGenAIUsageTotalTokens,
Value: &commonpb.AnyValue{Value: &commonpb.AnyValue_IntValue{IntValue: tt.totalTokens}},
})
}
if tt.cachedTokens != 0 {
attrs = append(attrs, &commonpb.KeyValue{
Key: semconvtrace.KeyGenAIUsageInputTokensCached,
Expand All @@ -729,6 +752,7 @@ func TestTransformCallLLM_UsageDetails(t *testing.T) {
for _, attr := range span.Attributes {
assert.NotEqual(t, semconvtrace.KeyGenAIUsageInputTokens, attr.Key)
assert.NotEqual(t, semconvtrace.KeyGenAIUsageOutputTokens, attr.Key)
assert.NotEqual(t, semconvtrace.KeyGenAIUsageTotalTokens, attr.Key)
assert.NotEqual(t, semconvtrace.KeyGenAIUsageInputTokensCached, attr.Key)
assert.NotEqual(t, semconvtrace.KeyGenAIUsageInputTokensCacheRead, attr.Key)
assert.NotEqual(t, semconvtrace.KeyGenAIUsageInputTokensCacheCreation, attr.Key)
Expand Down Expand Up @@ -779,6 +803,10 @@ func TestTransformInvokeAgent_CacheTokensFiltered(t *testing.T) {
Key: semconvtrace.KeyGenAIUsageOutputTokens,
Value: &commonpb.AnyValue{Value: &commonpb.AnyValue_IntValue{IntValue: 50}},
},
{
Key: semconvtrace.KeyGenAIUsageTotalTokens,
Value: &commonpb.AnyValue{Value: &commonpb.AnyValue_IntValue{IntValue: 150}},
},
{
Key: semconvtrace.KeyGenAIUsageInputTokensCached,
Value: &commonpb.AnyValue{Value: &commonpb.AnyValue_IntValue{IntValue: 30}},
Expand All @@ -800,6 +828,7 @@ func TestTransformInvokeAgent_CacheTokensFiltered(t *testing.T) {
for _, attr := range span.Attributes {
assert.NotEqual(t, semconvtrace.KeyGenAIUsageInputTokens, attr.Key)
assert.NotEqual(t, semconvtrace.KeyGenAIUsageOutputTokens, attr.Key)
assert.NotEqual(t, semconvtrace.KeyGenAIUsageTotalTokens, attr.Key)
assert.NotEqual(t, semconvtrace.KeyGenAIUsageInputTokensCached, attr.Key)
assert.NotEqual(t, semconvtrace.KeyGenAIUsageInputTokensCacheRead, attr.Key)
assert.NotEqual(t, semconvtrace.KeyGenAIUsageInputTokensCacheCreation, attr.Key)
Expand Down
Loading