@@ -7,9 +7,11 @@ import (
77 "fmt"
88 "log/slog"
99 "reflect"
10+ "regexp"
1011 "sort"
1112 "strconv"
1213 "strings"
14+ "sync"
1315
1416 policy "github.com/wso2/api-platform/sdk/core/policy/v1alpha2"
1517 "github.com/wso2/api-platform/sdk/core/utils"
@@ -568,7 +570,7 @@ func (a *AnalyticsPolicy) OnResponseBodyChunk(_ context.Context, ctx *policy.Res
568570 // Streaming responses are SSE; the last data event carries usage fields.
569571 tokenInfo , err := extractLLMProviderAnalyticsInfoFromBytes (
570572 template , ctx .RequestHeaders , ctx .ResponseHeaders ,
571- requestBodyBytes , accumulated ,
573+ requestBodyBytes , accumulated , ctx . RequestPath ,
572574 )
573575 if err != nil {
574576 slog .Warn ("Failed to extract LLM token info from streaming response" , "error" , err )
@@ -662,7 +664,7 @@ func extractLLMProviderAnalyticsInfo(template map[string]interface{}, ctx *polic
662664
663665 return extractLLMProviderAnalyticsInfoFromBytes (
664666 template , ctx .RequestHeaders , ctx .ResponseHeaders ,
665- requestBodyBytes , responseBodyBytes ,
667+ requestBodyBytes , responseBodyBytes , ctx . RequestPath ,
666668 )
667669}
668670
@@ -676,6 +678,7 @@ func extractLLMProviderAnalyticsInfoFromBytes(
676678 template map [string ]interface {},
677679 requestHeaders , responseHeaders * policy.Headers ,
678680 requestBodyBytes , responseBodyBytes []byte ,
681+ requestPath string ,
679682) (* LLMProviderAnalyticsInfo , error ) {
680683 var responseJSON map [string ]interface {}
681684 if len (responseBodyBytes ) > 0 {
@@ -693,14 +696,15 @@ func extractLLMProviderAnalyticsInfoFromBytes(
693696 _ = json .Unmarshal (requestBodyBytes , & requestJSON )
694697 }
695698
696- return extractLLMAnalyticsFromJSON (template , requestHeaders , responseHeaders , requestJSON , responseJSON )
699+ return extractLLMAnalyticsFromJSON (template , requestHeaders , responseHeaders , requestJSON , responseJSON , requestPath )
697700}
698701
699702// extractLLMAnalyticsFromJSON is the core extraction logic operating on pre-parsed JSON maps.
700703func extractLLMAnalyticsFromJSON (
701704 template map [string ]interface {},
702705 requestHeaders , responseHeaders * policy.Headers ,
703706 requestJSON , responseJSON map [string ]interface {},
707+ requestPath string ,
704708) (* LLMProviderAnalyticsInfo , error ) {
705709 if template == nil {
706710 return nil , fmt .Errorf ("template is nil" )
@@ -754,6 +758,14 @@ func extractLLMAnalyticsFromJSON(
754758 }
755759 }
756760 return nil , fmt .Errorf ("header %s not found" , identifier )
761+ case "pathparam" :
762+ // Model ids for providers like AWS Bedrock and Gemini live in the
763+ // request URL path (e.g. /model/{modelId}/converse), not the body or
764+ // a header. The identifier is a regex whose first capture group (when
765+ // present) is the value; otherwise the whole match is used. The path
766+ // is available for both buffered and streaming responses, so this
767+ // meters the model in either case.
768+ return extractPathParam (requestPath , identifier )
757769 default :
758770 return nil , fmt .Errorf ("unsupported location %s" , location )
759771 }
@@ -806,6 +818,48 @@ func extractLLMAnalyticsFromJSON(
806818 return info , nil
807819}
808820
821+ // pathParamRegexCache memoises compiled pathParam identifiers so the response
822+ // data path does not recompile a provider template's regex on every request.
823+ var pathParamRegexCache sync.Map // map[string]*regexp.Regexp
824+
825+ // extractPathParam applies a pathParam identifier (a regex) to the request URL
826+ // path and returns the first capture group when the pattern defines one,
827+ // otherwise the whole match. AWS Bedrock and Gemini carry the model id in the
828+ // path (e.g. /model/{modelId}/converse), which is why request/response model is
829+ // declared with `location: pathParam`. The path is present for both buffered
830+ // and streaming responses, so the model meters in either case. Go's regexp is
831+ // RE2 — identifiers must use a capture group, not lookaround.
832+ func extractPathParam (requestPath , pattern string ) (string , error ) {
833+ if requestPath == "" {
834+ return "" , fmt .Errorf ("request path not available" )
835+ }
836+ re , err := compilePathParamRegex (pattern )
837+ if err != nil {
838+ return "" , err
839+ }
840+ match := re .FindStringSubmatch (requestPath )
841+ if match == nil {
842+ return "" , fmt .Errorf ("pathParam identifier did not match request path" )
843+ }
844+ if len (match ) > 1 {
845+ return match [1 ], nil
846+ }
847+ return match [0 ], nil
848+ }
849+
850+ // compilePathParamRegex compiles (and caches) a pathParam identifier regex.
851+ func compilePathParamRegex (pattern string ) (* regexp.Regexp , error ) {
852+ if cached , ok := pathParamRegexCache .Load (pattern ); ok {
853+ return cached .(* regexp.Regexp ), nil
854+ }
855+ re , err := regexp .Compile (pattern )
856+ if err != nil {
857+ return nil , fmt .Errorf ("invalid pathParam identifier %q: %w" , pattern , err )
858+ }
859+ pathParamRegexCache .Store (pattern , re )
860+ return re , nil
861+ }
862+
809863// populateTokenAnalyticsMetadata copies LLM token fields into an analytics metadata map.
810864func populateTokenAnalyticsMetadata (analyticsMetadata map [string ]any , tokenInfo * LLMProviderAnalyticsInfo ) {
811865 if tokenInfo .PromptTokens != nil {
0 commit comments