From b051dc8087c045c9af3e284bab1e81d55a029fcf Mon Sep 17 00:00:00 2001 From: "V. Feitoza" Date: Fri, 15 May 2026 10:56:02 -0300 Subject: [PATCH] fix(passthrough): track usage for non-streaming responses and extract model from opaque bodies Non-streaming passthrough responses were not logging usage data because the response body was copied directly to the client without parsing. Additionally, when the request body was not fully captured (chunked transfer), the model field was not extracted for opaque passthrough routes because the peek logic required both model and provider to apply selector hints. - Read non-streaming response body and extract usage via ExtractFromCachedResponseBody before forwarding to the client - Apply body selector hints for BodyModeOpaque when only model is found, ensuring passthrough routes populate PassthroughRouteInfo.Model even without a provider field in the request body --- internal/server/passthrough_support.go | 45 +++++++++++++++++++++++- internal/server/request_selector_peek.go | 5 ++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/internal/server/passthrough_support.go b/internal/server/passthrough_support.go index c91ad322..32ee63dd 100644 --- a/internal/server/passthrough_support.go +++ b/internal/server/passthrough_support.go @@ -311,8 +311,25 @@ func (s *passthroughService) proxyPassthroughResponse(c *echo.Context, providerT return nil } + body, err := io.ReadAll(resp.Body) + if err != nil { + return handleError(c, core.NewProviderError(providerType, http.StatusBadGateway, "failed to read provider passthrough response body", err)) + } + + workflow := core.GetWorkflow(c.Request().Context()) + if s.usageLogger != nil && s.usageLogger.Config().Enabled && (workflow == nil || workflow.UsageEnabled()) { + model := "" + if info != nil { + model = strings.TrimSpace(info.Model) + } + model = resolvedModelFromWorkflow(workflow, model) + requestID := requestIDFromContextOrHeader(c.Request()) + usagePath := strings.TrimSpace(c.Request().URL.Path) + s.logPassthroughNonStreamUsage(body, model, providerType, providerName, requestID, usagePath, c.Request().Context()) + } + c.Response().WriteHeader(resp.StatusCode) - if _, err := io.Copy(c.Response(), resp.Body); err != nil { + if _, err := c.Response().Write(body); err != nil { return err } if f, ok := c.Response().(http.Flusher); ok { @@ -320,3 +337,29 @@ func (s *passthroughService) proxyPassthroughResponse(c *echo.Context, providerT } return nil } + +func (s *passthroughService) logPassthroughNonStreamUsage(body []byte, model, providerType, providerName, requestID, endpoint string, ctx context.Context) { + if len(body) == 0 { + return + } + + auditPath := passthroughStreamAuditPath(endpoint, providerType, endpoint) + var pricingArgs []*core.ModelPricing + if s.pricingResolver != nil { + pricingProvider := strings.TrimSpace(providerName) + if pricingProvider == "" { + pricingProvider = strings.TrimSpace(providerType) + } + if p := s.pricingResolver.ResolvePricing(model, pricingProvider); p != nil { + pricingArgs = append(pricingArgs, p) + } + } + + entry := usage.ExtractFromCachedResponseBody(body, requestID, model, providerType, auditPath, "", pricingArgs...) + if entry == nil { + return + } + entry.ProviderName = strings.TrimSpace(providerName) + entry.UserPath = core.UserPathFromContext(ctx) + s.usageLogger.Write(entry) +} diff --git a/internal/server/request_selector_peek.go b/internal/server/request_selector_peek.go index c038b4c4..2c0bfe88 100644 --- a/internal/server/request_selector_peek.go +++ b/internal/server/request_selector_peek.go @@ -26,7 +26,10 @@ func seedRequestBodySelectorHints(req *http.Request, bodyMode core.BodyMode, env } hints := peekRequestBodySelectorHints(req, requestSelectorPeekLimit) - if !hints.parsed || !hints.complete { + if !hints.parsed && !hints.complete { + if bodyMode == core.BodyModeOpaque && hints.model != "" { + core.ApplyBodySelectorHints(env, hints.model, hints.provider, hints.stream) + } return } core.ApplyBodySelectorHints(env, hints.model, hints.provider, hints.stream)