iop/apps/edge/internal/openai/usage_metrics.go

227 lines
8.6 KiB
Go

package openai
import (
"context"
"errors"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
// Usage metering is scoped to the OpenAI-compatible input surface only (SDD
// S04). CLI, A2A, and local-only adapter usage are intentionally excluded from
// these counters; this file is never imported by those surfaces.
// Terminal request status label values.
const (
usageStatusSuccess = "success"
usageStatusError = "error"
usageStatusCancel = "cancel"
)
// usage_source label values on iop_openai_requests_total. provider_reported
// means at least one token type was reported for the request; unavailable means
// no usage was observed (status-only request count).
const (
usageSourceProviderReported = "provider_reported"
usageSourceUnavailable = "unavailable"
)
// Endpoint label values distinguish the OpenAI-compatible route.
const (
usageEndpointChatCompletions = "chat.completions"
usageEndpointResponses = "responses"
)
// Token type label values on iop_openai_usage_tokens_total.
const (
tokenTypeInput = "input"
tokenTypeOutput = "output"
tokenTypeReasoning = "reasoning"
tokenTypeCachedInput = "cached_input"
)
// Label allowlists. These deliberately exclude request_id, session_id, raw
// bearer/provider tokens, and raw prompt/response text so the counters stay
// low-cardinality and never leak secrets (SDD S08). The label sets are exported
// as package vars so tests can assert the allowlist directly.
var (
usageRequestLabelNames = []string{
"edge_id", "principal_ref", "principal_alias", "token_ref",
"model_group", "endpoint", "response_mode", "status", "usage_source",
}
usageTokenLabelNames = []string{
"edge_id", "principal_ref", "principal_alias", "token_ref",
"model_group", "endpoint", "response_mode", "token_type",
}
usageReasoningLabelNames = []string{
"edge_id", "principal_ref", "principal_alias", "token_ref",
"model_group", "endpoint", "response_mode",
}
usageReasoningEstimateLabelNames = []string{
"edge_id", "principal_ref", "principal_alias", "token_ref",
"model_group", "endpoint", "response_mode", "estimation_method",
}
)
var (
openAIRequestsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "iop_openai_requests_total",
Help: "OpenAI-compatible requests processed by the Edge input surface, by terminal status and usage source.",
}, usageRequestLabelNames)
openAIUsageTokensTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "iop_openai_usage_tokens_total",
Help: "Provider-reported OpenAI-compatible token usage by token type. Reasoning tokens are only counted when the provider reports them.",
}, usageTokenLabelNames)
openAIReasoningObservedTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "iop_openai_reasoning_observed_total",
Help: "Requests where reasoning text was observed but the provider did not report reasoning token usage.",
}, usageReasoningLabelNames)
openAIReasoningCharsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "iop_openai_reasoning_chars_total",
Help: "Observed reasoning text characters for requests without provider-reported reasoning tokens. The token estimate is emitted separately via iop_openai_reasoning_estimated_tokens_total.",
}, usageReasoningLabelNames)
openAIReasoningEstimatedTokensTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "iop_openai_reasoning_estimated_tokens_total",
Help: "Estimated reasoning tokens for requests with observed reasoning text but without provider-reported reasoning token usage.",
}, usageReasoningEstimateLabelNames)
)
// usageObservation is the metric-facing view of a request's token usage. Counts
// are provider-reported; a zero count means the provider did not report that
// token type and the emitter never estimates it.
type usageObservation struct {
inputTokens int
outputTokens int
reasoningTokens int
cachedInputTokens int
// reasoningChars counts observed reasoning text characters. It feeds the
// reasoning-observed auxiliary metrics and the separate estimated-token
// counter when the provider does not report reasoning token usage.
reasoningChars int
}
func (o usageObservation) hasTokens() bool {
return o.inputTokens > 0 || o.outputTokens > 0 || o.reasoningTokens > 0 || o.cachedInputTokens > 0
}
func usageObservationFromOpenAIUsage(usage *openAIUsage, reasoningChars int) usageObservation {
obs := usageObservation{reasoningChars: reasoningChars}
if usage != nil {
obs.inputTokens = usage.PromptTokens
obs.outputTokens = usage.CompletionTokens
obs.reasoningTokens = usage.ReasoningTokens
obs.cachedInputTokens = usage.CachedInputTokens
}
return obs
}
// usageLabels carries the resolved low-cardinality label values for one request.
type usageLabels struct {
edgeID string
principalRef string
principalAlias string
tokenRef string
modelGroup string
endpoint string
responseMode string
}
// usageLabelsFor builds the metric label values for a request from its
// authenticated principal context. Identity is taken only from the resolved
// principal (never from caller-supplied metadata.user).
func (s *Server) usageLabelsFor(ctx context.Context, modelGroup, endpoint, responseMode string) usageLabels {
l := usageLabels{
edgeID: s.edgeIDValue(),
modelGroup: modelGroup,
endpoint: endpoint,
responseMode: responseMode,
}
if p, ok := principalFromContext(ctx); ok {
l.principalRef = p.PrincipalRef
l.principalAlias = p.PrincipalAlias
l.tokenRef = p.TokenRef
}
return l
}
func (l usageLabels) reasoningValues() []string {
return []string{l.edgeID, l.principalRef, l.principalAlias, l.tokenRef, l.modelGroup, l.endpoint, l.responseMode}
}
// estimatedReasoningTokens converts observed reasoning text characters to a
// token estimate using the chars/4 rule of thumb. This is an operator-grade
// observation estimate only, never billing-grade. Caller reports a non-zero
// provider `reasoningTokens` value to skip this path entirely.
func estimatedReasoningTokens(chars int) int {
return (chars + 3) / 4
}
// emitUsageMetrics increments the request counter for the terminal status and,
// when usage was observed, the per-token-type counters. Reasoning text observed
// without a provider-reported reasoning token count increments the auxiliary
// reasoning counters and emits a separate estimated-token counter; the
// provider-reported `token_type="reasoning"` counter stays exclusive to the
// provider's value.
func emitUsageMetrics(l usageLabels, status string, obs usageObservation) {
source := usageSourceUnavailable
if obs.hasTokens() {
source = usageSourceProviderReported
}
openAIRequestsTotal.WithLabelValues(
l.edgeID, l.principalRef, l.principalAlias, l.tokenRef,
l.modelGroup, l.endpoint, l.responseMode, status, source,
).Inc()
addTokenCount(l, tokenTypeInput, obs.inputTokens)
addTokenCount(l, tokenTypeOutput, obs.outputTokens)
addTokenCount(l, tokenTypeReasoning, obs.reasoningTokens)
addTokenCount(l, tokenTypeCachedInput, obs.cachedInputTokens)
if obs.reasoningChars > 0 && obs.reasoningTokens == 0 {
openAIReasoningObservedTotal.WithLabelValues(l.reasoningValues()...).Inc()
openAIReasoningCharsTotal.WithLabelValues(l.reasoningValues()...).Add(float64(obs.reasoningChars))
estimate := estimatedReasoningTokens(obs.reasoningChars)
openAIReasoningEstimatedTokensTotal.WithLabelValues(
l.edgeID, l.principalRef, l.principalAlias, l.tokenRef,
l.modelGroup, l.endpoint, l.responseMode, "chars_div_4",
).Add(float64(estimate))
}
}
func addTokenCount(l usageLabels, tokenType string, count int) {
if count <= 0 {
return
}
openAIUsageTokensTotal.WithLabelValues(
l.edgeID, l.principalRef, l.principalAlias, l.tokenRef,
l.modelGroup, l.endpoint, l.responseMode, tokenType,
).Add(float64(count))
}
// usageStatusForError classifies a post-dispatch run error into a terminal
// status label. Caller give-up (cancellation/timeout) is a cancel; everything
// else is an error.
func usageStatusForError(err error) string {
if err == nil {
return usageStatusSuccess
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, errRunTimedOut) {
return usageStatusCancel
}
return usageStatusError
}
const (
// responseModePassthrough and responseModeNormalized are internal execution
// labels for the response_mode usage metric. They are derived from the
// handler execution path, never from caller metadata: provider tunnel routes
// report passthrough, normalized RunEvent routes report normalized. Callers
// cannot select a response mode through OpenAI metadata.
responseModePassthrough = "passthrough"
responseModeNormalized = "normalized"
)