iop/apps/edge/internal/openai/usage_attribution_attempt_test.go
toki 04879f2b43 feat(openai): 실제 provider별 사용량 귀속을 기록한다
요청 종료 계수와 실제 provider 시도 사용량을 분리하고, 직접·pool·retry 경로의 attribution을 보존한다. 관련 계약·스펙과 완료된 task archive 정리도 함께 반영한다.
2026-07-31 20:22:23 +09:00

249 lines
11 KiB
Go

package openai
import (
"context"
"net/http"
"strings"
"testing"
"time"
"github.com/prometheus/client_golang/prometheus/testutil"
edgeservice "iop/apps/edge/internal/service"
"iop/packages/go/config"
"iop/packages/go/streamgate"
iop "iop/proto/gen/iop"
)
// TestProviderSwitchEmitsUsagePerActualAttemptAndOneRequest drives the real
// provider-pool request runtime through a tunnel-to-normalized recovery. The
// replaced attempt and the committed attempt report distinct provider usage;
// both must be retained while the HTTP request terminal advances only once.
func TestProviderSwitchEmitsUsagePerActualAttemptAndOneRequest(t *testing.T) {
const (
edgeID = "edge-provider-switch-usage"
routeModel = "client-model"
)
initialFrames := bufferedTunnelFrames(
&iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START, StatusCode: http.StatusOK, Headers: map[string]string{"Content-Type": "application/json"}},
&iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY, Body: []byte(`{"choices":[{"message":{"content":"discarded"}}],"usage":{"prompt_tokens":11,"completion_tokens":3,"prompt_tokens_details":{"cached_tokens":1},"completion_tokens_details":{"reasoning_tokens":2}}}`)},
&iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true},
)
service := newScriptedPoolRunService(
scriptedPoolAttempt{
path: string(edgeservice.ProviderPoolPathTunnel), runID: "usage-attempt-a",
provider: "provider-a", target: "served-a", frames: initialFrames,
},
scriptedPoolAttempt{
path: string(edgeservice.ProviderPoolPathNormalized), runID: "usage-attempt-b",
provider: "provider-b", target: "served-b",
runEvents: bufferedRunEvents(
&iop.RunEvent{Type: "delta", Delta: "accepted"},
&iop.RunEvent{Type: "complete", Usage: &iop.Usage{InputTokens: 17, OutputTokens: 5, ReasoningTokens: 4, CachedInputTokens: 6}},
),
},
)
rawBody := []byte(`{"model":"client-model","messages":[{"role":"user","content":"hi"}]}`)
srv, dc := buildStreamGatePoolChatFixture(t, service, rawBody, 3)
srv.SetEdgeID(edgeID)
dc.usage = &openAIUsageRecorder{
request: usageRequestLabels{edgeID: edgeID, routeModel: routeModel, endpoint: usageEndpointChatCompletions},
attempts: make(map[string]usageDispatchBinding),
}
labelsA := usageAttemptLabels{
usageRequestLabels: dc.usage.request, usageAttribution: config.UsageAttributionProvider,
providerID: "provider-a", servedModel: "served-a", responseMode: responseModePassthrough,
}
labelsB := usageAttemptLabels{
usageRequestLabels: dc.usage.request, usageAttribution: config.UsageAttributionProvider,
providerID: "provider-b", servedModel: "served-b", responseMode: responseModeNormalized,
}
aInputBefore := requestTokenValue(t, labelsA, tokenTypeInput)
aOutputBefore := requestTokenValue(t, labelsA, tokenTypeOutput)
aReasoningBefore := requestTokenValue(t, labelsA, tokenTypeReasoning)
aCachedBefore := requestTokenValue(t, labelsA, tokenTypeCachedInput)
bInputBefore := requestTokenValue(t, labelsB, tokenTypeInput)
bOutputBefore := requestTokenValue(t, labelsB, tokenTypeOutput)
bReasoningBefore := requestTokenValue(t, labelsB, tokenTypeReasoning)
bCachedBefore := requestTokenValue(t, labelsB, tokenTypeCachedInput)
requestBefore := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues(
edgeID, "", "", "", routeModel, usageEndpointChatCompletions,
responseModeNormalized, usageStatusSuccess, usageSourceProviderReported,
))
w, sink, runErr := runPoolChatStreamGate(t, srv, dc, newInjectedViolationRegistration(t, dc, "test.usage_switch", 1))
if runErr != nil {
t.Fatalf("runtime run: %v", runErr)
}
committed, success := sink.terminalStatus()
if !committed || !success || !strings.Contains(w.body.String(), "accepted") {
t.Fatalf("replacement response did not commit successfully: committed=%v success=%v body=%q", committed, success, w.body.String())
}
dc.finishUsageRequest(usageStatusSuccess, responseModeNormalized)
for name, gotWant := range map[string][2]float64{
"provider-a input": {requestTokenValue(t, labelsA, tokenTypeInput) - aInputBefore, 11},
"provider-a output": {requestTokenValue(t, labelsA, tokenTypeOutput) - aOutputBefore, 3},
"provider-a reasoning": {requestTokenValue(t, labelsA, tokenTypeReasoning) - aReasoningBefore, 2},
"provider-a cached": {requestTokenValue(t, labelsA, tokenTypeCachedInput) - aCachedBefore, 1},
"provider-b input": {requestTokenValue(t, labelsB, tokenTypeInput) - bInputBefore, 17},
"provider-b output": {requestTokenValue(t, labelsB, tokenTypeOutput) - bOutputBefore, 5},
"provider-b reasoning": {requestTokenValue(t, labelsB, tokenTypeReasoning) - bReasoningBefore, 4},
"provider-b cached": {requestTokenValue(t, labelsB, tokenTypeCachedInput) - bCachedBefore, 6},
} {
if gotWant[0] != gotWant[1] {
t.Fatalf("%s tokens = %v, want %v", name, gotWant[0], gotWant[1])
}
}
requestAfter := testutil.ToFloat64(openAIRequestsTotal.WithLabelValues(
edgeID, "", "", "", routeModel, usageEndpointChatCompletions,
responseModeNormalized, usageStatusSuccess, usageSourceProviderReported,
))
if got := requestAfter - requestBefore; got != 1 {
t.Fatalf("request terminal count = %v, want 1", got)
}
}
func TestNonStreamingTunnelAttemptFinalizesBodyUsage(t *testing.T) {
tests := []struct {
name string
endpoint string
status int
contentType string
body string
endKind iop.ProviderTunnelFrameKind
wantInput int
wantOutput int
wantReasoning int
wantCached int
wantProviderReported bool
wantHolderSet bool
}{
{
name: "chat completions success body",
endpoint: openAIRebuildEndpointChat,
status: http.StatusOK,
contentType: "application/json",
body: `{"choices":[{"message":{"content":"hello"}}],"usage":{"prompt_tokens":9,"completion_tokens":4,"prompt_tokens_details":{"cached_tokens":1},"completion_tokens_details":{"reasoning_tokens":2}}}`,
endKind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END,
wantInput: 9,
wantOutput: 4,
wantReasoning: 2,
wantCached: 1,
wantProviderReported: true,
wantHolderSet: true,
},
{
name: "responses API success body",
endpoint: openAIRebuildEndpointResponses,
status: http.StatusOK,
contentType: "application/json",
body: `{"output_text":"hello","usage":{"input_tokens":14,"output_tokens":7,"input_tokens_details":{"cached_tokens":3},"output_tokens_details":{"reasoning_tokens":5}}}`,
endKind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END,
wantInput: 14,
wantOutput: 7,
wantReasoning: 5,
wantCached: 3,
wantProviderReported: true,
wantHolderSet: true,
},
{
name: "chat completions provider error boundary",
endpoint: openAIRebuildEndpointChat,
status: http.StatusOK,
contentType: "application/json",
body: `{"error":{"message":"internal error"},"usage":{"prompt_tokens":5,"completion_tokens":2,"prompt_tokens_details":{"cached_tokens":0},"completion_tokens_details":{"reasoning_tokens":1}}}`,
endKind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_ERROR,
wantInput: 5,
wantOutput: 2,
wantReasoning: 1,
wantCached: 0,
wantProviderReported: true,
wantHolderSet: false,
},
{
name: "responses API provider error boundary",
endpoint: openAIRebuildEndpointResponses,
status: http.StatusOK,
contentType: "application/json",
body: `{"error":{"message":"internal error"},"usage":{"input_tokens":8,"output_tokens":3,"input_tokens_details":{"cached_tokens":2},"output_tokens_details":{"reasoning_tokens":0}}}`,
endKind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_ERROR,
wantInput: 8,
wantOutput: 3,
wantReasoning: 0,
wantCached: 2,
wantProviderReported: true,
wantHolderSet: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
frames := bufferedTunnelFrames(
&iop.ProviderTunnelFrame{
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START,
StatusCode: int32(tc.status),
Headers: map[string]string{"Content-Type": tc.contentType},
},
&iop.ProviderTunnelFrame{
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY,
Body: []byte(tc.body),
},
&iop.ProviderTunnelFrame{
Kind: tc.endKind,
End: tc.endKind == iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END,
},
)
stream := edgeservice.ProviderTunnelStream{Frames: frames}
assembler := &providerChatAssembler{streaming: false}
rewriter := newProviderModelRewriter(false, "request-model")
state := &openAITunnelCodecState{}
state.bindEndpoint(tc.endpoint)
src := newOpenAITunnelEndpointEventSource(stream, 5*time.Second, rewriter, assembler, tc.endpoint, state)
holder := &openAIStreamGateUsageHolder{}
attempt := &openAIAttemptUsage{}
tracking := &openAIStreamGateUsageTrackingTunnelSource{
openAITunnelEventSource: src,
usage: holder,
attempt: attempt,
}
ctx := context.Background()
for {
ev, err := tracking.NextEvent(ctx)
if err != nil || ev.Kind() == streamgate.EventKindTerminal || ev.Kind() == streamgate.EventKindProviderError {
break
}
}
attObs := attempt.snapshot()
if attObs.providerReported != tc.wantProviderReported {
t.Fatalf("attempt providerReported = %v, want %v", attObs.providerReported, tc.wantProviderReported)
}
if attObs.inputTokens != tc.wantInput || attObs.outputTokens != tc.wantOutput ||
attObs.reasoningTokens != tc.wantReasoning || attObs.cachedInputTokens != tc.wantCached {
t.Fatalf("attempt usage = %+v, want input=%d output=%d reasoning=%d cached=%d",
attObs, tc.wantInput, tc.wantOutput, tc.wantReasoning, tc.wantCached)
}
hObs := holder.get()
if tc.wantHolderSet {
if !hObs.providerReported {
t.Fatalf("holder providerReported = false, want true")
}
if hObs.inputTokens != tc.wantInput || hObs.outputTokens != tc.wantOutput ||
hObs.reasoningTokens != tc.wantReasoning || hObs.cachedInputTokens != tc.wantCached {
t.Fatalf("holder usage = %+v, want input=%d output=%d reasoning=%d cached=%d",
hObs, tc.wantInput, tc.wantOutput, tc.wantReasoning, tc.wantCached)
}
} else {
if hObs.providerReported {
t.Fatalf("holder providerReported = true, want false for non-terminal error")
}
}
})
}
}