Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions gateway/examples/bedrock-openai-proxy.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# --------------------------------------------------------------------
# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
# --------------------------------------------------------------------

# --------------------------------------------------------------------
# OpenAI -> AWS Bedrock LLM Proxy (single-provider)
#
# Exposes an OpenAI-shaped /chat/completions endpoint that the
# `openai-to-bedrock-transformer` policy translates into Bedrock's Converse API format
# (path /model/{modelId}/converse[-stream], body rewrite) and forwards to the
# existing `bedrock-provider` upstream. For streaming requests the policy
# decodes Bedrock's binary Amazon event-stream and re-emits OpenAI SSE, so
# OpenAI clients get `data: {...}` chunks terminated by `data: [DONE]`.
#
# Single-provider mode: there is no router in front of the policy, so nothing
# sets metadata["selected_provider"] and the translator runs on every request.
#
# The provider must already be deployed (see bedrock-provider.yaml) with its
# Bearer key on upstream.auth (the policy does NOT inject the Bedrock key).
# The provider example's api-key-auth policy expects the proxy to send the
# issued provider loopback key in X-API-Key.
# --------------------------------------------------------------------

apiVersion: gateway.api-platform.wso2.com/v1
kind: LlmProxy
metadata:
name: openai-to-bedrock
spec:
displayName: OpenAI to Bedrock Proxy
version: v1.0
context: /openai-bedrock
provider:
id: bedrock-provider
auth:
type: api-key
header: X-API-Key
value: REPLACE_WITH_BEDROCK_PROVIDER_LOOPBACK_KEY
policies:
- name: openai-to-bedrock-transformer
version: v1
paths:
- path: /chat/completions
methods: [POST]
params:
# Bedrock puts the model id in the URL path; the translator builds
# /model/<model>/converse[-stream] from this param. Use an inference
# profile id your key can invoke (e.g. us.* / eu.*).
model: us.amazon.nova-lite-v1:0
73 changes: 73 additions & 0 deletions gateway/examples/bedrock-provider.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# --------------------------------------------------------------------
# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
# --------------------------------------------------------------------

# --------------------------------------------------------------------
# AWS Bedrock LLM Provider Configuration (native Converse API)
#
# Uses the built-in `awsbedrock` template and points to the region-specific
# Bedrock runtime endpoint. AWS Bedrock now supports Bearer-token auth
# (Amazon Bedrock API keys) in addition to the legacy SigV4 flow -- this
# provider uses the Bearer key. Replace the placeholder with a real key and
# set the URL to the region your key and model access live in.
#
# The model id is carried in the URL path (/model/{modelId}/converse), so the
# awsbedrock template extracts request/response model via location: pathParam
# (a RE2 capture group), for both /converse and /converse-stream.
#
# Auth: upstream.auth is the gateway -> Bedrock vendor auth. The header is
# Authorization and the value is sent verbatim, so the "Bearer " prefix MUST
# be included here (the portal auto-prepends it; raw YAML does not). Proxies in
# front of this provider inherit this vendor auth automatically.
# --------------------------------------------------------------------

apiVersion: gateway.api-platform.wso2.com/v1
kind: LlmProvider
metadata:
name: bedrock-provider
spec:
displayName: AWS Bedrock Provider
version: v1.0
template: awsbedrock
context: /bedrock
upstream:
# Match the region your key/model access is in. For the no-AWS mock
# (bedrock-mock.py), use: http://host.docker.internal:9010
url: https://bedrock-runtime.us-east-1.amazonaws.com
auth:
type: api-key
header: Authorization
# Bedrock expects: Authorization: Bearer <key> -- sent verbatim, so the
# "Bearer " prefix MUST be included here. The mock ignores the value.
value: Bearer REPLACE_WITH_BEDROCK_API_KEY
policies:
- name: api-key-auth
version: v1
paths:
- path: /model/{modelId}/converse
methods: [POST]
params:
key: X-API-Key
in: header
- path: /model/{modelId}/converse-stream
methods: [POST]
params:
key: X-API-Key
in: header
accessControl:
mode: deny_all
exceptions:
- path: /model/{modelId}/converse
methods: [POST]
- path: /model/{modelId}/converse-stream
methods: [POST]

# The proxy in front of this provider authenticates over the internal loopback
# route with an API key issued for this LlmProvider (see bedrock-testing.md,
# "Issue a consumer key"). Set the issued value as the proxy's provider.auth.value.
12 changes: 12 additions & 0 deletions gateway/examples/openai-multi-provider-proxy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,16 @@ spec:
params:
model: gemini-2.5-flash
apiVersion: v1beta
- id: bedrock-provider
auth:
type: api-key
header: X-API-Key
value: REPLACE_WITH_BEDROCK_PROVIDER_LOOPBACK_KEY
transformer:
type: openai-to-bedrock-transformer
version: v1
params:
model: us.amazon.nova-lite-v1:0
policies:
- name: api-key-auth
version: v1
Expand Down Expand Up @@ -117,6 +127,8 @@ spec:
provider: mistral-provider
- headerValue: gemini
provider: gemini-provider
- headerValue: bedrock
provider: bedrock-provider

# - name: token-based-ratelimit
# version: v1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ spec:
identifier: $.usage.totalTokens
requestModel:
location: pathParam
identifier: (?<=model/)[a-zA-Z0-9.:-]+(?=/)
identifier: model/([A-Za-z0-9.:-]+)/
responseModel:
location: pathParam
identifier: (?<=model/)[a-zA-Z0-9.:-]+(?=/)
identifier: model/([A-Za-z0-9.:-]+)/
15 changes: 11 additions & 4 deletions gateway/gateway-controller/pkg/utils/llm_transformer.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,15 +179,22 @@ func (t *LLMProviderTransformer) transformProxy(proxy *api.LLMProxyConfiguration
if err != nil {
return nil, fmt.Errorf("failed to get context for additional provider '%s': %w", ap.Id, err)
}
addURL := fmt.Sprintf("%s://%s:%d%s",
constants.SchemeHTTP, constants.LocalhostIP, t.routerConfig.ListenerPort, addCtx)
defs = append(defs, api.UpstreamDefinition{
// Named upstream definition URLs are host-only. Keep the provider context
// in basePath so dynamic provider routing does not drop it and send the
// loopback request to the proxy listener root.
addURL := fmt.Sprintf("%s://%s:%d",
constants.SchemeHTTP, constants.LocalhostIP, t.routerConfig.ListenerPort)
def := api.UpstreamDefinition{
Name: name,
Upstreams: []struct {
Url string `json:"url" yaml:"url"`
Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"`
}{{Url: addURL}},
})
}
if addCtx != "" && addCtx != constants.BASE_PATH {
def.BasePath = &addCtx
}
defs = append(defs, def)
}
spec.UpstreamDefinitions = &defs
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ func TestLLMProviderTransformer_TransformProxy_AdditionalProviderAuthIsCondition
require.NotNil(t, result.Spec.UpstreamDefinitions)
require.Len(t, *result.Spec.UpstreamDefinitions, 1)
assert.Equal(t, "anthropic-provider", (*result.Spec.UpstreamDefinitions)[0].Name)
require.NotNil(t, (*result.Spec.UpstreamDefinitions)[0].BasePath)
assert.Equal(t, "/anthropic-provider", *(*result.Spec.UpstreamDefinitions)[0].BasePath)
require.Len(t, (*result.Spec.UpstreamDefinitions)[0].Upstreams, 1)
assert.Equal(t, "http://127.0.0.1:8080", (*result.Spec.UpstreamDefinitions)[0].Upstreams[0].Url)

var chatOp *api.Operation
for i := range result.Spec.Operations {
Expand Down
60 changes: 57 additions & 3 deletions gateway/system-policies/analytics/analytics.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import (
"fmt"
"log/slog"
"reflect"
"regexp"
"sort"
"strconv"
"strings"
"sync"

policy "github.com/wso2/api-platform/sdk/core/policy/v1alpha2"
"github.com/wso2/api-platform/sdk/core/utils"
Expand Down Expand Up @@ -568,7 +570,7 @@ func (a *AnalyticsPolicy) OnResponseBodyChunk(_ context.Context, ctx *policy.Res
// Streaming responses are SSE; the last data event carries usage fields.
tokenInfo, err := extractLLMProviderAnalyticsInfoFromBytes(
template, ctx.RequestHeaders, ctx.ResponseHeaders,
requestBodyBytes, accumulated,
requestBodyBytes, accumulated, ctx.RequestPath,
)
if err != nil {
slog.Warn("Failed to extract LLM token info from streaming response", "error", err)
Expand Down Expand Up @@ -662,7 +664,7 @@ func extractLLMProviderAnalyticsInfo(template map[string]interface{}, ctx *polic

return extractLLMProviderAnalyticsInfoFromBytes(
template, ctx.RequestHeaders, ctx.ResponseHeaders,
requestBodyBytes, responseBodyBytes,
requestBodyBytes, responseBodyBytes, ctx.RequestPath,
)
}

Expand All @@ -676,6 +678,7 @@ func extractLLMProviderAnalyticsInfoFromBytes(
template map[string]interface{},
requestHeaders, responseHeaders *policy.Headers,
requestBodyBytes, responseBodyBytes []byte,
requestPath string,
) (*LLMProviderAnalyticsInfo, error) {
var responseJSON map[string]interface{}
if len(responseBodyBytes) > 0 {
Expand All @@ -693,14 +696,15 @@ func extractLLMProviderAnalyticsInfoFromBytes(
_ = json.Unmarshal(requestBodyBytes, &requestJSON)
}

return extractLLMAnalyticsFromJSON(template, requestHeaders, responseHeaders, requestJSON, responseJSON)
return extractLLMAnalyticsFromJSON(template, requestHeaders, responseHeaders, requestJSON, responseJSON, requestPath)
}

// extractLLMAnalyticsFromJSON is the core extraction logic operating on pre-parsed JSON maps.
func extractLLMAnalyticsFromJSON(
template map[string]interface{},
requestHeaders, responseHeaders *policy.Headers,
requestJSON, responseJSON map[string]interface{},
requestPath string,
) (*LLMProviderAnalyticsInfo, error) {
if template == nil {
return nil, fmt.Errorf("template is nil")
Expand Down Expand Up @@ -754,6 +758,14 @@ func extractLLMAnalyticsFromJSON(
}
}
return nil, fmt.Errorf("header %s not found", identifier)
case "pathparam":
// Model ids for providers like AWS Bedrock and Gemini live in the
// request URL path (e.g. /model/{modelId}/converse), not the body or
// a header. The identifier is a regex whose first capture group (when
// present) is the value; otherwise the whole match is used. The path
// is available for both buffered and streaming responses, so this
// meters the model in either case.
return extractPathParam(requestPath, identifier)
default:
return nil, fmt.Errorf("unsupported location %s", location)
}
Expand Down Expand Up @@ -806,6 +818,48 @@ func extractLLMAnalyticsFromJSON(
return info, nil
}

// pathParamRegexCache memoises compiled pathParam identifiers so the response
// data path does not recompile a provider template's regex on every request.
var pathParamRegexCache sync.Map // map[string]*regexp.Regexp

// extractPathParam applies a pathParam identifier (a regex) to the request URL
// path and returns the first capture group when the pattern defines one,
// otherwise the whole match. AWS Bedrock and Gemini carry the model id in the
// path (e.g. /model/{modelId}/converse), which is why request/response model is
// declared with `location: pathParam`. The path is present for both buffered
// and streaming responses, so the model meters in either case. Go's regexp is
// RE2 — identifiers must use a capture group, not lookaround.
func extractPathParam(requestPath, pattern string) (string, error) {
if requestPath == "" {
return "", fmt.Errorf("request path not available")
}
re, err := compilePathParamRegex(pattern)
if err != nil {
return "", err
}
match := re.FindStringSubmatch(requestPath)
if match == nil {
return "", fmt.Errorf("pathParam identifier did not match request path")
}
if len(match) > 1 {
return match[1], nil
}
return match[0], nil
}

// compilePathParamRegex compiles (and caches) a pathParam identifier regex.
func compilePathParamRegex(pattern string) (*regexp.Regexp, error) {
if cached, ok := pathParamRegexCache.Load(pattern); ok {
return cached.(*regexp.Regexp), nil
}
re, err := regexp.Compile(pattern)
if err != nil {
return nil, fmt.Errorf("invalid pathParam identifier %q: %w", pattern, err)
}
pathParamRegexCache.Store(pattern, re)
return re, nil
}

// populateTokenAnalyticsMetadata copies LLM token fields into an analytics metadata map.
func populateTokenAnalyticsMetadata(analyticsMetadata map[string]any, tokenInfo *LLMProviderAnalyticsInfo) {
if tokenInfo.PromptTokens != nil {
Expand Down
Loading