Stream Gate 준비 실패에서도 요청 terminal 관측 계약을 지키고, direct provider identity 마이그레이션 누락으로 기존 검증과 운영 설정이 깨지지 않게 한다.
837 lines
37 KiB
Go
837 lines
37 KiB
Go
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.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.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",
|
|
}
|
|
// 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{"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", "route_model", "usage_attribution", "provider_id", "served_model", "endpoint", "response_mode"} {
|
|
if !containsString(usageReasoningEstimateLabelNames, want) {
|
|
t.Fatalf("reasoning estimate labels missing %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|