193 lines
6.9 KiB
Go
193 lines
6.9 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",
|
|
}
|
|
)
|
|
|
|
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. Never converted to a token estimate.",
|
|
}, usageReasoningLabelNames)
|
|
)
|
|
|
|
// 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 only and is never turned into a token
|
|
// estimate (SDD S07).
|
|
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}
|
|
}
|
|
|
|
// 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 only increments the
|
|
// auxiliary reasoning counters; it is never added to the token counter.
|
|
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))
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|