// Package llm_response_parser implements the SlotOnResponse middleware // that decodes OpenAI- and Anthropic-shaped LLM responses (buffered or // streaming) and emits token usage and completion metadata. Provider // and model are read from the request-side metadata bag emitted by // llm_request_parser; without that context the middleware is a no-op. package llm_response_parser import ( "bytes" "compress/flate" "compress/gzip" "compress/zlib" "context" "io" "strconv" "strings" "unicode/utf8" "github.com/netbirdio/netbird/proxy/internal/llm" "github.com/netbirdio/netbird/proxy/internal/middleware" "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_guardrail" ) // ID is the registry identifier for this middleware. const ID = "llm_response_parser" const version = "1.0.0" // maxCompletionBytes is the rune-safe cap applied to the extracted // completion text before emitting it as metadata. const maxCompletionBytes = 3500 // maxDecodedBytes bounds the inflated size of a compressed response body // so a small gzip/deflate payload can't expand into a memory blow-up. The // captured input is already capped (per-direction body cap), so this only // bounds the decompression ratio; the parser is best-effort and tolerates a // truncated decode. const maxDecodedBytes = 16 << 20 var ( acceptedContentTypes = []string{"application/json", "text/event-stream"} metadataKeys = []string{ middleware.KeyLLMInputTokens, middleware.KeyLLMOutputTokens, middleware.KeyLLMTotalTokens, middleware.KeyLLMCachedInputTokens, middleware.KeyLLMCacheCreationTokens, middleware.KeyLLMResponseCompletion, } ) // config is the wire-side configuration for this middleware. RedactPii, when // true, runs PII redaction on the extracted completion text BEFORE it is // emitted as llm.response_completion — keeping the access-log row free of // emails / SSNs / phone numbers the model itself generated. CaptureCompletion // gates emission of the completion key entirely: a nil pointer preserves // legacy emission (so callers without the toggle aren't broken), an explicit // false suppresses the key so the access-log row carries token / cost facts // only. Both are sourced by the synthesiser from the account's redact_pii // and enable_prompt_collection toggles respectively. type config struct { RedactPii bool `json:"redact_pii,omitempty"` CaptureCompletion *bool `json:"capture_completion,omitempty"` } // Middleware implements middleware.Middleware. type Middleware struct { parsers []llm.Parser redactPii bool captureCompletion bool } // New constructs a configured Middleware instance. func New(cfg config) *Middleware { capture := true if cfg.CaptureCompletion != nil { capture = *cfg.CaptureCompletion } return &Middleware{parsers: llm.Parsers(), redactPii: cfg.RedactPii, captureCompletion: capture} } // ID returns the registry identifier. func (m *Middleware) ID() string { return ID } // Version returns the implementation version. func (m *Middleware) Version() string { return version } // Slot reports that the middleware runs after the upstream call. func (m *Middleware) Slot() middleware.Slot { return middleware.SlotOnResponse } // AcceptedContentTypes lists the response content types the middleware // inspects. func (m *Middleware) AcceptedContentTypes() []string { return append([]string(nil), acceptedContentTypes...) } // MetadataKeys returns the closed allowlist of keys this middleware // may emit. func (m *Middleware) MetadataKeys() []string { return append([]string(nil), metadataKeys...) } // MutationsSupported reports that this middleware never mutates the // response. func (m *Middleware) MutationsSupported() bool { return false } // Close releases any resources held by the middleware. The parser-set // is stateless so this is a no-op. func (m *Middleware) Close() error { return nil } // Invoke decodes the response body and emits token-usage and completion // metadata. The decision is always DecisionAllow; parse errors degrade // silently to omitted metadata rather than chain failures. func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middleware.Output, error) { out := &middleware.Output{Decision: middleware.DecisionAllow} if in == nil { return out, nil } provider := lookupKV(in.Metadata, middleware.KeyLLMProvider) if provider == "" { return out, nil } parser := m.parserByName(provider) if parser == nil { return out, nil } // Upstreams compress the response when the client negotiated it // (Claude Code sends Accept-Encoding: gzip). The transport leaves it // compressed because the request carried an explicit Accept-Encoding, // so the captured copy is gzip/deflate bytes — decompress it before // parsing or token usage is silently lost. The forwarded client // stream is untouched; this only affects our parse copy. body := decodeResponseBody(in.RespBody, headerLookup(in.RespHeaders, "Content-Encoding")) contentType := headerLookup(in.RespHeaders, "Content-Type") switch { case isEventStream(contentType), isAWSEventStream(contentType): out.Metadata = m.invokeStreaming(parser, body) case isJSON(contentType): out.Metadata = m.invokeBuffered(parser, in, contentType, body) } return out, nil } // invokeBuffered decodes a non-streaming JSON response body. Status // codes >= 400 short-circuit because providers don't include usage on // error responses. func (m *Middleware) invokeBuffered(parser llm.Parser, in *middleware.Input, contentType string, body []byte) []middleware.KV { if in.Status >= 400 { return nil } var md []middleware.KV usage, err := parser.ParseResponse(in.Status, contentType, body) if err == nil { md = appendUsage(md, usage) } if completion := truncateCompletion(parser.ExtractCompletion(in.Status, contentType, body)); completion != "" && m.captureCompletion { if m.redactPii { completion = llm_guardrail.RedactPII(completion) } md = append(md, middleware.KV{Key: middleware.KeyLLMResponseCompletion, Value: completion}) } return md } // invokeStreaming walks the buffered SSE prefix and accumulates token // deltas plus completion text. Truncated bodies are processed // best-effort; partial usage is preferred over no metadata. func (m *Middleware) invokeStreaming(parser llm.Parser, body []byte) []middleware.KV { if len(body) == 0 { return nil } usage, completion := accumulateStream(parser.ProviderName(), body) var md []middleware.KV if usage.InputTokens > 0 || usage.OutputTokens > 0 || usage.TotalTokens > 0 { md = appendUsage(md, usage) } if c := truncateCompletion(completion); c != "" && m.captureCompletion { if m.redactPii { c = llm_guardrail.RedactPII(c) } md = append(md, middleware.KV{Key: middleware.KeyLLMResponseCompletion, Value: c}) } return md } // parserByName returns the parser matching the provider label emitted // by llm_request_parser, or nil when none claims it. func (m *Middleware) parserByName(name string) llm.Parser { for _, p := range m.parsers { if p.ProviderName() == name { return p } } return nil } // appendUsage emits the three baseline token-count metadata keys plus // optional cached / cache-creation bucket counts when nonzero. Total // is computed when the provider omitted one but reported per-direction // counts; cache buckets are excluded from the legacy total because // llm.input_tokens already absorbs the OpenAI cached subset and the // sum-of-everything is a separate downstream concern. func appendUsage(md []middleware.KV, usage llm.Usage) []middleware.KV { total := usage.TotalTokens if total == 0 && (usage.InputTokens > 0 || usage.OutputTokens > 0) { total = usage.InputTokens + usage.OutputTokens } md = append(md, middleware.KV{Key: middleware.KeyLLMInputTokens, Value: strconv.FormatInt(usage.InputTokens, 10)}, middleware.KV{Key: middleware.KeyLLMOutputTokens, Value: strconv.FormatInt(usage.OutputTokens, 10)}, middleware.KV{Key: middleware.KeyLLMTotalTokens, Value: strconv.FormatInt(total, 10)}, ) if usage.CachedInputTokens > 0 { md = append(md, middleware.KV{ Key: middleware.KeyLLMCachedInputTokens, Value: strconv.FormatInt(usage.CachedInputTokens, 10), }) } if usage.CacheCreationTokens > 0 { md = append(md, middleware.KV{ Key: middleware.KeyLLMCacheCreationTokens, Value: strconv.FormatInt(usage.CacheCreationTokens, 10), }) } return md } // truncateCompletion clamps an extracted completion to maxCompletionBytes. // The cut is rune-safe so we never split a multi-byte UTF-8 sequence. func truncateCompletion(s string) string { if len(s) <= maxCompletionBytes { return s } cut := maxCompletionBytes for cut > 0 && !utf8.RuneStart(s[cut]) { cut-- } return s[:cut] } func lookupKV(kvs []middleware.KV, key string) string { for _, kv := range kvs { if kv.Key == key { return kv.Value } } return "" } func headerLookup(h []middleware.KV, name string) string { lower := strings.ToLower(name) for _, kv := range h { if strings.ToLower(kv.Key) == lower { return kv.Value } } return "" } func isEventStream(contentType string) bool { return strings.Contains(strings.ToLower(contentType), "text/event-stream") } // isAWSEventStream reports whether contentType is the AWS binary event-stream // framing used by Bedrock's streaming endpoints. func isAWSEventStream(contentType string) bool { return strings.Contains(strings.ToLower(contentType), "application/vnd.amazon.eventstream") } func isJSON(contentType string) bool { lower := strings.ToLower(contentType) return strings.Contains(lower, "application/json") || strings.Contains(lower, "+json") } // decodeResponseBody returns body decompressed per its Content-Encoding, // or the original bytes when the encoding is identity, unrecognised // (e.g. br — no stdlib decoder), or the body isn't actually compressed. // Decoding is best-effort: a truncated stream (capture hit the byte cap) // yields the decompressed prefix rather than an error, which is enough to // recover the leading message_start usage on Anthropic SSE. func decodeResponseBody(body []byte, contentEncoding string) []byte { enc := strings.ToLower(strings.TrimSpace(contentEncoding)) // Content-Encoding may list multiple codings; the last applied is // the outermost on the wire. if idx := strings.LastIndex(enc, ","); idx >= 0 { enc = strings.TrimSpace(enc[idx+1:]) } switch enc { case "", "identity": return body case "gzip", "x-gzip": zr, err := gzip.NewReader(bytes.NewReader(body)) if err != nil { return body } defer zr.Close() if out := readCapped(zr); len(out) > 0 { return out } return body case "deflate": // "deflate" on the wire is usually zlib-wrapped; fall back to raw // flate when there's no zlib header. if zr, err := zlib.NewReader(bytes.NewReader(body)); err == nil { defer zr.Close() if out := readCapped(zr); len(out) > 0 { return out } return body } fr := flate.NewReader(bytes.NewReader(body)) defer fr.Close() if out := readCapped(fr); len(out) > 0 { return out } return body default: return body } } // readCapped reads at most maxDecodedBytes from r, discarding any excess. // Best-effort: a read error returns whatever was decoded so far, which is // enough for the parser to recover leading usage events. func readCapped(r io.Reader) []byte { out, _ := io.ReadAll(io.LimitReader(r, maxDecodedBytes)) return out }