package openai import ( "fmt" "net/http" "net/http/httptest" "strings" "testing" "github.com/prometheus/client_golang/prometheus/testutil" edgeservice "iop/apps/edge/internal/service" "iop/packages/go/config" iop "iop/proto/gen/iop" ) // principalTokenCfg builds an EdgeOpenAIConf whose bearer token maps to a fixed // principal so usage-metric label values are populated. func principalTokenCfg(rawToken, adapter string) config.EdgeOpenAIConf { return config.EdgeOpenAIConf{ Adapter: adapter, PrincipalTokens: []config.OpenAIPrincipalTokenConf{ {TokenRef: "iop-tok-alice", TokenHashSHA256: sha256Hex(rawToken), PrincipalRef: "user:alice", PrincipalAlias: "alice"}, }, } } func providerRouteUsageMetricServer(rawToken, edgeID, model, target string, fake *providerFakeRunService) *Server { conf := principalTokenCfg(rawToken, "ollama") conf.ModelRoutes = []config.OpenAIRouteEntry{ {Model: model, Adapter: "openai_compat", Target: target}, } srv := NewServer(conf, fake, nil) srv.SetEdgeID(edgeID) return srv } func requestTokenValue(t *testing.T, l usageAttemptLabels, tokenType string) float64 { t.Helper() return testutil.ToFloat64(openAIUsageTokensTotal.WithLabelValues( l.edgeID, l.principalRef, l.principalAlias, l.tokenRef, l.credentialSlotRef, fmt.Sprintf("%d", l.credentialRevision), l.routeModel, l.usageAttribution, l.providerID, l.servedModel, l.endpoint, l.responseMode, tokenType, )) } func requestReasoningEstimateValue(t *testing.T, l usageAttemptLabels, method string) float64 { t.Helper() return testutil.ToFloat64(openAIReasoningEstimatedTokensTotal.WithLabelValues( l.edgeID, l.principalRef, l.principalAlias, l.tokenRef, l.credentialSlotRef, fmt.Sprintf("%d", l.credentialRevision), l.routeModel, l.usageAttribution, l.providerID, l.servedModel, l.endpoint, l.responseMode, method, )) } func TestUsageRecorderFinishesRequestOnce(t *testing.T) { request := usageRequestLabels{ edgeID: "edge-recorder-once", principalRef: "user:once", principalAlias: "once", tokenRef: "token-once", routeModel: "route-once", endpoint: usageEndpointChatCompletions, } recorder := &openAIUsageRecorder{request: request, attempts: make(map[string]usageDispatchBinding)} binding := newUsageDispatchBinding(edgeservice.RunDispatch{ ProviderID: "provider-once", Target: "served-once", NodeID: "node-internal-only", UsageAttribution: config.UsageAttributionProvider, }, responseModeNormalized) binding.attemptID = "attempt-once" labels := usageAttemptLabels{ usageRequestLabels: request, usageAttribution: binding.usageAttribution, providerID: binding.providerID, servedModel: binding.servedModel, responseMode: binding.responseMode, } tokenBefore := requestTokenValue(t, labels, tokenTypeInput) requestBefore := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues( request.edgeID, request.principalRef, request.principalAlias, request.tokenRef, request.routeModel, request.endpoint, responseModeNormalized, usageStatusSuccess, usageSourceProviderReported, )) recorder.RecordAttempt(binding, usageObservation{inputTokens: 7, providerReported: true}) recorder.RecordAttempt(binding, usageObservation{inputTokens: 99, providerReported: true}) recorder.mu.Lock() recordedNode := recorder.attempts[binding.attemptID].nodeID recorder.mu.Unlock() if recordedNode != binding.nodeID { t.Fatalf("internal attempt node = %q, want %q", recordedNode, binding.nodeID) } recorder.FinishRequest(usageStatusSuccess, responseModeNormalized) recorder.FinishRequest(usageStatusError, responseModePassthrough) if got := requestTokenValue(t, labels, tokenTypeInput) - tokenBefore; got != 7 { t.Fatalf("deduplicated attempt input tokens = %v, want 7", got) } requestAfter := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues( request.edgeID, request.principalRef, request.principalAlias, request.tokenRef, request.routeModel, request.endpoint, responseModeNormalized, usageStatusSuccess, usageSourceProviderReported, )) if got := requestAfter - requestBefore; got != 1 { t.Fatalf("terminal request count = %v, want 1", got) } } func TestUsageRecorderReasoningOnlyKeepsUsageSourceUnavailable(t *testing.T) { request := usageRequestLabels{ edgeID: "edge-reasoning-only-source", routeModel: "route-reasoning-only", endpoint: usageEndpointChatCompletions, } recorder := &openAIUsageRecorder{request: request, attempts: make(map[string]usageDispatchBinding)} binding := usageDispatchBinding{ attemptID: "reasoning-only", usageAttribution: config.UsageAttributionProvider, providerID: "provider-reasoning", servedModel: "served-reasoning", responseMode: responseModeNormalized, } before := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues( request.edgeID, "", "", "", request.routeModel, request.endpoint, responseModeNormalized, usageStatusSuccess, usageSourceUnavailable, )) recorder.RecordAttempt(binding, usageObservation{reasoningChars: 8}) recorder.FinishRequest(usageStatusSuccess, responseModeNormalized) after := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues( request.edgeID, "", "", "", request.routeModel, request.endpoint, responseModeNormalized, usageStatusSuccess, usageSourceUnavailable, )) if got := after - before; got != 1 { t.Fatalf("reasoning-only unavailable request count = %v, want 1", got) } } // TestOpenAIUsageMetricsLabelsExcludeSecretsAndHighCardinality asserts the label // allowlists never carry raw tokens, prompt/response text, or per-request // high-cardinality identifiers (SDD S08). func TestOpenAIUsageMetricsLabelsExcludeSecretsAndHighCardinality(t *testing.T) { // Substrings that would indicate a per-request identifier, a raw secret, or // raw payload text. response_mode / route_model are low-cardinality values and // are intentionally not caught here. forbidden := []string{ "request_id", "session_id", "run_id", "attempt_id", "node_id", "token_hash", "bearer", "authorization", "api_key", "prompt", "secret", "raw", "slot_alias", "lease_id", "provider_secret", "target_url", } // token_ref is an allowlisted stable alias, not a raw token; guard against a // bare "token" label that could carry the secret itself. for _, set := range [][]string{ usageRequestLabelNames, usageTokenLabelNames, usageReasoningLabelNames, usageReasoningEstimateLabelNames, } { for _, name := range set { if name == "token" { t.Fatalf("label %q may carry a raw token value; use token_ref", name) } for _, bad := range forbidden { if strings.Contains(name, bad) { t.Fatalf("label %q contains forbidden fragment %q", name, bad) } } } } // The allowlist must still carry the intended identity/attribution labels. for _, want := range []string{"edge_id", "principal_ref", "principal_alias", "token_ref", "route_model", "endpoint", "response_mode"} { if !containsString(usageRequestLabelNames, want) { t.Fatalf("request labels missing %q", want) } } if !containsString(usageRequestLabelNames, "usage_source") || !containsString(usageRequestLabelNames, "status") { t.Fatalf("request labels must include status and usage_source: %v", usageRequestLabelNames) } if !containsString(usageTokenLabelNames, "token_type") { t.Fatalf("token labels must include token_type: %v", usageTokenLabelNames) } for _, want := range []string{"credential_slot_ref", "credential_revision", "usage_attribution", "provider_id", "served_model"} { if !containsString(usageTokenLabelNames, want) { t.Fatalf("token labels missing %q", want) } } // estimated reasoning labels: only estimation_method on top of the base set. if !containsString(usageReasoningEstimateLabelNames, "estimation_method") { t.Fatalf("reasoning estimate labels must include estimation_method: %v", usageReasoningEstimateLabelNames) } for _, want := range []string{"edge_id", "principal_ref", "principal_alias", "token_ref", "credential_slot_ref", "credential_revision", "route_model", "usage_attribution", "provider_id", "served_model", "endpoint", "response_mode"} { if !containsString(usageReasoningEstimateLabelNames, want) { t.Fatalf("reasoning estimate labels missing %q", want) } } for _, forbiddenRequestLabel := range []string{"credential_slot_ref", "credential_revision"} { if containsString(usageRequestLabelNames, forbiddenRequestLabel) { t.Fatalf("request terminal labels must not include provider-attempt dimension %q", forbiddenRequestLabel) } } } func TestCredentialSlotUsageAttributionSplitsAttemptsAndKeepsTerminalOnce(t *testing.T) { request := usageRequestLabels{ edgeID: "edge-two-slot", principalRef: "principal-one", principalAlias: "principal-alias", tokenRef: "inbound-token-ref", routeModel: "shared-route", endpoint: usageEndpointChatCompletions, } labelsA := usageAttemptLabels{ usageRequestLabels: request, credentialSlotRef: "slot-ref-a", credentialRevision: 11, usageAttribution: config.UsageAttributionProvider, providerID: "provider-shared", servedModel: "served-shared", responseMode: responseModePassthrough, } labelsB := labelsA labelsB.credentialSlotRef = "slot-ref-b" labelsB.credentialRevision = 19 beforeA := requestTokenValue(t, labelsA, tokenTypeInput) beforeB := requestTokenValue(t, labelsB, tokenTypeInput) cancelBefore := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues( request.edgeID, request.principalRef, request.principalAlias, request.tokenRef, request.routeModel, request.endpoint, responseModePassthrough, usageStatusCancel, usageSourceProviderReported, )) recorderA := &openAIUsageRecorder{request: request, attempts: make(map[string]usageDispatchBinding)} recorderA.RecordAttempt(usageDispatchBinding{ attemptID: "slot-a-initial", usageAttribution: labelsA.usageAttribution, providerID: labelsA.providerID, servedModel: labelsA.servedModel, credentialSlotRef: labelsA.credentialSlotRef, credentialRevision: labelsA.credentialRevision, responseMode: responseModePassthrough, }, usageObservation{inputTokens: 5, providerReported: true}) recorderA.RecordAttempt(usageDispatchBinding{ attemptID: "slot-a-retry", usageAttribution: labelsA.usageAttribution, providerID: labelsA.providerID, servedModel: labelsA.servedModel, credentialSlotRef: labelsA.credentialSlotRef, credentialRevision: labelsA.credentialRevision, responseMode: responseModePassthrough, }, usageObservation{inputTokens: 3, providerReported: true}) recorderA.FinishRequest(usageStatusCancel, responseModePassthrough) recorderA.FinishRequest(usageStatusError, responseModePassthrough) recorderB := &openAIUsageRecorder{request: request, attempts: make(map[string]usageDispatchBinding)} recorderB.RecordAttempt(usageDispatchBinding{ attemptID: "slot-b-initial", usageAttribution: labelsB.usageAttribution, providerID: labelsB.providerID, servedModel: labelsB.servedModel, credentialSlotRef: labelsB.credentialSlotRef, credentialRevision: labelsB.credentialRevision, responseMode: responseModePassthrough, }, usageObservation{inputTokens: 7, providerReported: true}) recorderB.FinishRequest(usageStatusSuccess, responseModePassthrough) if got := requestTokenValue(t, labelsA, tokenTypeInput) - beforeA; got != 8 { t.Fatalf("slot A retry total = %v, want 8", got) } if got := requestTokenValue(t, labelsB, tokenTypeInput) - beforeB; got != 7 { t.Fatalf("slot B total = %v, want 7", got) } cancelAfter := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues( request.edgeID, request.principalRef, request.principalAlias, request.tokenRef, request.routeModel, request.endpoint, responseModePassthrough, usageStatusCancel, usageSourceProviderReported, )) if got := cancelAfter - cancelBefore; got != 1 { t.Fatalf("cancel terminal count = %v, want 1", got) } } // TestOpenAIUsageMetricsCountTokenTypes drives a normalized chat completion whose // run reports input/output/reasoning/cached usage and asserts each token type is // counted under a provider_reported request (SDD S06). func TestUsageAttributionPolicyEmitsSingleCanonicalProviderSeries(t *testing.T) { const rawToken = "sk-count-raw-token" const edgeID = "edge-count-token-types" const model = "count-model" fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} fake.events <- &iop.RunEvent{Type: "delta", Delta: "hi"} fake.events <- &iop.RunEvent{Type: "complete", Usage: &iop.Usage{ InputTokens: 10, OutputTokens: 5, ReasoningTokens: 3, CachedInputTokens: 2, }} srv := NewServer(principalTokenCfg(rawToken, "ollama"), fake, nil) srv.SetEdgeID(edgeID) labels := usageAttemptLabels{ usageRequestLabels: usageRequestLabels{edgeID: edgeID, principalRef: "user:alice", principalAlias: "alice", tokenRef: "iop-tok-alice", routeModel: model, endpoint: usageEndpointChatCompletions}, usageAttribution: config.UsageAttributionProvider, providerID: "test-provider", servedModel: model, responseMode: responseModeNormalized, } before := map[string]float64{ tokenTypeInput: requestTokenValue(t, labels, tokenTypeInput), tokenTypeOutput: requestTokenValue(t, labels, tokenTypeOutput), tokenTypeReasoning: requestTokenValue(t, labels, tokenTypeReasoning), tokenTypeCachedInput: requestTokenValue(t, labels, tokenTypeCachedInput), } reqBefore := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues( edgeID, "user:alice", "alice", "iop-tok-alice", model, usageEndpointChatCompletions, "normalized", usageStatusSuccess, usageSourceProviderReported, )) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"count-model", "messages":[{"role":"user","content":"hi"}] }`)) req.Header.Set("Authorization", "Bearer "+rawToken) w := httptest.NewRecorder() srv.routes().ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } for tokenType, want := range map[string]float64{ tokenTypeInput: 10, tokenTypeOutput: 5, tokenTypeReasoning: 3, tokenTypeCachedInput: 2, } { got := requestTokenValue(t, labels, tokenType) - before[tokenType] if got != want { t.Fatalf("token_type %s: got delta %v, want %v", tokenType, got, want) } } reqAfter := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues( edgeID, "user:alice", "alice", "iop-tok-alice", model, usageEndpointChatCompletions, "normalized", usageStatusSuccess, usageSourceProviderReported, )) if reqAfter-reqBefore != 1 { t.Fatalf("requests_total success/provider_reported: got delta %v, want 1", reqAfter-reqBefore) } if containsString(usageTokenLabelNames, "model_group") { t.Fatalf("model_group must remain a query-time rollup, not a duplicate token label: %v", usageTokenLabelNames) } groupRequest := usageRequestLabels{edgeID: edgeID + "-group", routeModel: model, endpoint: usageEndpointChatCompletions} groupRecorder := &openAIUsageRecorder{request: groupRequest, attempts: make(map[string]usageDispatchBinding)} groupLabels := usageAttemptLabels{ usageRequestLabels: groupRequest, usageAttribution: config.UsageAttributionModelGroup, providerID: "provider-group-actual", servedModel: "served-group-actual", responseMode: responseModeNormalized, } groupBefore := requestTokenValue(t, groupLabels, tokenTypeInput) groupRecorder.RecordAttempt(usageDispatchBinding{ attemptID: "group-attempt", usageAttribution: config.UsageAttributionModelGroup, providerID: groupLabels.providerID, servedModel: groupLabels.servedModel, responseMode: responseModeNormalized, }, usageObservation{inputTokens: 6, providerReported: true}) if got := requestTokenValue(t, groupLabels, tokenTypeInput) - groupBefore; got != 6 { t.Fatalf("model-group policy canonical actual-provider tokens = %v, want 6", got) } } // TestOpenAIReasoningObservedEstimatesTokens verifies that reasoning text // observed without a provider-reported reasoning token count increments the // auxiliary reasoning counters and a separate estimated-token counter based on // chars/4 rounding up, while leaving the provider-reported reasoning token // counter untouched (SDD S07). func TestOpenAIReasoningObservedEstimatesTokens(t *testing.T) { const rawToken = "sk-reasoning-raw-token" const edgeID = "edge-reasoning-observed" const model = "reasoning-model" const reasoning = "abcde" // 5 chars fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)} fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: reasoning} fake.events <- &iop.RunEvent{Type: "delta", Delta: "answer"} fake.events <- &iop.RunEvent{Type: "complete", Usage: &iop.Usage{InputTokens: 8, OutputTokens: 4}} srv := NewServer(principalTokenCfg(rawToken, "ollama"), fake, nil) srv.SetEdgeID(edgeID) labels := usageAttemptLabels{ usageRequestLabels: usageRequestLabels{edgeID: edgeID, principalRef: "user:alice", principalAlias: "alice", tokenRef: "iop-tok-alice", routeModel: model, endpoint: usageEndpointChatCompletions}, usageAttribution: config.UsageAttributionProvider, providerID: "test-provider", servedModel: model, responseMode: responseModeNormalized, } reasoningBefore := requestTokenValue(t, labels, tokenTypeReasoning) estimatedBefore := requestReasoningEstimateValue(t, labels, "chars_div_4") observedBefore := testutil.ToFloat64(openAIReasoningObservedTotal.WithLabelValues(labels.reasoningValues()...)) charsBefore := testutil.ToFloat64(openAIReasoningCharsTotal.WithLabelValues(labels.reasoningValues()...)) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"reasoning-model", "messages":[{"role":"user","content":"hi"}] }`)) req.Header.Set("Authorization", "Bearer "+rawToken) w := httptest.NewRecorder() srv.routes().ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } if got := requestTokenValue(t, labels, tokenTypeReasoning) - reasoningBefore; got != 0 { t.Fatalf("provider-reported reasoning token counter must not move for estimated reasoning: got delta %v", got) } if got := requestReasoningEstimateValue(t, labels, "chars_div_4") - estimatedBefore; got != 2 { t.Fatalf("reasoning estimated tokens: got delta %v, want 2", got) } if got := testutil.ToFloat64(openAIReasoningObservedTotal.WithLabelValues(labels.reasoningValues()...)) - observedBefore; got != 1 { t.Fatalf("reasoning_observed_total: got delta %v, want 1", got) } if got := testutil.ToFloat64(openAIReasoningCharsTotal.WithLabelValues(labels.reasoningValues()...)) - charsBefore; got != float64(len(reasoning)) { t.Fatalf("reasoning_chars_total: got delta %v, want %d", got, len(reasoning)) } } // TestOpenAIReasoningProviderReportedDoesNotEstimate verifies that when the // provider reports reasoningTokens > 0 alongside observed reasoning text, the // estimated-token counter stays untouched and the provider-reported reasoning // counter advances by the reported count (SDD S07). func TestOpenAIReasoningProviderReportedDoesNotEstimate(t *testing.T) { const rawToken = "sk-reasoning-provider-token" const edgeID = "edge-reasoning-provider" const model = "reasoning-model" const reasoning = "abcdef" // 6 chars; irrelevant for the no-estimate path fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)} fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: reasoning} fake.events <- &iop.RunEvent{Type: "delta", Delta: "answer"} fake.events <- &iop.RunEvent{Type: "complete", Usage: &iop.Usage{ InputTokens: 8, OutputTokens: 4, ReasoningTokens: 3, }} srv := NewServer(principalTokenCfg(rawToken, "ollama"), fake, nil) srv.SetEdgeID(edgeID) labels := usageAttemptLabels{ usageRequestLabels: usageRequestLabels{edgeID: edgeID, principalRef: "user:alice", principalAlias: "alice", tokenRef: "iop-tok-alice", routeModel: model, endpoint: usageEndpointChatCompletions}, usageAttribution: config.UsageAttributionProvider, providerID: "test-provider", servedModel: model, responseMode: responseModeNormalized, } reasoningBefore := requestTokenValue(t, labels, tokenTypeReasoning) estimatedBefore := requestReasoningEstimateValue(t, labels, "chars_div_4") req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"reasoning-model", "messages":[{"role":"user","content":"hi"}] }`)) req.Header.Set("Authorization", "Bearer "+rawToken) w := httptest.NewRecorder() srv.routes().ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } if got := requestTokenValue(t, labels, tokenTypeReasoning) - reasoningBefore; got != 3 { t.Fatalf("provider-reported reasoning token: got delta %v, want 3", got) } if got := requestReasoningEstimateValue(t, labels, "chars_div_4") - estimatedBefore; got != 0 { t.Fatalf("estimated reasoning must be zero when provider reports reasoning tokens: got delta %v", got) } } // TestProviderTunnelPassthroughPreservesBodyAndObservesUsage verifies that a pure // passthrough provider response is relayed byte-for-byte while its usage is // observed into metrics (SDD S05/S06). func TestProviderTunnelPassthroughPreservesBodyAndObservesUsage(t *testing.T) { const rawToken = "sk-passthrough-raw-token" const edgeID = "edge-passthrough-usage" const model = "ornith:35b" providerBody := `{"choices":[{"message":{"role":"assistant","content":"hi there"}}],"usage":{"prompt_tokens":12,"completion_tokens":7,"prompt_tokens_details":{"cached_tokens":3},"completion_tokens_details":{"reasoning_tokens":4}}}` fake := &providerFakeRunService{ tunnelFrames: staticProviderTunnelFrames(providerBody), } srv := NewServer(config.EdgeOpenAIConf{ PrincipalTokens: []config.OpenAIPrincipalTokenConf{ {TokenRef: "iop-tok-alice", TokenHashSHA256: sha256Hex(rawToken), PrincipalRef: "user:alice", PrincipalAlias: "alice"}, }, }, fake, nil) srv.SetEdgeID(edgeID) srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: model, Providers: map[string]string{"prov-vllm": "Ornith-1.0-35B"}}}) labels := usageAttemptLabels{ usageRequestLabels: usageRequestLabels{edgeID: edgeID, principalRef: "user:alice", principalAlias: "alice", tokenRef: "iop-tok-alice", routeModel: model, endpoint: usageEndpointChatCompletions}, usageAttribution: config.UsageAttributionProvider, providerID: "prov-fake", servedModel: "served", responseMode: responseModePassthrough, } before := map[string]float64{ tokenTypeInput: requestTokenValue(t, labels, tokenTypeInput), tokenTypeOutput: requestTokenValue(t, labels, tokenTypeOutput), tokenTypeReasoning: requestTokenValue(t, labels, tokenTypeReasoning), tokenTypeCachedInput: requestTokenValue(t, labels, tokenTypeCachedInput), } req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"ornith:35b", "messages":[{"role":"user","content":"hello"}] }`)) req.Header.Set("Authorization", "Bearer "+rawToken) w := httptest.NewRecorder() srv.routes().ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } if w.Body.String() != providerBody { t.Fatalf("passthrough body must be preserved byte-for-byte.\n got: %s\nwant: %s", w.Body.String(), providerBody) } if len(fake.tunnelReqsSnapshot()) != 1 { t.Fatalf("expected exactly one provider tunnel dispatch, got %d", len(fake.tunnelReqsSnapshot())) } for tokenType, want := range map[string]float64{ tokenTypeInput: 12, tokenTypeOutput: 7, tokenTypeReasoning: 4, tokenTypeCachedInput: 3, } { got := requestTokenValue(t, labels, tokenType) - before[tokenType] if got != want { t.Fatalf("passthrough token_type %s: got delta %v, want %v", tokenType, got, want) } } } // TestResponsesProviderTunnelPassthroughObservesUsageMetrics verifies that a // successful /v1/responses provider passthrough attributes its usage metrics to // endpoint=responses, response_mode=passthrough, and route_model=request alias, // while the response body stays provider-original. It is a regression test for // the shared tunnel writer previously recomputing chat.completions labels with an // empty route_model for the Responses path (REVIEW_SEULGI_RESPONSES-1). func TestResponsesProviderTunnelPassthroughObservesUsageMetrics(t *testing.T) { const rawToken = "sk-responses-passthrough-token" const edgeID = "edge-responses-passthrough-usage" const model = "pool-model" // Body-only Responses JSON: no separate USAGE frame. This matches how the // real Node tunnel relay returns usage as part of the body JSON. providerBody := `{"id":"resp-1","object":"response","output_text":"hi there","usage":{"input_tokens":9,"output_tokens":6,"input_tokens_details":{"cached_tokens":3},"output_tokens_details":{"reasoning_tokens":4}}}` frames := make(chan *iop.ProviderTunnelFrame, 5) frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START, StatusCode: 200} frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY, Body: []byte(providerBody)} // NOTE: No proto USAGE frame — usage is only in the body JSON. frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true} close(frames) srv := NewServer(config.EdgeOpenAIConf{ PrincipalTokens: []config.OpenAIPrincipalTokenConf{ {TokenRef: "iop-tok-alice", TokenHashSHA256: sha256Hex(rawToken), PrincipalRef: "user:alice", PrincipalAlias: "alice"}, }, }, &providerFakeRunService{ tunnelFrames: frames, tunnelServedTarget: "served-model", }, nil) srv.SetEdgeID(edgeID) srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: model, Providers: map[string]string{"prov-1": "served-model"}}}) labels := usageAttemptLabels{ usageRequestLabels: usageRequestLabels{edgeID: edgeID, principalRef: "user:alice", principalAlias: "alice", tokenRef: "iop-tok-alice", routeModel: model, endpoint: usageEndpointResponses}, usageAttribution: config.UsageAttributionProvider, providerID: "prov-fake", servedModel: "served", responseMode: responseModePassthrough, } reqBefore := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues( edgeID, "user:alice", "alice", "iop-tok-alice", model, usageEndpointResponses, responseModePassthrough, usageStatusSuccess, usageSourceProviderReported, )) // The pre-fix bug recorded success under endpoint=chat.completions with an // empty route_model; assert that mislabeled counter does not move. wrongBefore := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues( edgeID, "user:alice", "alice", "iop-tok-alice", "", usageEndpointChatCompletions, responseModePassthrough, usageStatusSuccess, usageSourceProviderReported, )) before := map[string]float64{ tokenTypeInput: requestTokenValue(t, labels, tokenTypeInput), tokenTypeOutput: requestTokenValue(t, labels, tokenTypeOutput), tokenTypeReasoning: requestTokenValue(t, labels, tokenTypeReasoning), tokenTypeCachedInput: requestTokenValue(t, labels, tokenTypeCachedInput), } req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{ "model":"pool-model", "input":"hello" }`)) req.Header.Set("Authorization", "Bearer "+rawToken) w := httptest.NewRecorder() srv.routes().ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } if w.Body.String() != providerBody { t.Fatalf("responses passthrough body must be preserved byte-for-byte.\n got: %s\nwant: %s", w.Body.String(), providerBody) } reqAfter := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues( edgeID, "user:alice", "alice", "iop-tok-alice", model, usageEndpointResponses, responseModePassthrough, usageStatusSuccess, usageSourceProviderReported, )) if reqAfter-reqBefore != 1 { t.Fatalf("requests_total responses/success: got delta %v, want 1", reqAfter-reqBefore) } wrongAfter := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues( edgeID, "user:alice", "alice", "iop-tok-alice", "", usageEndpointChatCompletions, responseModePassthrough, usageStatusSuccess, usageSourceProviderReported, )) if wrongAfter-wrongBefore != 0 { t.Fatalf("mislabeled chat.completions/empty-model counter must not move: got delta %v, want 0", wrongAfter-wrongBefore) } // Body-only Responses usage (no proto USAGE frame): input, output, reasoning, // cached_input all come from the body JSON (REVIEW_REVIEW_SEULGI_RESPONSES-1). for tokenType, want := range map[string]float64{ tokenTypeInput: 9, tokenTypeOutput: 6, tokenTypeReasoning: 4, tokenTypeCachedInput: 3, } { got := requestTokenValue(t, labels, tokenType) - before[tokenType] if got != want { t.Fatalf("responses token_type %s: got delta %v, want %v", tokenType, got, want) } } } func TestResponsesProviderTunnelPassthroughStreamingObservesUsageMetrics(t *testing.T) { const rawToken = "sk-responses-streaming-token" const edgeID = "edge-responses-streaming-usage" const model = "pool-model" // Responses streaming SSE: delta event followed by a usage-bearing event // (response.completed with nested usage), matching the real provider tunnel. providerBody := `data: {"type":"response.created","response":{"id":"resp-1"}} data: {"type":"response.output_item.added","item":{"id":"msg-1","type":"message","role":"assistant"}} data: {"type":"response.message.delta","delta":"hello world"} data: {"type":"response.message.completed","item":{"id":"msg-1","role":"assistant","content":[{"type":"output_text","text":"hello world"}],"status":"completed"}} data: {"type":"response.completed","response":{"id":"resp-1","usage":{"input_tokens":15,"output_tokens":10,"input_tokens_details":{"cached_tokens":5},"output_tokens_details":{"reasoning_tokens":2}}}} data: [DONE] ` frames := make(chan *iop.ProviderTunnelFrame, 5) frames <- &iop.ProviderTunnelFrame{ Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START, StatusCode: 200, Headers: map[string]string{"Content-Type": "text/event-stream"}, } frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY, Body: []byte(providerBody)} // NOTE: No proto USAGE frame — usage is only in the SSE body. frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true} close(frames) srv := NewServer(config.EdgeOpenAIConf{ PrincipalTokens: []config.OpenAIPrincipalTokenConf{ {TokenRef: "iop-tok-alice", TokenHashSHA256: sha256Hex(rawToken), PrincipalRef: "user:alice", PrincipalAlias: "alice"}, }, }, &providerFakeRunService{ tunnelFrames: frames, tunnelServedTarget: "served-model", }, nil) srv.SetEdgeID(edgeID) srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: model, Providers: map[string]string{"prov-1": "served-model"}}}) labels := usageAttemptLabels{ usageRequestLabels: usageRequestLabels{edgeID: edgeID, principalRef: "user:alice", principalAlias: "alice", tokenRef: "iop-tok-alice", routeModel: model, endpoint: usageEndpointResponses}, usageAttribution: config.UsageAttributionProvider, providerID: "prov-fake", servedModel: "served", responseMode: responseModePassthrough, } reqBefore := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues( edgeID, "user:alice", "alice", "iop-tok-alice", model, usageEndpointResponses, responseModePassthrough, usageStatusSuccess, usageSourceProviderReported, )) before := map[string]float64{ tokenTypeInput: requestTokenValue(t, labels, tokenTypeInput), tokenTypeOutput: requestTokenValue(t, labels, tokenTypeOutput), tokenTypeReasoning: requestTokenValue(t, labels, tokenTypeReasoning), tokenTypeCachedInput: requestTokenValue(t, labels, tokenTypeCachedInput), } req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{ "model":"pool-model", "input":"hello", "stream":true }`)) req.Header.Set("Authorization", "Bearer "+rawToken) w := httptest.NewRecorder() srv.routes().ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } // Raw SSE body must be preserved byte-for-byte: no model echo rewrite on // passthrough because requestModel is empty (Responses passthrough prefers // provider-original bytes). Streaming body is written directly to the // response writer without modification. if w.Body.String() != providerBody { t.Fatalf("streaming SSE body must be preserved byte-for-byte.\n got: %s\nwant: %s", w.Body.String(), providerBody) } reqAfter := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues( edgeID, "user:alice", "alice", "iop-tok-alice", model, usageEndpointResponses, responseModePassthrough, usageStatusSuccess, usageSourceProviderReported, )) if reqAfter-reqBefore != 1 { t.Fatalf("requests_total responses/success: got delta %v, want 1", reqAfter-reqBefore) } // Body-only Responses streaming usage: all token types from SSE body. for tokenType, want := range map[string]float64{ tokenTypeInput: 15, tokenTypeOutput: 10, tokenTypeReasoning: 2, tokenTypeCachedInput: 5, } { got := requestTokenValue(t, labels, tokenType) - before[tokenType] if got != want { t.Fatalf("responses streaming token_type %s: got delta %v, want %v", tokenType, got, want) } } } // TestOpenAIScopeExcludesA2AAndNonOpenAISurfaces documents that usage metering is // scoped to the OpenAI-compatible input surface only (SDD S04). The endpoint // label allowlist carries the OpenAI routes and no A2A/CLI endpoint value, and // this plan does not modify the A2A surface. func TestOpenAIScopeExcludesA2AAndNonOpenAISurfaces(t *testing.T) { endpoints := map[string]struct{}{ usageEndpointChatCompletions: {}, usageEndpointResponses: {}, } for _, forbidden := range []string{"a2a", "cli", "ollama", "message/send", "tasks"} { if _, ok := endpoints[forbidden]; ok { t.Fatalf("usage endpoint label must not include non-OpenAI surface %q", forbidden) } } if !containsString(usageTokenLabelNames, "endpoint") { t.Fatalf("token metrics must carry an endpoint label to keep OpenAI scope explicit") } } func TestChatCompletionsDispatchFailureEmitsErrorMetric(t *testing.T) { const rawToken = "sk-dispatch-fail-token" const edgeID = "edge-dispatch-fail" const model = "pool-model" fake := &providerFakeRunService{ tunnelErr: fmt.Errorf("node unavailable"), } srv := NewServer(principalTokenCfg(rawToken, "ollama"), fake, nil) srv.SetEdgeID(edgeID) srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: model, Providers: map[string]string{"prov-1": "served-model"}}}) reqBefore := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues( edgeID, "user:alice", "alice", "iop-tok-alice", model, usageEndpointChatCompletions, responseModePassthrough, usageStatusError, usageSourceUnavailable, )) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"pool-model", "messages":[{"role":"user","content":"hello"}] }`)) req.Header.Set("Authorization", "Bearer "+rawToken) w := httptest.NewRecorder() srv.routes().ServeHTTP(w, req) if w.Code != http.StatusBadGateway { t.Fatalf("expected 502, got %d body=%s", w.Code, w.Body.String()) } if len(fake.tunnelReqsSnapshot()) == 0 { t.Fatal("expected tunnel dispatch attempt, got none") } reqAfter := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues( edgeID, "user:alice", "alice", "iop-tok-alice", model, usageEndpointChatCompletions, responseModePassthrough, usageStatusError, usageSourceUnavailable, )) if reqAfter-reqBefore != 1 { t.Fatalf("requests_total error: got delta %v, want 1", reqAfter-reqBefore) } } func TestProviderPoolStreamGateSetupFailureEmitsOneErrorMetric(t *testing.T) { const ( rawToken = "sk-gate-setup-fail-token" edgeID = "edge-gate-setup-fail" model = "gate-pool-model" ) for _, tc := range []struct { name string endpoint string path string body string }{ { name: "chat completions", endpoint: usageEndpointChatCompletions, path: "/v1/chat/completions", body: `{"model":"gate-pool-model","messages":[{"role":"user","content":"hello"}]}`, }, { name: "responses", endpoint: usageEndpointResponses, path: "/v1/responses", body: `{"model":"gate-pool-model","input":"hello"}`, }, } { t.Run(tc.name, func(t *testing.T) { fake := &providerFakeRunService{} cfg := principalTokenCfg(rawToken, "ollama") cfg.StreamEvidenceGate = config.StreamEvidenceGateConf{ Enabled: true, Filters: []config.StreamGateFilterPolicyConf{{Filter: "unsupported_test_filter"}}, } srv := NewServer(cfg, fake, nil) srv.SetEdgeID(edgeID) srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: model, Providers: map[string]string{"prov-1": "served-model"}}}) before := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues( edgeID, "user:alice", "alice", "iop-tok-alice", model, tc.endpoint, responseModePassthrough, usageStatusError, usageSourceUnavailable, )) req := httptest.NewRequest(http.MethodPost, tc.path, strings.NewReader(tc.body)) req.Header.Set("Authorization", "Bearer "+rawToken) w := httptest.NewRecorder() srv.routes().ServeHTTP(w, req) if w.Code != http.StatusInternalServerError { t.Fatalf("expected 500, got %d body=%s", w.Code, w.Body.String()) } if got := fake.poolSubmitCountSnapshot(); got != 0 { t.Fatalf("provider-pool dispatch count = %d, want 0", got) } after := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues( edgeID, "user:alice", "alice", "iop-tok-alice", model, tc.endpoint, responseModePassthrough, usageStatusError, usageSourceUnavailable, )) if got := after - before; got != 1 { t.Fatalf("requests_total error delta = %v, want 1", got) } }) } } // TestToolValidationRetryDispatchFailureEmitsErrorMetric verifies that a // non-stream Chat tool-validation retry SubmitRun failure emits the error // request metric exactly once and returns tool_validation_retry_error (REVIEW_USAGE_METRIC_RETRY-1). func TestToolValidationRetryDispatchFailureEmitsErrorMetric(t *testing.T) { const rawToken = "sk-retry-dispatch-fail-token" const edgeID = "edge-retry-dispatch-fail" const model = "retry-model" fake := &fakeRunService{ eventRuns: []chan *iop.RunEvent{ bufferedRunEvents(&iop.RunEvent{ Type: "complete", Metadata: map[string]string{ "finish_reason": "tool_calls", runtimeMetadataOpenAIToolCalls: `[{"id":"call_bad","type":"function","function":{"name":"cmd","arguments":"{\"unexpected\":true}"}}]`, }, }), }, runIDs: []string{"run-bad"}, submitErrAfter: 2, } srv := NewServer(principalTokenCfg(rawToken, "ollama"), fake, nil) srv.SetEdgeID(edgeID) reqBefore := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues( edgeID, "user:alice", "alice", "iop-tok-alice", model, usageEndpointChatCompletions, "normalized", usageStatusError, usageSourceUnavailable, )) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"retry-model", "messages":[{"role":"user","content":"hello"}], "tools":[{"type":"function","function":{"name":"cmd","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"}}},"required":["commands"]}}}]} }`)) req.Header.Set("Authorization", "Bearer "+rawToken) w := httptest.NewRecorder() srv.routes().ServeHTTP(w, req) if !strings.Contains(w.Body.String(), `"type":"tool_validation_retry_error"`) { t.Fatalf("expected tool_validation_retry_error, got: %s", w.Body.String()) } reqAfter := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues( edgeID, "user:alice", "alice", "iop-tok-alice", model, usageEndpointChatCompletions, "normalized", usageStatusError, usageSourceUnavailable, )) if reqAfter-reqBefore != 1 { t.Fatalf("requests_total error: got delta %v, want 1", reqAfter-reqBefore) } } // TestBufferedStreamToolValidationRetryDispatchFailureEmitsErrorMetric verifies // that a buffered stream Chat tool-validation retry SubmitRun failure emits // the error request metric exactly once (REVIEW_USAGE_METRIC_RETRY-1). func TestBufferedStreamToolValidationRetryDispatchFailureEmitsErrorMetric(t *testing.T) { const rawToken = "sk-buf-retry-dispatch-fail-token" const edgeID = "edge-buf-retry-dispatch" const model = "buf-retry-model" fake := &fakeRunService{ eventRuns: []chan *iop.RunEvent{ bufferedRunEvents(&iop.RunEvent{ Type: "complete", Metadata: map[string]string{ "finish_reason": "tool_calls", runtimeMetadataOpenAIToolCalls: `[{"id":"call_bad","type":"function","function":{"name":"cmd","arguments":"{\"unexpected\":true}"}}]`, }, }), }, runIDs: []string{"run-bad"}, submitErrAfter: 2, } srv := NewServer(principalTokenCfg(rawToken, ""), fake, nil) srv.SetEdgeID(edgeID) reqBefore := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues( edgeID, "user:alice", "alice", "iop-tok-alice", model, usageEndpointChatCompletions, "normalized", usageStatusError, usageSourceUnavailable, )) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"buf-retry-model", "stream":true, "messages":[{"role":"user","content":"hello"}], "tools":[{"type":"function","function":{"name":"cmd","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"}}},"required":["commands"]}}}]} }`)) req.Header.Set("Authorization", "Bearer "+rawToken) w := httptest.NewRecorder() srv.routes().ServeHTTP(w, req) body := w.Body.String() if !strings.Contains(body, `tool_validation_retry_error`) { t.Fatalf("expected tool_validation_retry_error in stream, got: %s", body) } reqAfter := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues( edgeID, "user:alice", "alice", "iop-tok-alice", model, usageEndpointChatCompletions, "normalized", usageStatusError, usageSourceUnavailable, )) if reqAfter-reqBefore != 1 { t.Fatalf("requests_total error: got delta %v, want 1", reqAfter-reqBefore) } }