From ef36bb09e203ec07c3ca09f8b59aa575db5caaf5 Mon Sep 17 00:00:00 2001 From: Takeshi Yoneda Date: Fri, 31 Jan 2025 11:27:40 -0800 Subject: [PATCH 01/40] test: removes apischema coverage exception (#255) **Commit Message**: This removes the test coverage exception of apischema package by adding more unit tests. Signed-off-by: Takeshi Yoneda Signed-off-by: Eric Mariasis --- .testcoverage.yml | 7 +- internal/apischema/awsbedrock/awsbedrock.go | 11 -- internal/apischema/openai/openai_test.go | 122 +++++++++++++++++++- 3 files changed, 123 insertions(+), 17 deletions(-) diff --git a/.testcoverage.yml b/.testcoverage.yml index a677b7606..9ff29f23d 100644 --- a/.testcoverage.yml +++ b/.testcoverage.yml @@ -12,10 +12,13 @@ threshold: exclude: paths: - - ^api/ + # Examples are not part of the main codebase. - ^examples/ - - apischema/ + # Main functions are always tested with integration tests. - cmd/ + # Generated code should not be tested. + - zz_generated.deepcopy.go + # This is the test library. - tests/internal/envtest.go # TODO: Remove this exclusion. - internal/controller/controller.go diff --git a/internal/apischema/awsbedrock/awsbedrock.go b/internal/apischema/awsbedrock/awsbedrock.go index e8b1f31ef..104bda9e2 100644 --- a/internal/apischema/awsbedrock/awsbedrock.go +++ b/internal/apischema/awsbedrock/awsbedrock.go @@ -1,10 +1,5 @@ package awsbedrock -import ( - "encoding/json" - "strings" -) - const ( // StopReasonEndTurn is a StopReason enum value. StopReasonEndTurn = "end_turn" @@ -392,12 +387,6 @@ type ConverseStreamEvent struct { Start *ContentBlockStart `json:"start,omitempty"` } -// String implements fmt.Stringer. -func (c ConverseStreamEvent) String() string { - buf, _ := json.Marshal(c) - return strings.ReplaceAll(string(buf), ",", ", ") -} - // ConverseStreamEventContentBlockDelta is defined in the AWS Bedrock API: // https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ContentBlockDelta.html type ConverseStreamEventContentBlockDelta struct { diff --git a/internal/apischema/openai/openai_test.go b/internal/apischema/openai/openai_test.go index 927822de9..431a0e654 100644 --- a/internal/apischema/openai/openai_test.go +++ b/internal/apischema/openai/openai_test.go @@ -7,13 +7,93 @@ import ( "github.com/google/go-cmp/cmp" "github.com/openai/openai-go" "github.com/stretchr/testify/require" + "k8s.io/utils/ptr" ) +func TestOpenAIChatCompletionContentPartUserUnionParamUnmarshal(t *testing.T) { + for _, tc := range []struct { + name string + in []byte + out *ChatCompletionContentPartUserUnionParam + expErr string + }{ + { + name: "text", + in: []byte(`{ +"type": "text", +"text": "what do you see in this image" +}`), + out: &ChatCompletionContentPartUserUnionParam{ + TextContent: &ChatCompletionContentPartTextParam{ + Type: string(ChatCompletionContentPartTextTypeText), + Text: "what do you see in this image", + }, + }, + }, + { + name: "image url", + in: []byte(`{ +"type": "image_url", +"image_url": {"url": "https://example.com/image.jpg"} +}`), + out: &ChatCompletionContentPartUserUnionParam{ + ImageContent: &ChatCompletionContentPartImageParam{ + Type: ChatCompletionContentPartImageTypeImageURL, + ImageURL: ChatCompletionContentPartImageImageURLParam{ + URL: "https://example.com/image.jpg", + }, + }, + }, + }, + { + name: "input audio", + in: []byte(`{ +"type": "input_audio", +"input_audio": {"data": "somebinarydata"} +}`), + out: &ChatCompletionContentPartUserUnionParam{ + InputAudioContent: &ChatCompletionContentPartInputAudioParam{ + Type: ChatCompletionContentPartInputAudioTypeInputAudio, + InputAudio: ChatCompletionContentPartInputAudioInputAudioParam{ + Data: "somebinarydata", + }, + }, + }, + }, + { + name: "type not exist", + in: []byte(`{}`), + expErr: "chat content does not have type", + }, + { + name: "unknown type", + in: []byte(`{ +"type": "unknown" +}`), + expErr: "unknown ChatCompletionContentPartUnionParam type: unknown", + }, + } { + t.Run(tc.name, func(t *testing.T) { + var contentPart ChatCompletionContentPartUserUnionParam + err := json.Unmarshal(tc.in, &contentPart) + if tc.expErr != "" { + require.ErrorContains(t, err, tc.expErr) + return + } + require.NoError(t, err) + if !cmp.Equal(&contentPart, tc.out) { + t.Errorf("UnmarshalOpenAIRequest(), diff(got, expected) = %s\n", cmp.Diff(&contentPart, tc.out)) + } + }) + } +} + func TestOpenAIChatCompletionMessageUnmarshal(t *testing.T) { for _, tc := range []struct { - name string - in []byte - out *ChatCompletionRequest + name string + in []byte + out *ChatCompletionRequest + expErr string }{ { name: "basic test", @@ -21,7 +101,11 @@ func TestOpenAIChatCompletionMessageUnmarshal(t *testing.T) { "messages": [ {"role": "system", "content": "you are a helpful assistant"}, {"role": "developer", "content": "you are a helpful dev assistant"}, - {"role": "user", "content": "what do you see in this image"}]}`), + {"role": "user", "content": "what do you see in this image"}, + {"role": "tool", "content": "some tool", "tool_call_id": "123"}, + {"role": "assistant", "content": {"text": "you are a helpful assistant"}} + ]} +`), out: &ChatCompletionRequest{ Model: "gpu-o4", Messages: []ChatCompletionMessageParamUnion{ @@ -52,6 +136,21 @@ func TestOpenAIChatCompletionMessageUnmarshal(t *testing.T) { }, Type: ChatMessageRoleUser, }, + { + Value: ChatCompletionToolMessageParam{ + Role: ChatMessageRoleTool, + ToolCallID: "123", + Content: StringOrArray{Value: "some tool"}, + }, + Type: ChatMessageRoleTool, + }, + { + Value: ChatCompletionAssistantMessageParam{ + Role: ChatMessageRoleAssistant, + Content: ChatCompletionAssistantMessageParamContent{Text: ptr.To("you are a helpful assistant")}, + }, + Type: ChatMessageRoleAssistant, + }, }, }, }, @@ -109,10 +208,25 @@ func TestOpenAIChatCompletionMessageUnmarshal(t *testing.T) { }, }, }, + { + name: "no role", + in: []byte(`{"model": "gpu-o4","messages": [{}]}`), + expErr: "chat message does not have role", + }, + { + name: "unknown role", + in: []byte(`{"model": "gpu-o4", + "messages": [{"role": "some-funky", "content": [{"text": "what do you see in this image", "type": "text"}]}]}`), + expErr: "unknown ChatCompletionMessageParam type: some-funky", + }, } { t.Run(tc.name, func(t *testing.T) { var chatCompletion ChatCompletionRequest err := json.Unmarshal(tc.in, &chatCompletion) + if tc.expErr != "" { + require.ErrorContains(t, err, tc.expErr) + return + } require.NoError(t, err) if !cmp.Equal(&chatCompletion, tc.out) { t.Errorf("UnmarshalOpenAIRequest(), diff(got, expected) = %s\n", cmp.Diff(&chatCompletion, tc.out)) From 5cb2bb5968fb73c25f1012425945e7f3f8b205fa Mon Sep 17 00:00:00 2001 From: Takeshi Yoneda Date: Fri, 31 Jan 2025 13:10:01 -0800 Subject: [PATCH 02/40] ci: upgrades Envoy Gateway to v1.3.0 (#259) **Commit Message**: This upgrades the Envoy Gateway version used in the end to end tests. Also, this changes the Envoy container tag to use "-latest" without specifying the patch version. --------- Signed-off-by: Takeshi Yoneda Signed-off-by: Eric Mariasis --- .github/workflows/style.yaml | 1 - .github/workflows/tests.yaml | 10 +++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/style.yaml b/.github/workflows/style.yaml index f7a9b3d5e..70317f30a 100644 --- a/.github/workflows/style.yaml +++ b/.github/workflows/style.yaml @@ -9,7 +9,6 @@ on: jobs: style: - if: github.event_name == 'pull_request' || github.event_name == 'push' name: Check runs-on: ubuntu-latest steps: diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 803699879..5bb9015b5 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -98,8 +98,8 @@ jobs: fail-fast: false matrix: include: - - name: v1.33.0 - envoy_version: envoyproxy/envoy:v1.33.0 + - name: v1.33 + envoy_version: envoyproxy/envoy:v1.33-latest - name: latest envoy_version: envoyproxy/envoy-dev:latest runs-on: ubuntu-latest @@ -145,8 +145,8 @@ jobs: fail-fast: false matrix: include: - - name: v1.3.0-rc.1 - envoy_gateway_version: v1.3.0-rc.1 + - name: v1.3.0 + envoy_gateway_version: v1.3.0 - name: latest envoy_gateway_version: v0.0.0-latest steps: @@ -185,7 +185,7 @@ jobs: needs: [unittest, test_cel_validation, test_controller, test_extproc, test_e2e] uses: ./.github/workflows/docker_builds_template.yaml - push_helm: + helm_push: name: Push Helm chart # Only push the Helm chart to the GHR when merged into the main branch. if: github.event_name == 'push' From bc44655009d50fc3b3dcf0b3521554172fc46172 Mon Sep 17 00:00:00 2001 From: ericmariasis Date: Sun, 2 Feb 2025 16:47:36 -0500 Subject: [PATCH 03/40] updates for better headers and a back to top button Signed-off-by: ericmariasis Signed-off-by: Eric Mariasis --- site/crd-ref-docs/config.yaml | 5 +++++ site/crd-ref-docs/templates/gv_details.tpl | 9 ++++++--- site/crd-ref-docs/templates/gv_list.tpl | 6 +++--- site/crd-ref-docs/templates/type.tpl | 2 +- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/site/crd-ref-docs/config.yaml b/site/crd-ref-docs/config.yaml index 72798535c..7fcc524b4 100644 --- a/site/crd-ref-docs/config.yaml +++ b/site/crd-ref-docs/config.yaml @@ -34,3 +34,8 @@ render: - name: ExtProc package: github.com/envoyproxy/gateway/api/v1alpha1 link: https://gateway.envoyproxy.io/docs/api/extension_types/#extproc + +navigation: + includeTOC: true + includeBackToTopLinks: true + \ No newline at end of file diff --git a/site/crd-ref-docs/templates/gv_details.tpl b/site/crd-ref-docs/templates/gv_details.tpl index 30ad0d751..235ef7728 100644 --- a/site/crd-ref-docs/templates/gv_details.tpl +++ b/site/crd-ref-docs/templates/gv_details.tpl @@ -1,14 +1,15 @@ {{- define "gvDetails" -}} {{- $gv := . -}} -## {{ $gv.GroupVersionString }} + +# {{ $gv.GroupVersionString }} {{ $gv.Doc }} {{- if $gv.Kinds }} -### Resource Types +## Resource Types {{- range $gv.SortedKinds }} -- {{ $gv.TypeForKind . | markdownRenderTypeLink }} +- {{ markdownRenderTypeLink ($gv.TypeForKind .) }} {{- end }} {{ end }} @@ -16,4 +17,6 @@ {{ template "type" . }} {{ end }} +[Back to Packages](#api_references) + {{- end -}} diff --git a/site/crd-ref-docs/templates/gv_list.tpl b/site/crd-ref-docs/templates/gv_list.tpl index d31129ed7..57a1e6d5c 100644 --- a/site/crd-ref-docs/templates/gv_list.tpl +++ b/site/crd-ref-docs/templates/gv_list.tpl @@ -1,15 +1,15 @@ {{- define "gvList" -}} {{- $groupVersions := . -}} - --- id: api_references title: API Reference --- -## Packages + +# Packages {{- range $groupVersions }} -- {{ markdownRenderGVLink . }} +- [{{ .GroupVersionString }}](#{{ lower (replace .GroupVersionString " " "-" ) }}) {{- end }} {{ range $groupVersions }} diff --git a/site/crd-ref-docs/templates/type.tpl b/site/crd-ref-docs/templates/type.tpl index 3fb6832bb..be79192e5 100644 --- a/site/crd-ref-docs/templates/type.tpl +++ b/site/crd-ref-docs/templates/type.tpl @@ -2,7 +2,7 @@ {{- $type := . -}} {{- if markdownShouldRenderType $type -}} -#### {{ $type.Name }} +## {{ $type.Name }} {{ if $type.IsAlias }}_Underlying type:_ _{{ markdownRenderTypeLink $type.UnderlyingType }}_{{ end }} From 91336ea41e962e4e8b0386769408ecf5c1e20ee0 Mon Sep 17 00:00:00 2001 From: ericmariasis Date: Sun, 2 Feb 2025 16:51:11 -0500 Subject: [PATCH 04/40] fixing whitespace in config.yaml Signed-off-by: ericmariasis Signed-off-by: Eric Mariasis --- site/crd-ref-docs/config.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/site/crd-ref-docs/config.yaml b/site/crd-ref-docs/config.yaml index 7fcc524b4..5f16ff33f 100644 --- a/site/crd-ref-docs/config.yaml +++ b/site/crd-ref-docs/config.yaml @@ -38,4 +38,3 @@ render: navigation: includeTOC: true includeBackToTopLinks: true - \ No newline at end of file From 4ed8aff66c9d198d624acfada7f196a191b75cdf Mon Sep 17 00:00:00 2001 From: Eric Mariasis Date: Sun, 2 Feb 2025 18:11:38 -0500 Subject: [PATCH 05/40] ran precommit Signed-off-by: Eric Mariasis --- site/docs/api.md | 60 ++++++++++++++++++++++++++---------------------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/site/docs/api.md b/site/docs/api.md index 6680ef432..9a6346b35 100644 --- a/site/docs/api.md +++ b/site/docs/api.md @@ -3,17 +3,19 @@ id: api_references title: API Reference --- -## Packages -- [aigateway.envoyproxy.io/v1alpha1](#aigatewayenvoyproxyiov1alpha1) + +# Packages +- [aigateway.envoyproxy.io/v1alpha1](#-) -## aigateway.envoyproxy.io/v1alpha1 + +# aigateway.envoyproxy.io/v1alpha1 Package v1alpha1 contains API schema definitions for the aigateway.envoyproxy.io API group. -### Resource Types +## Resource Types - [AIGatewayRoute](#aigatewayroute) - [AIGatewayRouteList](#aigatewayroutelist) - [AIServiceBackend](#aiservicebackend) @@ -23,7 +25,7 @@ API group. -#### AIGatewayFilterConfig +## AIGatewayFilterConfig @@ -38,7 +40,7 @@ _Appears in:_ | `externalProcess` | _[AIGatewayFilterConfigExternalProcess](#aigatewayfilterconfigexternalprocess)_ | false | ExternalProcess is the configuration for the external process filter.
This is optional, and if not set, the default values of Deployment spec will be used. | -#### AIGatewayFilterConfigExternalProcess +## AIGatewayFilterConfigExternalProcess @@ -53,7 +55,7 @@ _Appears in:_ | `resources` | _[ResourceRequirements](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#resourcerequirements-v1-core)_ | false | Resources required by the external process container.
More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ | -#### AIGatewayFilterConfigType +## AIGatewayFilterConfigType _Underlying type:_ _string_ @@ -68,7 +70,7 @@ _Appears in:_ | `DynamicModule` | | -#### AIGatewayRoute +## AIGatewayRoute @@ -99,7 +101,7 @@ _Appears in:_ | `spec` | _[AIGatewayRouteSpec](#aigatewayroutespec)_ | true | Spec defines the details of the AIGatewayRoute. | -#### AIGatewayRouteList +## AIGatewayRouteList @@ -115,7 +117,7 @@ AIGatewayRouteList contains a list of AIGatewayRoute. | `items` | _[AIGatewayRoute](#aigatewayroute) array_ | true | | -#### AIGatewayRouteRule +## AIGatewayRouteRule @@ -130,7 +132,7 @@ _Appears in:_ | `matches` | _[AIGatewayRouteRuleMatch](#aigatewayrouterulematch) array_ | false | Matches is the list of AIGatewayRouteMatch that this rule will match the traffic to.
This is a subset of the HTTPRouteMatch in the Gateway API. See for the details:
https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io%2fv1.HTTPRouteMatch | -#### AIGatewayRouteRuleBackendRef +## AIGatewayRouteRuleBackendRef @@ -145,7 +147,7 @@ _Appears in:_ | `weight` | _integer_ | false | Weight is the weight of the AIServiceBackend. This is exactly the same as the weight in
the BackendRef in the Gateway API. See for the details:
https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io%2fv1.BackendRef

Default is 1. | -#### AIGatewayRouteRuleMatch +## AIGatewayRouteRuleMatch @@ -159,7 +161,7 @@ _Appears in:_ | `headers` | _HTTPHeaderMatch array_ | false | Headers specifies HTTP request header matchers. See HeaderMatch in the Gateway API for the details:
https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io%2fv1.HTTPHeaderMatch

Currently, only the exact header matching is supported. | -#### AIGatewayRouteSpec +## AIGatewayRouteSpec @@ -177,7 +179,7 @@ _Appears in:_ | `llmRequestCosts` | _[LLMRequestCost](#llmrequestcost) array_ | false | LLMRequestCosts specifies how to capture the cost of the LLM-related request, notably the token usage.
The AI Gateway filter will capture each specified number and store it in the Envoy's dynamic
metadata per HTTP request. The namespaced key is "io.envoy.ai_gateway",

For example, let's say we have the following LLMRequestCosts configuration:

llmRequestCosts:
- metadataKey: llm_input_token
type: InputToken
- metadataKey: llm_output_token
type: OutputToken
- metadataKey: llm_total_token
type: TotalToken

Then, with the following BackendTrafficPolicy of Envoy Gateway, you can have three
rate limit buckets for each unique x-user-id header value. One bucket is for the input token,
the other is for the output token, and the last one is for the total token.
Each bucket will be reduced by the corresponding token usage captured by the AI Gateway filter.

apiVersion: gateway.envoyproxy.io/v1alpha1
kind: BackendTrafficPolicy
metadata:
name: some-example-token-rate-limit
namespace: default
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: usage-rate-limit
rateLimit:
type: Global
global:
rules:
- clientSelectors:
# Do the rate limiting based on the x-user-id header.
- headers:
- name: x-user-id
type: Distinct
limit:
# Configures the number of "tokens" allowed per hour.
requests: 10000
unit: Hour
cost:
request:
from: Number
# Setting the request cost to zero allows to only check the rate limit budget,
# and not consume the budget on the request path.
number: 0
# This specifies the cost of the response retrieved from the dynamic metadata set by the AI Gateway filter.
# The extracted value will be used to consume the rate limit budget, and subsequent requests will be rate limited
# if the budget is exhausted.
response:
from: Metadata
metadata:
namespace: io.envoy.ai_gateway
key: llm_input_token
- clientSelectors:
- headers:
- name: x-user-id
type: Distinct
limit:
requests: 10000
unit: Hour
cost:
request:
from: Number
number: 0
response:
from: Metadata
metadata:
namespace: io.envoy.ai_gateway
key: llm_output_token
- clientSelectors:
- headers:
- name: x-user-id
type: Distinct
limit:
requests: 10000
unit: Hour
cost:
request:
from: Number
number: 0
response:
from: Metadata
metadata:
namespace: io.envoy.ai_gateway
key: llm_total_token | -#### AIServiceBackend +## AIServiceBackend @@ -203,7 +205,7 @@ _Appears in:_ | `spec` | _[AIServiceBackendSpec](#aiservicebackendspec)_ | true | Spec defines the details of AIServiceBackend. | -#### AIServiceBackendList +## AIServiceBackendList @@ -219,7 +221,7 @@ AIServiceBackendList contains a list of AIServiceBackends. | `items` | _[AIServiceBackend](#aiservicebackend) array_ | true | | -#### AIServiceBackendSpec +## AIServiceBackendSpec @@ -235,7 +237,7 @@ _Appears in:_ | `backendSecurityPolicyRef` | _[LocalObjectReference](#localobjectreference)_ | false | BackendSecurityPolicyRef is the name of the BackendSecurityPolicy resources this backend
is being attached to. | -#### APISchema +## APISchema _Underlying type:_ _string_ @@ -250,7 +252,7 @@ _Appears in:_ | `AWSBedrock` | APISchemaAWSBedrock is the AWS Bedrock schema.
https://docs.aws.amazon.com/bedrock/latest/APIReference/API_Operations_Amazon_Bedrock_Runtime.html
| -#### AWSCredentialsFile +## AWSCredentialsFile @@ -266,7 +268,7 @@ _Appears in:_ | `profile` | _string_ | true | Profile is the profile to use in the credentials file. | -#### AWSOIDCExchangeToken +## AWSOIDCExchangeToken @@ -285,7 +287,7 @@ _Appears in:_ | `awsRoleArn` | _string_ | true | AwsRoleArn is the AWS IAM Role with the permission to use specific resources in AWS account
which maps to the temporary AWS security credentials exchanged using the authentication token issued by OIDC provider. | -#### BackendSecurityPolicy +## BackendSecurityPolicy @@ -303,7 +305,7 @@ _Appears in:_ | `spec` | _[BackendSecurityPolicySpec](#backendsecuritypolicyspec)_ | true | | -#### BackendSecurityPolicyAPIKey +## BackendSecurityPolicyAPIKey @@ -317,7 +319,7 @@ _Appears in:_ | `secretRef` | _[SecretObjectReference](https://gateway-api.sigs.k8s.io/references/spec/#gateway.networking.k8s.io/v1.SecretObjectReference)_ | true | SecretRef is the reference to the secret containing the API key.
ai-gateway must be given the permission to read this secret.
The key of the secret should be "apiKey". | -#### BackendSecurityPolicyAWSCredentials +## BackendSecurityPolicyAWSCredentials @@ -333,7 +335,7 @@ _Appears in:_ | `oidcExchangeToken` | _[AWSOIDCExchangeToken](#awsoidcexchangetoken)_ | false | OIDCExchangeToken specifies the oidc configurations used to obtain an oidc token. The oidc token will be
used to obtain temporary credentials to access AWS. | -#### BackendSecurityPolicyList +## BackendSecurityPolicyList @@ -349,7 +351,7 @@ BackendSecurityPolicyList contains a list of BackendSecurityPolicy | `items` | _[BackendSecurityPolicy](#backendsecuritypolicy) array_ | true | | -#### BackendSecurityPolicySpec +## BackendSecurityPolicySpec @@ -369,7 +371,7 @@ _Appears in:_ | `awsCredentials` | _[BackendSecurityPolicyAWSCredentials](#backendsecuritypolicyawscredentials)_ | false | AWSCredentials is a mechanism to access a backend(s). AWS specific logic will be applied. | -#### BackendSecurityPolicyType +## BackendSecurityPolicyType _Underlying type:_ _string_ @@ -384,7 +386,7 @@ _Appears in:_ | `AWSCredentials` | | -#### LLMRequestCost +## LLMRequestCost @@ -400,7 +402,7 @@ _Appears in:_ | `celExpression` | _string_ | false | CELExpression is the CEL expression to calculate the cost of the request.
The CEL expression must return a signed or unsigned integer. If the
return value is negative, it will be error.

The expression can use the following variables:

* model: the model name extracted from the request content. Type: string.
* backend: the backend name in the form of "name.namespace". Type: string.
* input_tokens: the number of input tokens. Type: unsigned integer.
* output_tokens: the number of output tokens. Type: unsigned integer.
* total_tokens: the total number of tokens. Type: unsigned integer.

For example, the following expressions are valid:

* "model == 'llama' ? input_tokens + output_token * 0.5 : total_tokens"
* "backend == 'foo.default' ? input_tokens + output_tokens : total_tokens"
* "input_tokens + output_tokens + total_tokens"
* "input_tokens * output_tokens" | -#### LLMRequestCostType +## LLMRequestCostType _Underlying type:_ _string_ @@ -417,7 +419,7 @@ _Appears in:_ | `CEL` | LLMRequestCostTypeCEL is for calculating the cost using the CEL expression.
| -#### VersionedAPISchema +## VersionedAPISchema @@ -441,3 +443,5 @@ _Appears in:_ | `version` | _string_ | true | Version is the version of the API schema. | + +[Back to Packages](#api_references) From 357f28a5f43e9c77349b44794cca248c82883f14 Mon Sep 17 00:00:00 2001 From: Eric Mariasis Date: Fri, 7 Feb 2025 09:26:30 -0500 Subject: [PATCH 06/40] updating for more user friendly bulleted list approach Signed-off-by: Eric Mariasis --- site/crd-ref-docs/templates/type.tpl | 20 +- site/docs/api.md | 371 ++++++++++++++++++--------- 2 files changed, 261 insertions(+), 130 deletions(-) diff --git a/site/crd-ref-docs/templates/type.tpl b/site/crd-ref-docs/templates/type.tpl index be79192e5..2232e21d9 100644 --- a/site/crd-ref-docs/templates/type.tpl +++ b/site/crd-ref-docs/templates/type.tpl @@ -2,7 +2,7 @@ {{- $type := . -}} {{- if markdownShouldRenderType $type -}} -## {{ $type.Name }} +### {{ $type.Name }} {{ if $type.IsAlias }}_Underlying type:_ _{{ markdownRenderTypeLink $type.UnderlyingType }}_{{ end }} @@ -16,20 +16,25 @@ _Appears in:_ {{- end }} {{ if $type.Members -}} -| Field | Type | Required | Description | -| --- | --- | --- | --- | {{ if $type.GVK -}} -| `apiVersion` | _string_ | |`{{ $type.GVK.Group }}/{{ $type.GVK.Version }}` -| `kind` | _string_ | |`{{ $type.GVK.Kind }}` +- **apiVersion** + - **Type:** _string_ + - **Value:** `{{ $type.GVK.Group }}/{{ $type.GVK.Version }}` +- **kind** + - **Type:** _string_ + - **Value:** `{{ $type.GVK.Kind }}` {{ end -}} - {{ range $type.Members -}} {{- with .Markers.notImplementedHide -}} {{- else -}} -| `{{ .Name }}` | _{{ markdownRenderType .Type }}_ | {{ with .Markers.optional }} {{ "false" }} {{ else }} {{ "true" }} {{end}} | {{ template "type_members" . }} | +- **{{ .Name }}** + - **Type:** _{{ markdownRenderType .Type }}_ + - **Required:** {{ if .Markers.optional }}No{{ else }}Yes{{ end }} + - **Description:** {{ template "type_members" . }} {{ end -}} {{- end -}} {{- end -}} + {{ if $type.EnumValues -}} | Value | Description | | ----- | ----------- | @@ -37,5 +42,6 @@ _Appears in:_ | `{{ .Name }}` | {{ markdownRenderFieldDoc .Doc }} | {{ end -}} {{- end -}} + {{- end -}} {{- end -}} diff --git a/site/docs/api.md b/site/docs/api.md index 9a6346b35..fec72d7ab 100644 --- a/site/docs/api.md +++ b/site/docs/api.md @@ -25,7 +25,7 @@ API group. -## AIGatewayFilterConfig +### AIGatewayFilterConfig @@ -34,13 +34,17 @@ API group. _Appears in:_ - [AIGatewayRouteSpec](#aigatewayroutespec) -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `type` | _[AIGatewayFilterConfigType](#aigatewayfilterconfigtype)_ | true | Type specifies the type of the filter configuration.

Currently, only ExternalProcess is supported, and default is ExternalProcess. | -| `externalProcess` | _[AIGatewayFilterConfigExternalProcess](#aigatewayfilterconfigexternalprocess)_ | false | ExternalProcess is the configuration for the external process filter.
This is optional, and if not set, the default values of Deployment spec will be used. | +- **type** + - **Type:** _[AIGatewayFilterConfigType](#aigatewayfilterconfigtype)_ + - **Required:** Yes + - **Description:** Type specifies the type of the filter configuration.

Currently, only ExternalProcess is supported, and default is ExternalProcess. +- **externalProcess** + - **Type:** _[AIGatewayFilterConfigExternalProcess](#aigatewayfilterconfigexternalprocess)_ + - **Required:** No + - **Description:** ExternalProcess is the configuration for the external process filter.
This is optional, and if not set, the default values of Deployment spec will be used. -## AIGatewayFilterConfigExternalProcess +### AIGatewayFilterConfigExternalProcess @@ -49,13 +53,17 @@ _Appears in:_ _Appears in:_ - [AIGatewayFilterConfig](#aigatewayfilterconfig) -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `replicas` | _integer_ | false | Replicas is the number of desired pods of the external process deployment. | -| `resources` | _[ResourceRequirements](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#resourcerequirements-v1-core)_ | false | Resources required by the external process container.
More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ | +- **replicas** + - **Type:** _integer_ + - **Required:** No + - **Description:** Replicas is the number of desired pods of the external process deployment. +- **resources** + - **Type:** _[ResourceRequirements](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#resourcerequirements-v1-core)_ + - **Required:** No + - **Description:** Resources required by the external process container.
More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ -## AIGatewayFilterConfigType +### AIGatewayFilterConfigType _Underlying type:_ _string_ @@ -70,7 +78,7 @@ _Appears in:_ | `DynamicModule` | | -## AIGatewayRoute +### AIGatewayRoute @@ -93,15 +101,23 @@ The generated HTTPRoute has the owner reference set to this AIGatewayRoute. _Appears in:_ - [AIGatewayRouteList](#aigatewayroutelist) -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `apiVersion` | _string_ | |`aigateway.envoyproxy.io/v1alpha1` -| `kind` | _string_ | |`AIGatewayRoute` -| `metadata` | _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#objectmeta-v1-meta)_ | true | Refer to Kubernetes API documentation for fields of `metadata`. | -| `spec` | _[AIGatewayRouteSpec](#aigatewayroutespec)_ | true | Spec defines the details of the AIGatewayRoute. | +- **apiVersion** + - **Type:** _string_ + - **Value:** `aigateway.envoyproxy.io/v1alpha1` +- **kind** + - **Type:** _string_ + - **Value:** `AIGatewayRoute` +- **metadata** + - **Type:** _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#objectmeta-v1-meta)_ + - **Required:** Yes + - **Description:** Refer to Kubernetes API documentation for fields of `metadata`. +- **spec** + - **Type:** _[AIGatewayRouteSpec](#aigatewayroutespec)_ + - **Required:** Yes + - **Description:** Spec defines the details of the AIGatewayRoute. -## AIGatewayRouteList +### AIGatewayRouteList @@ -109,15 +125,23 @@ AIGatewayRouteList contains a list of AIGatewayRoute. -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `apiVersion` | _string_ | |`aigateway.envoyproxy.io/v1alpha1` -| `kind` | _string_ | |`AIGatewayRouteList` -| `metadata` | _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#listmeta-v1-meta)_ | true | Refer to Kubernetes API documentation for fields of `metadata`. | -| `items` | _[AIGatewayRoute](#aigatewayroute) array_ | true | | +- **apiVersion** + - **Type:** _string_ + - **Value:** `aigateway.envoyproxy.io/v1alpha1` +- **kind** + - **Type:** _string_ + - **Value:** `AIGatewayRouteList` +- **metadata** + - **Type:** _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#listmeta-v1-meta)_ + - **Required:** Yes + - **Description:** Refer to Kubernetes API documentation for fields of `metadata`. +- **items** + - **Type:** _[AIGatewayRoute](#aigatewayroute) array_ + - **Required:** Yes + - **Description:** -## AIGatewayRouteRule +### AIGatewayRouteRule @@ -126,13 +150,17 @@ AIGatewayRouteRule is a rule that defines the routing behavior of the AIGatewayR _Appears in:_ - [AIGatewayRouteSpec](#aigatewayroutespec) -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `backendRefs` | _[AIGatewayRouteRuleBackendRef](#aigatewayrouterulebackendref) array_ | false | BackendRefs is the list of AIServiceBackend that this rule will route the traffic to.
Each backend can have a weight that determines the traffic distribution.

The namespace of each backend is "local", i.e. the same namespace as the AIGatewayRoute. | -| `matches` | _[AIGatewayRouteRuleMatch](#aigatewayrouterulematch) array_ | false | Matches is the list of AIGatewayRouteMatch that this rule will match the traffic to.
This is a subset of the HTTPRouteMatch in the Gateway API. See for the details:
https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io%2fv1.HTTPRouteMatch | +- **backendRefs** + - **Type:** _[AIGatewayRouteRuleBackendRef](#aigatewayrouterulebackendref) array_ + - **Required:** No + - **Description:** BackendRefs is the list of AIServiceBackend that this rule will route the traffic to.
Each backend can have a weight that determines the traffic distribution.

The namespace of each backend is "local", i.e. the same namespace as the AIGatewayRoute. +- **matches** + - **Type:** _[AIGatewayRouteRuleMatch](#aigatewayrouterulematch) array_ + - **Required:** No + - **Description:** Matches is the list of AIGatewayRouteMatch that this rule will match the traffic to.
This is a subset of the HTTPRouteMatch in the Gateway API. See for the details:
https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io%2fv1.HTTPRouteMatch -## AIGatewayRouteRuleBackendRef +### AIGatewayRouteRuleBackendRef @@ -141,13 +169,17 @@ AIGatewayRouteRuleBackendRef is a reference to a AIServiceBackend with a weight. _Appears in:_ - [AIGatewayRouteRule](#aigatewayrouterule) -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `name` | _string_ | true | Name is the name of the AIServiceBackend. | -| `weight` | _integer_ | false | Weight is the weight of the AIServiceBackend. This is exactly the same as the weight in
the BackendRef in the Gateway API. See for the details:
https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io%2fv1.BackendRef

Default is 1. | +- **name** + - **Type:** _string_ + - **Required:** Yes + - **Description:** Name is the name of the AIServiceBackend. +- **weight** + - **Type:** _integer_ + - **Required:** No + - **Description:** Weight is the weight of the AIServiceBackend. This is exactly the same as the weight in
the BackendRef in the Gateway API. See for the details:
https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io%2fv1.BackendRef

Default is 1. -## AIGatewayRouteRuleMatch +### AIGatewayRouteRuleMatch @@ -156,12 +188,13 @@ _Appears in:_ _Appears in:_ - [AIGatewayRouteRule](#aigatewayrouterule) -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `headers` | _HTTPHeaderMatch array_ | false | Headers specifies HTTP request header matchers. See HeaderMatch in the Gateway API for the details:
https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io%2fv1.HTTPHeaderMatch

Currently, only the exact header matching is supported. | +- **headers** + - **Type:** _HTTPHeaderMatch array_ + - **Required:** No + - **Description:** Headers specifies HTTP request header matchers. See HeaderMatch in the Gateway API for the details:
https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io%2fv1.HTTPHeaderMatch

Currently, only the exact header matching is supported. -## AIGatewayRouteSpec +### AIGatewayRouteSpec @@ -170,16 +203,29 @@ AIGatewayRouteSpec details the AIGatewayRoute configuration. _Appears in:_ - [AIGatewayRoute](#aigatewayroute) -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `targetRefs` | _[LocalPolicyTargetReferenceWithSectionName](https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io/v1alpha2.LocalPolicyTargetReferenceWithSectionName) array_ | true | TargetRefs are the names of the Gateway resources this AIGatewayRoute is being attached to. | -| `schema` | _[VersionedAPISchema](#versionedapischema)_ | true | APISchema specifies the API schema of the input that the target Gateway(s) will receive.
Based on this schema, the ai-gateway will perform the necessary transformation to the
output schema specified in the selected AIServiceBackend during the routing process.

Currently, the only supported schema is OpenAI as the input schema. | -| `rules` | _[AIGatewayRouteRule](#aigatewayrouterule) array_ | true | Rules is the list of AIGatewayRouteRule that this AIGatewayRoute will match the traffic to.
Each rule is a subset of the HTTPRoute in the Gateway API (https://gateway-api.sigs.k8s.io/api-types/httproute/).

AI Gateway controller will generate a HTTPRoute based on the configuration given here with the additional
modifications to achieve the necessary jobs, notably inserting the AI Gateway filter responsible for
the transformation of the request and response, etc.

In the matching conditions in the AIGatewayRouteRule, `x-ai-eg-model` header is available
if we want to describe the routing behavior based on the model name. The model name is extracted
from the request content before the routing decision.

How multiple rules are matched is the same as the Gateway API. See for the details:
https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io%2fv1.HTTPRoute | -| `filterConfig` | _[AIGatewayFilterConfig](#aigatewayfilterconfig)_ | true | FilterConfig is the configuration for the AI Gateway filter inserted in the generated HTTPRoute.

An AI Gateway filter is responsible for the transformation of the request and response
as well as the routing behavior based on the model name extracted from the request content, etc.

Currently, the filter is only implemented as an external process filter, which might be
extended to other types of filters in the future. See https://github.com/envoyproxy/ai-gateway/issues/90 | -| `llmRequestCosts` | _[LLMRequestCost](#llmrequestcost) array_ | false | LLMRequestCosts specifies how to capture the cost of the LLM-related request, notably the token usage.
The AI Gateway filter will capture each specified number and store it in the Envoy's dynamic
metadata per HTTP request. The namespaced key is "io.envoy.ai_gateway",

For example, let's say we have the following LLMRequestCosts configuration:

llmRequestCosts:
- metadataKey: llm_input_token
type: InputToken
- metadataKey: llm_output_token
type: OutputToken
- metadataKey: llm_total_token
type: TotalToken

Then, with the following BackendTrafficPolicy of Envoy Gateway, you can have three
rate limit buckets for each unique x-user-id header value. One bucket is for the input token,
the other is for the output token, and the last one is for the total token.
Each bucket will be reduced by the corresponding token usage captured by the AI Gateway filter.

apiVersion: gateway.envoyproxy.io/v1alpha1
kind: BackendTrafficPolicy
metadata:
name: some-example-token-rate-limit
namespace: default
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: usage-rate-limit
rateLimit:
type: Global
global:
rules:
- clientSelectors:
# Do the rate limiting based on the x-user-id header.
- headers:
- name: x-user-id
type: Distinct
limit:
# Configures the number of "tokens" allowed per hour.
requests: 10000
unit: Hour
cost:
request:
from: Number
# Setting the request cost to zero allows to only check the rate limit budget,
# and not consume the budget on the request path.
number: 0
# This specifies the cost of the response retrieved from the dynamic metadata set by the AI Gateway filter.
# The extracted value will be used to consume the rate limit budget, and subsequent requests will be rate limited
# if the budget is exhausted.
response:
from: Metadata
metadata:
namespace: io.envoy.ai_gateway
key: llm_input_token
- clientSelectors:
- headers:
- name: x-user-id
type: Distinct
limit:
requests: 10000
unit: Hour
cost:
request:
from: Number
number: 0
response:
from: Metadata
metadata:
namespace: io.envoy.ai_gateway
key: llm_output_token
- clientSelectors:
- headers:
- name: x-user-id
type: Distinct
limit:
requests: 10000
unit: Hour
cost:
request:
from: Number
number: 0
response:
from: Metadata
metadata:
namespace: io.envoy.ai_gateway
key: llm_total_token | +- **targetRefs** + - **Type:** _[LocalPolicyTargetReferenceWithSectionName](https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io/v1alpha2.LocalPolicyTargetReferenceWithSectionName) array_ + - **Required:** Yes + - **Description:** TargetRefs are the names of the Gateway resources this AIGatewayRoute is being attached to. +- **schema** + - **Type:** _[VersionedAPISchema](#versionedapischema)_ + - **Required:** Yes + - **Description:** APISchema specifies the API schema of the input that the target Gateway(s) will receive.
Based on this schema, the ai-gateway will perform the necessary transformation to the
output schema specified in the selected AIServiceBackend during the routing process.

Currently, the only supported schema is OpenAI as the input schema. +- **rules** + - **Type:** _[AIGatewayRouteRule](#aigatewayrouterule) array_ + - **Required:** Yes + - **Description:** Rules is the list of AIGatewayRouteRule that this AIGatewayRoute will match the traffic to.
Each rule is a subset of the HTTPRoute in the Gateway API (https://gateway-api.sigs.k8s.io/api-types/httproute/).

AI Gateway controller will generate a HTTPRoute based on the configuration given here with the additional
modifications to achieve the necessary jobs, notably inserting the AI Gateway filter responsible for
the transformation of the request and response, etc.

In the matching conditions in the AIGatewayRouteRule, `x-ai-eg-model` header is available
if we want to describe the routing behavior based on the model name. The model name is extracted
from the request content before the routing decision.

How multiple rules are matched is the same as the Gateway API. See for the details:
https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io%2fv1.HTTPRoute +- **filterConfig** + - **Type:** _[AIGatewayFilterConfig](#aigatewayfilterconfig)_ + - **Required:** Yes + - **Description:** FilterConfig is the configuration for the AI Gateway filter inserted in the generated HTTPRoute.

An AI Gateway filter is responsible for the transformation of the request and response
as well as the routing behavior based on the model name extracted from the request content, etc.

Currently, the filter is only implemented as an external process filter, which might be
extended to other types of filters in the future. See https://github.com/envoyproxy/ai-gateway/issues/90 +- **llmRequestCosts** + - **Type:** _[LLMRequestCost](#llmrequestcost) array_ + - **Required:** No + - **Description:** LLMRequestCosts specifies how to capture the cost of the LLM-related request, notably the token usage.
The AI Gateway filter will capture each specified number and store it in the Envoy's dynamic
metadata per HTTP request. The namespaced key is "io.envoy.ai_gateway",

For example, let's say we have the following LLMRequestCosts configuration:

llmRequestCosts:
- metadataKey: llm_input_token
type: InputToken
- metadataKey: llm_output_token
type: OutputToken
- metadataKey: llm_total_token
type: TotalToken

Then, with the following BackendTrafficPolicy of Envoy Gateway, you can have three
rate limit buckets for each unique x-user-id header value. One bucket is for the input token,
the other is for the output token, and the last one is for the total token.
Each bucket will be reduced by the corresponding token usage captured by the AI Gateway filter.

apiVersion: gateway.envoyproxy.io/v1alpha1
kind: BackendTrafficPolicy
metadata:
name: some-example-token-rate-limit
namespace: default
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: usage-rate-limit
rateLimit:
type: Global
global:
rules:
- clientSelectors:
# Do the rate limiting based on the x-user-id header.
- headers:
- name: x-user-id
type: Distinct
limit:
# Configures the number of "tokens" allowed per hour.
requests: 10000
unit: Hour
cost:
request:
from: Number
# Setting the request cost to zero allows to only check the rate limit budget,
# and not consume the budget on the request path.
number: 0
# This specifies the cost of the response retrieved from the dynamic metadata set by the AI Gateway filter.
# The extracted value will be used to consume the rate limit budget, and subsequent requests will be rate limited
# if the budget is exhausted.
response:
from: Metadata
metadata:
namespace: io.envoy.ai_gateway
key: llm_input_token
- clientSelectors:
- headers:
- name: x-user-id
type: Distinct
limit:
requests: 10000
unit: Hour
cost:
request:
from: Number
number: 0
response:
from: Metadata
metadata:
namespace: io.envoy.ai_gateway
key: llm_output_token
- clientSelectors:
- headers:
- name: x-user-id
type: Distinct
limit:
requests: 10000
unit: Hour
cost:
request:
from: Number
number: 0
response:
from: Metadata
metadata:
namespace: io.envoy.ai_gateway
key: llm_total_token -## AIServiceBackend +### AIServiceBackend @@ -197,15 +243,23 @@ the backend specific logic in the final HTTPRoute. _Appears in:_ - [AIServiceBackendList](#aiservicebackendlist) -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `apiVersion` | _string_ | |`aigateway.envoyproxy.io/v1alpha1` -| `kind` | _string_ | |`AIServiceBackend` -| `metadata` | _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#objectmeta-v1-meta)_ | true | Refer to Kubernetes API documentation for fields of `metadata`. | -| `spec` | _[AIServiceBackendSpec](#aiservicebackendspec)_ | true | Spec defines the details of AIServiceBackend. | +- **apiVersion** + - **Type:** _string_ + - **Value:** `aigateway.envoyproxy.io/v1alpha1` +- **kind** + - **Type:** _string_ + - **Value:** `AIServiceBackend` +- **metadata** + - **Type:** _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#objectmeta-v1-meta)_ + - **Required:** Yes + - **Description:** Refer to Kubernetes API documentation for fields of `metadata`. +- **spec** + - **Type:** _[AIServiceBackendSpec](#aiservicebackendspec)_ + - **Required:** Yes + - **Description:** Spec defines the details of AIServiceBackend. -## AIServiceBackendList +### AIServiceBackendList @@ -213,15 +267,23 @@ AIServiceBackendList contains a list of AIServiceBackends. -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `apiVersion` | _string_ | |`aigateway.envoyproxy.io/v1alpha1` -| `kind` | _string_ | |`AIServiceBackendList` -| `metadata` | _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#listmeta-v1-meta)_ | true | Refer to Kubernetes API documentation for fields of `metadata`. | -| `items` | _[AIServiceBackend](#aiservicebackend) array_ | true | | +- **apiVersion** + - **Type:** _string_ + - **Value:** `aigateway.envoyproxy.io/v1alpha1` +- **kind** + - **Type:** _string_ + - **Value:** `AIServiceBackendList` +- **metadata** + - **Type:** _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#listmeta-v1-meta)_ + - **Required:** Yes + - **Description:** Refer to Kubernetes API documentation for fields of `metadata`. +- **items** + - **Type:** _[AIServiceBackend](#aiservicebackend) array_ + - **Required:** Yes + - **Description:** -## AIServiceBackendSpec +### AIServiceBackendSpec @@ -230,14 +292,21 @@ AIServiceBackendSpec details the AIServiceBackend configuration. _Appears in:_ - [AIServiceBackend](#aiservicebackend) -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `schema` | _[VersionedAPISchema](#versionedapischema)_ | true | APISchema specifies the API schema of the output format of requests from
Envoy that this AIServiceBackend can accept as incoming requests.
Based on this schema, the ai-gateway will perform the necessary transformation for
the pair of AIGatewayRouteSpec.APISchema and AIServiceBackendSpec.APISchema.

This is required to be set. | -| `backendRef` | _[BackendObjectReference](https://gateway-api.sigs.k8s.io/references/spec/#gateway.networking.k8s.io/v1.BackendObjectReference)_ | true | BackendRef is the reference to the Backend resource that this AIServiceBackend corresponds to.

A backend can be of either k8s Service or Backend resource of Envoy Gateway.

This is required to be set. | -| `backendSecurityPolicyRef` | _[LocalObjectReference](#localobjectreference)_ | false | BackendSecurityPolicyRef is the name of the BackendSecurityPolicy resources this backend
is being attached to. | +- **schema** + - **Type:** _[VersionedAPISchema](#versionedapischema)_ + - **Required:** Yes + - **Description:** APISchema specifies the API schema of the output format of requests from
Envoy that this AIServiceBackend can accept as incoming requests.
Based on this schema, the ai-gateway will perform the necessary transformation for
the pair of AIGatewayRouteSpec.APISchema and AIServiceBackendSpec.APISchema.

This is required to be set. +- **backendRef** + - **Type:** _[BackendObjectReference](https://gateway-api.sigs.k8s.io/references/spec/#gateway.networking.k8s.io/v1.BackendObjectReference)_ + - **Required:** Yes + - **Description:** BackendRef is the reference to the Backend resource that this AIServiceBackend corresponds to.

A backend can be of either k8s Service or Backend resource of Envoy Gateway.

This is required to be set. +- **backendSecurityPolicyRef** + - **Type:** _[LocalObjectReference](#localobjectreference)_ + - **Required:** No + - **Description:** BackendSecurityPolicyRef is the name of the BackendSecurityPolicy resources this backend
is being attached to. -## APISchema +### APISchema _Underlying type:_ _string_ @@ -252,7 +321,7 @@ _Appears in:_ | `AWSBedrock` | APISchemaAWSBedrock is the AWS Bedrock schema.
https://docs.aws.amazon.com/bedrock/latest/APIReference/API_Operations_Amazon_Bedrock_Runtime.html
| -## AWSCredentialsFile +### AWSCredentialsFile @@ -262,13 +331,17 @@ Envoy reads the secret file, and the profile to use is specified by the Profile _Appears in:_ - [BackendSecurityPolicyAWSCredentials](#backendsecuritypolicyawscredentials) -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `secretRef` | _[SecretObjectReference](https://gateway-api.sigs.k8s.io/references/spec/#gateway.networking.k8s.io/v1.SecretObjectReference)_ | true | SecretRef is the reference to the credential file.

The secret should contain the AWS credentials file keyed on "credentials". | -| `profile` | _string_ | true | Profile is the profile to use in the credentials file. | +- **secretRef** + - **Type:** _[SecretObjectReference](https://gateway-api.sigs.k8s.io/references/spec/#gateway.networking.k8s.io/v1.SecretObjectReference)_ + - **Required:** Yes + - **Description:** SecretRef is the reference to the credential file.

The secret should contain the AWS credentials file keyed on "credentials". +- **profile** + - **Type:** _string_ + - **Required:** Yes + - **Description:** Profile is the profile to use in the credentials file. -## AWSOIDCExchangeToken +### AWSOIDCExchangeToken @@ -279,15 +352,25 @@ and store them in a temporary credentials file. _Appears in:_ - [BackendSecurityPolicyAWSCredentials](#backendsecuritypolicyawscredentials) -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `oidc` | _[OIDC](#oidc)_ | true | OIDC is used to obtain oidc tokens via an SSO server which will be used to exchange for temporary AWS credentials. | -| `grantType` | _string_ | false | GrantType is the method application gets access token. | -| `aud` | _string_ | false | Aud defines the audience that this ID Token is intended for. | -| `awsRoleArn` | _string_ | true | AwsRoleArn is the AWS IAM Role with the permission to use specific resources in AWS account
which maps to the temporary AWS security credentials exchanged using the authentication token issued by OIDC provider. | +- **oidc** + - **Type:** _[OIDC](#oidc)_ + - **Required:** Yes + - **Description:** OIDC is used to obtain oidc tokens via an SSO server which will be used to exchange for temporary AWS credentials. +- **grantType** + - **Type:** _string_ + - **Required:** No + - **Description:** GrantType is the method application gets access token. +- **aud** + - **Type:** _string_ + - **Required:** No + - **Description:** Aud defines the audience that this ID Token is intended for. +- **awsRoleArn** + - **Type:** _string_ + - **Required:** Yes + - **Description:** AwsRoleArn is the AWS IAM Role with the permission to use specific resources in AWS account
which maps to the temporary AWS security credentials exchanged using the authentication token issued by OIDC provider. -## BackendSecurityPolicy +### BackendSecurityPolicy @@ -297,15 +380,23 @@ exiting the gateway to the backend. _Appears in:_ - [BackendSecurityPolicyList](#backendsecuritypolicylist) -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `apiVersion` | _string_ | |`aigateway.envoyproxy.io/v1alpha1` -| `kind` | _string_ | |`BackendSecurityPolicy` -| `metadata` | _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#objectmeta-v1-meta)_ | true | Refer to Kubernetes API documentation for fields of `metadata`. | -| `spec` | _[BackendSecurityPolicySpec](#backendsecuritypolicyspec)_ | true | | +- **apiVersion** + - **Type:** _string_ + - **Value:** `aigateway.envoyproxy.io/v1alpha1` +- **kind** + - **Type:** _string_ + - **Value:** `BackendSecurityPolicy` +- **metadata** + - **Type:** _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#objectmeta-v1-meta)_ + - **Required:** Yes + - **Description:** Refer to Kubernetes API documentation for fields of `metadata`. +- **spec** + - **Type:** _[BackendSecurityPolicySpec](#backendsecuritypolicyspec)_ + - **Required:** Yes + - **Description:** -## BackendSecurityPolicyAPIKey +### BackendSecurityPolicyAPIKey @@ -314,12 +405,13 @@ BackendSecurityPolicyAPIKey specifies the API key. _Appears in:_ - [BackendSecurityPolicySpec](#backendsecuritypolicyspec) -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `secretRef` | _[SecretObjectReference](https://gateway-api.sigs.k8s.io/references/spec/#gateway.networking.k8s.io/v1.SecretObjectReference)_ | true | SecretRef is the reference to the secret containing the API key.
ai-gateway must be given the permission to read this secret.
The key of the secret should be "apiKey". | +- **secretRef** + - **Type:** _[SecretObjectReference](https://gateway-api.sigs.k8s.io/references/spec/#gateway.networking.k8s.io/v1.SecretObjectReference)_ + - **Required:** Yes + - **Description:** SecretRef is the reference to the secret containing the API key.
ai-gateway must be given the permission to read this secret.
The key of the secret should be "apiKey". -## BackendSecurityPolicyAWSCredentials +### BackendSecurityPolicyAWSCredentials @@ -328,14 +420,21 @@ BackendSecurityPolicyAWSCredentials contains the supported authentication mechan _Appears in:_ - [BackendSecurityPolicySpec](#backendsecuritypolicyspec) -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `region` | _string_ | true | Region specifies the AWS region associated with the policy. | -| `credentialsFile` | _[AWSCredentialsFile](#awscredentialsfile)_ | false | CredentialsFile specifies the credentials file to use for the AWS provider. | -| `oidcExchangeToken` | _[AWSOIDCExchangeToken](#awsoidcexchangetoken)_ | false | OIDCExchangeToken specifies the oidc configurations used to obtain an oidc token. The oidc token will be
used to obtain temporary credentials to access AWS. | +- **region** + - **Type:** _string_ + - **Required:** Yes + - **Description:** Region specifies the AWS region associated with the policy. +- **credentialsFile** + - **Type:** _[AWSCredentialsFile](#awscredentialsfile)_ + - **Required:** No + - **Description:** CredentialsFile specifies the credentials file to use for the AWS provider. +- **oidcExchangeToken** + - **Type:** _[AWSOIDCExchangeToken](#awsoidcexchangetoken)_ + - **Required:** No + - **Description:** OIDCExchangeToken specifies the oidc configurations used to obtain an oidc token. The oidc token will be
used to obtain temporary credentials to access AWS. -## BackendSecurityPolicyList +### BackendSecurityPolicyList @@ -343,15 +442,23 @@ BackendSecurityPolicyList contains a list of BackendSecurityPolicy -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `apiVersion` | _string_ | |`aigateway.envoyproxy.io/v1alpha1` -| `kind` | _string_ | |`BackendSecurityPolicyList` -| `metadata` | _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#listmeta-v1-meta)_ | true | Refer to Kubernetes API documentation for fields of `metadata`. | -| `items` | _[BackendSecurityPolicy](#backendsecuritypolicy) array_ | true | | +- **apiVersion** + - **Type:** _string_ + - **Value:** `aigateway.envoyproxy.io/v1alpha1` +- **kind** + - **Type:** _string_ + - **Value:** `BackendSecurityPolicyList` +- **metadata** + - **Type:** _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#listmeta-v1-meta)_ + - **Required:** Yes + - **Description:** Refer to Kubernetes API documentation for fields of `metadata`. +- **items** + - **Type:** _[BackendSecurityPolicy](#backendsecuritypolicy) array_ + - **Required:** Yes + - **Description:** -## BackendSecurityPolicySpec +### BackendSecurityPolicySpec @@ -364,14 +471,21 @@ Only one type of BackendSecurityPolicy can be defined. _Appears in:_ - [BackendSecurityPolicy](#backendsecuritypolicy) -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `type` | _[BackendSecurityPolicyType](#backendsecuritypolicytype)_ | true | Type specifies the auth mechanism used to access the provider. Currently, only "APIKey", AND "AWSCredentials" are supported. | -| `apiKey` | _[BackendSecurityPolicyAPIKey](#backendsecuritypolicyapikey)_ | false | APIKey is a mechanism to access a backend(s). The API key will be injected into the Authorization header. | -| `awsCredentials` | _[BackendSecurityPolicyAWSCredentials](#backendsecuritypolicyawscredentials)_ | false | AWSCredentials is a mechanism to access a backend(s). AWS specific logic will be applied. | +- **type** + - **Type:** _[BackendSecurityPolicyType](#backendsecuritypolicytype)_ + - **Required:** Yes + - **Description:** Type specifies the auth mechanism used to access the provider. Currently, only "APIKey", AND "AWSCredentials" are supported. +- **apiKey** + - **Type:** _[BackendSecurityPolicyAPIKey](#backendsecuritypolicyapikey)_ + - **Required:** No + - **Description:** APIKey is a mechanism to access a backend(s). The API key will be injected into the Authorization header. +- **awsCredentials** + - **Type:** _[BackendSecurityPolicyAWSCredentials](#backendsecuritypolicyawscredentials)_ + - **Required:** No + - **Description:** AWSCredentials is a mechanism to access a backend(s). AWS specific logic will be applied. -## BackendSecurityPolicyType +### BackendSecurityPolicyType _Underlying type:_ _string_ @@ -386,7 +500,7 @@ _Appears in:_ | `AWSCredentials` | | -## LLMRequestCost +### LLMRequestCost @@ -395,14 +509,21 @@ LLMRequestCost configures each request cost. _Appears in:_ - [AIGatewayRouteSpec](#aigatewayroutespec) -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `metadataKey` | _string_ | true | MetadataKey is the key of the metadata to store this cost of the request. | -| `type` | _[LLMRequestCostType](#llmrequestcosttype)_ | true | Type specifies the type of the request cost. The default is "OutputToken",
and it uses "output token" as the cost. The other types are "InputToken", "TotalToken",
and "CEL". | -| `celExpression` | _string_ | false | CELExpression is the CEL expression to calculate the cost of the request.
The CEL expression must return a signed or unsigned integer. If the
return value is negative, it will be error.

The expression can use the following variables:

* model: the model name extracted from the request content. Type: string.
* backend: the backend name in the form of "name.namespace". Type: string.
* input_tokens: the number of input tokens. Type: unsigned integer.
* output_tokens: the number of output tokens. Type: unsigned integer.
* total_tokens: the total number of tokens. Type: unsigned integer.

For example, the following expressions are valid:

* "model == 'llama' ? input_tokens + output_token * 0.5 : total_tokens"
* "backend == 'foo.default' ? input_tokens + output_tokens : total_tokens"
* "input_tokens + output_tokens + total_tokens"
* "input_tokens * output_tokens" | +- **metadataKey** + - **Type:** _string_ + - **Required:** Yes + - **Description:** MetadataKey is the key of the metadata to store this cost of the request. +- **type** + - **Type:** _[LLMRequestCostType](#llmrequestcosttype)_ + - **Required:** Yes + - **Description:** Type specifies the type of the request cost. The default is "OutputToken",
and it uses "output token" as the cost. The other types are "InputToken", "TotalToken",
and "CEL". +- **celExpression** + - **Type:** _string_ + - **Required:** No + - **Description:** CELExpression is the CEL expression to calculate the cost of the request.
The CEL expression must return a signed or unsigned integer. If the
return value is negative, it will be error.

The expression can use the following variables:

* model: the model name extracted from the request content. Type: string.
* backend: the backend name in the form of "name.namespace". Type: string.
* input_tokens: the number of input tokens. Type: unsigned integer.
* output_tokens: the number of output tokens. Type: unsigned integer.
* total_tokens: the total number of tokens. Type: unsigned integer.

For example, the following expressions are valid:

* "model == 'llama' ? input_tokens + output_token * 0.5 : total_tokens"
* "backend == 'foo.default' ? input_tokens + output_tokens : total_tokens"
* "input_tokens + output_tokens + total_tokens"
* "input_tokens * output_tokens" -## LLMRequestCostType +### LLMRequestCostType _Underlying type:_ _string_ @@ -419,7 +540,7 @@ _Appears in:_ | `CEL` | LLMRequestCostTypeCEL is for calculating the cost using the CEL expression.
| -## VersionedAPISchema +### VersionedAPISchema @@ -437,10 +558,14 @@ _Appears in:_ - [AIGatewayRouteSpec](#aigatewayroutespec) - [AIServiceBackendSpec](#aiservicebackendspec) -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `name` | _[APISchema](#apischema)_ | true | Name is the name of the API schema of the AIGatewayRoute or AIServiceBackend. | -| `version` | _string_ | true | Version is the version of the API schema. | +- **name** + - **Type:** _[APISchema](#apischema)_ + - **Required:** Yes + - **Description:** Name is the name of the API schema of the AIGatewayRoute or AIServiceBackend. +- **version** + - **Type:** _string_ + - **Required:** Yes + - **Description:** Version is the version of the API schema. From 5439428d9685150398d9fdd1487eb0b24ac53d61 Mon Sep 17 00:00:00 2001 From: Eric Mariasis Date: Sat, 1 Feb 2025 15:21:13 -0500 Subject: [PATCH 07/40] docs: removal of deprecated kubectl command in installation.md (#264) **Commit Message**: Removal of deprecated kubectl command below from [the installation getting started guide](https://github.com/envoyproxy/ai-gateway/blob/main/site/docs/getting-started/installation.md). `kubectl wait --timeout=2m -n envoy-ai-gateway-system deployment/ai-gateway-controller --for=create ` It seems to include a deprecated arg ```--for=create``` **Related Issues/PRs (if applicable)**: Fixes [Issue 262](https://github.com/envoyproxy/ai-gateway/issues/262) **Special notes for reviewers (if applicable)**: Signed-off-by: ericmariasis Signed-off-by: Eric Mariasis --- site/docs/getting-started/installation.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/site/docs/getting-started/installation.md b/site/docs/getting-started/installation.md index 5325feae5..8983116a2 100644 --- a/site/docs/getting-started/installation.md +++ b/site/docs/getting-started/installation.md @@ -19,8 +19,6 @@ helm upgrade -i aieg oci://ghcr.io/envoyproxy/ai-gateway/ai-gateway-helm \ --namespace envoy-ai-gateway-system \ --create-namespace -kubectl wait --timeout=2m -n envoy-ai-gateway-system deployment/ai-gateway-controller --for=create - kubectl wait --timeout=2m -n envoy-ai-gateway-system deployment/ai-gateway-controller --for=condition=Available ``` From 23cab14c0cf724573763bd8909638a008ddff1c9 Mon Sep 17 00:00:00 2001 From: Takeshi Yoneda Date: Sat, 1 Feb 2025 13:16:40 -0800 Subject: [PATCH 08/40] chore: fixes PR style check (#266) **Commit Message** This makes the PR style check stricter than before: * The description's first line must be `**Commit Message**` * The PR title length must be less than 53 characters --------- Signed-off-by: Takeshi Yoneda Signed-off-by: Eric Mariasis --- .github/PULL_REQUEST_TEMPLATE.md | 6 +++--- .github/workflows/pr_title_check.yaml | 30 ++++++++++++++++++++------- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 9af24f5de..e1efee589 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,4 +1,4 @@ -**Commit Message**: +**Commit Message** -**Related Issues/PRs (if applicable)**: +**Related Issues/PRs (if applicable)** -**Special notes for reviewers (if applicable)**: +**Special notes for reviewers (if applicable)** " ]]; then + if [[ $BODY =~ "-->" ]]; then echo "PR description contains '-->'. Please remove all the comment out lines in the template after carefully reading them." exit 1 fi - if [[ ! $TITLE =~ "**Commit Message**:" ]]; then - echo "PR description must begin with '**Commit Message**:'." - exit 1 + first_line=$(echo -n "$BODY" | head -n 1) + trimmed_first_line=$(echo "$first_line" | sed 's/[[:space:]]*$//') + echo "$trimmed_first_line='$trimmed_first_line'" + if [[ "$trimmed_first_line" != "**Commit Message**" ]]; then + echo "The first line of the PR description must be '**Commit Message**'" + exit 1 fi title: @@ -63,3 +66,16 @@ jobs: The subject "{subject}" found in the pull request title "{title}" didn't match the configured pattern. Please ensure that the subject doesn't start with an uppercase character. + + - name: Check length of PR title + env: + # Do not use ${{ github.event.pull_request.title }} directly in run command. + TITLE: ${{ github.event.pull_request.title }} + # We want to make sure that each commit "subject" is under 53 characters not to + # be truncated in the git log as well as in the GitHub UI. + # https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/Documentation/process/submitting-patches.rst?id=bc7938deaca7f474918c41a0372a410049bd4e13#n664 + run: | + if (( ${#TITLE} >= 53 )); then + echo "The PR title is too long. Please keep it under 52 characters." + exit 1 + fi From 3ed81cdb9061c346bc76cb8c674bd667694b1c7c Mon Sep 17 00:00:00 2001 From: Takeshi Yoneda Date: Sat, 1 Feb 2025 13:41:44 -0800 Subject: [PATCH 09/40] ci: cleanup PR Style Check GHA (#268) **Commit Message** This refactors the PR style check github workflow. * Renamed the file to match the workflow name. * Split the description checks into multiple runs. Signed-off-by: Takeshi Yoneda Signed-off-by: Eric Mariasis --- .../{pr_title_check.yaml => pr_style_check.yaml} | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) rename .github/workflows/{pr_title_check.yaml => pr_style_check.yaml} (90%) diff --git a/.github/workflows/pr_title_check.yaml b/.github/workflows/pr_style_check.yaml similarity index 90% rename from .github/workflows/pr_title_check.yaml rename to .github/workflows/pr_style_check.yaml index eec45b9ea..a53179207 100644 --- a/.github/workflows/pr_title_check.yaml +++ b/.github/workflows/pr_style_check.yaml @@ -14,11 +14,11 @@ jobs: description: name: Description runs-on: ubuntu-latest + env: + # Do not use ${{ github.event.pull_request.body }} directly in run command. + BODY: ${{ github.event.pull_request.body }} steps: - - name: Check PR description - env: - # Do not use ${{ github.event.pull_request.body }} directly in run command. - BODY: ${{ github.event.pull_request.body }} + - name: Check comment out lines run: | if [[ $BODY =~ "'. Please remove all the comment out lines in the template after carefully reading them." exit 1 fi + - name: Check the first line matches '**Commit Message**' + run: | first_line=$(echo -n "$BODY" | head -n 1) trimmed_first_line=$(echo "$first_line" | sed 's/[[:space:]]*$//') echo "$trimmed_first_line='$trimmed_first_line'" From 41e4271d0167d4f07801066d8f30156af7f75dea Mon Sep 17 00:00:00 2001 From: Takeshi Yoneda Date: Sat, 1 Feb 2025 14:00:24 -0800 Subject: [PATCH 10/40] ci: relaxes PR title length check (#269) **Commit Message** This follows up on #266 and relaxes the PR title length restriction to 60 characters. It was too strict. Signed-off-by: Takeshi Yoneda Signed-off-by: Eric Mariasis --- .github/workflows/pr_style_check.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr_style_check.yaml b/.github/workflows/pr_style_check.yaml index a53179207..e01b56903 100644 --- a/.github/workflows/pr_style_check.yaml +++ b/.github/workflows/pr_style_check.yaml @@ -73,11 +73,11 @@ jobs: env: # Do not use ${{ github.event.pull_request.title }} directly in run command. TITLE: ${{ github.event.pull_request.title }} - # We want to make sure that each commit "subject" is under 53 characters not to + # We want to make sure that each commit "subject" is under 60 characters not to # be truncated in the git log as well as in the GitHub UI. # https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/Documentation/process/submitting-patches.rst?id=bc7938deaca7f474918c41a0372a410049bd4e13#n664 run: | - if (( ${#TITLE} >= 53 )); then - echo "The PR title is too long. Please keep it under 52 characters." + if (( ${#TITLE} >= 60 )); then + echo "The PR title is too long. Please keep it under 60 characters." exit 1 fi From bcd2d131199858643f0eea9e762dbb7728839878 Mon Sep 17 00:00:00 2001 From: Eric Mariasis Date: Sat, 1 Feb 2025 17:04:10 -0500 Subject: [PATCH 11/40] docs: add note for Windows users in basic usage (#265) **Commit Message** Added note for Windows users in basic usage that they can use WSL for commands not working on cmd prompt. **Related Issues/PRs (if applicable)** Fixes #263 --------- Signed-off-by: ericmariasis Co-authored-by: Takeshi Yoneda Signed-off-by: Eric Mariasis --- site/docs/getting-started/basic-usage.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/site/docs/getting-started/basic-usage.md b/site/docs/getting-started/basic-usage.md index 4d129b06e..fcdf38e98 100644 --- a/site/docs/getting-started/basic-usage.md +++ b/site/docs/getting-started/basic-usage.md @@ -9,6 +9,8 @@ import TabItem from '@theme/TabItem'; This guide will help you set up a basic AI Gateway configuration and make your first request. +For Windows users, note that you are able to use Windows Subsystem for Linux (WSL) to run the commands below if they do not work on the Windows command prompt. + ## Setting Up Your Environment ### Deploy Basic Configuration From 2bbf52da08ddc92f17e3cebe847b6591e75499d4 Mon Sep 17 00:00:00 2001 From: Takeshi Yoneda Date: Sat, 1 Feb 2025 21:43:02 -0800 Subject: [PATCH 12/40] chore: more clarify what to do on Style check failure (#272) **Commit Message** Apparently, the message was clear enough Signed-off-by: Takeshi Yoneda Signed-off-by: Eric Mariasis --- .github/workflows/style.yaml | 2 +- Makefile | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/style.yaml b/.github/workflows/style.yaml index 70317f30a..ed5c2b8d2 100644 --- a/.github/workflows/style.yaml +++ b/.github/workflows/style.yaml @@ -25,5 +25,5 @@ jobs: ~/go/pkg/mod ~/go/bin key: code-style-check-${{ hashFiles('**/go.mod', '**/go.sum', '**/Makefile') }} - - name: Run code style check + - name: Ensure `make precommit` is executed run: make check diff --git a/Makefile b/Makefile index b9d8e6fe5..9a0a584e6 100644 --- a/Makefile +++ b/Makefile @@ -107,6 +107,8 @@ check: precommit @if [ ! -z "`git status -s`" ]; then \ echo "The following differences will fail CI until committed:"; \ git diff --exit-code; \ + echo "Please ensure you have run 'make precommit' and committed the changes."; \ + exit 1; \ fi # This runs the editorconfig-checker on the codebase. From 89de59a3468865eac7e3776cf8f200bddb55ca7f Mon Sep 17 00:00:00 2001 From: Eric Mariasis Date: Fri, 7 Feb 2025 10:00:19 -0500 Subject: [PATCH 13/40] Fix description render issue Signed-off-by: Eric Mariasis --- site/crd-ref-docs/templates/type.tpl | 4 +- site/docs/api.md | 232 +++++++++++++++++++++++---- 2 files changed, 205 insertions(+), 31 deletions(-) diff --git a/site/crd-ref-docs/templates/type.tpl b/site/crd-ref-docs/templates/type.tpl index 2232e21d9..9611bdb1e 100644 --- a/site/crd-ref-docs/templates/type.tpl +++ b/site/crd-ref-docs/templates/type.tpl @@ -30,7 +30,9 @@ _Appears in:_ - **{{ .Name }}** - **Type:** _{{ markdownRenderType .Type }}_ - **Required:** {{ if .Markers.optional }}No{{ else }}Yes{{ end }} - - **Description:** {{ template "type_members" . }} + {{- if .Doc }} + - **Description:** {{ .Doc }} + {{- end }} {{ end -}} {{- end -}} {{- end -}} diff --git a/site/docs/api.md b/site/docs/api.md index fec72d7ab..d41dd687f 100644 --- a/site/docs/api.md +++ b/site/docs/api.md @@ -37,11 +37,15 @@ _Appears in:_ - **type** - **Type:** _[AIGatewayFilterConfigType](#aigatewayfilterconfigtype)_ - **Required:** Yes - - **Description:** Type specifies the type of the filter configuration.

Currently, only ExternalProcess is supported, and default is ExternalProcess. + - **Description:** Type specifies the type of the filter configuration. + + +Currently, only ExternalProcess is supported, and default is ExternalProcess. - **externalProcess** - **Type:** _[AIGatewayFilterConfigExternalProcess](#aigatewayfilterconfigexternalprocess)_ - **Required:** No - - **Description:** ExternalProcess is the configuration for the external process filter.
This is optional, and if not set, the default values of Deployment spec will be used. + - **Description:** ExternalProcess is the configuration for the external process filter. +This is optional, and if not set, the default values of Deployment spec will be used. ### AIGatewayFilterConfigExternalProcess @@ -60,7 +64,8 @@ _Appears in:_ - **resources** - **Type:** _[ResourceRequirements](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#resourcerequirements-v1-core)_ - **Required:** No - - **Description:** Resources required by the external process container.
More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + - **Description:** Resources required by the external process container. +More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ ### AIGatewayFilterConfigType @@ -110,7 +115,6 @@ _Appears in:_ - **metadata** - **Type:** _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#objectmeta-v1-meta)_ - **Required:** Yes - - **Description:** Refer to Kubernetes API documentation for fields of `metadata`. - **spec** - **Type:** _[AIGatewayRouteSpec](#aigatewayroutespec)_ - **Required:** Yes @@ -134,11 +138,9 @@ AIGatewayRouteList contains a list of AIGatewayRoute. - **metadata** - **Type:** _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#listmeta-v1-meta)_ - **Required:** Yes - - **Description:** Refer to Kubernetes API documentation for fields of `metadata`. - **items** - **Type:** _[AIGatewayRoute](#aigatewayroute) array_ - **Required:** Yes - - **Description:** ### AIGatewayRouteRule @@ -153,11 +155,17 @@ _Appears in:_ - **backendRefs** - **Type:** _[AIGatewayRouteRuleBackendRef](#aigatewayrouterulebackendref) array_ - **Required:** No - - **Description:** BackendRefs is the list of AIServiceBackend that this rule will route the traffic to.
Each backend can have a weight that determines the traffic distribution.

The namespace of each backend is "local", i.e. the same namespace as the AIGatewayRoute. + - **Description:** BackendRefs is the list of AIServiceBackend that this rule will route the traffic to. +Each backend can have a weight that determines the traffic distribution. + + +The namespace of each backend is "local", i.e. the same namespace as the AIGatewayRoute. - **matches** - **Type:** _[AIGatewayRouteRuleMatch](#aigatewayrouterulematch) array_ - **Required:** No - - **Description:** Matches is the list of AIGatewayRouteMatch that this rule will match the traffic to.
This is a subset of the HTTPRouteMatch in the Gateway API. See for the details:
https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io%2fv1.HTTPRouteMatch + - **Description:** Matches is the list of AIGatewayRouteMatch that this rule will match the traffic to. +This is a subset of the HTTPRouteMatch in the Gateway API. See for the details: +https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io%2fv1.HTTPRouteMatch ### AIGatewayRouteRuleBackendRef @@ -176,7 +184,12 @@ _Appears in:_ - **weight** - **Type:** _integer_ - **Required:** No - - **Description:** Weight is the weight of the AIServiceBackend. This is exactly the same as the weight in
the BackendRef in the Gateway API. See for the details:
https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io%2fv1.BackendRef

Default is 1. + - **Description:** Weight is the weight of the AIServiceBackend. This is exactly the same as the weight in +the BackendRef in the Gateway API. See for the details: +https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io%2fv1.BackendRef + + +Default is 1. ### AIGatewayRouteRuleMatch @@ -191,7 +204,11 @@ _Appears in:_ - **headers** - **Type:** _HTTPHeaderMatch array_ - **Required:** No - - **Description:** Headers specifies HTTP request header matchers. See HeaderMatch in the Gateway API for the details:
https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io%2fv1.HTTPHeaderMatch

Currently, only the exact header matching is supported. + - **Description:** Headers specifies HTTP request header matchers. See HeaderMatch in the Gateway API for the details: +https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io%2fv1.HTTPHeaderMatch + + +Currently, only the exact header matching is supported. ### AIGatewayRouteSpec @@ -210,19 +227,138 @@ _Appears in:_ - **schema** - **Type:** _[VersionedAPISchema](#versionedapischema)_ - **Required:** Yes - - **Description:** APISchema specifies the API schema of the input that the target Gateway(s) will receive.
Based on this schema, the ai-gateway will perform the necessary transformation to the
output schema specified in the selected AIServiceBackend during the routing process.

Currently, the only supported schema is OpenAI as the input schema. + - **Description:** APISchema specifies the API schema of the input that the target Gateway(s) will receive. +Based on this schema, the ai-gateway will perform the necessary transformation to the +output schema specified in the selected AIServiceBackend during the routing process. + + +Currently, the only supported schema is OpenAI as the input schema. - **rules** - **Type:** _[AIGatewayRouteRule](#aigatewayrouterule) array_ - **Required:** Yes - - **Description:** Rules is the list of AIGatewayRouteRule that this AIGatewayRoute will match the traffic to.
Each rule is a subset of the HTTPRoute in the Gateway API (https://gateway-api.sigs.k8s.io/api-types/httproute/).

AI Gateway controller will generate a HTTPRoute based on the configuration given here with the additional
modifications to achieve the necessary jobs, notably inserting the AI Gateway filter responsible for
the transformation of the request and response, etc.

In the matching conditions in the AIGatewayRouteRule, `x-ai-eg-model` header is available
if we want to describe the routing behavior based on the model name. The model name is extracted
from the request content before the routing decision.

How multiple rules are matched is the same as the Gateway API. See for the details:
https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io%2fv1.HTTPRoute + - **Description:** Rules is the list of AIGatewayRouteRule that this AIGatewayRoute will match the traffic to. +Each rule is a subset of the HTTPRoute in the Gateway API (https://gateway-api.sigs.k8s.io/api-types/httproute/). + + +AI Gateway controller will generate a HTTPRoute based on the configuration given here with the additional +modifications to achieve the necessary jobs, notably inserting the AI Gateway filter responsible for +the transformation of the request and response, etc. + + +In the matching conditions in the AIGatewayRouteRule, `x-ai-eg-model` header is available +if we want to describe the routing behavior based on the model name. The model name is extracted +from the request content before the routing decision. + + +How multiple rules are matched is the same as the Gateway API. See for the details: +https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io%2fv1.HTTPRoute - **filterConfig** - **Type:** _[AIGatewayFilterConfig](#aigatewayfilterconfig)_ - **Required:** Yes - - **Description:** FilterConfig is the configuration for the AI Gateway filter inserted in the generated HTTPRoute.

An AI Gateway filter is responsible for the transformation of the request and response
as well as the routing behavior based on the model name extracted from the request content, etc.

Currently, the filter is only implemented as an external process filter, which might be
extended to other types of filters in the future. See https://github.com/envoyproxy/ai-gateway/issues/90 + - **Description:** FilterConfig is the configuration for the AI Gateway filter inserted in the generated HTTPRoute. + + +An AI Gateway filter is responsible for the transformation of the request and response +as well as the routing behavior based on the model name extracted from the request content, etc. + + +Currently, the filter is only implemented as an external process filter, which might be +extended to other types of filters in the future. See https://github.com/envoyproxy/ai-gateway/issues/90 - **llmRequestCosts** - **Type:** _[LLMRequestCost](#llmrequestcost) array_ - **Required:** No - - **Description:** LLMRequestCosts specifies how to capture the cost of the LLM-related request, notably the token usage.
The AI Gateway filter will capture each specified number and store it in the Envoy's dynamic
metadata per HTTP request. The namespaced key is "io.envoy.ai_gateway",

For example, let's say we have the following LLMRequestCosts configuration:

llmRequestCosts:
- metadataKey: llm_input_token
type: InputToken
- metadataKey: llm_output_token
type: OutputToken
- metadataKey: llm_total_token
type: TotalToken

Then, with the following BackendTrafficPolicy of Envoy Gateway, you can have three
rate limit buckets for each unique x-user-id header value. One bucket is for the input token,
the other is for the output token, and the last one is for the total token.
Each bucket will be reduced by the corresponding token usage captured by the AI Gateway filter.

apiVersion: gateway.envoyproxy.io/v1alpha1
kind: BackendTrafficPolicy
metadata:
name: some-example-token-rate-limit
namespace: default
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: usage-rate-limit
rateLimit:
type: Global
global:
rules:
- clientSelectors:
# Do the rate limiting based on the x-user-id header.
- headers:
- name: x-user-id
type: Distinct
limit:
# Configures the number of "tokens" allowed per hour.
requests: 10000
unit: Hour
cost:
request:
from: Number
# Setting the request cost to zero allows to only check the rate limit budget,
# and not consume the budget on the request path.
number: 0
# This specifies the cost of the response retrieved from the dynamic metadata set by the AI Gateway filter.
# The extracted value will be used to consume the rate limit budget, and subsequent requests will be rate limited
# if the budget is exhausted.
response:
from: Metadata
metadata:
namespace: io.envoy.ai_gateway
key: llm_input_token
- clientSelectors:
- headers:
- name: x-user-id
type: Distinct
limit:
requests: 10000
unit: Hour
cost:
request:
from: Number
number: 0
response:
from: Metadata
metadata:
namespace: io.envoy.ai_gateway
key: llm_output_token
- clientSelectors:
- headers:
- name: x-user-id
type: Distinct
limit:
requests: 10000
unit: Hour
cost:
request:
from: Number
number: 0
response:
from: Metadata
metadata:
namespace: io.envoy.ai_gateway
key: llm_total_token + - **Description:** LLMRequestCosts specifies how to capture the cost of the LLM-related request, notably the token usage. +The AI Gateway filter will capture each specified number and store it in the Envoy's dynamic +metadata per HTTP request. The namespaced key is "io.envoy.ai_gateway", + + +For example, let's say we have the following LLMRequestCosts configuration: + + + llmRequestCosts: + - metadataKey: llm_input_token + type: InputToken + - metadataKey: llm_output_token + type: OutputToken + - metadataKey: llm_total_token + type: TotalToken + + +Then, with the following BackendTrafficPolicy of Envoy Gateway, you can have three +rate limit buckets for each unique x-user-id header value. One bucket is for the input token, +the other is for the output token, and the last one is for the total token. +Each bucket will be reduced by the corresponding token usage captured by the AI Gateway filter. + + + apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: BackendTrafficPolicy + metadata: + name: some-example-token-rate-limit + namespace: default + spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: usage-rate-limit + rateLimit: + type: Global + global: + rules: + - clientSelectors: + # Do the rate limiting based on the x-user-id header. + - headers: + - name: x-user-id + type: Distinct + limit: + # Configures the number of "tokens" allowed per hour. + requests: 10000 + unit: Hour + cost: + request: + from: Number + # Setting the request cost to zero allows to only check the rate limit budget, + # and not consume the budget on the request path. + number: 0 + # This specifies the cost of the response retrieved from the dynamic metadata set by the AI Gateway filter. + # The extracted value will be used to consume the rate limit budget, and subsequent requests will be rate limited + # if the budget is exhausted. + response: + from: Metadata + metadata: + namespace: io.envoy.ai_gateway + key: llm_input_token + - clientSelectors: + - headers: + - name: x-user-id + type: Distinct + limit: + requests: 10000 + unit: Hour + cost: + request: + from: Number + number: 0 + response: + from: Metadata + metadata: + namespace: io.envoy.ai_gateway + key: llm_output_token + - clientSelectors: + - headers: + - name: x-user-id + type: Distinct + limit: + requests: 10000 + unit: Hour + cost: + request: + from: Number + number: 0 + response: + from: Metadata + metadata: + namespace: io.envoy.ai_gateway + key: llm_total_token ### AIServiceBackend @@ -252,7 +388,6 @@ _Appears in:_ - **metadata** - **Type:** _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#objectmeta-v1-meta)_ - **Required:** Yes - - **Description:** Refer to Kubernetes API documentation for fields of `metadata`. - **spec** - **Type:** _[AIServiceBackendSpec](#aiservicebackendspec)_ - **Required:** Yes @@ -276,11 +411,9 @@ AIServiceBackendList contains a list of AIServiceBackends. - **metadata** - **Type:** _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#listmeta-v1-meta)_ - **Required:** Yes - - **Description:** Refer to Kubernetes API documentation for fields of `metadata`. - **items** - **Type:** _[AIServiceBackend](#aiservicebackend) array_ - **Required:** Yes - - **Description:** ### AIServiceBackendSpec @@ -295,15 +428,28 @@ _Appears in:_ - **schema** - **Type:** _[VersionedAPISchema](#versionedapischema)_ - **Required:** Yes - - **Description:** APISchema specifies the API schema of the output format of requests from
Envoy that this AIServiceBackend can accept as incoming requests.
Based on this schema, the ai-gateway will perform the necessary transformation for
the pair of AIGatewayRouteSpec.APISchema and AIServiceBackendSpec.APISchema.

This is required to be set. + - **Description:** APISchema specifies the API schema of the output format of requests from +Envoy that this AIServiceBackend can accept as incoming requests. +Based on this schema, the ai-gateway will perform the necessary transformation for +the pair of AIGatewayRouteSpec.APISchema and AIServiceBackendSpec.APISchema. + + +This is required to be set. - **backendRef** - **Type:** _[BackendObjectReference](https://gateway-api.sigs.k8s.io/references/spec/#gateway.networking.k8s.io/v1.BackendObjectReference)_ - **Required:** Yes - - **Description:** BackendRef is the reference to the Backend resource that this AIServiceBackend corresponds to.

A backend can be of either k8s Service or Backend resource of Envoy Gateway.

This is required to be set. + - **Description:** BackendRef is the reference to the Backend resource that this AIServiceBackend corresponds to. + + +A backend can be of either k8s Service or Backend resource of Envoy Gateway. + + +This is required to be set. - **backendSecurityPolicyRef** - **Type:** _[LocalObjectReference](#localobjectreference)_ - **Required:** No - - **Description:** BackendSecurityPolicyRef is the name of the BackendSecurityPolicy resources this backend
is being attached to. + - **Description:** BackendSecurityPolicyRef is the name of the BackendSecurityPolicy resources this backend +is being attached to. ### APISchema @@ -334,7 +480,10 @@ _Appears in:_ - **secretRef** - **Type:** _[SecretObjectReference](https://gateway-api.sigs.k8s.io/references/spec/#gateway.networking.k8s.io/v1.SecretObjectReference)_ - **Required:** Yes - - **Description:** SecretRef is the reference to the credential file.

The secret should contain the AWS credentials file keyed on "credentials". + - **Description:** SecretRef is the reference to the credential file. + + +The secret should contain the AWS credentials file keyed on "credentials". - **profile** - **Type:** _string_ - **Required:** Yes @@ -367,7 +516,8 @@ _Appears in:_ - **awsRoleArn** - **Type:** _string_ - **Required:** Yes - - **Description:** AwsRoleArn is the AWS IAM Role with the permission to use specific resources in AWS account
which maps to the temporary AWS security credentials exchanged using the authentication token issued by OIDC provider. + - **Description:** AwsRoleArn is the AWS IAM Role with the permission to use specific resources in AWS account +which maps to the temporary AWS security credentials exchanged using the authentication token issued by OIDC provider. ### BackendSecurityPolicy @@ -389,11 +539,9 @@ _Appears in:_ - **metadata** - **Type:** _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#objectmeta-v1-meta)_ - **Required:** Yes - - **Description:** Refer to Kubernetes API documentation for fields of `metadata`. - **spec** - **Type:** _[BackendSecurityPolicySpec](#backendsecuritypolicyspec)_ - **Required:** Yes - - **Description:** ### BackendSecurityPolicyAPIKey @@ -408,7 +556,9 @@ _Appears in:_ - **secretRef** - **Type:** _[SecretObjectReference](https://gateway-api.sigs.k8s.io/references/spec/#gateway.networking.k8s.io/v1.SecretObjectReference)_ - **Required:** Yes - - **Description:** SecretRef is the reference to the secret containing the API key.
ai-gateway must be given the permission to read this secret.
The key of the secret should be "apiKey". + - **Description:** SecretRef is the reference to the secret containing the API key. +ai-gateway must be given the permission to read this secret. +The key of the secret should be "apiKey". ### BackendSecurityPolicyAWSCredentials @@ -431,7 +581,8 @@ _Appears in:_ - **oidcExchangeToken** - **Type:** _[AWSOIDCExchangeToken](#awsoidcexchangetoken)_ - **Required:** No - - **Description:** OIDCExchangeToken specifies the oidc configurations used to obtain an oidc token. The oidc token will be
used to obtain temporary credentials to access AWS. + - **Description:** OIDCExchangeToken specifies the oidc configurations used to obtain an oidc token. The oidc token will be +used to obtain temporary credentials to access AWS. ### BackendSecurityPolicyList @@ -451,11 +602,9 @@ BackendSecurityPolicyList contains a list of BackendSecurityPolicy - **metadata** - **Type:** _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#listmeta-v1-meta)_ - **Required:** Yes - - **Description:** Refer to Kubernetes API documentation for fields of `metadata`. - **items** - **Type:** _[BackendSecurityPolicy](#backendsecuritypolicy) array_ - **Required:** Yes - - **Description:** ### BackendSecurityPolicySpec @@ -516,11 +665,34 @@ _Appears in:_ - **type** - **Type:** _[LLMRequestCostType](#llmrequestcosttype)_ - **Required:** Yes - - **Description:** Type specifies the type of the request cost. The default is "OutputToken",
and it uses "output token" as the cost. The other types are "InputToken", "TotalToken",
and "CEL". + - **Description:** Type specifies the type of the request cost. The default is "OutputToken", +and it uses "output token" as the cost. The other types are "InputToken", "TotalToken", +and "CEL". - **celExpression** - **Type:** _string_ - **Required:** No - - **Description:** CELExpression is the CEL expression to calculate the cost of the request.
The CEL expression must return a signed or unsigned integer. If the
return value is negative, it will be error.

The expression can use the following variables:

* model: the model name extracted from the request content. Type: string.
* backend: the backend name in the form of "name.namespace". Type: string.
* input_tokens: the number of input tokens. Type: unsigned integer.
* output_tokens: the number of output tokens. Type: unsigned integer.
* total_tokens: the total number of tokens. Type: unsigned integer.

For example, the following expressions are valid:

* "model == 'llama' ? input_tokens + output_token * 0.5 : total_tokens"
* "backend == 'foo.default' ? input_tokens + output_tokens : total_tokens"
* "input_tokens + output_tokens + total_tokens"
* "input_tokens * output_tokens" + - **Description:** CELExpression is the CEL expression to calculate the cost of the request. +The CEL expression must return a signed or unsigned integer. If the +return value is negative, it will be error. + + +The expression can use the following variables: + + + * model: the model name extracted from the request content. Type: string. + * backend: the backend name in the form of "name.namespace". Type: string. + * input_tokens: the number of input tokens. Type: unsigned integer. + * output_tokens: the number of output tokens. Type: unsigned integer. + * total_tokens: the total number of tokens. Type: unsigned integer. + + +For example, the following expressions are valid: + + + * "model == 'llama' ? input_tokens + output_token * 0.5 : total_tokens" + * "backend == 'foo.default' ? input_tokens + output_tokens : total_tokens" + * "input_tokens + output_tokens + total_tokens" + * "input_tokens * output_tokens" ### LLMRequestCostType From e98802b9d864c3de315fa04c6bc6cbb4b0dc202a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Han?= Date: Mon, 3 Feb 2025 16:00:40 +0100 Subject: [PATCH 14/40] ci: skip style checks for dependabot PRs (#282) Signed-off-by: Eric Mariasis --- .github/workflows/pr_style_check.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/pr_style_check.yaml b/.github/workflows/pr_style_check.yaml index e01b56903..8b81efced 100644 --- a/.github/workflows/pr_style_check.yaml +++ b/.github/workflows/pr_style_check.yaml @@ -17,6 +17,7 @@ jobs: env: # Do not use ${{ github.event.pull_request.body }} directly in run command. BODY: ${{ github.event.pull_request.body }} + if: ${{ github.actor != 'dependabot[bot]' }} steps: - name: Check comment out lines run: | @@ -41,6 +42,7 @@ jobs: title: name: Title runs-on: ubuntu-latest + if: ${{ github.actor != 'dependabot[bot]' }} steps: - uses: amannn/action-semantic-pull-request@v5 env: From 008a7e3b2161f77825855c48e6286a45a964e790 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Feb 2025 07:12:59 -0800 Subject: [PATCH 15/40] build(deps): bump github.com/envoyproxy/go-control-plane/envoy from 1.32.3 to 1.32.4 (#281) Signed-off-by: Eric Mariasis --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 90d8e20b1..3b692840b 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.8 github.com/aws/aws-sdk-go-v2/config v1.29.3 github.com/envoyproxy/gateway v1.3.0 - github.com/envoyproxy/go-control-plane/envoy v1.32.3 + github.com/envoyproxy/go-control-plane/envoy v1.32.4 github.com/go-logr/logr v1.4.2 github.com/google/cel-go v0.23.1 github.com/google/go-cmp v0.6.0 diff --git a/go.sum b/go.sum index 2f3067081..161611def 100644 --- a/go.sum +++ b/go.sum @@ -44,8 +44,8 @@ github.com/emicklei/go-restful/v3 v3.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtz github.com/emicklei/go-restful/v3 v3.12.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/envoyproxy/gateway v1.3.0 h1:c/JpMvgIylKbaCUEMjLdSPHRq7B4S3wsqpVVv/30U50= github.com/envoyproxy/gateway v1.3.0/go.mod h1:5bQVQZet9ME0YYC5+WAP9KhFltdiRcRtzhUeljqD+ws= -github.com/envoyproxy/go-control-plane/envoy v1.32.3 h1:hVEaommgvzTjTd4xCaFd+kEQ2iYBtGxP6luyLrx6uOk= -github.com/envoyproxy/go-control-plane/envoy v1.32.3/go.mod h1:F6hWupPfh75TBXGKA++MCT/CZHFq5r9/uwt/kQYkZfE= +github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= +github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= From 707d7618cc009e08ce2c39ecdc5e5a1fbb74f37e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Feb 2025 07:13:16 -0800 Subject: [PATCH 16/40] build(deps): bump github.com/openai/openai-go from 0.1.0-alpha.50 to 0.1.0-alpha.51 (#280) Signed-off-by: Eric Mariasis --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 3b692840b..44c833e87 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/go-logr/logr v1.4.2 github.com/google/cel-go v0.23.1 github.com/google/go-cmp v0.6.0 - github.com/openai/openai-go v0.1.0-alpha.50 + github.com/openai/openai-go v0.1.0-alpha.51 github.com/stretchr/testify v1.10.0 go.uber.org/goleak v1.3.0 go.uber.org/zap v1.27.0 diff --git a/go.sum b/go.sum index 161611def..f58f67097 100644 --- a/go.sum +++ b/go.sum @@ -117,8 +117,8 @@ github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= -github.com/openai/openai-go v0.1.0-alpha.50 h1:ZxC7Uq0RvkZIxNIpKpcKwkahhyinEUCfgbjMQeVnEv8= -github.com/openai/openai-go v0.1.0-alpha.50/go.mod h1:3SdE6BffOX9HPEQv8IL/fi3LYZ5TUpRYaqGQZbyk11A= +github.com/openai/openai-go v0.1.0-alpha.51 h1:/iuF8QoWt4x9yoEr6AdMsSBc2SglamxA/a7wClrDrqw= +github.com/openai/openai-go v0.1.0-alpha.51/go.mod h1:3SdE6BffOX9HPEQv8IL/fi3LYZ5TUpRYaqGQZbyk11A= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= From 69085d2058e40ceff216c1d6ccb049f1917cb9db Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Feb 2025 07:13:37 -0800 Subject: [PATCH 17/40] build(deps): bump github.com/aws/aws-sdk-go-v2 from 1.35.0 to 1.36.0 (#277) Signed-off-by: Eric Mariasis --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 44c833e87..bbf4f4b9a 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/envoyproxy/ai-gateway go 1.23.5 require ( - github.com/aws/aws-sdk-go-v2 v1.35.0 + github.com/aws/aws-sdk-go-v2 v1.36.0 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.8 github.com/aws/aws-sdk-go-v2/config v1.29.3 github.com/envoyproxy/gateway v1.3.0 diff --git a/go.sum b/go.sum index f58f67097..44f65b5b4 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ cel.dev/expr v0.19.2 h1:V354PbqIXr9IQdwy4SYA4xa0HXaWq1BUPAGzugBY5V4= cel.dev/expr v0.19.2/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= -github.com/aws/aws-sdk-go-v2 v1.35.0 h1:jTPxEJyzjSuuz0wB+302hr8Eu9KUI+Zv8zlujMGJpVI= -github.com/aws/aws-sdk-go-v2 v1.35.0/go.mod h1:JgstGg0JjWU1KpVJjD5H0y0yyAIpSdKEq556EI6yOOM= +github.com/aws/aws-sdk-go-v2 v1.36.0 h1:b1wM5CcE65Ujwn565qcwgtOTT1aT4ADOHHgglKjG7fk= +github.com/aws/aws-sdk-go-v2 v1.36.0/go.mod h1:5PMILGVKiW32oDzjj6RU52yrNrDPUHcbZQYr1sM7qmM= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.8 h1:zAxi9p3wsZMIaVCdoiQp2uZ9k1LsZvmAnoTBeZPXom0= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.8/go.mod h1:3XkePX5dSaxveLAYY7nsbsZZrKxCyEuE5pM4ziFxyGg= github.com/aws/aws-sdk-go-v2/config v1.29.3 h1:a5Ucjxe6iV+LHEBmYA9w40rT5aGxWybx/4l/O/fvJlE= From 757f275613096a50175add7f03f053563e798f7a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Feb 2025 07:13:51 -0800 Subject: [PATCH 18/40] build(deps): bump github.com/google/cel-go from 0.23.1 to 0.23.2 (#279) Signed-off-by: Eric Mariasis --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index bbf4f4b9a..d6883ddd5 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/envoyproxy/gateway v1.3.0 github.com/envoyproxy/go-control-plane/envoy v1.32.4 github.com/go-logr/logr v1.4.2 - github.com/google/cel-go v0.23.1 + github.com/google/cel-go v0.23.2 github.com/google/go-cmp v0.6.0 github.com/openai/openai-go v0.1.0-alpha.51 github.com/stretchr/testify v1.10.0 diff --git a/go.sum b/go.sum index 44f65b5b4..61fb0c0d7 100644 --- a/go.sum +++ b/go.sum @@ -76,8 +76,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= -github.com/google/cel-go v0.23.1 h1:91ThhEZlBcE5rB2adBVXqvDoqdL8BG2oyhd0bK1I/r4= -github.com/google/cel-go v0.23.1/go.mod h1:52Pb6QsDbC5kvgxvZhiL9QX1oZEkcUF/ZqaPx1J5Wwo= +github.com/google/cel-go v0.23.2 h1:UdEe3CvQh3Nv+E/j9r1Y//WO0K0cSyD7/y0bzyLIMI4= +github.com/google/cel-go v0.23.2/go.mod h1:52Pb6QsDbC5kvgxvZhiL9QX1oZEkcUF/ZqaPx1J5Wwo= github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= From 6d1fc677f14053de237d48370bcf33acd04da847 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Han?= Date: Mon, 3 Feb 2025 16:15:27 +0100 Subject: [PATCH 19/40] test: resolve warnings in envoy.yaml (#283) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Commit Message** Addressed warnings observed during `make test-extproc`, except for the `reuse_port` warning, which is platform-dependent and cannot be fixed on non-Linux systems. - Updated `json_format` to be nested under `log_format` to align with Envoy's expected configuration. - Added `overload_manager` configuration to manage downstream connection limits. - The warning related to `reuse_port` being force disabled on non-Linux platforms was **not** fixed, as it is platform-dependent. Signed-off-by: Sébastien Han **Related Issues/PRs (if applicable)** #274 **Special notes for reviewers (if applicable)** The warning related to `reuse_port` being force disabled on non-Linux platforms was **not** fixed, as it is platform-dependent. Any suggestions for a fix are welcome. Signed-off-by: Sébastien Han Signed-off-by: Eric Mariasis --- tests/extproc/envoy.yaml | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/extproc/envoy.yaml b/tests/extproc/envoy.yaml index 84fb3e444..4762f59f0 100644 --- a/tests/extproc/envoy.yaml +++ b/tests/extproc/envoy.yaml @@ -16,9 +16,10 @@ static_resources: typed_config: "@type": type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog path: ACCESS_LOG_PATH - json_format: - "used_token": "%DYNAMIC_METADATA(ai_gateway_llm_ns:used_token)%" - "some_cel": "%DYNAMIC_METADATA(ai_gateway_llm_ns:some_cel)%" + log_format: + json_format: + used_token: "%DYNAMIC_METADATA(ai_gateway_llm_ns:used_token)%" + some_cel: "%DYNAMIC_METADATA(ai_gateway_llm_ns:some_cel)%" route_config: virtual_hosts: - name: local_route @@ -142,3 +143,11 @@ static_resources: typed_config: "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext sni: api.openai.com + +overload_manager: + refresh_interval: 0.25s + resource_monitors: + - name: "envoy.resource_monitors.global_downstream_max_connections" + typed_config: + "@type": type.googleapis.com/envoy.extensions.resource_monitors.downstream_connections.v3.DownstreamConnectionsConfig + max_active_downstream_connections: 1000 From 6f0470f0775398b66eb24441d8793ead72c3b3f6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Feb 2025 07:21:59 -0800 Subject: [PATCH 20/40] build(deps): bump github.com/aws/aws-sdk-go-v2/config from 1.29.3 to 1.29.4 (#278) Signed-off-by: Eric Mariasis --- go.mod | 18 +++++++++--------- go.sum | 36 ++++++++++++++++++------------------ 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/go.mod b/go.mod index d6883ddd5..ae9f83d30 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.23.5 require ( github.com/aws/aws-sdk-go-v2 v1.36.0 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.8 - github.com/aws/aws-sdk-go-v2/config v1.29.3 + github.com/aws/aws-sdk-go-v2/config v1.29.4 github.com/envoyproxy/gateway v1.3.0 github.com/envoyproxy/go-control-plane/envoy v1.32.4 github.com/go-logr/logr v1.4.2 @@ -32,16 +32,16 @@ require ( require ( cel.dev/expr v0.19.2 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.17.56 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.26 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.30 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.30 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.57 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.31 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.31 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.11 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.24.13 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.12 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.33.11 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.12 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.33.12 // indirect github.com/aws/smithy-go v1.22.2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect diff --git a/go.sum b/go.sum index 61fb0c0d7..efdc62369 100644 --- a/go.sum +++ b/go.sum @@ -6,28 +6,28 @@ github.com/aws/aws-sdk-go-v2 v1.36.0 h1:b1wM5CcE65Ujwn565qcwgtOTT1aT4ADOHHgglKjG github.com/aws/aws-sdk-go-v2 v1.36.0/go.mod h1:5PMILGVKiW32oDzjj6RU52yrNrDPUHcbZQYr1sM7qmM= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.8 h1:zAxi9p3wsZMIaVCdoiQp2uZ9k1LsZvmAnoTBeZPXom0= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.8/go.mod h1:3XkePX5dSaxveLAYY7nsbsZZrKxCyEuE5pM4ziFxyGg= -github.com/aws/aws-sdk-go-v2/config v1.29.3 h1:a5Ucjxe6iV+LHEBmYA9w40rT5aGxWybx/4l/O/fvJlE= -github.com/aws/aws-sdk-go-v2/config v1.29.3/go.mod h1:pt9z1x12zDiDb4iFLrxoeAKLVCU/Gp9DL/5BnwlY77o= -github.com/aws/aws-sdk-go-v2/credentials v1.17.56 h1:JKMBreKudV+ozx6rZJLvEtiexv48aEdhdC7mXUw9MLs= -github.com/aws/aws-sdk-go-v2/credentials v1.17.56/go.mod h1:S3xRjIHD8HHFgMTz4L56q/7IldfNtGL9JjH/vP3U6DA= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.26 h1:XMBqBEuZLf8yxtH+mU/uUDyQbN4iD/xv9h6he2+lzhw= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.26/go.mod h1:d0+wQ/3CYGPuHEfBTPpQdfUX7gjk0/Lxs5Q6KzdEGY8= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.30 h1:+7AzSGNhHoY53di13lvztf9Dyd/9ofzoYGBllkWp3a0= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.30/go.mod h1:Jxd/FrCny99yURiQiMywgXvBhd7tmgdv6KdlUTNzMSo= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.30 h1:Ex06eY6I5rO7IX0HalGfa5nGjpBoOsS1Qm3xfjkuszs= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.30/go.mod h1:AvyEMA9QcX59kFhVizBpIBpEMThUTXssuJe+emBdcGM= +github.com/aws/aws-sdk-go-v2/config v1.29.4 h1:ObNqKsDYFGr2WxnoXKOhCvTlf3HhwtoGgc+KmZ4H5yg= +github.com/aws/aws-sdk-go-v2/config v1.29.4/go.mod h1:j2/AF7j/qxVmsNIChw1tWfsVKOayJoGRDjg1Tgq7NPk= +github.com/aws/aws-sdk-go-v2/credentials v1.17.57 h1:kFQDsbdBAR3GZsB8xA+51ptEnq9TIj3tS4MuP5b+TcQ= +github.com/aws/aws-sdk-go-v2/credentials v1.17.57/go.mod h1:2kerxPUUbTagAr/kkaHiqvj/bcYHzi2qiJS/ZinllU0= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 h1:7lOW8NUwE9UZekS1DYoiPdVAqZ6A+LheHWb+mHbNOq8= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27/go.mod h1:w1BASFIPOPUae7AgaH4SbjNbfdkxuggLyGfNFTn8ITY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.31 h1:lWm9ucLSRFiI4dQQafLrEOmEDGry3Swrz0BIRdiHJqQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.31/go.mod h1:Huu6GG0YTfbPphQkDSo4dEGmQRTKb9k9G7RdtyQWxuI= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.31 h1:ACxDklUKKXb48+eg5ROZXi1vDgfMyfIA/WyvqHcHI0o= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.31/go.mod h1:yadnfsDwqXeVaohbGc/RaD287PuyRw2wugkh5ZL2J6k= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 h1:Pg9URiobXy85kgFev3og2CuOZ8JZUBENF+dcgWBaYNk= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2 h1:D4oz8/CzT9bAEYtVhSBmFj2dNOtaHOtMKc2vHBwYizA= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2/go.mod h1:Za3IHqTQ+yNcRHxu1OFucBh0ACZT4j4VQFF0BqpZcLY= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.11 h1:5JKQ2J3BBW4ovy6A/5Lwx9SpA6IzgH8jB3bquGZ1NUw= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.11/go.mod h1:VShCk7rfCzK/b9U1aSkzLwcOoaDlYna16482QqEavis= -github.com/aws/aws-sdk-go-v2/service/sso v1.24.13 h1:q4pOAKxypbFoUJzOpgo939bF50qb4DgYshiDfcsdN0M= -github.com/aws/aws-sdk-go-v2/service/sso v1.24.13/go.mod h1:G/0PTg7+vQT42ictQGjJhixzTcVZtHFvrN/OeTXrRfQ= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.12 h1:4sGSGshSSfO1vrcXruPick3ioSf8nhhD6nuB2ni37P4= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.12/go.mod h1:NHpu/pLOelViA4qxkAFH10VLqh+XeLhZfXDaFyMVgSs= -github.com/aws/aws-sdk-go-v2/service/sts v1.33.11 h1:RIXOjp7Dp4siCYJRwBHUcBdVgOWflSJGlq4ZhMI5Ta0= -github.com/aws/aws-sdk-go-v2/service/sts v1.33.11/go.mod h1:ZR17k9bPKPR8u0IkyA6xVsjr56doNQ4ZB1fs7abYBfE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.12 h1:O+8vD2rGjfihBewr5bT+QUfYUHIxCVgG61LHoT59shM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.12/go.mod h1:usVdWJaosa66NMvmCrr08NcWDBRv4E6+YFG2pUdw1Lk= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 h1:c5WJ3iHz7rLIgArznb3JCSQT3uUMiz9DLZhIX+1G8ok= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.14/go.mod h1:+JJQTxB6N4niArC14YNtxcQtwEqzS3o9Z32n7q33Rfs= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 h1:f1L/JtUkVODD+k1+IiSJUUv8A++2qVr+Xvb3xWXETMU= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13/go.mod h1:tvqlFoja8/s0o+UruA1Nrezo/df0PzdunMDDurUfg6U= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.12 h1:fqg6c1KVrc3SYWma/egWue5rKI4G2+M4wMQN2JosNAA= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.12/go.mod h1:7Yn+p66q/jt38qMoVfNvjbm3D89mGBnkwDcijgtih8w= github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= From ef2e4289e97a678bba1f5ae78d6b1b445e692b69 Mon Sep 17 00:00:00 2001 From: Soma Utsumi <53121322+soma00333@users.noreply.github.com> Date: Tue, 4 Feb 2025 00:24:00 +0900 Subject: [PATCH 21/40] ci: add issue title check (#273) **Commit Message** This introduces a new GitHub Actions workflow to check the length of issue titles and provide feedback if they exceed a specified limit. **Related Issues/PRs (if applicable)** Fixes https://github.com/envoyproxy/ai-gateway/issues/270 --------- Signed-off-by: soma00333 Signed-off-by: Eric Mariasis --- .github/workflows/issue_title_check.yaml | 36 ++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .github/workflows/issue_title_check.yaml diff --git a/.github/workflows/issue_title_check.yaml b/.github/workflows/issue_title_check.yaml new file mode 100644 index 000000000..129ab6728 --- /dev/null +++ b/.github/workflows/issue_title_check.yaml @@ -0,0 +1,36 @@ +name: Issue Title Check + +on: + issues: + types: [opened, edited] + +permissions: + issues: write + +jobs: + check_title_length: + runs-on: ubuntu-latest + steps: + - name: Check Issue Title Length + uses: actions/github-script@v6 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const issueNumber = context.issue.number; + const repoOwner = context.repo.owner; + const repoName = context.repo.repo; + + const issueTitle = context.payload.issue.title; + const length = issueTitle.length; + + console.log(`Title: ${issueTitle}`); + console.log(`Length: ${length}`); + + if (length >= 60) { + await github.rest.issues.createComment({ + owner: repoOwner, + repo: repoName, + issue_number: issueNumber, + body: "The issue title is too long (over 60 characters). Please shorten it and ensure it is well summarized." + }); + } From 78ec79661712fd35440bd824587ecfa5ee7ddaeb Mon Sep 17 00:00:00 2001 From: Takeshi Yoneda Date: Mon, 3 Feb 2025 09:57:52 -0800 Subject: [PATCH 22/40] ci: adjust issue/pr style checks (#284) **Commit Message** This adjusts the issue title check as well as the PR style check. For issue title, I adopt the suggestion by @leseb as indeed 60 was too strict. For the PR stuff, it simply changes it to forbid >60. --------- Signed-off-by: Takeshi Yoneda Signed-off-by: Eric Mariasis --- .github/workflows/issue_title_check.yaml | 4 ++-- .github/workflows/pr_style_check.yaml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/issue_title_check.yaml b/.github/workflows/issue_title_check.yaml index 129ab6728..dc713758a 100644 --- a/.github/workflows/issue_title_check.yaml +++ b/.github/workflows/issue_title_check.yaml @@ -26,11 +26,11 @@ jobs: console.log(`Title: ${issueTitle}`); console.log(`Length: ${length}`); - if (length >= 60) { + if (length > 80) { await github.rest.issues.createComment({ owner: repoOwner, repo: repoName, issue_number: issueNumber, - body: "The issue title is too long (over 60 characters). Please shorten it and ensure it is well summarized." + body: "The issue title is too long (over 80 characters). Please shorten it and ensure it is well summarized." }); } diff --git a/.github/workflows/pr_style_check.yaml b/.github/workflows/pr_style_check.yaml index 8b81efced..ec0deb2b3 100644 --- a/.github/workflows/pr_style_check.yaml +++ b/.github/workflows/pr_style_check.yaml @@ -75,11 +75,11 @@ jobs: env: # Do not use ${{ github.event.pull_request.title }} directly in run command. TITLE: ${{ github.event.pull_request.title }} - # We want to make sure that each commit "subject" is under 60 characters not to + # We want to make sure that each commit "subject" is <=60 characters not to # be truncated in the git log as well as in the GitHub UI. # https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/Documentation/process/submitting-patches.rst?id=bc7938deaca7f474918c41a0372a410049bd4e13#n664 run: | - if (( ${#TITLE} >= 60 )); then - echo "The PR title is too long. Please keep it under 60 characters." + if (( ${#TITLE} > 60 )); then + echo "The PR title is too long. Please keep it <=60 characters." exit 1 fi From d4df342ea8967b768cfec92b0ab3afe125bd0727 Mon Sep 17 00:00:00 2001 From: Takeshi Yoneda Date: Mon, 3 Feb 2025 22:19:34 -0800 Subject: [PATCH 23/40] ci: stricter permissions on style/tests workflows (#288) **Commit Message** This puts stricter permissions on Tests and Style GitHub Actions workflows. Signed-off-by: Takeshi Yoneda Signed-off-by: Eric Mariasis --- .github/workflows/style.yaml | 3 +++ .github/workflows/tests.yaml | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/.github/workflows/style.yaml b/.github/workflows/style.yaml index ed5c2b8d2..eb9f0bd7b 100644 --- a/.github/workflows/style.yaml +++ b/.github/workflows/style.yaml @@ -7,6 +7,9 @@ on: branches: - main +permissions: + contents: read + jobs: style: name: Check diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 5bb9015b5..3bd1fc2f1 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -29,6 +29,10 @@ concurrency: group: ${{ github.ref }}-${{ github.workflow }}-${{ github.actor }}-${{ github.event_name }} cancel-in-progress: true +permissions: + contents: read + packages: write + jobs: unittest: if: github.event_name == 'pull_request' || github.event_name == 'push' From 63865c795ee242f7e1da9e299eeea1d540b9b700 Mon Sep 17 00:00:00 2001 From: Loong Dai Date: Wed, 5 Feb 2025 01:01:00 +0800 Subject: [PATCH 24/40] controller: mark all volume mounts ReadOnly=true (#252) **Commit Message** Mark all volume mounts readonly to ensure security. **Related Issues/PRs (if applicable)** Fix #250 --------- Signed-off-by: Loong Co-authored-by: Takeshi Yoneda Signed-off-by: Eric Mariasis --- internal/controller/sink.go | 7 ++++++- internal/controller/sink_test.go | 21 +++++++++++---------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/internal/controller/sink.go b/internal/controller/sink.go index 3005b9453..9bf46b917 100644 --- a/internal/controller/sink.go +++ b/internal/controller/sink.go @@ -491,7 +491,11 @@ func (c *configSink) syncExtProcDeployment(ctx context.Context, aiGatewayRoute * "-logLevel", c.extProcLogLevel, }, VolumeMounts: []corev1.VolumeMount{ - {Name: "config", MountPath: "/etc/ai-gateway/extproc"}, + { + Name: "config", + MountPath: "/etc/ai-gateway/extproc", + ReadOnly: true, + }, }, }, }, @@ -613,6 +617,7 @@ func (c *configSink) mountBackendSecurityPolicySecrets(spec *corev1.PodSpec, aiG container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{ Name: volumeName, MountPath: backendSecurityMountPath(volumeName), + ReadOnly: true, }) } } diff --git a/internal/controller/sink_test.go b/internal/controller/sink_test.go index 7addc245f..3cdd3cbc7 100644 --- a/internal/controller/sink_test.go +++ b/internal/controller/sink_test.go @@ -639,6 +639,10 @@ func TestConfigSink_SyncExtprocDeployment(t *testing.T) { require.Equal(t, "AIGatewayRoute", extProcDeployment.OwnerReferences[0].Kind) require.Equal(t, int32(456), *extProcDeployment.Spec.Replicas) require.Equal(t, newResourceLimits, &extProcDeployment.Spec.Template.Spec.Containers[0].Resources) + + for _, v := range extProcDeployment.Spec.Template.Spec.Containers[0].VolumeMounts { + require.True(t, v.ReadOnly) + } return true }, 30*time.Second, 200*time.Millisecond) }) @@ -755,25 +759,18 @@ func TestConfigSink_MountBackendSecurityPolicySecrets(t *testing.T) { spec := corev1.PodSpec{ Volumes: []corev1.Volume{ { - Name: "some-cm-policy", + Name: "extproc-config", VolumeSource: corev1.VolumeSource{ ConfigMap: &corev1.ConfigMapVolumeSource{ LocalObjectReference: corev1.LocalObjectReference{ - Name: "some-cm-policy", + Name: "extproc-config", }, }, }, }, }, Containers: []corev1.Container{ - { - VolumeMounts: []corev1.VolumeMount{ - { - Name: "some-cm-policy", - MountPath: "some-path", - }, - }, - }, + {VolumeMounts: []corev1.VolumeMount{{Name: "extproc-config", MountPath: "some-path", ReadOnly: true}}}, }, } @@ -821,6 +818,10 @@ func TestConfigSink_MountBackendSecurityPolicySecrets(t *testing.T) { require.Equal(t, "rule0-backref0-some-other-backend-security-policy-2", updatedSpec.Volumes[1].Name) require.Equal(t, "rule0-backref0-some-other-backend-security-policy-2", updatedSpec.Containers[0].VolumeMounts[1].Name) require.Equal(t, "/etc/backend_security_policy/rule0-backref0-some-other-backend-security-policy-2", updatedSpec.Containers[0].VolumeMounts[1].MountPath) + + for _, v := range updatedSpec.Containers[0].VolumeMounts { + require.True(t, v.ReadOnly, v.Name) + } } func Test_backendSecurityPolicyVolumeName(t *testing.T) { From 219ffce68763acb7b9934fe6312ad4387ec5e69e Mon Sep 17 00:00:00 2001 From: Takeshi Yoneda Date: Tue, 4 Feb 2025 09:32:22 -0800 Subject: [PATCH 25/40] examples: adds token rate limit (#289) **Commit Message** This add a CEL expression token usage example that is missing from the example as well as the e2e test. **Related Issues/PRs (if applicable)** closes #257 Signed-off-by: Takeshi Yoneda Signed-off-by: Eric Mariasis --- examples/token_ratelimit/token_ratelimit.yaml | 25 +++++++++++++++ tests/e2e/token_ratelimit_test.go | 32 +++++++++++++------ 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/examples/token_ratelimit/token_ratelimit.yaml b/examples/token_ratelimit/token_ratelimit.yaml index 699061592..8f5024c8d 100644 --- a/examples/token_ratelimit/token_ratelimit.yaml +++ b/examples/token_ratelimit/token_ratelimit.yaml @@ -46,6 +46,12 @@ spec: type: OutputToken - metadataKey: llm_total_token type: TotalToken + # This configures the token limit based on the CEL expression. + # For a demonstration purpose, the CEL expression returns 100000000 only when the input token is 3, + # otherwise it returns 0 (no token usage). + - metadataKey: llm_cel_calculated_token + type: CEL + celExpression: "input_tokens == uint(3) ? 100000000 : 0" --- apiVersion: aigateway.envoyproxy.io/v1alpha1 kind: AIServiceBackend @@ -139,3 +145,22 @@ spec: metadata: namespace: io.envoy.ai_gateway key: llm_total_token + + # Repeat the same configuration for a different token type. + # This configures the token limit based on the CEL expression. + - clientSelectors: + - headers: + - name: x-user-id + type: Distinct + limit: + requests: 1000 + unit: Hour + cost: + request: + from: Number + number: 0 + response: + from: Metadata + metadata: + namespace: io.envoy.ai_gateway + key: llm_cel_calculated_token diff --git a/tests/e2e/token_ratelimit_test.go b/tests/e2e/token_ratelimit_test.go index b9b15879e..0e10cf39c 100644 --- a/tests/e2e/token_ratelimit_test.go +++ b/tests/e2e/token_ratelimit_test.go @@ -50,10 +50,6 @@ func Test_Examples_TokenRateLimit(t *testing.T) { require.NoError(t, err) defer func() { _ = resp.Body.Close() }() - for key, values := range resp.Header { - t.Logf("key: %s, values: %v\n", key, values) - } - body, err := io.ReadAll(resp.Body) if resp.StatusCode == http.StatusOK { var oaiBody openai.ChatCompletion @@ -70,19 +66,35 @@ func Test_Examples_TokenRateLimit(t *testing.T) { // Test the input token limit. baseID := int(time.Now().UnixNano()) // To avoid collision with previous runs. usedID := strconv.Itoa(baseID) + // This input number exceeds the limit. makeRequest(usedID, 10000, 0, 0, 200) - makeRequest(usedID, 1, 0, 0, 429) + // Any request with the same user ID should be rejected. + makeRequest(usedID, 0, 0, 0, 429) // Test the output token limit. usedID = strconv.Itoa(baseID + 1) - makeRequest(usedID, 0, 20, 0, 200) // This output number exceeds the input limit, but should still be allowed. + // This output number exceeds the input limit, but should still be allowed. + makeRequest(usedID, 0, 20, 0, 200) + // This output number exceeds the output limit. makeRequest(usedID, 0, 10000, 0, 200) - makeRequest(usedID, 0, 1, 0, 429) + // Any request with the same user ID should be rejected. + makeRequest(usedID, 0, 0, 0, 429) // Test the total token limit. usedID = strconv.Itoa(baseID + 2) - makeRequest(usedID, 0, 0, 20, 200) // This total number exceeds the input limit, but should still be allowed. - makeRequest(usedID, 0, 0, 200, 200) // This total number exceeds the output limit, but should still be allowed. + // This total number exceeds the input limit, but should still be allowed. + makeRequest(usedID, 0, 0, 20, 200) + // This total number exceeds the output limit, but should still be allowed. + makeRequest(usedID, 0, 0, 200, 200) + // This total number exceeds the total limit. makeRequest(usedID, 0, 0, 1000000, 200) - makeRequest(usedID, 0, 0, 1, 429) + // Any request with the same user ID should be rejected. + makeRequest(usedID, 0, 0, 0, 429) + + // Test the CEL token limit. + usedID = strconv.Itoa(baseID + 3) + // When the input number is 7, the CEL expression returns 100000000 which exceeds the limit. + makeRequest(usedID, 3, 0, 0, 200) + // Any request with the same user ID should be rejected. + makeRequest(usedID, 0, 0, 0, 429) } From ff82f6f468eb57c02deb024bbf4ea36fa1d50966 Mon Sep 17 00:00:00 2001 From: Takeshi Yoneda Date: Tue, 4 Feb 2025 09:58:16 -0800 Subject: [PATCH 26/40] test: clean up real provider extproc test (#291) **Commit Message** This cleans up some logic for old Envoy versions in log parsing. Also, this adds additional test log for debugging a flaky streaming test with openai. **Related Issues/PRs (if applicable)** Related to #290 --------- Signed-off-by: Takeshi Yoneda Signed-off-by: Eric Mariasis --- tests/extproc/real_providers_test.go | 35 ++++++++++------------------ 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/tests/extproc/real_providers_test.go b/tests/extproc/real_providers_test.go index 117882451..893bb5bce 100644 --- a/tests/extproc/real_providers_test.go +++ b/tests/extproc/real_providers_test.go @@ -9,7 +9,6 @@ import ( "encoding/json" "fmt" "os" - "strconv" "testing" "time" @@ -121,7 +120,7 @@ func TestWithRealProviders(t *testing.T) { } } return nonEmptyCompletion - }, 10*time.Second, 1*time.Second) + }, 30*time.Second, 2*time.Second) }) } }) @@ -135,8 +134,8 @@ func TestWithRealProviders(t *testing.T) { require.NoError(t, err) // This should match the format of the access log in envoy.yaml. type lineFormat struct { - UsedToken any `json:"used_token"` - SomeCel any `json:"some_cel"` + UsedToken float64 `json:"used_token,omitempty"` + SomeCel float64 `json:"some_cel,omitempty"` } scanner := bufio.NewScanner(bytes.NewReader(accessLog)) for scanner.Scan() { @@ -147,19 +146,18 @@ func TestWithRealProviders(t *testing.T) { continue } t.Logf("line: %s", line) - if !anyCostGreaterThanZero(l.SomeCel) { + if l.SomeCel == 0 { t.Log("some_cel is not existent or greater than zero") continue } - if !anyCostGreaterThanZero(l.UsedToken) { + if l.UsedToken == 0 { t.Log("used_token is not existent or greater than zero") continue } - t.Log("cannot find used token in line") return true } return false - }, 10*time.Second, 1*time.Second) + }, 30*time.Second, 2*time.Second) }) t.Run("streaming", func(t *testing.T) { @@ -205,8 +203,12 @@ func TestWithRealProviders(t *testing.T) { nonEmptyCompletion = true } } + if !nonEmptyCompletion { + // Log the whole response for debugging. + t.Logf("response: %+v", acc) + } return nonEmptyCompletion - }, 10*time.Second, 1*time.Second) + }, 30*time.Second, 2*time.Second) }) } }) @@ -259,21 +261,8 @@ func TestWithRealProviders(t *testing.T) { } } return returnsToolCall - }, 10*time.Second, 500*time.Millisecond) + }, 30*time.Second, 2*time.Second) }) } }) } - -func anyCostGreaterThanZero(cost any) bool { - // The access formatter somehow changed its behavior sometimes between 1.31 and the latest Envoy, - // so we need to check for both float64 and string. - if num, ok := cost.(float64); ok && num > 0 { - return true - } else if str, ok := cost.(string); ok { - if num, err := strconv.Atoi(str); err == nil && num > 0 { - return true - } - } - return false -} From 3aefe8c5ca54bca00542c403ef229c4e4bede406 Mon Sep 17 00:00:00 2001 From: Takeshi Yoneda Date: Wed, 5 Feb 2025 09:46:42 -0800 Subject: [PATCH 27/40] examples: simplifies token_ratelimit.yaml (#292) **Commit Message** The weight field does not need to be specified if there's only one backend since #156. This removes it in token_ratelimit.yaml. **Related Issues/PRs (if applicable)** Follow up on #289 Signed-off-by: Takeshi Yoneda Signed-off-by: Eric Mariasis --- examples/token_ratelimit/token_ratelimit.yaml | 1 - tests/e2e/token_ratelimit_test.go | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/token_ratelimit/token_ratelimit.yaml b/examples/token_ratelimit/token_ratelimit.yaml index 8f5024c8d..c45273593 100644 --- a/examples/token_ratelimit/token_ratelimit.yaml +++ b/examples/token_ratelimit/token_ratelimit.yaml @@ -37,7 +37,6 @@ spec: value: gpt-4o-mini backendRefs: - name: envoy-ai-gateway-token-ratelimit-testupstream - weight: 100 # The following metadata keys are used to store the costs from the LLM request. llmRequestCosts: - metadataKey: llm_input_token diff --git a/tests/e2e/token_ratelimit_test.go b/tests/e2e/token_ratelimit_test.go index 0e10cf39c..9e917edd0 100644 --- a/tests/e2e/token_ratelimit_test.go +++ b/tests/e2e/token_ratelimit_test.go @@ -93,7 +93,7 @@ func Test_Examples_TokenRateLimit(t *testing.T) { // Test the CEL token limit. usedID = strconv.Itoa(baseID + 3) - // When the input number is 7, the CEL expression returns 100000000 which exceeds the limit. + // When the input number is 3, the CEL expression returns 100000000 which exceeds the limit. makeRequest(usedID, 3, 0, 0, 200) // Any request with the same user ID should be rejected. makeRequest(usedID, 0, 0, 0, 429) From 32c49d17d916851f33f5262450c7f132acc12fdd Mon Sep 17 00:00:00 2001 From: Takeshi Yoneda Date: Wed, 5 Feb 2025 18:02:27 -0800 Subject: [PATCH 28/40] controller: eliminates context.Background (#295) **Commit Message** This refactors the controller package to eliminate all the context.Background usages in the main code path. Signed-off-by: Takeshi Yoneda Signed-off-by: Eric Mariasis --- internal/controller/controller.go | 10 ++-- internal/controller/sink.go | 82 +++++++++++++++---------------- internal/controller/sink_test.go | 26 +++++----- 3 files changed, 59 insertions(+), 59 deletions(-) diff --git a/internal/controller/controller.go b/internal/controller/controller.go index b0047a90e..6056bdc84 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -63,7 +63,7 @@ func StartControllers(ctx context.Context, config *rest.Config, logger logr.Logg c := mgr.GetClient() indexer := mgr.GetFieldIndexer() - if err = applyIndexing(indexer.IndexField); err != nil { + if err = applyIndexing(ctx, indexer.IndexField); err != nil { return fmt.Errorf("failed to apply indexing: %w", err) } @@ -130,18 +130,18 @@ const ( k8sClientIndexBackendSecurityPolicyToReferencingAIServiceBackend = "BackendSecurityPolicyToReferencingAIServiceBackend" ) -func applyIndexing(indexer func(ctx context.Context, obj client.Object, field string, extractValue client.IndexerFunc) error) error { - err := indexer(context.Background(), &aigv1a1.AIGatewayRoute{}, +func applyIndexing(ctx context.Context, indexer func(ctx context.Context, obj client.Object, field string, extractValue client.IndexerFunc) error) error { + err := indexer(ctx, &aigv1a1.AIGatewayRoute{}, k8sClientIndexBackendToReferencingAIGatewayRoute, aiGatewayRouteIndexFunc) if err != nil { return fmt.Errorf("failed to index field for AIGatewayRoute: %w", err) } - err = indexer(context.Background(), &aigv1a1.AIServiceBackend{}, + err = indexer(ctx, &aigv1a1.AIServiceBackend{}, k8sClientIndexBackendSecurityPolicyToReferencingAIServiceBackend, aiServiceBackendIndexFunc) if err != nil { return fmt.Errorf("failed to index field for AIServiceBackend: %w", err) } - err = indexer(context.Background(), &aigv1a1.BackendSecurityPolicy{}, + err = indexer(ctx, &aigv1a1.BackendSecurityPolicy{}, k8sClientIndexSecretToReferencingBackendSecurityPolicy, backendSecurityPolicyIndexFunc) if err != nil { return fmt.Errorf("failed to index field for BackendSecurityPolicy: %w", err) diff --git a/internal/controller/sink.go b/internal/controller/sink.go index 9bf46b917..470b51855 100644 --- a/internal/controller/sink.go +++ b/internal/controller/sink.go @@ -82,17 +82,17 @@ func newConfigSink( return c } -func (c *configSink) backend(namespace, name string) (*aigv1a1.AIServiceBackend, error) { +func (c *configSink) backend(ctx context.Context, namespace, name string) (*aigv1a1.AIServiceBackend, error) { backend := &aigv1a1.AIServiceBackend{} - if err := c.client.Get(context.Background(), client.ObjectKey{Name: name, Namespace: namespace}, backend); err != nil { + if err := c.client.Get(ctx, client.ObjectKey{Name: name, Namespace: namespace}, backend); err != nil { return nil, err } return backend, nil } -func (c *configSink) backendSecurityPolicy(namespace, name string) (*aigv1a1.BackendSecurityPolicy, error) { +func (c *configSink) backendSecurityPolicy(ctx context.Context, namespace, name string) (*aigv1a1.BackendSecurityPolicy, error) { backendSecurityPolicy := &aigv1a1.BackendSecurityPolicy{} - if err := c.client.Get(context.Background(), client.ObjectKey{Name: name, Namespace: namespace}, backendSecurityPolicy); err != nil { + if err := c.client.Get(ctx, client.ObjectKey{Name: name, Namespace: namespace}, backendSecurityPolicy); err != nil { return nil, err } return backendSecurityPolicy, nil @@ -107,7 +107,7 @@ func (c *configSink) init(ctx context.Context) error { close(c.eventChan) return case event := <-c.eventChan: - c.handleEvent(event) + c.handleEvent(ctx, event) } } }() @@ -115,25 +115,25 @@ func (c *configSink) init(ctx context.Context) error { } // handleEvent handles the event received from the controllers in a single goroutine. -func (c *configSink) handleEvent(event ConfigSinkEvent) { +func (c *configSink) handleEvent(ctx context.Context, event ConfigSinkEvent) { switch e := event.(type) { case *aigv1a1.AIServiceBackend: - c.syncAIServiceBackend(e) + c.syncAIServiceBackend(ctx, e) case *aigv1a1.AIGatewayRoute: - c.syncAIGatewayRoute(e) + c.syncAIGatewayRoute(ctx, e) case *aigv1a1.BackendSecurityPolicy: - c.syncBackendSecurityPolicy(e) + c.syncBackendSecurityPolicy(ctx, e) case ConfigSinkEventSecretUpdate: - c.syncSecret(e.Namespace, e.Name) + c.syncSecret(ctx, e.Namespace, e.Name) default: panic(fmt.Sprintf("unexpected event type: %T", e)) } } -func (c *configSink) syncAIGatewayRoute(aiGatewayRoute *aigv1a1.AIGatewayRoute) { +func (c *configSink) syncAIGatewayRoute(ctx context.Context, aiGatewayRoute *aigv1a1.AIGatewayRoute) { // Check if the HTTPRouteFilter exists in the namespace. var httpRouteFilter egv1a1.HTTPRouteFilter - err := c.client.Get(context.Background(), + err := c.client.Get(ctx, client.ObjectKey{Name: hostRewriteHTTPFilterName, Namespace: aiGatewayRoute.Namespace}, &httpRouteFilter) if apierrors.IsNotFound(err) { httpRouteFilter = egv1a1.HTTPRouteFilter{ @@ -149,7 +149,7 @@ func (c *configSink) syncAIGatewayRoute(aiGatewayRoute *aigv1a1.AIGatewayRoute) }, }, } - if err := c.client.Create(context.Background(), &httpRouteFilter); err != nil { + if err := c.client.Create(ctx, &httpRouteFilter); err != nil { c.logger.Error(err, "failed to create HTTPRouteFilter", "namespace", aiGatewayRoute.Namespace, "name", hostRewriteHTTPFilterName) return } @@ -161,7 +161,7 @@ func (c *configSink) syncAIGatewayRoute(aiGatewayRoute *aigv1a1.AIGatewayRoute) // Check if the HTTPRoute exists. c.logger.Info("syncing AIGatewayRoute", "namespace", aiGatewayRoute.Namespace, "name", aiGatewayRoute.Name) var httpRoute gwapiv1.HTTPRoute - err = c.client.Get(context.Background(), client.ObjectKey{Name: aiGatewayRoute.Name, Namespace: aiGatewayRoute.Namespace}, &httpRoute) + err = c.client.Get(ctx, client.ObjectKey{Name: aiGatewayRoute.Name, Namespace: aiGatewayRoute.Namespace}, &httpRoute) existingRoute := err == nil if apierrors.IsNotFound(err) { // This means that this AIGatewayRoute is a new one. @@ -181,20 +181,20 @@ func (c *configSink) syncAIGatewayRoute(aiGatewayRoute *aigv1a1.AIGatewayRoute) } // Update the HTTPRoute with the new AIGatewayRoute. - if err := c.newHTTPRoute(&httpRoute, aiGatewayRoute); err != nil { + if err := c.newHTTPRoute(ctx, &httpRoute, aiGatewayRoute); err != nil { c.logger.Error(err, "failed to update HTTPRoute with AIGatewayRoute", "namespace", aiGatewayRoute.Namespace, "name", aiGatewayRoute.Name) return } if existingRoute { c.logger.Info("updating HTTPRoute", "namespace", httpRoute.Namespace, "name", httpRoute.Name) - if err := c.client.Update(context.Background(), &httpRoute); err != nil { + if err := c.client.Update(ctx, &httpRoute); err != nil { c.logger.Error(err, "failed to update HTTPRoute", "namespace", httpRoute.Namespace, "name", httpRoute.Name) return } } else { c.logger.Info("creating HTTPRoute", "namespace", httpRoute.Namespace, "name", httpRoute.Name) - if err := c.client.Create(context.Background(), &httpRoute); err != nil { + if err := c.client.Create(ctx, &httpRoute); err != nil { c.logger.Error(err, "failed to create HTTPRoute", "namespace", httpRoute.Namespace, "name", httpRoute.Name) return } @@ -202,30 +202,30 @@ func (c *configSink) syncAIGatewayRoute(aiGatewayRoute *aigv1a1.AIGatewayRoute) // Update the extproc configmap. uuid := string(uuid2.NewUUID()) - if err := c.updateExtProcConfigMap(aiGatewayRoute, uuid); err != nil { + if err := c.updateExtProcConfigMap(ctx, aiGatewayRoute, uuid); err != nil { c.logger.Error(err, "failed to update extproc configmap", "namespace", aiGatewayRoute.Namespace, "name", aiGatewayRoute.Name) return } // Deploy extproc deployment with potential updates. - err = c.syncExtProcDeployment(context.Background(), aiGatewayRoute) + err = c.syncExtProcDeployment(ctx, aiGatewayRoute) if err != nil { c.logger.Error(err, "failed to deploy ext proc", "namespace", aiGatewayRoute.Namespace, "name", aiGatewayRoute.Name) return } // Annotate all pods with the new config. - err = c.annotateExtProcPods(context.Background(), aiGatewayRoute, uuid) + err = c.annotateExtProcPods(ctx, aiGatewayRoute, uuid) if err != nil { c.logger.Error(err, "failed to annotate pods", "namespace", aiGatewayRoute.Namespace, "name", aiGatewayRoute.Name) return } } -func (c *configSink) syncAIServiceBackend(aiBackend *aigv1a1.AIServiceBackend) { +func (c *configSink) syncAIServiceBackend(ctx context.Context, aiBackend *aigv1a1.AIServiceBackend) { key := fmt.Sprintf("%s.%s", aiBackend.Name, aiBackend.Namespace) var aiGatewayRoutes aigv1a1.AIGatewayRouteList - err := c.client.List(context.Background(), &aiGatewayRoutes, client.MatchingFields{k8sClientIndexBackendToReferencingAIGatewayRoute: key}) + err := c.client.List(ctx, &aiGatewayRoutes, client.MatchingFields{k8sClientIndexBackendToReferencingAIGatewayRoute: key}) if err != nil { c.logger.Error(err, "failed to list AIGatewayRoute", "backend", key) return @@ -235,27 +235,27 @@ func (c *configSink) syncAIServiceBackend(aiBackend *aigv1a1.AIServiceBackend) { "namespace", aiGatewayRoute.Namespace, "name", aiGatewayRoute.Name, "referenced_backend", aiBackend.Name, "referenced_backend_namespace", aiBackend.Namespace, ) - c.syncAIGatewayRoute(&aiGatewayRoute) + c.syncAIGatewayRoute(ctx, &aiGatewayRoute) } } -func (c *configSink) syncBackendSecurityPolicy(bsp *aigv1a1.BackendSecurityPolicy) { +func (c *configSink) syncBackendSecurityPolicy(ctx context.Context, bsp *aigv1a1.BackendSecurityPolicy) { key := fmt.Sprintf("%s.%s", bsp.Name, bsp.Namespace) var aiServiceBackends aigv1a1.AIServiceBackendList - err := c.client.List(context.Background(), &aiServiceBackends, client.MatchingFields{k8sClientIndexBackendSecurityPolicyToReferencingAIServiceBackend: key}) + err := c.client.List(ctx, &aiServiceBackends, client.MatchingFields{k8sClientIndexBackendSecurityPolicyToReferencingAIServiceBackend: key}) if err != nil { c.logger.Error(err, "failed to list AIServiceBackendList", "backendSecurityPolicy", key) return } for i := range aiServiceBackends.Items { aiBackend := &aiServiceBackends.Items[i] - c.syncAIServiceBackend(aiBackend) + c.syncAIServiceBackend(ctx, aiBackend) } } // updateExtProcConfigMap updates the external process configmap with the new AIGatewayRoute. -func (c *configSink) updateExtProcConfigMap(aiGatewayRoute *aigv1a1.AIGatewayRoute, uuid string) error { - configMap, err := c.kube.CoreV1().ConfigMaps(aiGatewayRoute.Namespace).Get(context.Background(), extProcName(aiGatewayRoute), metav1.GetOptions{}) +func (c *configSink) updateExtProcConfigMap(ctx context.Context, aiGatewayRoute *aigv1a1.AIGatewayRoute, uuid string) error { + configMap, err := c.kube.CoreV1().ConfigMaps(aiGatewayRoute.Namespace).Get(ctx, extProcName(aiGatewayRoute), metav1.GetOptions{}) if err != nil { // This is a bug since we should have created the configmap before sending the AIGatewayRoute to the configSink. panic(fmt.Errorf("failed to get configmap %s: %w", extProcName(aiGatewayRoute), err)) @@ -277,7 +277,7 @@ func (c *configSink) updateExtProcConfigMap(aiGatewayRoute *aigv1a1.AIGatewayRou key := fmt.Sprintf("%s.%s", backend.Name, aiGatewayRoute.Namespace) ec.Rules[i].Backends[j].Name = key ec.Rules[i].Backends[j].Weight = backend.Weight - backendObj, err := c.backend(aiGatewayRoute.Namespace, backend.Name) + backendObj, err := c.backend(ctx, aiGatewayRoute.Namespace, backend.Name) if err != nil { return fmt.Errorf("failed to get AIServiceBackend %s: %w", key, err) } else { @@ -289,7 +289,7 @@ func (c *configSink) updateExtProcConfigMap(aiGatewayRoute *aigv1a1.AIGatewayRou volumeName := backendSecurityPolicyVolumeName( i, j, string(backendObj.Spec.BackendSecurityPolicyRef.Name), ) - backendSecurityPolicy, err := c.backendSecurityPolicy(aiGatewayRoute.Namespace, string(bspRef.Name)) + backendSecurityPolicy, err := c.backendSecurityPolicy(ctx, aiGatewayRoute.Namespace, string(bspRef.Name)) if err != nil { return fmt.Errorf("failed to get BackendSecurityPolicy %s: %w", bspRef.Name, err) } @@ -357,14 +357,14 @@ func (c *configSink) updateExtProcConfigMap(aiGatewayRoute *aigv1a1.AIGatewayRou configMap.Data = make(map[string]string) } configMap.Data[expProcConfigFileName] = string(marshaled) - if _, err := c.kube.CoreV1().ConfigMaps(aiGatewayRoute.Namespace).Update(context.Background(), configMap, metav1.UpdateOptions{}); err != nil { + if _, err := c.kube.CoreV1().ConfigMaps(aiGatewayRoute.Namespace).Update(ctx, configMap, metav1.UpdateOptions{}); err != nil { return fmt.Errorf("failed to update configmap %s: %w", configMap.Name, err) } return nil } // newHTTPRoute updates the HTTPRoute with the new AIGatewayRoute. -func (c *configSink) newHTTPRoute(dst *gwapiv1.HTTPRoute, aiGatewayRoute *aigv1a1.AIGatewayRoute) error { +func (c *configSink) newHTTPRoute(ctx context.Context, dst *gwapiv1.HTTPRoute, aiGatewayRoute *aigv1a1.AIGatewayRoute) error { var backends []*aigv1a1.AIServiceBackend dedup := make(map[string]struct{}) for _, rule := range aiGatewayRoute.Spec.Rules { @@ -374,7 +374,7 @@ func (c *configSink) newHTTPRoute(dst *gwapiv1.HTTPRoute, aiGatewayRoute *aigv1a continue } dedup[key] = struct{}{} - backend, err := c.backend(aiGatewayRoute.Namespace, br.Name) + backend, err := c.backend(ctx, aiGatewayRoute.Namespace, br.Name) if err != nil { return fmt.Errorf("AIServiceBackend %s not found", key) } @@ -516,7 +516,7 @@ func (c *configSink) syncExtProcDeployment(ctx context.Context, aiGatewayRoute * if err := ctrlutil.SetControllerReference(aiGatewayRoute, deployment, c.client.Scheme()); err != nil { panic(fmt.Errorf("BUG: failed to set controller reference for deployment: %w", err)) } - updatedSpec, err := c.mountBackendSecurityPolicySecrets(&deployment.Spec.Template.Spec, aiGatewayRoute) + updatedSpec, err := c.mountBackendSecurityPolicySecrets(ctx, &deployment.Spec.Template.Spec, aiGatewayRoute) if err == nil { deployment.Spec.Template.Spec = *updatedSpec } @@ -530,7 +530,7 @@ func (c *configSink) syncExtProcDeployment(ctx context.Context, aiGatewayRoute * return fmt.Errorf("failed to get deployment: %w", err) } } else { - updatedSpec, err := c.mountBackendSecurityPolicySecrets(&deployment.Spec.Template.Spec, aiGatewayRoute) + updatedSpec, err := c.mountBackendSecurityPolicySecrets(ctx, &deployment.Spec.Template.Spec, aiGatewayRoute) if err == nil { deployment.Spec.Template.Spec = *updatedSpec } @@ -569,7 +569,7 @@ func (c *configSink) syncExtProcDeployment(ctx context.Context, aiGatewayRoute * } // mountBackendSecurityPolicySecrets will mount secrets based on backendSecurityPolicies attached to AIServiceBackend. -func (c *configSink) mountBackendSecurityPolicySecrets(spec *corev1.PodSpec, aiGatewayRoute *aigv1a1.AIGatewayRoute) (*corev1.PodSpec, error) { +func (c *configSink) mountBackendSecurityPolicySecrets(ctx context.Context, spec *corev1.PodSpec, aiGatewayRoute *aigv1a1.AIGatewayRoute) (*corev1.PodSpec, error) { // Mount from scratch to avoid secrets that should be unmounted. // Only keep the original mount which should be the config volume. spec.Volumes = spec.Volumes[:1] @@ -580,13 +580,13 @@ func (c *configSink) mountBackendSecurityPolicySecrets(spec *corev1.PodSpec, aiG rule := &aiGatewayRoute.Spec.Rules[i] for j := range rule.BackendRefs { backendRef := &rule.BackendRefs[j] - backend, err := c.backend(aiGatewayRoute.Namespace, backendRef.Name) + backend, err := c.backend(ctx, aiGatewayRoute.Namespace, backendRef.Name) if err != nil { return nil, fmt.Errorf("failed to get backend %s: %w", backendRef.Name, err) } if backendSecurityPolicyRef := backend.Spec.BackendSecurityPolicyRef; backendSecurityPolicyRef != nil { - backendSecurityPolicy, err := c.backendSecurityPolicy(aiGatewayRoute.Namespace, string(backendSecurityPolicyRef.Name)) + backendSecurityPolicy, err := c.backendSecurityPolicy(ctx, aiGatewayRoute.Namespace, string(backendSecurityPolicyRef.Name)) if err != nil { return nil, fmt.Errorf("failed to get backend security policy %s: %w", backendSecurityPolicyRef.Name, err) } @@ -626,9 +626,9 @@ func (c *configSink) mountBackendSecurityPolicySecrets(spec *corev1.PodSpec, aiG } // syncSecret syncs the state of all resource referencing the given secret. -func (c *configSink) syncSecret(namespace, name string) { +func (c *configSink) syncSecret(ctx context.Context, namespace, name string) { var backendSecurityPolicies aigv1a1.BackendSecurityPolicyList - err := c.client.List(context.Background(), &backendSecurityPolicies, + err := c.client.List(ctx, &backendSecurityPolicies, client.MatchingFields{ k8sClientIndexSecretToReferencingBackendSecurityPolicy: fmt.Sprintf("%s.%s", name, namespace), }, @@ -639,7 +639,7 @@ func (c *configSink) syncSecret(namespace, name string) { } for i := range backendSecurityPolicies.Items { backendSecurityPolicy := &backendSecurityPolicies.Items[i] - c.syncBackendSecurityPolicy(backendSecurityPolicy) + c.syncBackendSecurityPolicy(ctx, backendSecurityPolicy) } } diff --git a/internal/controller/sink_test.go b/internal/controller/sink_test.go index 3cdd3cbc7..df0aae86f 100644 --- a/internal/controller/sink_test.go +++ b/internal/controller/sink_test.go @@ -30,7 +30,7 @@ import ( func requireNewFakeClientWithIndexes(t *testing.T) client.Client { builder := fake.NewClientBuilder().WithScheme(scheme) - err := applyIndexing(func(ctx context.Context, obj client.Object, field string, extractValue client.IndexerFunc) error { + err := applyIndexing(context.Background(), func(ctx context.Context, obj client.Object, field string, extractValue client.IndexerFunc) error { builder = builder.WithIndex(obj, field, extractValue) return nil }) @@ -51,10 +51,10 @@ func TestConfigSink_handleEvent(t *testing.T) { }, metav1.CreateOptions{}) require.NoError(t, err) - s.handleEvent(ConfigSinkEventSecretUpdate{Namespace: "ns", Name: "some-secret"}) - s.handleEvent(&aigv1a1.AIServiceBackend{ObjectMeta: metav1.ObjectMeta{Name: "apple", Namespace: "ns"}}) - s.handleEvent(&aigv1a1.BackendSecurityPolicy{ObjectMeta: metav1.ObjectMeta{Name: "apple", Namespace: "ns"}}) - s.handleEvent(&aigv1a1.AIGatewayRoute{ObjectMeta: metav1.ObjectMeta{Name: "apple", Namespace: "ns"}}) + s.handleEvent(context.Background(), ConfigSinkEventSecretUpdate{Namespace: "ns", Name: "some-secret"}) + s.handleEvent(context.Background(), &aigv1a1.AIServiceBackend{ObjectMeta: metav1.ObjectMeta{Name: "apple", Namespace: "ns"}}) + s.handleEvent(context.Background(), &aigv1a1.BackendSecurityPolicy{ObjectMeta: metav1.ObjectMeta{Name: "apple", Namespace: "ns"}}) + s.handleEvent(context.Background(), &aigv1a1.AIGatewayRoute{ObjectMeta: metav1.ObjectMeta{Name: "apple", Namespace: "ns"}}) } func TestConfigSink_syncAIGatewayRoute(t *testing.T) { @@ -105,7 +105,7 @@ func TestConfigSink_syncAIGatewayRoute(t *testing.T) { require.NoError(t, err) // Then sync, which should update the HTTPRoute. - s.syncAIGatewayRoute(route) + s.syncAIGatewayRoute(context.Background(), route) var updatedHTTPRoute gwapiv1.HTTPRoute err = fakeClient.Get(context.Background(), client.ObjectKey{Name: "route1", Namespace: "ns1"}, &updatedHTTPRoute) require.NoError(t, err) @@ -144,7 +144,7 @@ func TestConfigSink_syncAIServiceBackend(t *testing.T) { require.NoError(t, fakeClient.Create(context.Background(), route, &client.CreateOptions{})) s := newConfigSink(fakeClient, nil, logr.Discard(), eventChan, "defaultExtProcImage", "debug") - s.syncAIServiceBackend(&aigv1a1.AIServiceBackend{ + s.syncAIServiceBackend(context.Background(), &aigv1a1.AIServiceBackend{ ObjectMeta: metav1.ObjectMeta{Name: "apple", Namespace: "ns1"}, Spec: aigv1a1.AIServiceBackendSpec{ BackendRef: gwapiv1.BackendObjectReference{Name: "some-backend", Namespace: ptr.To[gwapiv1.Namespace]("ns1")}, @@ -166,7 +166,7 @@ func TestConfigSink_syncBackendSecurityPolicy(t *testing.T) { require.NoError(t, fakeClient.Create(context.Background(), &backend, &client.CreateOptions{})) s := newConfigSink(fakeClient, nil, logr.Discard(), eventChan, "defaultExtProcImage", "debug") - s.syncBackendSecurityPolicy(&aigv1a1.BackendSecurityPolicy{ + s.syncBackendSecurityPolicy(context.Background(), &aigv1a1.BackendSecurityPolicy{ ObjectMeta: metav1.ObjectMeta{Name: "apple", Namespace: "ns"}, }) } @@ -235,7 +235,7 @@ func Test_newHTTPRoute(t *testing.T) { err := s.client.Create(context.Background(), backend, &client.CreateOptions{}) require.NoError(t, err) } - err := s.newHTTPRoute(httpRoute, aiGatewayRoute) + err := s.newHTTPRoute(context.Background(), httpRoute, aiGatewayRoute) require.NoError(t, err) expRules := []gwapiv1.HTTPRouteRule{ @@ -466,7 +466,7 @@ func Test_updateExtProcConfigMap(t *testing.T) { }, metav1.CreateOptions{}) require.NoError(t, err) - err = s.updateExtProcConfigMap(tc.route, tc.exp.UUID) + err = s.updateExtProcConfigMap(context.Background(), tc.route, tc.exp.UUID) require.NoError(t, err) cm, err := s.kube.CoreV1().ConfigMaps(tc.route.Namespace).Get(context.Background(), extProcName(tc.route), metav1.GetOptions{}) @@ -776,7 +776,7 @@ func TestConfigSink_MountBackendSecurityPolicySecrets(t *testing.T) { require.NoError(t, fakeClient.Create(context.Background(), &aiGateway, &client.CreateOptions{})) - updatedSpec, err := s.mountBackendSecurityPolicySecrets(&spec, &aiGateway) + updatedSpec, err := s.mountBackendSecurityPolicySecrets(context.Background(), &spec, &aiGateway) require.NoError(t, err) require.Len(t, updatedSpec.Volumes, 3) @@ -809,7 +809,7 @@ func TestConfigSink_MountBackendSecurityPolicySecrets(t *testing.T) { require.NoError(t, fakeClient.Create(context.Background(), &backend, &client.CreateOptions{})) require.NotNil(t, s) - updatedSpec, err = s.mountBackendSecurityPolicySecrets(&spec, &aiGateway) + updatedSpec, err = s.mountBackendSecurityPolicySecrets(context.Background(), &spec, &aiGateway) require.NoError(t, err) require.Len(t, updatedSpec.Volumes, 3) @@ -877,5 +877,5 @@ func Test_syncSecret(t *testing.T) { Data: map[string][]byte{"key": []byte("value")}, }, metav1.CreateOptions{}) require.NoError(t, err) - s.syncSecret("ns", "some-secret") + s.syncSecret(context.Background(), "ns", "some-secret") } From e44314b03e3cae40728843e5d01d696acbf20615 Mon Sep 17 00:00:00 2001 From: Takeshi Yoneda Date: Wed, 5 Feb 2025 18:05:49 -0800 Subject: [PATCH 29/40] extproc: eliminates context.Background (#296) **Commit Message** This refactors the extproc package and eliminates all context.Background usages. Signed-off-by: Takeshi Yoneda Signed-off-by: Eric Mariasis --- internal/extproc/backendauth/api_key.go | 3 ++- internal/extproc/backendauth/api_key_test.go | 3 ++- internal/extproc/backendauth/auth.go | 3 ++- internal/extproc/backendauth/aws.go | 6 +++--- internal/extproc/backendauth/aws_test.go | 5 +++-- internal/extproc/processor.go | 4 ++-- 6 files changed, 14 insertions(+), 10 deletions(-) diff --git a/internal/extproc/backendauth/api_key.go b/internal/extproc/backendauth/api_key.go index 3a94fb0e5..e7c36869d 100644 --- a/internal/extproc/backendauth/api_key.go +++ b/internal/extproc/backendauth/api_key.go @@ -1,6 +1,7 @@ package backendauth import ( + "context" "fmt" "os" "strings" @@ -27,7 +28,7 @@ func newAPIKeyHandler(auth *filterapi.APIKeyAuth) (Handler, error) { // Do implements [Handler.Do]. // // Extracts the api key from the local file and set it as an authorization header. -func (a *apiKeyHandler) Do(requestHeaders map[string]string, headerMut *extprocv3.HeaderMutation, _ *extprocv3.BodyMutation) error { +func (a *apiKeyHandler) Do(_ context.Context, requestHeaders map[string]string, headerMut *extprocv3.HeaderMutation, _ *extprocv3.BodyMutation) error { requestHeaders["Authorization"] = fmt.Sprintf("Bearer %s", a.apiKey) headerMut.SetHeaders = append(headerMut.SetHeaders, &corev3.HeaderValueOption{ Header: &corev3.HeaderValue{Key: "Authorization", RawValue: []byte(requestHeaders["Authorization"])}, diff --git a/internal/extproc/backendauth/api_key_test.go b/internal/extproc/backendauth/api_key_test.go index cfb3ce135..6f08f3882 100644 --- a/internal/extproc/backendauth/api_key_test.go +++ b/internal/extproc/backendauth/api_key_test.go @@ -1,6 +1,7 @@ package backendauth import ( + "context" "os" "testing" @@ -62,7 +63,7 @@ func TestApiKeyHandler_Do(t *testing.T) { Body: []byte(`{"messages": [{"role": "user", "content": [{"text": "Say this is a test!"}]}]}`), }, } - err = handler.Do(requestHeaders, headerMut, bodyMut) + err = handler.Do(context.Background(), requestHeaders, headerMut, bodyMut) require.NoError(t, err) bearerToken, ok := requestHeaders["Authorization"] diff --git a/internal/extproc/backendauth/auth.go b/internal/extproc/backendauth/auth.go index e8375843f..8dd4fbc8b 100644 --- a/internal/extproc/backendauth/auth.go +++ b/internal/extproc/backendauth/auth.go @@ -1,6 +1,7 @@ package backendauth import ( + "context" "errors" extprocv3 "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" @@ -13,7 +14,7 @@ import ( // TODO: maybe this can be just "post-transformation" handler, as it is not really only about auth. type Handler interface { // Do performs the backend auth, and make changes to the request headers and body mutations. - Do(requestHeaders map[string]string, headerMut *extprocv3.HeaderMutation, bodyMut *extprocv3.BodyMutation) error + Do(ctx context.Context, requestHeaders map[string]string, headerMut *extprocv3.HeaderMutation, bodyMut *extprocv3.BodyMutation) error } // NewHandler returns a new implementation of [Handler] based on the configuration. diff --git a/internal/extproc/backendauth/aws.go b/internal/extproc/backendauth/aws.go index 65fbf098c..e01ecc661 100644 --- a/internal/extproc/backendauth/aws.go +++ b/internal/extproc/backendauth/aws.go @@ -27,7 +27,7 @@ type awsHandler struct { region string } -func newAWSHandler(awsAuth *filterapi.AWSAuth) (*awsHandler, error) { +func newAWSHandler(awsAuth *filterapi.AWSAuth) (Handler, error) { var credentials aws.Credentials var region string @@ -60,7 +60,7 @@ func newAWSHandler(awsAuth *filterapi.AWSAuth) (*awsHandler, error) { // // This assumes that during the transformation, the path is set in the header mutation as well as // the body in the body mutation. -func (a *awsHandler) Do(requestHeaders map[string]string, headerMut *extprocv3.HeaderMutation, bodyMut *extprocv3.BodyMutation) error { +func (a *awsHandler) Do(ctx context.Context, requestHeaders map[string]string, headerMut *extprocv3.HeaderMutation, bodyMut *extprocv3.BodyMutation) error { method := requestHeaders[":method"] path := "" if headerMut.SetHeaders != nil { @@ -90,7 +90,7 @@ func (a *awsHandler) Do(requestHeaders map[string]string, headerMut *extprocv3.H return fmt.Errorf("cannot create request: %w", err) } - err = a.signer.SignHTTP(context.Background(), a.credentials, req, + err = a.signer.SignHTTP(ctx, a.credentials, req, hex.EncodeToString(payloadHash[:]), "bedrock", a.region, time.Now()) if err != nil { return fmt.Errorf("cannot sign request: %w", err) diff --git a/internal/extproc/backendauth/aws_test.go b/internal/extproc/backendauth/aws_test.go index 3a3da56cb..e0d922b6f 100644 --- a/internal/extproc/backendauth/aws_test.go +++ b/internal/extproc/backendauth/aws_test.go @@ -1,6 +1,7 @@ package backendauth import ( + "context" "os" "testing" @@ -42,7 +43,7 @@ func TestAWSHandler_Do(t *testing.T) { for _, tc := range []struct { name string - handler *awsHandler + handler Handler }{ { name: "Using AWS Credential File", @@ -64,7 +65,7 @@ func TestAWSHandler_Do(t *testing.T) { Body: []byte(`{"messages": [{"role": "user", "content": [{"text": "Say this is a test!"}]}]}`), }, } - err = tc.handler.Do(requestHeaders, headerMut, bodyMut) + err = tc.handler.Do(context.Background(), requestHeaders, headerMut, bodyMut) require.NoError(t, err) }) } diff --git a/internal/extproc/processor.go b/internal/extproc/processor.go index 7af784240..bf1610e07 100644 --- a/internal/extproc/processor.go +++ b/internal/extproc/processor.go @@ -80,7 +80,7 @@ func (p *Processor) ProcessRequestHeaders(_ context.Context, headers *corev3.Hea } // ProcessRequestBody implements [Processor.ProcessRequestBody]. -func (p *Processor) ProcessRequestBody(_ context.Context, rawBody *extprocv3.HttpBody) (res *extprocv3.ProcessingResponse, err error) { +func (p *Processor) ProcessRequestBody(ctx context.Context, rawBody *extprocv3.HttpBody) (res *extprocv3.ProcessingResponse, err error) { path := p.requestHeaders[":path"] model, body, err := p.config.bodyParser(path, rawBody) if err != nil { @@ -122,7 +122,7 @@ func (p *Processor) ProcessRequestBody(_ context.Context, rawBody *extprocv3.Htt }) if authHandler, ok := p.config.backendAuthHandlers[b.Name]; ok { - if err := authHandler.Do(p.requestHeaders, headerMutation, bodyMutation); err != nil { + if err := authHandler.Do(ctx, p.requestHeaders, headerMutation, bodyMutation); err != nil { return nil, fmt.Errorf("failed to do auth request: %w", err) } } From 44470e65c59fa96d009c00228eee02dc1f10759b Mon Sep 17 00:00:00 2001 From: Takeshi Yoneda Date: Wed, 5 Feb 2025 18:17:55 -0800 Subject: [PATCH 30/40] extproc: eliminates context.Background in config loader (#297) **Commit Message** This eliminates context.Background in config loading code path. **Related Issues/PRs (if applicable)** This is a follow up on #296 Signed-off-by: Takeshi Yoneda Signed-off-by: Eric Mariasis --- filterapi/filterconfig_test.go | 3 ++- internal/extproc/backendauth/auth.go | 4 ++-- internal/extproc/backendauth/auth_test.go | 3 ++- internal/extproc/backendauth/aws.go | 6 +++--- internal/extproc/backendauth/aws_test.go | 4 ++-- internal/extproc/server.go | 4 ++-- internal/extproc/server_test.go | 4 ++-- internal/extproc/watcher.go | 5 +++-- internal/extproc/watcher_test.go | 2 +- 9 files changed, 19 insertions(+), 16 deletions(-) diff --git a/filterapi/filterconfig_test.go b/filterapi/filterconfig_test.go index 89913c91f..411c698d9 100644 --- a/filterapi/filterconfig_test.go +++ b/filterapi/filterconfig_test.go @@ -1,6 +1,7 @@ package filterapi_test import ( + "context" "log/slog" "os" "path" @@ -22,7 +23,7 @@ func TestDefaultConfig(t *testing.T) { err = yaml.Unmarshal([]byte(filterapi.DefaultConfig), &cfg) require.NoError(t, err) - err = server.LoadConfig(&cfg) + err = server.LoadConfig(context.Background(), &cfg) require.NoError(t, err) } diff --git a/internal/extproc/backendauth/auth.go b/internal/extproc/backendauth/auth.go index 8dd4fbc8b..1ba62ba31 100644 --- a/internal/extproc/backendauth/auth.go +++ b/internal/extproc/backendauth/auth.go @@ -18,9 +18,9 @@ type Handler interface { } // NewHandler returns a new implementation of [Handler] based on the configuration. -func NewHandler(config *filterapi.BackendAuth) (Handler, error) { +func NewHandler(ctx context.Context, config *filterapi.BackendAuth) (Handler, error) { if config.AWSAuth != nil { - return newAWSHandler(config.AWSAuth) + return newAWSHandler(ctx, config.AWSAuth) } else if config.APIKey != nil { return newAPIKeyHandler(config.APIKey) } diff --git a/internal/extproc/backendauth/auth_test.go b/internal/extproc/backendauth/auth_test.go index 90d0ab977..3e24dd952 100644 --- a/internal/extproc/backendauth/auth_test.go +++ b/internal/extproc/backendauth/auth_test.go @@ -1,6 +1,7 @@ package backendauth import ( + "context" "os" "testing" @@ -40,7 +41,7 @@ aws_secret_access_key = test }, } { t.Run(tt.name, func(t *testing.T) { - _, err := NewHandler(tt.config) + _, err := NewHandler(context.Background(), tt.config) require.NoError(t, err) }) } diff --git a/internal/extproc/backendauth/aws.go b/internal/extproc/backendauth/aws.go index e01ecc661..0bf6aa77b 100644 --- a/internal/extproc/backendauth/aws.go +++ b/internal/extproc/backendauth/aws.go @@ -27,7 +27,7 @@ type awsHandler struct { region string } -func newAWSHandler(awsAuth *filterapi.AWSAuth) (Handler, error) { +func newAWSHandler(ctx context.Context, awsAuth *filterapi.AWSAuth) (Handler, error) { var credentials aws.Credentials var region string @@ -35,14 +35,14 @@ func newAWSHandler(awsAuth *filterapi.AWSAuth) (Handler, error) { region = awsAuth.Region if len(awsAuth.CredentialFileName) != 0 { cfg, err := config.LoadDefaultConfig( - context.Background(), + ctx, config.WithSharedCredentialsFiles([]string{awsAuth.CredentialFileName}), config.WithRegion(awsAuth.Region), ) if err != nil { return nil, fmt.Errorf("cannot load from credentials file: %w", err) } - credentials, err = cfg.Credentials.Retrieve(context.Background()) + credentials, err = cfg.Credentials.Retrieve(ctx) if err != nil { return nil, fmt.Errorf("cannot retrieve AWS credentials: %w", err) } diff --git a/internal/extproc/backendauth/aws_test.go b/internal/extproc/backendauth/aws_test.go index e0d922b6f..19d00c393 100644 --- a/internal/extproc/backendauth/aws_test.go +++ b/internal/extproc/backendauth/aws_test.go @@ -16,7 +16,7 @@ func TestNewAWSHandler(t *testing.T) { t.Setenv("AWS_ACCESS_KEY_ID", "test") t.Setenv("AWS_SECRET_ACCESS_KEY", "secret") - handler, err := newAWSHandler(&filterapi.AWSAuth{}) + handler, err := newAWSHandler(context.Background(), &filterapi.AWSAuth{}) require.NoError(t, err) require.NotNil(t, handler) } @@ -36,7 +36,7 @@ func TestAWSHandler_Do(t *testing.T) { require.NoError(t, err) require.NoError(t, file.Sync()) - credentialFileHandler, err := newAWSHandler(&filterapi.AWSAuth{ + credentialFileHandler, err := newAWSHandler(context.Background(), &filterapi.AWSAuth{ CredentialFileName: awsCredentialFile, Region: "us-east-1", }) diff --git a/internal/extproc/server.go b/internal/extproc/server.go index 533e3418f..c791d8185 100644 --- a/internal/extproc/server.go +++ b/internal/extproc/server.go @@ -44,7 +44,7 @@ func NewServer[P ProcessorIface](logger *slog.Logger, newProcessor func(*process } // LoadConfig updates the configuration of the external processor. -func (s *Server[P]) LoadConfig(config *filterapi.Config) error { +func (s *Server[P]) LoadConfig(ctx context.Context, config *filterapi.Config) error { bodyParser, err := router.NewRequestBodyParser(config.Schema) if err != nil { return fmt.Errorf("cannot create request body parser: %w", err) @@ -66,7 +66,7 @@ func (s *Server[P]) LoadConfig(config *filterapi.Config) error { } if b.Auth != nil { - h, err := backendauth.NewHandler(b.Auth) + h, err := backendauth.NewHandler(ctx, b.Auth) if err != nil { return fmt.Errorf("cannot create backend auth handler: %w", err) } diff --git a/internal/extproc/server_test.go b/internal/extproc/server_test.go index 4d44ebf7a..2e6436345 100644 --- a/internal/extproc/server_test.go +++ b/internal/extproc/server_test.go @@ -30,7 +30,7 @@ func requireNewServerWithMockProcessor(t *testing.T) *Server[*mockProcessor] { func TestServer_LoadConfig(t *testing.T) { t.Run("invalid input schema", func(t *testing.T) { s := requireNewServerWithMockProcessor(t) - err := s.LoadConfig(&filterapi.Config{ + err := s.LoadConfig(context.Background(), &filterapi.Config{ Schema: filterapi.VersionedAPISchema{Name: "some-invalid-schema"}, }) require.Error(t, err) @@ -73,7 +73,7 @@ func TestServer_LoadConfig(t *testing.T) { }, } s := requireNewServerWithMockProcessor(t) - err := s.LoadConfig(config) + err := s.LoadConfig(context.Background(), config) require.NoError(t, err) require.NotNil(t, s.config) diff --git a/internal/extproc/watcher.go b/internal/extproc/watcher.go index 70ffab516..871f8849e 100644 --- a/internal/extproc/watcher.go +++ b/internal/extproc/watcher.go @@ -12,9 +12,10 @@ import ( ) // ConfigReceiver is an interface that can receive *filterapi.Config updates. +// This is mostly for decoupling and testing purposes. type ConfigReceiver interface { // LoadConfig updates the configuration. - LoadConfig(config *filterapi.Config) error + LoadConfig(ctx context.Context, config *filterapi.Config) error } type configWatcher struct { @@ -85,7 +86,7 @@ func (cw *configWatcher) loadConfig(ctx context.Context) error { if err != nil { return err } - return cw.rcv.LoadConfig(cfg) + return cw.rcv.LoadConfig(ctx, cfg) } // getConfigString gets a string representation of the current config diff --git a/internal/extproc/watcher_test.go b/internal/extproc/watcher_test.go index 3f383bf57..be66ba59c 100644 --- a/internal/extproc/watcher_test.go +++ b/internal/extproc/watcher_test.go @@ -22,7 +22,7 @@ type mockReceiver struct { } // LoadConfig implements ConfigReceiver. -func (m *mockReceiver) LoadConfig(cfg *filterapi.Config) error { +func (m *mockReceiver) LoadConfig(_ context.Context, cfg *filterapi.Config) error { m.mux.Lock() defer m.mux.Unlock() m.cfg = cfg From 061a659ad031d9dc4dc86b19b047556aa615f2dc Mon Sep 17 00:00:00 2001 From: Takeshi Yoneda Date: Wed, 5 Feb 2025 19:52:08 -0800 Subject: [PATCH 31/40] chore: stricter golangci-lint (#298) **Commit Message** This makes the lint settings stricter and starts enforcing more rules. Signed-off-by: Takeshi Yoneda Signed-off-by: Eric Mariasis --- .golangci.yml | 6 --- internal/apischema/awsbedrock/awsbedrock.go | 10 +++-- internal/controller/ai_gateway_route.go | 2 +- internal/controller/ai_gateway_route_test.go | 2 +- .../controller/ai_service_backend_test.go | 4 -- internal/controller/controller_test.go | 7 ---- internal/controller/sink.go | 31 +++++++------- internal/controller/sink_test.go | 8 ++-- internal/extproc/mocks_test.go | 4 +- internal/extproc/router/request_body.go | 3 +- internal/extproc/router/router_test.go | 2 +- internal/extproc/server.go | 3 +- .../extproc/translator/openai_awsbedrock.go | 42 +++++++++---------- .../translator/openai_awsbedrock_test.go | 14 +++---- internal/extproc/translator/openai_openai.go | 8 ++-- internal/extproc/watcher.go | 9 ++-- internal/llmcostcel/cel.go | 2 +- tests/controller/controller_test.go | 6 +-- tests/internal/envtest.go | 8 ++-- .../testupstreamlib/testupstream/main.go | 27 ++++++------ .../testupstreamlib/testupstream/main_test.go | 3 +- 21 files changed, 92 insertions(+), 109 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 2711c771d..4b4ac85ef 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -71,13 +71,7 @@ linters-settings: govet: enable-all: true disable: - - shadow - fieldalignment - revive: - rules: - # TODO: enable if-return check - - name: if-return - disabled: true testifylint: disable: - float-compare diff --git a/internal/apischema/awsbedrock/awsbedrock.go b/internal/apischema/awsbedrock/awsbedrock.go index 104bda9e2..6f28c3b99 100644 --- a/internal/apischema/awsbedrock/awsbedrock.go +++ b/internal/apischema/awsbedrock/awsbedrock.go @@ -337,7 +337,9 @@ type ConverseMetrics struct { LatencyMs *int64 `json:"latencyMs"` } -type ConverseOutput struct { +// ConverseResponse is the response from a call to Converse. +// https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_Converse.html +type ConverseResponse struct { // Metrics for the call to Converse. // // Metrics is a required field @@ -346,7 +348,7 @@ type ConverseOutput struct { // The result from the call to Converse. // // Output is a required field - Output *ConverseOutput_ `json:"output"` + Output *ConverseOutput `json:"output"` // The reason why the model stopped generating output. // @@ -362,9 +364,9 @@ type ConverseOutput struct { Usage *TokenUsage `json:"usage"` } -// ConverseOutput_ ConverseResponseOutput is defined in the AWS Bedrock API: +// ConverseOutput is defined in the AWS Bedrock API: // https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ConverseOutput.html -type ConverseOutput_ struct { +type ConverseOutput struct { Message Message `json:"message,omitempty"` } diff --git a/internal/controller/ai_gateway_route.go b/internal/controller/ai_gateway_route.go index b76cdbc4f..58c824997 100644 --- a/internal/controller/ai_gateway_route.go +++ b/internal/controller/ai_gateway_route.go @@ -153,7 +153,7 @@ func (c *aiGatewayRouteController) ensuresExtProcConfigMapExists(ctx context.Con }, Data: map[string]string{expProcConfigFileName: filterapi.DefaultConfig}, } - if err := ctrlutil.SetControllerReference(aiGatewayRoute, configMap, c.client.Scheme()); err != nil { + if err = ctrlutil.SetControllerReference(aiGatewayRoute, configMap, c.client.Scheme()); err != nil { panic(fmt.Errorf("BUG: failed to set controller reference for extproc configmap: %w", err)) } _, err = c.kube.CoreV1().ConfigMaps(aiGatewayRoute.Namespace).Create(ctx, configMap, metav1.CreateOptions{}) diff --git a/internal/controller/ai_gateway_route_test.go b/internal/controller/ai_gateway_route_test.go index ca72353fb..5fe2f2c16 100644 --- a/internal/controller/ai_gateway_route_test.go +++ b/internal/controller/ai_gateway_route_test.go @@ -157,7 +157,7 @@ func Test_applyExtProcDeploymentConfigUpdate(t *testing.T) { }, }, } - t.Run("not panic", func(t *testing.T) { + t.Run("not panic", func(_ *testing.T) { applyExtProcDeploymentConfigUpdate(dep, nil) applyExtProcDeploymentConfigUpdate(dep, &aigv1a1.AIGatewayFilterConfig{}) applyExtProcDeploymentConfigUpdate(dep, &aigv1a1.AIGatewayFilterConfig{ diff --git a/internal/controller/ai_service_backend_test.go b/internal/controller/ai_service_backend_test.go index 1806e688c..575abb3da 100644 --- a/internal/controller/ai_service_backend_test.go +++ b/internal/controller/ai_service_backend_test.go @@ -6,7 +6,6 @@ import ( "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" fake2 "k8s.io/client-go/kubernetes/fake" "k8s.io/utils/ptr" @@ -42,9 +41,6 @@ func TestAIServiceBackendController_Reconcile(t *testing.T) { } func Test_AiServiceBackendIndexFunc(t *testing.T) { - scheme := runtime.NewScheme() - require.NoError(t, aigv1a1.AddToScheme(scheme)) - c := fake.NewClientBuilder(). WithScheme(scheme). WithIndex(&aigv1a1.AIServiceBackend{}, k8sClientIndexBackendSecurityPolicyToReferencingAIServiceBackend, aiServiceBackendIndexFunc). diff --git a/internal/controller/controller_test.go b/internal/controller/controller_test.go index f1e4bb40a..4cc0b5c89 100644 --- a/internal/controller/controller_test.go +++ b/internal/controller/controller_test.go @@ -7,7 +7,6 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/goleak" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" @@ -22,9 +21,6 @@ func TestMain(m *testing.M) { } func Test_aiGatewayRouteIndexFunc(t *testing.T) { - scheme := runtime.NewScheme() - require.NoError(t, aigv1a1.AddToScheme(scheme)) - c := fake.NewClientBuilder(). WithScheme(scheme). WithIndex(&aigv1a1.AIGatewayRoute{}, k8sClientIndexBackendToReferencingAIGatewayRoute, aiGatewayRouteIndexFunc). @@ -137,9 +133,6 @@ func Test_backendSecurityPolicyIndexFunc(t *testing.T) { }, } { t.Run(bsp.name, func(t *testing.T) { - scheme := runtime.NewScheme() - require.NoError(t, aigv1a1.AddToScheme(scheme)) - c := fake.NewClientBuilder(). WithScheme(scheme). WithIndex(&aigv1a1.BackendSecurityPolicy{}, k8sClientIndexSecretToReferencingBackendSecurityPolicy, backendSecurityPolicyIndexFunc). diff --git a/internal/controller/sink.go b/internal/controller/sink.go index 470b51855..136a0c628 100644 --- a/internal/controller/sink.go +++ b/internal/controller/sink.go @@ -149,7 +149,7 @@ func (c *configSink) syncAIGatewayRoute(ctx context.Context, aiGatewayRoute *aig }, }, } - if err := c.client.Create(ctx, &httpRouteFilter); err != nil { + if err = c.client.Create(ctx, &httpRouteFilter); err != nil { c.logger.Error(err, "failed to create HTTPRouteFilter", "namespace", aiGatewayRoute.Namespace, "name", hostRewriteHTTPFilterName) return } @@ -181,20 +181,20 @@ func (c *configSink) syncAIGatewayRoute(ctx context.Context, aiGatewayRoute *aig } // Update the HTTPRoute with the new AIGatewayRoute. - if err := c.newHTTPRoute(ctx, &httpRoute, aiGatewayRoute); err != nil { + if err = c.newHTTPRoute(ctx, &httpRoute, aiGatewayRoute); err != nil { c.logger.Error(err, "failed to update HTTPRoute with AIGatewayRoute", "namespace", aiGatewayRoute.Namespace, "name", aiGatewayRoute.Name) return } if existingRoute { c.logger.Info("updating HTTPRoute", "namespace", httpRoute.Namespace, "name", httpRoute.Name) - if err := c.client.Update(ctx, &httpRoute); err != nil { + if err = c.client.Update(ctx, &httpRoute); err != nil { c.logger.Error(err, "failed to update HTTPRoute", "namespace", httpRoute.Namespace, "name", httpRoute.Name) return } } else { c.logger.Info("creating HTTPRoute", "namespace", httpRoute.Namespace, "name", httpRoute.Name) - if err := c.client.Create(ctx, &httpRoute); err != nil { + if err = c.client.Create(ctx, &httpRoute); err != nil { c.logger.Error(err, "failed to create HTTPRoute", "namespace", httpRoute.Namespace, "name", httpRoute.Name) return } @@ -202,7 +202,7 @@ func (c *configSink) syncAIGatewayRoute(ctx context.Context, aiGatewayRoute *aig // Update the extproc configmap. uuid := string(uuid2.NewUUID()) - if err := c.updateExtProcConfigMap(ctx, aiGatewayRoute, uuid); err != nil { + if err = c.updateExtProcConfigMap(ctx, aiGatewayRoute, uuid); err != nil { c.logger.Error(err, "failed to update extproc configmap", "namespace", aiGatewayRoute.Namespace, "name", aiGatewayRoute.Name) return } @@ -277,19 +277,20 @@ func (c *configSink) updateExtProcConfigMap(ctx context.Context, aiGatewayRoute key := fmt.Sprintf("%s.%s", backend.Name, aiGatewayRoute.Namespace) ec.Rules[i].Backends[j].Name = key ec.Rules[i].Backends[j].Weight = backend.Weight - backendObj, err := c.backend(ctx, aiGatewayRoute.Namespace, backend.Name) + var backendObj *aigv1a1.AIServiceBackend + backendObj, err = c.backend(ctx, aiGatewayRoute.Namespace, backend.Name) if err != nil { return fmt.Errorf("failed to get AIServiceBackend %s: %w", key, err) - } else { - ec.Rules[i].Backends[j].Schema.Name = filterapi.APISchemaName(backendObj.Spec.APISchema.Name) - ec.Rules[i].Backends[j].Schema.Version = backendObj.Spec.APISchema.Version } + ec.Rules[i].Backends[j].Schema.Name = filterapi.APISchemaName(backendObj.Spec.APISchema.Name) + ec.Rules[i].Backends[j].Schema.Version = backendObj.Spec.APISchema.Version if bspRef := backendObj.Spec.BackendSecurityPolicyRef; bspRef != nil { volumeName := backendSecurityPolicyVolumeName( i, j, string(backendObj.Spec.BackendSecurityPolicyRef.Name), ) - backendSecurityPolicy, err := c.backendSecurityPolicy(ctx, aiGatewayRoute.Namespace, string(bspRef.Name)) + var backendSecurityPolicy *aigv1a1.BackendSecurityPolicy + backendSecurityPolicy, err = c.backendSecurityPolicy(ctx, aiGatewayRoute.Namespace, string(bspRef.Name)) if err != nil { return fmt.Errorf("failed to get BackendSecurityPolicy %s: %w", bspRef.Name, err) } @@ -338,7 +339,7 @@ func (c *configSink) updateExtProcConfigMap(ctx context.Context, aiGatewayRoute fc.Type = filterapi.LLMRequestCostTypeCELExpression expr := *cost.CELExpression // Sanity check the CEL expression. - _, err := llmcostcel.NewProgram(expr) + _, err = llmcostcel.NewProgram(expr) if err != nil { return fmt.Errorf("invalid CEL expression: %w", err) } @@ -513,10 +514,11 @@ func (c *configSink) syncExtProcDeployment(ctx context.Context, aiGatewayRoute * }, }, } - if err := ctrlutil.SetControllerReference(aiGatewayRoute, deployment, c.client.Scheme()); err != nil { + if err = ctrlutil.SetControllerReference(aiGatewayRoute, deployment, c.client.Scheme()); err != nil { panic(fmt.Errorf("BUG: failed to set controller reference for deployment: %w", err)) } - updatedSpec, err := c.mountBackendSecurityPolicySecrets(ctx, &deployment.Spec.Template.Spec, aiGatewayRoute) + var updatedSpec *corev1.PodSpec + updatedSpec, err = c.mountBackendSecurityPolicySecrets(ctx, &deployment.Spec.Template.Spec, aiGatewayRoute) if err == nil { deployment.Spec.Template.Spec = *updatedSpec } @@ -530,7 +532,8 @@ func (c *configSink) syncExtProcDeployment(ctx context.Context, aiGatewayRoute * return fmt.Errorf("failed to get deployment: %w", err) } } else { - updatedSpec, err := c.mountBackendSecurityPolicySecrets(ctx, &deployment.Spec.Template.Spec, aiGatewayRoute) + var updatedSpec *corev1.PodSpec + updatedSpec, err = c.mountBackendSecurityPolicySecrets(ctx, &deployment.Spec.Template.Spec, aiGatewayRoute) if err == nil { deployment.Spec.Template.Spec = *updatedSpec } diff --git a/internal/controller/sink_test.go b/internal/controller/sink_test.go index df0aae86f..a35d221c4 100644 --- a/internal/controller/sink_test.go +++ b/internal/controller/sink_test.go @@ -30,7 +30,7 @@ import ( func requireNewFakeClientWithIndexes(t *testing.T) client.Client { builder := fake.NewClientBuilder().WithScheme(scheme) - err := applyIndexing(context.Background(), func(ctx context.Context, obj client.Object, field string, extractValue client.IndexerFunc) error { + err := applyIndexing(context.Background(), func(_ context.Context, obj client.Object, field string, extractValue client.IndexerFunc) error { builder = builder.WithIndex(obj, field, extractValue) return nil }) @@ -501,8 +501,7 @@ func TestConfigSink_SyncExtprocDeployment(t *testing.T) { }, }, } { - err := fakeClient.Create(context.Background(), bsp, &client.CreateOptions{}) - require.NoError(t, err) + require.NoError(t, fakeClient.Create(context.Background(), bsp, &client.CreateOptions{})) } for _, b := range []*aigv1a1.AIServiceBackend{ @@ -530,8 +529,7 @@ func TestConfigSink_SyncExtprocDeployment(t *testing.T) { }, }, } { - err := fakeClient.Create(context.Background(), b, &client.CreateOptions{}) - require.NoError(t, err) + require.NoError(t, fakeClient.Create(context.Background(), b, &client.CreateOptions{})) } require.NotNil(t, s) diff --git a/internal/extproc/mocks_test.go b/internal/extproc/mocks_test.go index d1f2d5f9e..c731d80c4 100644 --- a/internal/extproc/mocks_test.go +++ b/internal/extproc/mocks_test.go @@ -97,7 +97,7 @@ func (m mockTranslator) ResponseError(_ map[string]string, body io.Reader) (head } // ResponseBody implements [translator.Translator.ResponseBody]. -func (m mockTranslator) ResponseBody(respHeader map[string]string, body io.Reader, _ bool) (headerMutation *extprocv3.HeaderMutation, bodyMutation *extprocv3.BodyMutation, tokenUsage translator.LLMTokenUsage, err error) { +func (m mockTranslator) ResponseBody(_ map[string]string, body io.Reader, _ bool) (headerMutation *extprocv3.HeaderMutation, bodyMutation *extprocv3.BodyMutation, tokenUsage translator.LLMTokenUsage, err error) { if m.expResponseBody != nil { buf, err := io.ReadAll(body) require.NoError(m.t, err) @@ -179,7 +179,7 @@ func (m mockExternalProcessingStream) Recv() (*extprocv3.ProcessingRequest, erro } // SetHeader implements [extprocv3.ExternalProcessor_ProcessServer]. -func (m mockExternalProcessingStream) SetHeader(md metadata.MD) error { panic("TODO") } +func (m mockExternalProcessingStream) SetHeader(_ metadata.MD) error { panic("TODO") } // SendHeader implements [extprocv3.ExternalProcessor_ProcessServer]. func (m mockExternalProcessingStream) SendHeader(metadata.MD) error { panic("TODO") } diff --git a/internal/extproc/router/request_body.go b/internal/extproc/router/request_body.go index 724e039f7..bc298f28a 100644 --- a/internal/extproc/router/request_body.go +++ b/internal/extproc/router/request_body.go @@ -32,7 +32,6 @@ func openAIParseBody(path string, body *extprocv3.HttpBody) (modelName string, r return "", nil, fmt.Errorf("failed to unmarshal body: %w", err) } return openAIReq.Model, &openAIReq, nil - } else { - return "", nil, fmt.Errorf("unsupported path: %s", path) } + return "", nil, fmt.Errorf("unsupported path: %s", path) } diff --git a/internal/extproc/router/router_test.go b/internal/extproc/router/router_test.go index c6a359b02..34b23e79a 100644 --- a/internal/extproc/router/router_test.go +++ b/internal/extproc/router/router_test.go @@ -18,7 +18,7 @@ func (c *dummyCustomRouter) Calculate(map[string]string) (*filterapi.Backend, er } func TestRouter_NewRouter_Custom(t *testing.T) { - r, err := NewRouter(&filterapi.Config{}, func(defaultRouter x.Router, config *filterapi.Config) x.Router { + r, err := NewRouter(&filterapi.Config{}, func(defaultRouter x.Router, _ *filterapi.Config) x.Router { require.NotNil(t, defaultRouter) _, ok := defaultRouter.(*router) require.True(t, ok) // Checking if the default router is correctly passed. diff --git a/internal/extproc/server.go b/internal/extproc/server.go index c791d8185..68627d754 100644 --- a/internal/extproc/server.go +++ b/internal/extproc/server.go @@ -66,11 +66,10 @@ func (s *Server[P]) LoadConfig(ctx context.Context, config *filterapi.Config) er } if b.Auth != nil { - h, err := backendauth.NewHandler(ctx, b.Auth) + backendAuthHandlers[b.Name], err = backendauth.NewHandler(ctx, b.Auth) if err != nil { return fmt.Errorf("cannot create backend auth handler: %w", err) } - backendAuthHandlers[b.Name] = h } } } diff --git a/internal/extproc/translator/openai_awsbedrock.go b/internal/extproc/translator/openai_awsbedrock.go index 55c93980a..3297cc55e 100644 --- a/internal/extproc/translator/openai_awsbedrock.go +++ b/internal/extproc/translator/openai_awsbedrock.go @@ -25,9 +25,8 @@ import ( func newOpenAIToAWSBedrockTranslator(path string) (Translator, error) { if path == "/v1/chat/completions" { return &openAIToAWSBedrockTranslatorV1ChatCompletion{}, nil - } else { - return nil, fmt.Errorf("unsupported path: %s", path) } + return nil, fmt.Errorf("unsupported path: %s", path) } // openAIToAWSBedrockTranslator implements [Translator] for /v1/chat/completions. @@ -92,10 +91,8 @@ func (o *openAIToAWSBedrockTranslatorV1ChatCompletion) RequestBody(body router.R } mut := &extprocv3.BodyMutation_Body{} - if b, err := json.Marshal(bedrockReq); err != nil { + if mut.Body, err = json.Marshal(bedrockReq); err != nil { return nil, nil, nil, fmt.Errorf("failed to marshal body: %w", err) - } else { - mut.Body = b } setContentLength(headerMutation, mut.Body) return headerMutation, &extprocv3.BodyMutation{Mutation: mut}, override, nil @@ -238,9 +235,8 @@ func (o *openAIToAWSBedrockTranslatorV1ChatCompletion) openAIMessageToBedrockMes } } return chatMessage, nil - } else { - return nil, fmt.Errorf("unexpected content type") } + return nil, fmt.Errorf("unexpected content type") } // unmarshalToolCallArguments is a helper method to unmarshal tool call arguments. @@ -468,7 +464,7 @@ func (o *openAIToAWSBedrockTranslatorV1ChatCompletion) ResponseError(respHeaders var openaiError openai.Error if v, ok := respHeaders[contentTypeHeaderName]; ok && v == jsonContentType { var bedrockError awsbedrock.BedrockException - if err := json.NewDecoder(body).Decode(&bedrockError); err != nil { + if err = json.NewDecoder(body).Decode(&bedrockError); err != nil { return nil, nil, fmt.Errorf("failed to unmarshal error body: %w", err) } openaiError = openai.Error{ @@ -480,7 +476,8 @@ func (o *openAIToAWSBedrockTranslatorV1ChatCompletion) ResponseError(respHeaders }, } } else { - buf, err := io.ReadAll(body) + var buf []byte + buf, err = io.ReadAll(body) if err != nil { return nil, nil, fmt.Errorf("failed to read error body: %w", err) } @@ -494,10 +491,9 @@ func (o *openAIToAWSBedrockTranslatorV1ChatCompletion) ResponseError(respHeaders } } mut := &extprocv3.BodyMutation_Body{} - if errBody, err := json.Marshal(openaiError); err != nil { + mut.Body, err = json.Marshal(openaiError) + if err != nil { return nil, nil, fmt.Errorf("failed to marshal error body: %w", err) - } else { - mut.Body = errBody } headerMutation = &extprocv3.HeaderMutation{} setContentLength(headerMutation, mut.Body) @@ -508,9 +504,10 @@ func (o *openAIToAWSBedrockTranslatorV1ChatCompletion) ResponseError(respHeaders func (o *openAIToAWSBedrockTranslatorV1ChatCompletion) ResponseBody(respHeaders map[string]string, body io.Reader, endOfStream bool) ( headerMutation *extprocv3.HeaderMutation, bodyMutation *extprocv3.BodyMutation, tokenUsage LLMTokenUsage, err error, ) { - if v, ok := respHeaders[statusHeaderName]; ok { - if v, err := strconv.Atoi(v); err == nil { - if !isGoodStatusCode(v) { + if statusStr, ok := respHeaders[statusHeaderName]; ok { + var status int + if status, err = strconv.Atoi(statusStr); err == nil { + if !isGoodStatusCode(status) { headerMutation, bodyMutation, err = o.ResponseError(respHeaders, body) return headerMutation, bodyMutation, LLMTokenUsage{}, err } @@ -518,7 +515,8 @@ func (o *openAIToAWSBedrockTranslatorV1ChatCompletion) ResponseBody(respHeaders } mut := &extprocv3.BodyMutation_Body{} if o.stream { - buf, err := io.ReadAll(body) + var buf []byte + buf, err = io.ReadAll(body) if err != nil { return nil, nil, tokenUsage, fmt.Errorf("failed to read body: %w", err) } @@ -538,7 +536,8 @@ func (o *openAIToAWSBedrockTranslatorV1ChatCompletion) ResponseBody(respHeaders if !ok { continue } - oaiEventBytes, err := json.Marshal(oaiEvent) + var oaiEventBytes []byte + oaiEventBytes, err = json.Marshal(oaiEvent) if err != nil { panic(fmt.Errorf("failed to marshal event: %w", err)) } @@ -553,8 +552,8 @@ func (o *openAIToAWSBedrockTranslatorV1ChatCompletion) ResponseBody(respHeaders return headerMutation, &extprocv3.BodyMutation{Mutation: mut}, tokenUsage, nil } - var bedrockResp awsbedrock.ConverseOutput - if err := json.NewDecoder(body).Decode(&bedrockResp); err != nil { + var bedrockResp awsbedrock.ConverseResponse + if err = json.NewDecoder(body).Decode(&bedrockResp); err != nil { return nil, nil, tokenUsage, fmt.Errorf("failed to unmarshal body: %w", err) } @@ -590,10 +589,9 @@ func (o *openAIToAWSBedrockTranslatorV1ChatCompletion) ResponseBody(respHeaders openAIResp.Choices = append(openAIResp.Choices, choice) } - if b, err := json.Marshal(openAIResp); err != nil { + mut.Body, err = json.Marshal(openAIResp) + if err != nil { return nil, nil, tokenUsage, fmt.Errorf("failed to marshal body: %w", err) - } else { - mut.Body = b } headerMutation = &extprocv3.HeaderMutation{} setContentLength(headerMutation, mut.Body) diff --git a/internal/extproc/translator/openai_awsbedrock_test.go b/internal/extproc/translator/openai_awsbedrock_test.go index 22bb23268..873ba0d63 100644 --- a/internal/extproc/translator/openai_awsbedrock_test.go +++ b/internal/extproc/translator/openai_awsbedrock_test.go @@ -881,18 +881,18 @@ func TestOpenAIToAWSBedrockTranslatorV1ChatCompletion_ResponseBody(t *testing.T) }) tests := []struct { name string - input awsbedrock.ConverseOutput + input awsbedrock.ConverseResponse output openai.ChatCompletionResponse }{ { name: "basic_testing", - input: awsbedrock.ConverseOutput{ + input: awsbedrock.ConverseResponse{ Usage: &awsbedrock.TokenUsage{ InputTokens: 10, OutputTokens: 20, TotalTokens: 30, }, - Output: &awsbedrock.ConverseOutput_{ + Output: &awsbedrock.ConverseOutput{ Message: awsbedrock.Message{ Role: "assistant", Content: []*awsbedrock.ContentBlock{ @@ -940,14 +940,14 @@ func TestOpenAIToAWSBedrockTranslatorV1ChatCompletion_ResponseBody(t *testing.T) }, { name: "test stop reason", - input: awsbedrock.ConverseOutput{ + input: awsbedrock.ConverseResponse{ Usage: &awsbedrock.TokenUsage{ InputTokens: 10, OutputTokens: 20, TotalTokens: 30, }, StopReason: ptr.To("stop_sequence"), - Output: &awsbedrock.ConverseOutput_{ + Output: &awsbedrock.ConverseOutput{ Message: awsbedrock.Message{ Role: awsbedrock.ConversationRoleAssistant, Content: []*awsbedrock.ContentBlock{ @@ -977,9 +977,9 @@ func TestOpenAIToAWSBedrockTranslatorV1ChatCompletion_ResponseBody(t *testing.T) }, { name: "test tool use", - input: awsbedrock.ConverseOutput{ + input: awsbedrock.ConverseResponse{ StopReason: ptr.To(awsbedrock.StopReasonToolUse), - Output: &awsbedrock.ConverseOutput_{ + Output: &awsbedrock.ConverseOutput{ Message: awsbedrock.Message{ Role: awsbedrock.ConversationRoleAssistant, Content: []*awsbedrock.ContentBlock{ diff --git a/internal/extproc/translator/openai_openai.go b/internal/extproc/translator/openai_openai.go index 14b29a4b9..e14779ff1 100644 --- a/internal/extproc/translator/openai_openai.go +++ b/internal/extproc/translator/openai_openai.go @@ -18,9 +18,8 @@ import ( func newOpenAIToOpenAITranslator(path string) (Translator, error) { if path == "/v1/chat/completions" { return &openAIToOpenAITranslatorV1ChatCompletion{}, nil - } else { - return nil, fmt.Errorf("unsupported path: %s", path) } + return nil, fmt.Errorf("unsupported path: %s", path) } // openAIToOpenAITranslatorV1ChatCompletion implements [Translator] for /v1/chat/completions. @@ -70,10 +69,9 @@ func (o *openAIToOpenAITranslatorV1ChatCompletion) ResponseError(respHeaders map }, } mut := &extprocv3.BodyMutation_Body{} - if errBody, err := json.Marshal(openaiError); err != nil { + mut.Body, err = json.Marshal(openaiError) + if err != nil { return nil, nil, fmt.Errorf("failed to marshal error body: %w", err) - } else { - mut.Body = errBody } headerMutation = &extprocv3.HeaderMutation{} setContentLength(headerMutation, mut.Body) diff --git a/internal/extproc/watcher.go b/internal/extproc/watcher.go index 871f8849e..20bf23c33 100644 --- a/internal/extproc/watcher.go +++ b/internal/extproc/watcher.go @@ -74,12 +74,12 @@ func (cw *configWatcher) loadConfig(ctx context.Context) error { if cw.l.Enabled(ctx, slog.LevelDebug) { // Re-hydrate the current config file for later diffing. previous := cw.current - current, err := cw.getConfigString() + cw.current, err = cw.getConfigString() if err != nil { return fmt.Errorf("failed to read the config file: %w", err) } - cw.diff(previous, current) + cw.diff(previous, cw.current) } cfg, err := filterapi.UnmarshalConfigYaml(cw.path) @@ -96,10 +96,7 @@ func (cw *configWatcher) getConfigString() (string, error) { if err != nil { return "", err } - current := string(currentByte) - cw.current = current - - return current, nil + return string(currentByte), nil } func (cw *configWatcher) diff(oldConfig, newConfig string) { diff --git a/internal/llmcostcel/cel.go b/internal/llmcostcel/cel.go index bd67d3f60..6055cdf11 100644 --- a/internal/llmcostcel/cel.go +++ b/internal/llmcostcel/cel.go @@ -38,7 +38,7 @@ func init() { func NewProgram(expr string) (prog cel.Program, err error) { ast, issues := env.Compile(expr) if issues != nil && issues.Err() != nil { - err := issues.Err() + err = issues.Err() return nil, fmt.Errorf("cannot compile CEL expression: %w", err) } prog, err = env.Program(ast) diff --git a/tests/controller/controller_test.go b/tests/controller/controller_test.go index 3ce300f2a..eb90691ab 100644 --- a/tests/controller/controller_test.go +++ b/tests/controller/controller_test.go @@ -260,7 +260,7 @@ func TestStartControllers(t *testing.T) { // Verify that the HTTPRoute resource is recreated. require.Eventually(t, func() bool { var egExtPolicy egv1a1.EnvoyExtensionPolicy - err := c.Get(ctx, client.ObjectKey{Name: policyName, Namespace: policyNamespace}, &egExtPolicy) + err = c.Get(ctx, client.ObjectKey{Name: policyName, Namespace: policyNamespace}, &egExtPolicy) if err != nil { t.Logf("failed to get envoy extension policy %s: %v", policyName, err) return false @@ -277,7 +277,7 @@ func TestStartControllers(t *testing.T) { // Verify that the HTTPRoute resource is recreated. require.Eventually(t, func() bool { var httpRoute gwapiv1.HTTPRoute - err := c.Get(ctx, client.ObjectKey{Name: routeName, Namespace: routeNamespace}, &httpRoute) + err = c.Get(ctx, client.ObjectKey{Name: routeName, Namespace: routeNamespace}, &httpRoute) if err != nil { t.Logf("failed to get http route %s: %v", routeName, err) return false @@ -296,7 +296,7 @@ func TestStartControllers(t *testing.T) { // Verify that the deployment is recreated. require.Eventually(t, func() bool { var deployment appsv1.Deployment - err := c.Get(ctx, client.ObjectKey{Name: deployName, Namespace: deployNamespace}, &deployment) + err = c.Get(ctx, client.ObjectKey{Name: deployName, Namespace: deployNamespace}, &deployment) if err != nil { t.Logf("failed to get deployment %s: %v", deployName, err) return false diff --git a/tests/internal/envtest.go b/tests/internal/envtest.go index b696eb826..c0171ad5c 100644 --- a/tests/internal/envtest.go +++ b/tests/internal/envtest.go @@ -42,7 +42,7 @@ func NewEnvTest(t *testing.T) (c client.Client, cfg *rest.Config, k kubernetes.I cfg, err = env.Start() require.NoError(t, err) t.Cleanup(func() { - if err := env.Stop(); err != nil { + if err = env.Stop(); err != nil { panic(fmt.Sprintf("Failed to stop testenv: %v", err)) } }) @@ -59,9 +59,11 @@ func NewEnvTest(t *testing.T) (c client.Client, cfg *rest.Config, k kubernetes.I // It returns the path to the CRD as-is to make it easier to use in the caller. func requireThirdPartyCRDDownloaded(t *testing.T, path, url string) string { if _, err := os.Stat(path); os.IsNotExist(err) { - crd, err := http.DefaultClient.Get(url) + var crd *http.Response + crd, err = http.DefaultClient.Get(url) require.NoError(t, err) - body, err := os.Create(path) + var body *os.File + body, err = os.Create(path) defer func() { _ = crd.Body.Close() }() diff --git a/tests/internal/testupstreamlib/testupstream/main.go b/tests/internal/testupstreamlib/testupstream/main.go index 6301767d1..999b75d35 100644 --- a/tests/internal/testupstreamlib/testupstream/main.go +++ b/tests/internal/testupstreamlib/testupstream/main.go @@ -47,7 +47,7 @@ func doMain(l net.Listener) { } } defer l.Close() - http.HandleFunc("/health", func(writer http.ResponseWriter, request *http.Request) { writer.WriteHeader(http.StatusOK) }) + http.HandleFunc("/health", func(writer http.ResponseWriter, _ *http.Request) { writer.WriteHeader(http.StatusOK) }) http.HandleFunc("/", handler) if err := http.Serve(l, nil); err != nil { // nolint: gosec logger.Printf("failed to serve: %v", err) @@ -127,9 +127,8 @@ func handler(w http.ResponseWriter, r *http.Request) { logger.Println(msg) http.Error(w, msg, http.StatusBadRequest) return - } else { - logger.Println("testupstream-id matched:", v) } + logger.Println("testupstream-id matched:", v) } else { logger.Println("no expected testupstream-id") } @@ -157,7 +156,8 @@ func handler(w http.ResponseWriter, r *http.Request) { } if expectedReqBody := r.Header.Get(testupstreamlib.ExpectedRequestBodyHeaderKey); expectedReqBody != "" { - expectedBody, err := base64.StdEncoding.DecodeString(expectedReqBody) + var expectedBody []byte + expectedBody, err = base64.StdEncoding.DecodeString(expectedReqBody) if err != nil { logger.Println("failed to decode the expected request body") http.Error(w, "failed to decode the expected request body", http.StatusBadRequest) @@ -174,7 +174,8 @@ func handler(w http.ResponseWriter, r *http.Request) { } if v := r.Header.Get(testupstreamlib.ResponseHeadersKey); v != "" { - responseHeaders, err := base64.StdEncoding.DecodeString(v) + var responseHeaders []byte + responseHeaders, err = base64.StdEncoding.DecodeString(v) if err != nil { logger.Println("failed to decode the response headers") http.Error(w, "failed to decode the response headers", http.StatusBadRequest) @@ -212,8 +213,8 @@ func handler(w http.ResponseWriter, r *http.Request) { switch r.Header.Get(testupstreamlib.ResponseTypeKey) { case "sse": w.Header().Set("Content-Type", "text/event-stream") - - expResponseBody, err := base64.StdEncoding.DecodeString(r.Header.Get(testupstreamlib.ResponseBodyHeaderKey)) + var expResponseBody []byte + expResponseBody, err = base64.StdEncoding.DecodeString(r.Header.Get(testupstreamlib.ResponseBodyHeaderKey)) if err != nil { logger.Println("failed to decode the response body") http.Error(w, "failed to decode the response body", http.StatusBadRequest) @@ -245,7 +246,8 @@ func handler(w http.ResponseWriter, r *http.Request) { case "aws-event-stream": w.Header().Set("Content-Type", "application/vnd.amazon.eventstream") - expResponseBody, err := base64.StdEncoding.DecodeString(r.Header.Get(testupstreamlib.ResponseBodyHeaderKey)) + var expResponseBody []byte + expResponseBody, err = base64.StdEncoding.DecodeString(r.Header.Get(testupstreamlib.ResponseBodyHeaderKey)) if err != nil { logger.Println("failed to decode the response body") http.Error(w, "failed to decode the response body", http.StatusBadRequest) @@ -260,7 +262,7 @@ func handler(w http.ResponseWriter, r *http.Request) { continue } time.Sleep(streamingInterval) - if err := e.Encode(w, eventstream.Message{ + if err = e.Encode(w, eventstream.Message{ Headers: eventstream.Headers{{Name: "event-type", Value: eventstream.StringValue("content")}}, Payload: line, }); err != nil { @@ -270,7 +272,7 @@ func handler(w http.ResponseWriter, r *http.Request) { logger.Println("response line sent:", string(line)) } - if err := e.Encode(w, eventstream.Message{ + if err = e.Encode(w, eventstream.Message{ Headers: eventstream.Headers{{Name: "event-type", Value: eventstream.StringValue("end")}}, Payload: []byte("this-is-end"), }); err != nil { @@ -351,8 +353,9 @@ func getFakeResponse(path string) ([]byte, error) { case "/v1/chat/completions": const template = `{"choices":[{"message":{"content":"%s"}}]}` msg := fmt.Sprintf(template, - chatCompletionFakeResponses[rand.New(rand.NewSource(uint64(time.Now().UnixNano()))). //nolint:gosec - Intn(len(chatCompletionFakeResponses))]) + //nolint:gosec + chatCompletionFakeResponses[rand.New(rand.NewSource(uint64(time.Now().UnixNano()))). + Intn(len(chatCompletionFakeResponses))]) return []byte(msg), nil default: return nil, fmt.Errorf("unknown path: %s", path) diff --git a/tests/internal/testupstreamlib/testupstream/main_test.go b/tests/internal/testupstreamlib/testupstream/main_test.go index 2ed58eea4..c98ac32f3 100644 --- a/tests/internal/testupstreamlib/testupstream/main_test.go +++ b/tests/internal/testupstreamlib/testupstream/main_test.go @@ -275,7 +275,8 @@ func Test_main(t *testing.T) { decoder := eventstream.NewDecoder() for i := 0; i < 5; i++ { - message, err := decoder.Decode(response.Body, nil) + var message eventstream.Message + message, err = decoder.Decode(response.Body, nil) require.NoError(t, err) require.Equal(t, "content", message.Headers.Get("event-type").String()) require.Equal(t, fmt.Sprintf("%d", i+1), string(message.Payload)) From 9c7279a473e7c07e2675c09a0ea00cc3e22f4207 Mon Sep 17 00:00:00 2001 From: Takeshi Yoneda Date: Wed, 5 Feb 2025 20:06:31 -0800 Subject: [PATCH 32/40] docs: more comment about generated resources in API (#294) **Commit Message** This adds additional doc comments on AIGatewayRoute resource about which k8s resources will be created by Envoy AI Gateway. **Related Issues/PRs (if applicable)** Closes #258 --------- Signed-off-by: Takeshi Yoneda Signed-off-by: Eric Mariasis --- api/v1alpha1/api.go | 17 +++++++++++++++-- ...gateway.envoyproxy.io_aigatewayroutes.yaml | 17 +++++++++++++++-- site/docs/api.md | 19 +++++++++++++++++-- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/api/v1alpha1/api.go b/api/v1alpha1/api.go index a21d7e1bd..2244c3567 100644 --- a/api/v1alpha1/api.go +++ b/api/v1alpha1/api.go @@ -20,8 +20,21 @@ import ( // on the output schema of the AIServiceBackend while doing the other necessary jobs like // upstream authentication, rate limit, etc. // -// AIGatewayRoute generates a HTTPRoute resource based on the configuration basis for routing the traffic. -// The generated HTTPRoute has the owner reference set to this AIGatewayRoute. +// For Advanced Users: Envoy AI Gateway will generate the following k8s resources corresponding to the AIGatewayRoute: +// +// - Deployment, Service, and ConfigMap of the k8s API for the AI Gateway filter. +// The name of these resources are `ai-eg-route-extproc-${name}`. +// - HTTPRoute of the Gateway API as a top-level resource to bind all backends. +// The name of the HTTPRoute is the same as the AIGatewayRoute. +// - EnvoyExtensionPolicy of the Envoy Gateway API to attach the AI Gateway filter into the HTTPRoute. +// The name of the EnvoyExtensionPolicy is `ai-eg-route-extproc-${name}` which is the same as the Deployment, etc. +// - HTTPRouteFilter of the Envoy Gateway API per namespace for automatic hostname rewrite. +// The name of the HTTPRouteFilter is `ai-eg-host-rewrite`. +// +// All of these resources are created in the same namespace as the AIGatewayRoute. Note that this is the implementation +// detail subject to change. If you want to customize the default behavior of the Envoy AI Gateway, you can use these +// resources as a reference and create your own resources. Alternatively, you can use EnvoyPatchPolicy API of the Envoy +// Gateway to patch the generated resources. For example, you can insert a custom filter into the filter chain. type AIGatewayRoute struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` diff --git a/manifests/charts/ai-gateway-helm/crds/aigateway.envoyproxy.io_aigatewayroutes.yaml b/manifests/charts/ai-gateway-helm/crds/aigateway.envoyproxy.io_aigatewayroutes.yaml index 873758110..bcb071f12 100644 --- a/manifests/charts/ai-gateway-helm/crds/aigateway.envoyproxy.io_aigatewayroutes.yaml +++ b/manifests/charts/ai-gateway-helm/crds/aigateway.envoyproxy.io_aigatewayroutes.yaml @@ -28,8 +28,21 @@ spec: on the output schema of the AIServiceBackend while doing the other necessary jobs like upstream authentication, rate limit, etc. - AIGatewayRoute generates a HTTPRoute resource based on the configuration basis for routing the traffic. - The generated HTTPRoute has the owner reference set to this AIGatewayRoute. + For Advanced Users: Envoy AI Gateway will generate the following k8s resources corresponding to the AIGatewayRoute: + + - Deployment, Service, and ConfigMap of the k8s API for the AI Gateway filter. + The name of these resources are `ai-eg-route-extproc-${name}`. + - HTTPRoute of the Gateway API as a top-level resource to bind all backends. + The name of the HTTPRoute is the same as the AIGatewayRoute. + - EnvoyExtensionPolicy of the Envoy Gateway API to attach the AI Gateway filter into the HTTPRoute. + The name of the EnvoyExtensionPolicy is `ai-eg-route-extproc-${name}` which is the same as the Deployment, etc. + - HTTPRouteFilter of the Envoy Gateway API per namespace for automatic hostname rewrite. + The name of the HTTPRouteFilter is `ai-eg-host-rewrite`. + + All of these resources are created in the same namespace as the AIGatewayRoute. Note that this is the implementation + detail subject to change. If you want to customize the default behavior of the Envoy AI Gateway, you can use these + resources as a reference and create your own resources. Alternatively, you can use EnvoyPatchPolicy API of the Envoy + Gateway to patch the generated resources. For example, you can insert a custom filter into the filter chain. properties: apiVersion: description: |- diff --git a/site/docs/api.md b/site/docs/api.md index d41dd687f..76bab9102 100644 --- a/site/docs/api.md +++ b/site/docs/api.md @@ -100,8 +100,23 @@ on the output schema of the AIServiceBackend while doing the other necessary job upstream authentication, rate limit, etc. -AIGatewayRoute generates a HTTPRoute resource based on the configuration basis for routing the traffic. -The generated HTTPRoute has the owner reference set to this AIGatewayRoute. +For Advanced Users: Envoy AI Gateway will generate the following k8s resources corresponding to the AIGatewayRoute: + + + - Deployment, Service, and ConfigMap of the k8s API for the AI Gateway filter. + The name of these resources are `ai-eg-route-extproc-${name}`. + - HTTPRoute of the Gateway API as a top-level resource to bind all backends. + The name of the HTTPRoute is the same as the AIGatewayRoute. + - EnvoyExtensionPolicy of the Envoy Gateway API to attach the AI Gateway filter into the HTTPRoute. + The name of the EnvoyExtensionPolicy is `ai-eg-route-extproc-${name}` which is the same as the Deployment, etc. + - HTTPRouteFilter of the Envoy Gateway API per namespace for automatic hostname rewrite. + The name of the HTTPRouteFilter is `ai-eg-host-rewrite`. + + +All of these resources are created in the same namespace as the AIGatewayRoute. Note that this is the implementation +detail subject to change. If you want to customize the default behavior of the Envoy AI Gateway, you can use these +resources as a reference and create your own resources. Alternatively, you can use EnvoyPatchPolicy API of the Envoy +Gateway to patch the generated resources. For example, you can insert a custom filter into the filter chain. _Appears in:_ - [AIGatewayRouteList](#aigatewayroutelist) From 03885eaa66fbb33a7e0fadb42bf7ec2439f39484 Mon Sep 17 00:00:00 2001 From: Takeshi Yoneda Date: Wed, 5 Feb 2025 20:07:39 -0800 Subject: [PATCH 33/40] docs: adds architecture overview (#293) **Commit Message** This adds a site page for the architecture overview of the project. **Related Issues/PRs (if applicable)** Closes #240 Signed-off-by: Takeshi Yoneda Signed-off-by: Eric Mariasis --- site/docs/architecture.md | 95 ++++++++++++++++++++++++++++++ site/static/img/control_plane.png | Bin 0 -> 133673 bytes site/static/img/data_plane.png | Bin 0 -> 162909 bytes 3 files changed, 95 insertions(+) create mode 100644 site/docs/architecture.md create mode 100644 site/static/img/control_plane.png create mode 100644 site/static/img/data_plane.png diff --git a/site/docs/architecture.md b/site/docs/architecture.md new file mode 100644 index 000000000..8d4a05653 --- /dev/null +++ b/site/docs/architecture.md @@ -0,0 +1,95 @@ +--- +id: architecture +title: Architecture Overview +sidebar_position: 2 +--- + +# Architecture Overview + +This page provides an overview of the architecture of Envoy AI Gateway, and how it integrates with Envoy Gateway +as well as the AI providers. Note that this documents the current state of the project and may change in the future. + +## Terminology + +### Control Plane + +A control plan is a component that manages the configuration of the data plane. +We utilize the Envoy Gateway as a central control plane for the Envoy AI Gateway and +Envoy AI Gateway works in conjunction with the Envoy Gateway to manage the data plane configuration. + +### Data Plane + +The data plane is the component that sits in the request path and processes the requests. +In the context of Envoy AI Gateway, the data plane consists of Envoy Proxy and the AI Gateway external +processor that processes the AI requests. + +### AI Provider / Service / Backend / Platform + +AI Provider is a service that servers AI models via a REST API, such as OpenAI, AWS Bedrock, etc. +Not only the commercial AI providers, but also the self-hosted AI services can be considered as AI providers +in our context. We may sometimes refer to AI providers as AI Backend, AI Service, or AI Platform in some contexts. They +are all the same thing in the sense that they host AI model serving REST APIs. + +### Token Rate Limiting + +The major AI model endpoints, such as `/v1/chat/completions` of OpenAI, return usage metrics called "tokens" +per HTTP request. It represents the amount of "tokens" consumed by the request. In other words, it can be used +to measure how expensive a request is. One of the major features of Envoy AI Gateway is to do a rate limit +based on the token usage instead of the standard "requests per second" style rate limiting. + +We call such rate limiting as "Token Rate Limiting" in our context and the metrics that represents the token usage +is called "Token Usage" or "Used Tokens". + +## How it works: Control Plane + +On the control plane side, Envoy AI Gateway has a k8s controller that watches the AI Gateway CRD and updates the +Envoy Gateway configuration accordingly. In other words, the data plane itself is managed by Envoy Gateway so +that the Envoy AI Gateway controller only needs to update the Envoy Gateway configuration. + +![Control Plane](../static/img/control_plane.png) + +The above diagram shows how the control plane works. The Envoy AI Gateway controller watches the AI Gateway CRD +and updates the Envoy Gateway configuration. These configuration include the settings for the AI Gateway external +processor so that it can intercept the incoming requests. + +In response to the configuration update by Envoy AI Gateway, +the Envoy Gateway updates the Envoy Proxy configuration so that the Envoy Proxy +can process the AI traffic based on the configuration. + +## How it works: Data Plane + +On the data plane side, the Envoy AI Gateway external processor intercepts all the incoming requests. + +There are several steps that the external processor does to process the HTTP requests on request paths: +1. Routing: It calculates the destination AI provider based on the request contents, such as the path, headers and most notably +the model name extracted from the request path. +2. Request Transformation: After it determines the destination AI provider, it does a necessary transformation to the request so that +the AI provider can understand the request. This includes the transformation of the request body as +well as the request path. +3. Upstream Authorization: Optionally, it does the upstream authorization to the AI provider. For example, it can +append the API key to the request headers, etc. + +After the external processor does the above steps, the request goes through the Envoy filter chain such +as the rate limiting filter, etc. and then it goes to the AI provider. When rate limiting is enabled, the rate limiting +filter will only check if the rate limit budget is left based on the consumed tokens. + +On the response path, the external processor does the following: +1. Response Transformation: It transforms the response from the AI provider so that the client can understand the response. +2. Token Usage Extraction/Calculation: When configured, it extracts the token usage from the response and +calculates the token usage based on the configuration. The calculated number is stored in the per-request dynamic metadata of +Envoy filter chain so that the rate limiting filter can reduce the rate limit budget based on that token usage when +the HTTP request completes. + +![Data Plane](../static/img/data_plane.png) + +The above diagram illustrates how the data plane works as described above. + +## Summary + +In summary, the Envoy AI Gateway is a control plane component that works in coordination with the Envoy Gateway +to manage the data plane configuration for AI traffic. The Envoy AI Gateway external processor intercepts all the incoming +requests and processes them based on the configuration. It does the necessary routing, request transformation, +response transformation, and token usage calculation, etc. + +If you are more interested in the implementation details, feel free to dive into the source code of +[the project](https://github.com/envoyproxy/ai-gateway). diff --git a/site/static/img/control_plane.png b/site/static/img/control_plane.png new file mode 100644 index 0000000000000000000000000000000000000000..d6086f6aa4b74c9af789ee5a906177cdfd08f660 GIT binary patch literal 133673 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
|phYNQ_gO?SSFN&y1{-f%W)sjrV)fHlm~ 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 literal 0 HcmV?d00001 diff --git a/site/static/img/data_plane.png b/site/static/img/data_plane.png new file mode 100644 index 0000000000000000000000000000000000000000..6b0df3a713ef6d5845bf69e8544566046a18f2aa GIT binary patch literal 162909 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
${codeContent.trim()}
`; + }); + + // Then handle single backtick inline code + processed = processed.replace(/`([^`]+)`/g, '$1'); + + return processed; +}; + +const ApiField: React.FC = ({ + name, + type, + required, + description, + enumValues, + defaultValue +}) => { + const isEnum = type === 'enum'; + + return ( +
+ {isEnum ? ( + <> +
+ {name} +
+
+ {description && ( +
+ )} +
+ + ) : ( + <> +
+ {name} + + {required === "true" ? 'required' : 'optional'} + +
+
$1' + ) + }}/> +
+
+
+ {defaultValue && ( +
+ {defaultValue} +
+ )} + {description && ( +
+ )} + {enumValues && ( +
+ Enum:{' '} + {enumValues.map(v => {v})} +
+ )} +
+ + )} +
+ ); +}; + +export default ApiField; diff --git a/site/src/css/custom.css b/site/src/css/custom.css index 93e7a7f98..e94994f92 100644 --- a/site/src/css/custom.css +++ b/site/src/css/custom.css @@ -22,65 +22,450 @@ --ifm-navbar-background-color: #b923ff; --ifm-footer-background-color: #f8ebff; --ifm-navbar-link-color: #fff; - --ifm-line-height-base: 1.3; + --ifm-line-height-base: 1.5; + --ifm-container-width: 1340px; + --ifm-container-width-xl: 1520px; + --ifm-h1-font-size: 2.5rem; + --ifm-h2-font-size: 2rem; + --ifm-h3-font-size: 1.7rem; + --ifm-h4-font-size: 1.4rem; + --ifm-h5-font-size: 1.2rem; + --ifm-h6-font-size: 1rem; + + /* New custom variables */ + --custom-content-line-height: 1.7; + --custom-paragraph-spacing: 1.5rem; + --custom-text-color: #2c3e50; + --custom-heading-color: #1a202c; + --custom-link-color: #6c1899; + --custom-link-hover-color: #8a20c4; + --custom-code-background: #f8f9fa; + /* Required field badge colors */ + --custom-required-badge-bg: #fef2f2; + --custom-required-badge-border: #fecaca; + --custom-required-badge-text: #dc2626; } -.navbar-sidebar__item { - .menu__link{ - color: #fff; - } +/* Word wrapping rules */ +.markdown { + overflow-wrap: break-word; + word-wrap: break-word; + word-break: keep-all; + hyphens: none; + color: var(--custom-text-color); + line-height: var(--custom-content-line-height); +} + +.markdown p { + margin-bottom: var(--custom-paragraph-spacing); +} + +.markdown h1, .markdown h2, .markdown h3, .markdown h4, .markdown h5, .markdown h6 { + color: var(--custom-heading-color); + font-weight: 700; + margin-top: 1em; + margin-bottom: 1em; +} + +.markdown h3 { + color: var(--ifm-color-primary-light); +} + +/* Enhanced Links */ +.markdown a { + color: var(--custom-link-color); + text-decoration: none; + border-bottom: 1px solid transparent; + transition: border-color 0.2s ease; +} + +.markdown a:hover { + color: var(--custom-link-hover-color); + border-bottom-color: currentColor; +} + +/* Enhanced Code Blocks */ +.markdown pre { + background-color: var(--custom-code-background); + border: 1px solid #e2e8f0; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); } -.navbar { - box-shadow: none; +.markdown code { + background-color: var(--custom-code-background); + padding: 0.2rem 0.4rem; + border-radius: 4px; + font-size: 0.9em; } -.heroBanner { - font-family: "Nunito", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; - padding: 2rem 0rem 10rem 0rem; - text-align: center; +/* Important Information Highlighting */ +.markdown blockquote { + border-left: 4px solid var(--ifm-color-primary); + background-color: #f8f9fa; + margin: 1.5rem 0; + padding: 1rem; + border-radius: 0 8px 8px 0; +} + +/* Lists Enhancement */ +.markdown ul, .markdown ol { + padding-left: 1.5rem; +} + +/* API Documentation List Styles */ +.markdown ul, +.markdown ol { + padding-left: 1.25rem; + margin: 1rem 0; + list-style-position: outside; +} + +.markdown ul { + list-style-type: none; +} + +.markdown ul li { position: relative; + padding-left: 1.5rem; + margin-bottom: 0.75rem; + line-height: 1.6; +} + +.markdown ul li::before { + content: "•"; + color: var(--ifm-color-primary); + font-weight: bold; + position: absolute; + left: 0; + top: -1px; + font-size: 1.2em; +} + +.markdown ol li { + padding-left: 0.5rem; + margin-bottom: 0.75rem; + line-height: 1.6; +} + + + +/* Nested list items */ +.markdown ul li li::before { + content: "◦"; + font-size: 1.1em; +} + +.markdown ul li li li::before { + content: "▪"; + font-size: 0.8em; + top: 2px; +} + +/* Tables Enhancement */ +.markdown table { + border-collapse: separate; + border-spacing: 0; + width: 100%; + margin: 2rem 0; + border: 1px solid #e2e8f0; + border-radius: 8px; overflow: hidden; - background-color: #6c1899; - background-image: linear-gradient(0deg, #6c1899, #b923ff); - clip-path: polygon(0 0, 100% 0, 100% 80%, 0 100%); - color: #fff; } -.hero__subtitle{ - max-width: 800px; - margin-left: auto; - margin-right: auto; +.markdown table th { + background-color: #f8f9fa; + font-weight: 700; + text-align: left; + padding: 1rem; + border-bottom: 2px solid #e2e8f0; } -.heroImage{ - max-height: 30vh; +.markdown table td { + padding: 1rem; + border-bottom: 1px solid #e2e8f0; } -@media screen and (max-width: 996px) { - .heroBanner { - padding: 2rem 0rem 10rem 0rem; - align-items: start; - clip-path: polygon(0 0, 100% 0, 100% 87%, 0 100%); - } + +.markdown table tr:last-child td { + border-bottom: none; +} + +/* API Field Component Styles */ +.api-field { + display: flex; + gap: 2rem; + padding: 1.25rem; + margin: 1rem 0; + border: 1px solid rgba(60, 60, 67, 0.15); + flex-wrap: wrap; + background-color: #ffffff; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04), 0 0 1px rgba(0, 0, 0, 0.08); + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.api-field:hover { + border-color: rgba(60, 60, 67, 0.2); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.04), 0 0 2px rgba(0, 0, 0, 0.1); +} + +/* Enum specific styling */ +.api-field[data-type="enum"] { + background-color: var(--custom-code-background); + padding: 0.75rem 1rem; + margin: 0.5rem 0; + border-radius: 6px; + border: 1px solid rgba(60, 60, 67, 0.12); +} + +.api-field[data-type="enum"] .api-field__name { + font-family: var(--ifm-font-family-monospace); + font-size: 0.9rem; + color: var(--ifm-color-primary); + background: transparent; + padding: 0; + margin: 0; +} + +.api-field[data-type="enum"] .api-field__description { + font-size: 0.875rem; + color: var(--ifm-color-emphasis-700); + margin-top: 0.25rem; +} + +.api-field__left { + flex: 0 0 400px; + min-width: 0; + margin-right: 1rem; + position: relative; +} + +.api-field__right { + flex: 1; + min-width: 300px; + position: relative; +} + +.api-field__name { + font-family: var(--ifm-font-family-monospace); + font-size: 1rem; + padding: 0.2rem 0.4rem; + margin-right: 0.5rem; + background: var(--ifm-color-gray-100); + border-radius: 4px; + display: inline-block; + word-break: break-word; + max-width: 100%; + vertical-align: middle; + font-weight: 700; + color: var(--ifm-color-primary); } -@media screen and (max-width: 500px) { - .heroBanner { - padding: 2rem 0rem 5rem 0rem; - align-items: start; - clip-path: polygon(0 0, 100% 0, 100% 90%, 0 100%); +.api-field__type { + margin-top: 0.5rem; + font-family: var(--ifm-font-family-monospace); + font-size: 0.875rem; + color: var(--ifm-color-emphasis-700); +} + +.api-field__type > div { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} +.api-field__type::before { + content: "Type: "; + color: var(--ifm-color-emphasis); + font-weight: 700; +} +.api-field__type a { + color: var(--ifm-color-primary); + text-decoration: none; + padding: 0.2rem 0.4rem; + padding-right: 1.8rem; + border-radius: 4px; + background-color: var(--custom-code-background); + border: 1px solid rgba(60, 60, 67, 0.16); + transition: all 0.2s ease; + position: relative; + display: inline-block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.api-field__type a::after { + content: "→"; + position: absolute; + right: 0.4rem; + top: 50%; + transform: translateY(-50%); + font-size: 1.1em; + transition: transform 0.2s ease; +} + +.api-field__type a:hover { + text-decoration: none; + background-color: var(--ifm-color-primary); + border-color: var(--ifm-color-primary-dark); + color: white; +} + +.api-field__type a:hover::after { + transform: translate(2px, -50%); +} + +.api-field__badge { + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + border-radius: 12px; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 4px; + letter-spacing: 0.025em; + text-transform: uppercase; +} + +.api-field__badge--required { + color: var(--custom-required-badge-text); + background: var(--custom-required-badge-bg); + border: 1px solid var(--custom-required-badge-border); + font-weight: 700; + box-shadow: 0 0 0 1px var(--custom-required-badge-border); +} + +.api-field__badge--optional { + color: var(--ifm-color-gray-600); + background: var(--ifm-color-gray-100); + border: 1px solid var(--ifm-color-gray-200); +} + +.api-field__default { + right: 1rem; + top: 0.5rem; + font-size: 0.875rem; + color: var(--ifm-color-emphasis); + background: var(--custom-code-background); + padding: 0.2rem 0.5rem; + border-radius: 4px; + border: 1px solid rgba(60, 60, 67, 0.12); + font-family: var(--ifm-font-family-monospace); + margin-bottom: 0.5rem; +} + +.api-field__default::before { + content: "Default Value: "; + color: var(--ifm-color-emphasis); + font-weight: 700; +} + +.api-field__description { + line-height: var(--custom-content-line-height); + font-size: 0.9rem; + width: 100%; + overflow-x: hidden; +} + +.api-field__description code { + font-size: 0.8rem; + padding: 0.2rem 0.4rem; + background: var(--ifm-code-background); + border-radius: 4px; + color: var(--ifm-code-color); + font-family: var(--ifm-font-family-monospace); +} + +.api-field__description pre { + margin: 1rem 0; + padding: 1rem; + background-color: var(--ifm-code-background); + border-radius: var(--ifm-code-border-radius); + max-width: 100%; + overflow-x: auto; +} + +.api-field__description pre code { + background: transparent; + padding: 0; + font-size: var(--ifm-code-font-size); + border-radius: 0; + display: block; + line-height: 1.5; + color: inherit; +} + +.api-field__description .prism-code { + background-color: rgb(246, 247, 248); + margin: 0; + padding: 1rem; + border-radius: var(--ifm-code-border-radius); + font-family: var(--ifm-font-family-monospace); + width: 100%; + overflow-x: auto; +} + +.api-field__description .codeBlock { + display: block; + white-space: pre; + overflow-x: auto; + padding: 0; + margin: 0; + color: rgb(57, 58, 52); + width: 100%; +} + +.api-field__description .language-yaml .codeBlock { + color: rgb(57, 58, 52); +} + +.api-field__enum { + margin-top: 1rem; + font-size: 0.875rem; +} + +.api-field__enum code { + margin-right: 0.5rem; + padding: 0.2rem 0.4rem; + background: var(--ifm-color-gray-100); + border-radius: 4px; +} + +@media screen and (max-width: 768px) { + .api-field { + flex-direction: column; + gap: 1rem; } - .heroImage{ - max-height: 15vh; + + .api-field__left { + flex: 0 0 auto; + margin-right: 0; + width: 100%; } - .hero__subtitle{ - font-size: 1.2rem; + .api-field__right { + width: 100%; + } + + .api-field__name { + margin-bottom: 0.5rem; } } -.buttons { - display: flex; - align-items: center; - justify-content: center; +/* Override default table styles */ +.theme-admonition-api-field td:first-child { + width: 200px; +} + +.theme-admonition-api-field td { + border: none; +} + +.theme-admonition-api-field tr { + border-top: none; +} + +.theme-admonition-api-field tr:last-child { + border-bottom: none; } diff --git a/site/src/theme/MDXComponents.tsx b/site/src/theme/MDXComponents.tsx new file mode 100644 index 000000000..ac02b1c99 --- /dev/null +++ b/site/src/theme/MDXComponents.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import ApiField from '../components/ApiField'; + +const MDXComponents = { + ApiField, +}; + +export default MDXComponents; From 728d328ae13a0fb8800bc97f265931c44dc890a5 Mon Sep 17 00:00:00 2001 From: Erica Hughberg Date: Sat, 8 Feb 2025 20:24:12 -0500 Subject: [PATCH 38/40] Fix minor style issues in the new CSS Signed-off-by: Erica Hughberg --- Makefile | 9 +- site/crd-ref-docs/templates/type.tpl | 2 +- site/docs/api/api.mdx | 42 +- site/docs/api/core.mdx | 877 --------------------------- site/src/css/custom.css | 58 +- 5 files changed, 26 insertions(+), 962 deletions(-) delete mode 100644 site/docs/api/core.mdx diff --git a/Makefile b/Makefile index b7bf05225..bc6a40848 100644 --- a/Makefile +++ b/Makefile @@ -86,11 +86,8 @@ apigen: controller-gen @$(CONTROLLER_GEN) object crd paths="./api/v1alpha1/..." output:dir=./api/v1alpha1 output:crd:dir=./manifests/charts/ai-gateway-helm/crds # This generates the API documentation for the API defined in the api/v1alpha1 directory. -.PHONY: apidoc-all -apidoc-all: apidoc-core - -.PHONY: apidoc-core -apidoc-core: crd-ref-docs +.PHONY: apidoc +apidoc: crd-ref-docs @$(CRD_REF_DOCS) \ --source-path=api/v1alpha1 \ --config=site/crd-ref-docs/config-core.yaml \ @@ -101,7 +98,7 @@ apidoc-core: crd-ref-docs # This runs all necessary steps to prepare for a commit. .PHONY: precommit -precommit: tidy codespell apigen apidoc-all format lint editorconfig yamllint helm-lint +precommit: tidy codespell apigen apidoc format lint editorconfig yamllint helm-lint # This runs precommit and checks for any differences in the codebase, failing if there are any. .PHONY: check diff --git a/site/crd-ref-docs/templates/type.tpl b/site/crd-ref-docs/templates/type.tpl index f2733778a..d6e87552d 100644 --- a/site/crd-ref-docs/templates/type.tpl +++ b/site/crd-ref-docs/templates/type.tpl @@ -6,7 +6,7 @@ {{ if $type.IsAlias }}**Underlying type:** {{ markdownRenderTypeLink $type.UnderlyingType }}{{ end }} {{ if $type.References }} -**Appears in** +**Appears in:** {{- range $type.SortedReferences }} - {{ markdownRenderTypeLink . }} {{- end }} diff --git a/site/docs/api/api.mdx b/site/docs/api/api.mdx index fc3b3e724..87422dc3a 100644 --- a/site/docs/api/api.mdx +++ b/site/docs/api/api.mdx @@ -27,7 +27,7 @@ API group. -**Appears in** +**Appears in:** - [AIGatewayRouteList](#aigatewayroutelist) AIGatewayRoute combines multiple AIServiceBackends and attaching them to Gateway(s) resources. @@ -130,7 +130,7 @@ AIGatewayRouteList contains a list of AIGatewayRoute. -**Appears in** +**Appears in:** - [AIServiceBackendList](#aiservicebackendlist) AIServiceBackend is a resource that represents a single backend for AIGatewayRoute. @@ -213,7 +213,7 @@ AIServiceBackendList contains a list of AIServiceBackends. -**Appears in** +**Appears in:** - [BackendSecurityPolicyList](#backendsecuritypolicylist) BackendSecurityPolicy specifies configuration for authentication and authorization rules on the traffic @@ -311,7 +311,7 @@ BackendSecurityPolicyList contains a list of BackendSecurityPolicy -**Appears in** +**Appears in:** - [AIGatewayRouteSpec](#aigatewayroutespec) @@ -338,7 +338,7 @@ BackendSecurityPolicyList contains a list of BackendSecurityPolicy -**Appears in** +**Appears in:** - [AIGatewayFilterConfig](#aigatewayfilterconfig) @@ -364,7 +364,7 @@ BackendSecurityPolicyList contains a list of BackendSecurityPolicy **Underlying type:** string -**Appears in** +**Appears in:** - [AIGatewayFilterConfig](#aigatewayfilterconfig) AIGatewayFilterConfigType specifies the type of the filter configuration. @@ -388,7 +388,7 @@ AIGatewayFilterConfigType specifies the type of the filter configuration. -**Appears in** +**Appears in:** - [AIGatewayRouteSpec](#aigatewayroutespec) AIGatewayRouteRule is a rule that defines the routing behavior of the AIGatewayRoute. @@ -414,7 +414,7 @@ AIGatewayRouteRule is a rule that defines the routing behavior of the AIGatewayR -**Appears in** +**Appears in:** - [AIGatewayRouteRule](#aigatewayrouterule) AIGatewayRouteRuleBackendRef is a reference to a AIServiceBackend with a weight. @@ -441,7 +441,7 @@ AIGatewayRouteRuleBackendRef is a reference to a AIServiceBackend with a weight. -**Appears in** +**Appears in:** - [AIGatewayRouteRule](#aigatewayrouterule) @@ -462,7 +462,7 @@ AIGatewayRouteRuleBackendRef is a reference to a AIServiceBackend with a weight. -**Appears in** +**Appears in:** - [AIGatewayRoute](#aigatewayroute) AIGatewayRouteSpec details the AIGatewayRoute configuration. @@ -503,7 +503,7 @@ AIGatewayRouteSpec details the AIGatewayRoute configuration. -**Appears in** +**Appears in:** - [AIServiceBackend](#aiservicebackend) AIServiceBackendSpec details the AIServiceBackend configuration. @@ -534,7 +534,7 @@ AIServiceBackendSpec details the AIServiceBackend configuration. **Underlying type:** string -**Appears in** +**Appears in:** - [VersionedAPISchema](#versionedapischema) APISchema defines the API schema. @@ -558,7 +558,7 @@ APISchema defines the API schema. -**Appears in** +**Appears in:** - [BackendSecurityPolicyAWSCredentials](#backendsecuritypolicyawscredentials) AWSCredentialsFile specifies the credentials file to use for the AWS provider. @@ -586,7 +586,7 @@ Envoy reads the secret file, and the profile to use is specified by the Profile -**Appears in** +**Appears in:** - [BackendSecurityPolicyAWSCredentials](#backendsecuritypolicyawscredentials) AWSOIDCExchangeToken specifies credentials to obtain oidc token from a sso server. @@ -624,7 +624,7 @@ and store them in a temporary credentials file. -**Appears in** +**Appears in:** - [BackendSecurityPolicySpec](#backendsecuritypolicyspec) BackendSecurityPolicyAPIKey specifies the API key. @@ -645,7 +645,7 @@ BackendSecurityPolicyAPIKey specifies the API key. -**Appears in** +**Appears in:** - [BackendSecurityPolicySpec](#backendsecuritypolicyspec) BackendSecurityPolicyAWSCredentials contains the supported authentication mechanisms to access aws @@ -676,7 +676,7 @@ BackendSecurityPolicyAWSCredentials contains the supported authentication mechan -**Appears in** +**Appears in:** - [BackendSecurityPolicy](#backendsecuritypolicy) BackendSecurityPolicySpec specifies authentication rules on access the provider from the Gateway. @@ -711,7 +711,7 @@ Only one type of BackendSecurityPolicy can be defined. **Underlying type:** string -**Appears in** +**Appears in:** - [BackendSecurityPolicySpec](#backendsecuritypolicyspec) BackendSecurityPolicyType specifies the type of auth mechanism used to access a backend. @@ -735,7 +735,7 @@ BackendSecurityPolicyType specifies the type of auth mechanism used to access a -**Appears in** +**Appears in:** - [AIGatewayRouteSpec](#aigatewayroutespec) LLMRequestCost configures each request cost. @@ -766,7 +766,7 @@ LLMRequestCost configures each request cost. **Underlying type:** string -**Appears in** +**Appears in:** - [LLMRequestCost](#llmrequestcost) LLMRequestCostType specifies the type of the LLMRequestCost. @@ -800,7 +800,7 @@ LLMRequestCostType specifies the type of the LLMRequestCost. -**Appears in** +**Appears in:** - [AIGatewayRouteSpec](#aigatewayroutespec) - [AIServiceBackendSpec](#aiservicebackendspec) diff --git a/site/docs/api/core.mdx b/site/docs/api/core.mdx deleted file mode 100644 index 7a1c64b64..000000000 --- a/site/docs/api/core.mdx +++ /dev/null @@ -1,877 +0,0 @@ ---- -id: api_references -title: API Reference -toc_min_heading_level: 2 -toc_max_heading_level: 4 ---- - - -## aigateway.envoyproxy.io/v1alpha1 - -Package v1alpha1 contains API schema definitions for the aigateway.envoyproxy.io -API group. - - -## Resource Kinds - -### Available Kinds -- [AIGatewayRoute](#aigatewayroute) -- [AIGatewayRouteList](#aigatewayroutelist) -- [AIServiceBackend](#aiservicebackend) -- [AIServiceBackendList](#aiservicebackendlist) -- [BackendSecurityPolicy](#backendsecuritypolicy) -- [BackendSecurityPolicyList](#backendsecuritypolicylist) - -### Kind Definitions -#### AIGatewayRoute - - - -**Appears in** -- [AIGatewayRouteList](#aigatewayroutelist) - -AIGatewayRoute combines multiple AIServiceBackends and attaching them to Gateway(s) resources. - - -This serves as a way to define a "unified" AI API for a Gateway which allows downstream -clients to use a single schema API to interact with multiple AI backends. - - -The schema field is used to determine the structure of the requests that the Gateway will -receive. And then the Gateway will route the traffic to the appropriate AIServiceBackend based -on the output schema of the AIServiceBackend while doing the other necessary jobs like -upstream authentication, rate limit, etc. - - -For Advanced Users: Envoy AI Gateway will generate the following k8s resources corresponding to the AIGatewayRoute: - - - - Deployment, Service, and ConfigMap of the k8s API for the AI Gateway filter. - The name of these resources are `ai-eg-route-extproc-${name}`. - - HTTPRoute of the Gateway API as a top-level resource to bind all backends. - The name of the HTTPRoute is the same as the AIGatewayRoute. - - EnvoyExtensionPolicy of the Envoy Gateway API to attach the AI Gateway filter into the HTTPRoute. - The name of the EnvoyExtensionPolicy is `ai-eg-route-extproc-${name}` which is the same as the Deployment, etc. - - HTTPRouteFilter of the Envoy Gateway API per namespace for automatic hostname rewrite. - The name of the HTTPRouteFilter is `ai-eg-host-rewrite`. - - -All of these resources are created in the same namespace as the AIGatewayRoute. Note that this is the implementation -detail subject to change. If you want to customize the default behavior of the Envoy AI Gateway, you can use these -resources as a reference and create your own resources. Alternatively, you can use EnvoyPatchPolicy API of the Envoy -Gateway to patch the generated resources. For example, you can insert a custom filter into the filter chain. - -##### Fields - - - - - - - - -#### AIGatewayRouteList - - - - -AIGatewayRouteList contains a list of AIGatewayRoute. - -##### Fields - - - - - - - - -#### AIServiceBackend - - - -**Appears in** -- [AIServiceBackendList](#aiservicebackendlist) - -AIServiceBackend is a resource that represents a single backend for AIGatewayRoute. -A backend is a service that handles traffic with a concrete API specification. - - -A AIServiceBackend is "attached" to a Backend which is either a k8s Service or a Backend resource of the Envoy Gateway. - - -When a backend with an attached AIServiceBackend is used as a routing target in the AIGatewayRoute (more precisely, the -HTTPRouteSpec defined in the AIGatewayRoute), the ai-gateway will generate the necessary configuration to do -the backend specific logic in the final HTTPRoute. - -##### Fields - - - - - - - - -#### AIServiceBackendList - - - - -AIServiceBackendList contains a list of AIServiceBackends. - -##### Fields - - - - - - - - -#### BackendSecurityPolicy - - - -**Appears in** -- [BackendSecurityPolicyList](#backendsecuritypolicylist) - -BackendSecurityPolicy specifies configuration for authentication and authorization rules on the traffic -exiting the gateway to the backend. - -##### Fields - - - - - - - - -#### BackendSecurityPolicyList - - - - -BackendSecurityPolicyList contains a list of BackendSecurityPolicy - -##### Fields - - - - - - - - -## Supporting Types - -### Available Types -- [AIGatewayFilterConfig](#aigatewayfilterconfig) -- [AIGatewayFilterConfigExternalProcess](#aigatewayfilterconfigexternalprocess) -- [AIGatewayFilterConfigType](#aigatewayfilterconfigtype) -- [AIGatewayRouteRule](#aigatewayrouterule) -- [AIGatewayRouteRuleBackendRef](#aigatewayrouterulebackendref) -- [AIGatewayRouteRuleMatch](#aigatewayrouterulematch) -- [AIGatewayRouteSpec](#aigatewayroutespec) -- [AIServiceBackendSpec](#aiservicebackendspec) -- [APISchema](#apischema) -- [AWSCredentialsFile](#awscredentialsfile) -- [AWSOIDCExchangeToken](#awsoidcexchangetoken) -- [BackendSecurityPolicyAPIKey](#backendsecuritypolicyapikey) -- [BackendSecurityPolicyAWSCredentials](#backendsecuritypolicyawscredentials) -- [BackendSecurityPolicySpec](#backendsecuritypolicyspec) -- [BackendSecurityPolicyType](#backendsecuritypolicytype) -- [LLMRequestCost](#llmrequestcost) -- [LLMRequestCostType](#llmrequestcosttype) -- [VersionedAPISchema](#versionedapischema) - -### Type Definitions -#### AIGatewayFilterConfig - - - -**Appears in** -- [AIGatewayRouteSpec](#aigatewayroutespec) - - - -##### Fields - - - - - - -#### AIGatewayFilterConfigExternalProcess - - - -**Appears in** -- [AIGatewayFilterConfig](#aigatewayfilterconfig) - - - -##### Fields - - - - - - -#### AIGatewayFilterConfigType - -**Underlying type:** string - -**Appears in** -- [AIGatewayFilterConfig](#aigatewayfilterconfig) - -AIGatewayFilterConfigType specifies the type of the filter configuration. - - - -##### Possible Values - - -#### AIGatewayRouteRule - - - -**Appears in** -- [AIGatewayRouteSpec](#aigatewayroutespec) - -AIGatewayRouteRule is a rule that defines the routing behavior of the AIGatewayRoute. - -##### Fields - - - - - - -#### AIGatewayRouteRuleBackendRef - - - -**Appears in** -- [AIGatewayRouteRule](#aigatewayrouterule) - -AIGatewayRouteRuleBackendRef is a reference to a AIServiceBackend with a weight. - -##### Fields - - - - - - -#### AIGatewayRouteRuleMatch - - - -**Appears in** -- [AIGatewayRouteRule](#aigatewayrouterule) - - - -##### Fields - - - - - - -#### AIGatewayRouteSpec - - - -**Appears in** -- [AIGatewayRoute](#aigatewayroute) - -AIGatewayRouteSpec details the AIGatewayRoute configuration. - -##### Fields - - - - - - -#### AIServiceBackendSpec - - - -**Appears in** -- [AIServiceBackend](#aiservicebackend) - -AIServiceBackendSpec details the AIServiceBackend configuration. - -##### Fields - - - - - - -#### APISchema - -**Underlying type:** string - -**Appears in** -- [VersionedAPISchema](#versionedapischema) - -APISchema defines the API schema. - - - -##### Possible Values - - -#### AWSCredentialsFile - - - -**Appears in** -- [BackendSecurityPolicyAWSCredentials](#backendsecuritypolicyawscredentials) - -AWSCredentialsFile specifies the credentials file to use for the AWS provider. -Envoy reads the secret file, and the profile to use is specified by the Profile field. - -##### Fields - - - - - - -#### AWSOIDCExchangeToken - - - -**Appears in** -- [BackendSecurityPolicyAWSCredentials](#backendsecuritypolicyawscredentials) - -AWSOIDCExchangeToken specifies credentials to obtain oidc token from a sso server. -For AWS, the controller will query STS to obtain AWS AccessKeyId, SecretAccessKey, and SessionToken, -and store them in a temporary credentials file. - -##### Fields - - - - - - -#### BackendSecurityPolicyAPIKey - - - -**Appears in** -- [BackendSecurityPolicySpec](#backendsecuritypolicyspec) - -BackendSecurityPolicyAPIKey specifies the API key. - -##### Fields - - - - - - -#### BackendSecurityPolicyAWSCredentials - - - -**Appears in** -- [BackendSecurityPolicySpec](#backendsecuritypolicyspec) - -BackendSecurityPolicyAWSCredentials contains the supported authentication mechanisms to access aws - -##### Fields - - - - - - -#### BackendSecurityPolicySpec - - - -**Appears in** -- [BackendSecurityPolicy](#backendsecuritypolicy) - -BackendSecurityPolicySpec specifies authentication rules on access the provider from the Gateway. -Only one mechanism to access a backend(s) can be specified. - - -Only one type of BackendSecurityPolicy can be defined. - -##### Fields - - - - - - -#### BackendSecurityPolicyType - -**Underlying type:** string - -**Appears in** -- [BackendSecurityPolicySpec](#backendsecuritypolicyspec) - -BackendSecurityPolicyType specifies the type of auth mechanism used to access a backend. - - - -##### Possible Values - - -#### LLMRequestCost - - - -**Appears in** -- [AIGatewayRouteSpec](#aigatewayroutespec) - -LLMRequestCost configures each request cost. - -##### Fields - - - - - - -#### LLMRequestCostType - -**Underlying type:** string - -**Appears in** -- [LLMRequestCost](#llmrequestcost) - -LLMRequestCostType specifies the type of the LLMRequestCost. - - - -##### Possible Values - - -#### VersionedAPISchema - - - -**Appears in** -- [AIGatewayRouteSpec](#aigatewayroutespec) -- [AIServiceBackendSpec](#aiservicebackendspec) - -VersionedAPISchema defines the API schema of either AIGatewayRoute (the input) or AIServiceBackend (the output). - - -This allows the ai-gateway to understand the input and perform the necessary transformation -depending on the API schema pair (input, output). - - -Note that this is vendor specific, and the stability of the API schema is not guaranteed by -the ai-gateway, but by the vendor via proper versioning. - -##### Fields - - - - - - diff --git a/site/src/css/custom.css b/site/src/css/custom.css index e94994f92..572ad8321 100644 --- a/site/src/css/custom.css +++ b/site/src/css/custom.css @@ -108,60 +108,6 @@ border-radius: 0 8px 8px 0; } -/* Lists Enhancement */ -.markdown ul, .markdown ol { - padding-left: 1.5rem; -} - -/* API Documentation List Styles */ -.markdown ul, -.markdown ol { - padding-left: 1.25rem; - margin: 1rem 0; - list-style-position: outside; -} - -.markdown ul { - list-style-type: none; -} - -.markdown ul li { - position: relative; - padding-left: 1.5rem; - margin-bottom: 0.75rem; - line-height: 1.6; -} - -.markdown ul li::before { - content: "•"; - color: var(--ifm-color-primary); - font-weight: bold; - position: absolute; - left: 0; - top: -1px; - font-size: 1.2em; -} - -.markdown ol li { - padding-left: 0.5rem; - margin-bottom: 0.75rem; - line-height: 1.6; -} - - - -/* Nested list items */ -.markdown ul li li::before { - content: "◦"; - font-size: 1.1em; -} - -.markdown ul li li li::before { - content: "▪"; - font-size: 0.8em; - top: 2px; -} - /* Tables Enhancement */ .markdown table { border-collapse: separate; @@ -222,9 +168,7 @@ font-family: var(--ifm-font-family-monospace); font-size: 0.9rem; color: var(--ifm-color-primary); - background: transparent; - padding: 0; - margin: 0; + background-color: var(--custom-code-background); } .api-field[data-type="enum"] .api-field__description { From 295aa80fe9e25dd4fb660409835631c99343d5e1 Mon Sep 17 00:00:00 2001 From: Erica Hughberg Date: Sat, 8 Feb 2025 20:37:17 -0500 Subject: [PATCH 39/40] Fix CSS for index landing page Signed-off-by: Erica Hughberg --- site/src/css/custom.css | 51 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/site/src/css/custom.css b/site/src/css/custom.css index 572ad8321..6ab9676c3 100644 --- a/site/src/css/custom.css +++ b/site/src/css/custom.css @@ -46,6 +46,57 @@ --custom-required-badge-text: #dc2626; } +/* Index Header */ +.heroBanner { + font-family: "Nunito", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; + padding: 2rem 0rem 10rem 0rem; + text-align: center; + overflow: hidden; + background-color: #6c1899; + background-image: linear-gradient(0deg, #6c1899, #b923ff); + clip-path: polygon(0 0, 100% 0, 100% 80%, 0 100%); + color: #fff; + position: relative; + + + .hero__subtitle{ + max-width: 800px; + margin-left: auto; + margin-right: auto; + } + .heroImage{ + max-height: 30vh; + } +} + + @media screen and (max-width: 996px) { + .heroBanner { + padding: 2rem 0rem 10rem 0rem; + align-items: start; + clip-path: polygon(0 0, 100% 0, 100% 87%, 0 100%); + } +} + @media screen and (max-width: 500px) { + .heroBanner { + padding: 2rem 0rem 5rem 0rem; + align-items: start; + clip-path: polygon(0 0, 100% 0, 100% 90%, 0 100%); + .heroImage{ + max-height: 15vh; + } + } + } + +.navbar { + box-shadow: none; +} + +.buttons { + display: flex; + align-items: center; + justify-content: center; +} + /* Word wrapping rules */ .markdown { overflow-wrap: break-word; From fa6d2f7192f3398f7ea3950b1f853e70189a5974 Mon Sep 17 00:00:00 2001 From: Erica Hughberg Date: Sat, 8 Feb 2025 20:38:26 -0500 Subject: [PATCH 40/40] Remove a whitespace in css file Signed-off-by: Erica Hughberg --- site/src/css/custom.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/css/custom.css b/site/src/css/custom.css index 6ab9676c3..f223ff904 100644 --- a/site/src/css/custom.css +++ b/site/src/css/custom.css @@ -86,7 +86,7 @@ } } } - + .navbar { box-shadow: none; }

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 WyP3G!&>P@Cil@%~nIdQ4@qYlTQlk3+ literal 0 HcmV?d00001 From 3a12cf43fb505e1f0b1f1b6e3b7e765387495ecc Mon Sep 17 00:00:00 2001 From: Dan Sun Date: Thu, 6 Feb 2025 10:50:07 -0500 Subject: [PATCH 34/40] e2e: support AWS Session Token in real provider test (#299) **Commit Message** Allow setting AWS session token in the AWS credential file when using AWS temporary credentials to test AWS Bedrock. **Related Issues/PRs (if applicable)** **Special notes for reviewers (if applicable)** TEST_AWS_SESSION_TOKEN is optional if using static AWS credential. Signed-off-by: Dan Sun Signed-off-by: Eric Mariasis --- tests/extproc/real_providers_test.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/extproc/real_providers_test.go b/tests/extproc/real_providers_test.go index 893bb5bce..b6332184f 100644 --- a/tests/extproc/real_providers_test.go +++ b/tests/extproc/real_providers_test.go @@ -46,7 +46,15 @@ func TestWithRealProviders(t *testing.T) { // Set up credential file for AWS. awsAccessKeyID := getEnvVarOrSkip(t, "TEST_AWS_ACCESS_KEY_ID") awsSecretAccessKey := getEnvVarOrSkip(t, "TEST_AWS_SECRET_ACCESS_KEY") - awsCredentialsBody := fmt.Sprintf("[default]\nAWS_ACCESS_KEY_ID=%s\nAWS_SECRET_ACCESS_KEY=%s\n", awsAccessKeyID, awsSecretAccessKey) + awsSessionToken := os.Getenv("TEST_AWS_SESSION_TOKEN") + var awsCredentialsBody string + if awsSessionToken != "" { + awsCredentialsBody = fmt.Sprintf("[default]\nAWS_ACCESS_KEY_ID=%s\nAWS_SECRET_ACCESS_KEY=%s\nTEST_AWS_SESSION_TOKEN=%s\n", + awsAccessKeyID, awsSecretAccessKey, awsSessionToken) + } else { + awsCredentialsBody = fmt.Sprintf("[default]\nAWS_ACCESS_KEY_ID=%s\nAWS_SECRET_ACCESS_KEY=%s\n", + awsAccessKeyID, awsSecretAccessKey) + } // Test with AWS Credential File. awsFilePath := t.TempDir() + "/aws-credential-file" From 214934a2308c0e8fea7d3a2c3a0331660e55ae3a Mon Sep 17 00:00:00 2001 From: Takeshi Yoneda Date: Thu, 6 Feb 2025 09:24:00 -0800 Subject: [PATCH 35/40] chore: fixes CEL validation test target name (#300) **Commit Message** This renames `make test-cel` to `make test-crdcel` to be precise about what it is testing since we are also using CEL for LLM costs. Also, this renames the tests/cel-validation directory to tests/crdcel since it was previously incorrect and didn't match the make target. --------- Signed-off-by: Takeshi Yoneda Signed-off-by: Eric Mariasis --- .github/workflows/tests.yaml | 25 +++++++------------ Makefile | 16 ++++++------ tests/{cel-validation => crdcel}/main_test.go | 2 +- .../testdata/aigatewayroutes/basic.yaml | 0 .../testdata/aigatewayroutes/llmcosts.yaml | 0 .../aigatewayroutes/no_target_refs.yaml | 0 .../aigatewayroutes/non_openai_schema.yaml | 0 .../aigatewayroutes/unknown_schema.yaml | 0 .../aigatewayroutes/unsupported_match.yaml | 0 .../aiservicebackends/basic-eg-backend.yaml | 0 .../testdata/aiservicebackends/basic.yaml | 0 .../aiservicebackends/unknown_schema.yaml | 0 .../aws_credential_file.yaml | 0 .../backendsecuritypolicies/aws_oidc.yaml | 0 .../backendsecuritypolicies/basic.yaml | 0 .../backendsecuritypolicies/missing_type.yaml | 0 .../multiple_security_policies.yaml | 0 .../unknown_provider.yaml | 0 18 files changed, 18 insertions(+), 25 deletions(-) rename tests/{cel-validation => crdcel}/main_test.go (99%) rename tests/{cel-validation => crdcel}/testdata/aigatewayroutes/basic.yaml (100%) rename tests/{cel-validation => crdcel}/testdata/aigatewayroutes/llmcosts.yaml (100%) rename tests/{cel-validation => crdcel}/testdata/aigatewayroutes/no_target_refs.yaml (100%) rename tests/{cel-validation => crdcel}/testdata/aigatewayroutes/non_openai_schema.yaml (100%) rename tests/{cel-validation => crdcel}/testdata/aigatewayroutes/unknown_schema.yaml (100%) rename tests/{cel-validation => crdcel}/testdata/aigatewayroutes/unsupported_match.yaml (100%) rename tests/{cel-validation => crdcel}/testdata/aiservicebackends/basic-eg-backend.yaml (100%) rename tests/{cel-validation => crdcel}/testdata/aiservicebackends/basic.yaml (100%) rename tests/{cel-validation => crdcel}/testdata/aiservicebackends/unknown_schema.yaml (100%) rename tests/{cel-validation => crdcel}/testdata/backendsecuritypolicies/aws_credential_file.yaml (100%) rename tests/{cel-validation => crdcel}/testdata/backendsecuritypolicies/aws_oidc.yaml (100%) rename tests/{cel-validation => crdcel}/testdata/backendsecuritypolicies/basic.yaml (100%) rename tests/{cel-validation => crdcel}/testdata/backendsecuritypolicies/missing_type.yaml (100%) rename tests/{cel-validation => crdcel}/testdata/backendsecuritypolicies/multiple_security_policies.yaml (100%) rename tests/{cel-validation => crdcel}/testdata/backendsecuritypolicies/unknown_provider.yaml (100%) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 3bd1fc2f1..de56a5be8 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -51,12 +51,11 @@ jobs: ~/go/pkg/mod ~/go/bin key: unittest-${{ hashFiles('**/go.mod', '**/go.sum', '**/Makefile') }} - - name: Run unit tests - run: make test-coverage + - run: make test-coverage - test_cel_validation: + test_crdcel: if: github.event_name == 'pull_request' || github.event_name == 'push' - name: CEL Validation Test + name: CRD CEL Validation Test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -71,8 +70,7 @@ jobs: ~/go/pkg/mod ~/go/bin key: celvalidation-test-${{ hashFiles('**/go.mod', '**/go.sum', '**/Makefile') }} - - name: Run unit tests - run: make test-cel + - run: make test-crdcel test_controller: if: github.event_name == 'pull_request' || github.event_name == 'push' @@ -91,8 +89,7 @@ jobs: ~/go/pkg/mod ~/go/bin key: controller-test-${{ hashFiles('**/go.mod', '**/go.sum', '**/Makefile') }} - - name: Run unit tests - run: make test-controller + - run: make test-controller test_extproc: name: External Processor Test (Envoy ${{ matrix.name }}) @@ -133,8 +130,7 @@ jobs: docker run -v $ENVOY_BIN_DIR:/tmp/ci -w /tmp/ci \ --entrypoint /bin/cp ${{ matrix.envoy_version }} /usr/local/bin/envoy . echo $ENVOY_BIN_DIR >> $GITHUB_PATH - - name: Run unit tests - env: + - env: TEST_AWS_ACCESS_KEY_ID: ${{ secrets.AWS_BEDROCK_USER_AWS_ACCESS_KEY_ID }} TEST_AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_BEDROCK_USER_AWS_SECRET_ACCESS_KEY }} TEST_OPENAI_API_KEY: ${{ secrets.ENVOY_AI_GATEWAY_OPENAI_API_KEY }} @@ -174,8 +170,7 @@ jobs: ~/go/bin key: e2e-test-${{ hashFiles('**/go.mod', '**/go.sum', '**/Makefile') }} - uses: docker/setup-buildx-action@v3 - - name: Run E2E tests - env: + - env: EG_VERSION: ${{ matrix.envoy_gateway_version }} TEST_AWS_ACCESS_KEY_ID: ${{ secrets.AWS_BEDROCK_USER_AWS_ACCESS_KEY_ID }} TEST_AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_BEDROCK_USER_AWS_SECRET_ACCESS_KEY }} @@ -186,7 +181,7 @@ jobs: # Docker builds are verified in test_e2e job, so we only need to push the images when the event is a push event. if: github.event_name == 'push' name: Push Docker Images - needs: [unittest, test_cel_validation, test_controller, test_extproc, test_e2e] + needs: [unittest, test_crdcel, test_controller, test_extproc, test_e2e] uses: ./.github/workflows/docker_builds_template.yaml helm_push: @@ -203,6 +198,4 @@ jobs: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Push Helm chart - run: | - make helm-push + - run: make helm-push diff --git a/Makefile b/Makefile index 9a0a584e6..8e94a7d54 100644 --- a/Makefile +++ b/Makefile @@ -32,12 +32,12 @@ help: @echo "All core targets needed for contributing:" @echo " precommit Run all necessary steps to prepare for a commit." @echo " test Run the unit tests for the codebase." - @echo " test-coverage Run the unit tests for the codebase with coverage check." - @echo " test-cel Run the integration tests of CEL validation rules in API definitions with envtest." + @echo " test-coverage Run the unit tests for the codebase with coverage check." + @echo " test-crdcel Run the integration tests of CEL validation in CRD definitions with envtest." @echo " This will be needed when changing API definitions." @echo " test-extproc Run the integration tests for extproc without controller or k8s at all." @echo " test-controller Run the integration tests for the controller with envtest." - @echo " test-e2e Run the end-to-end tests with a local kind cluster." + @echo " test-e2e Run the end-to-end tests with a local kind cluster." @echo "" @echo "For example, 'make precommit test' should be enough for initial iterations, and later 'make test-cel' etc. for the normal development cycle." @echo "Note that some cases run by test-e2e or test-extproc use credentials and these will be skipped when not available." @@ -48,7 +48,7 @@ help: .PHONY: lint lint: golangci-lint @echo "lint => ./..." - @$(GOLANGCI_LINT) run --build-tags==test_cel_validation,test_controller,test_extproc ./... + @$(GOLANGCI_LINT) run --build-tags==test_crdcel,test_controller,test_extproc ./... .PHONY: codespell CODESPELL_SKIP := $(shell cat .codespell.skip | tr \\n ',') @@ -124,15 +124,15 @@ test: ENVTEST_K8S_VERSIONS ?= 1.29.0 1.30.0 1.31.0 -# This runs the integration tests of CEL validation rules in API definitions. +# This runs the integration tests of CEL validation rules in CRD definitions. # # This requires the EnvTest binary to be built. -.PHONY: test-cel -test-cel: envtest apigen +.PHONY: test-crdcel +test-crdcel: envtest apigen @for k8sVersion in $(ENVTEST_K8S_VERSIONS); do \ echo "Run CEL Validation on k8s $$k8sVersion"; \ KUBEBUILDER_ASSETS="$$($(ENVTEST) use $$k8sVersion -p path)" \ - go test ./tests/cel-validation $(GO_TEST_ARGS) $(GO_TEST_E2E_ARGS) --tags test_cel_validation; \ + go test ./tests/crdcel $(GO_TEST_ARGS) $(GO_TEST_E2E_ARGS) --tags test_crdcel; \ done # This runs the end-to-end tests for extproc without controller or k8s at all. diff --git a/tests/cel-validation/main_test.go b/tests/crdcel/main_test.go similarity index 99% rename from tests/cel-validation/main_test.go rename to tests/crdcel/main_test.go index 7058c279e..a4554918d 100644 --- a/tests/cel-validation/main_test.go +++ b/tests/crdcel/main_test.go @@ -1,4 +1,4 @@ -//go:build test_cel_validation +//go:build test_crdcel package celvalidation diff --git a/tests/cel-validation/testdata/aigatewayroutes/basic.yaml b/tests/crdcel/testdata/aigatewayroutes/basic.yaml similarity index 100% rename from tests/cel-validation/testdata/aigatewayroutes/basic.yaml rename to tests/crdcel/testdata/aigatewayroutes/basic.yaml diff --git a/tests/cel-validation/testdata/aigatewayroutes/llmcosts.yaml b/tests/crdcel/testdata/aigatewayroutes/llmcosts.yaml similarity index 100% rename from tests/cel-validation/testdata/aigatewayroutes/llmcosts.yaml rename to tests/crdcel/testdata/aigatewayroutes/llmcosts.yaml diff --git a/tests/cel-validation/testdata/aigatewayroutes/no_target_refs.yaml b/tests/crdcel/testdata/aigatewayroutes/no_target_refs.yaml similarity index 100% rename from tests/cel-validation/testdata/aigatewayroutes/no_target_refs.yaml rename to tests/crdcel/testdata/aigatewayroutes/no_target_refs.yaml diff --git a/tests/cel-validation/testdata/aigatewayroutes/non_openai_schema.yaml b/tests/crdcel/testdata/aigatewayroutes/non_openai_schema.yaml similarity index 100% rename from tests/cel-validation/testdata/aigatewayroutes/non_openai_schema.yaml rename to tests/crdcel/testdata/aigatewayroutes/non_openai_schema.yaml diff --git a/tests/cel-validation/testdata/aigatewayroutes/unknown_schema.yaml b/tests/crdcel/testdata/aigatewayroutes/unknown_schema.yaml similarity index 100% rename from tests/cel-validation/testdata/aigatewayroutes/unknown_schema.yaml rename to tests/crdcel/testdata/aigatewayroutes/unknown_schema.yaml diff --git a/tests/cel-validation/testdata/aigatewayroutes/unsupported_match.yaml b/tests/crdcel/testdata/aigatewayroutes/unsupported_match.yaml similarity index 100% rename from tests/cel-validation/testdata/aigatewayroutes/unsupported_match.yaml rename to tests/crdcel/testdata/aigatewayroutes/unsupported_match.yaml diff --git a/tests/cel-validation/testdata/aiservicebackends/basic-eg-backend.yaml b/tests/crdcel/testdata/aiservicebackends/basic-eg-backend.yaml similarity index 100% rename from tests/cel-validation/testdata/aiservicebackends/basic-eg-backend.yaml rename to tests/crdcel/testdata/aiservicebackends/basic-eg-backend.yaml diff --git a/tests/cel-validation/testdata/aiservicebackends/basic.yaml b/tests/crdcel/testdata/aiservicebackends/basic.yaml similarity index 100% rename from tests/cel-validation/testdata/aiservicebackends/basic.yaml rename to tests/crdcel/testdata/aiservicebackends/basic.yaml diff --git a/tests/cel-validation/testdata/aiservicebackends/unknown_schema.yaml b/tests/crdcel/testdata/aiservicebackends/unknown_schema.yaml similarity index 100% rename from tests/cel-validation/testdata/aiservicebackends/unknown_schema.yaml rename to tests/crdcel/testdata/aiservicebackends/unknown_schema.yaml diff --git a/tests/cel-validation/testdata/backendsecuritypolicies/aws_credential_file.yaml b/tests/crdcel/testdata/backendsecuritypolicies/aws_credential_file.yaml similarity index 100% rename from tests/cel-validation/testdata/backendsecuritypolicies/aws_credential_file.yaml rename to tests/crdcel/testdata/backendsecuritypolicies/aws_credential_file.yaml diff --git a/tests/cel-validation/testdata/backendsecuritypolicies/aws_oidc.yaml b/tests/crdcel/testdata/backendsecuritypolicies/aws_oidc.yaml similarity index 100% rename from tests/cel-validation/testdata/backendsecuritypolicies/aws_oidc.yaml rename to tests/crdcel/testdata/backendsecuritypolicies/aws_oidc.yaml diff --git a/tests/cel-validation/testdata/backendsecuritypolicies/basic.yaml b/tests/crdcel/testdata/backendsecuritypolicies/basic.yaml similarity index 100% rename from tests/cel-validation/testdata/backendsecuritypolicies/basic.yaml rename to tests/crdcel/testdata/backendsecuritypolicies/basic.yaml diff --git a/tests/cel-validation/testdata/backendsecuritypolicies/missing_type.yaml b/tests/crdcel/testdata/backendsecuritypolicies/missing_type.yaml similarity index 100% rename from tests/cel-validation/testdata/backendsecuritypolicies/missing_type.yaml rename to tests/crdcel/testdata/backendsecuritypolicies/missing_type.yaml diff --git a/tests/cel-validation/testdata/backendsecuritypolicies/multiple_security_policies.yaml b/tests/crdcel/testdata/backendsecuritypolicies/multiple_security_policies.yaml similarity index 100% rename from tests/cel-validation/testdata/backendsecuritypolicies/multiple_security_policies.yaml rename to tests/crdcel/testdata/backendsecuritypolicies/multiple_security_policies.yaml diff --git a/tests/cel-validation/testdata/backendsecuritypolicies/unknown_provider.yaml b/tests/crdcel/testdata/backendsecuritypolicies/unknown_provider.yaml similarity index 100% rename from tests/cel-validation/testdata/backendsecuritypolicies/unknown_provider.yaml rename to tests/crdcel/testdata/backendsecuritypolicies/unknown_provider.yaml From d328bc83cae5276fb5ac9b7d523302beda6c6f9c Mon Sep 17 00:00:00 2001 From: Takeshi Yoneda Date: Thu, 6 Feb 2025 09:40:10 -0800 Subject: [PATCH 36/40] chore: updates tools (#301) **Commit Message** This updates several tool dependencies. Signed-off-by: Takeshi Yoneda Signed-off-by: Eric Mariasis --- Makefile.tools.mk | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Makefile.tools.mk b/Makefile.tools.mk index e0744432a..5734f903f 100644 --- a/Makefile.tools.mk +++ b/Makefile.tools.mk @@ -16,15 +16,15 @@ CRD_REF_DOCS = $(LOCALBIN)/crd-ref-docs GO_TEST_COVERAGE ?= $(LOCALBIN)/go-test-coverage ## Tool versions. -CONTROLLER_TOOLS_VERSION ?= v0.17.1 -ENVTEST_VERSION ?= release-0.19 -GOLANGCI_LINT_VERSION ?= v1.63.4 -GO_FUMPT_VERSION ?= v0.7.0 -GCI_VERSION ?= v0.13.5 -EDITORCONFIG_CHECKER_VERSION ?= v3.1.2 -KIND_VERSION ?= v0.26.0 -CRD_REF_DOCS_VERSION ?= v0.1.0 -GO_TEST_COVERAGE_VERSION ?= v2.11.4 +CONTROLLER_TOOLS_VERSION ?= v0.17.1 # https://github.com/kubernetes-sigs/controller-tools/releases +ENVTEST_VERSION ?= release-0.20 # https://github.com/kubernetes-sigs/controller-runtime/releases Note: this needs to point to a release branch. +GOLANGCI_LINT_VERSION ?= v1.63.4 # https://github.com/golangci/golangci-lint/releases +GO_FUMPT_VERSION ?= v0.7.0 # https://github.com/mvdan/gofumpt/releases +GCI_VERSION ?= v0.13.5 # https://github.com/daixiang0/gci/releases +EDITORCONFIG_CHECKER_VERSION ?= v3.2.0 # https://github.com/editorconfig-checker/editorconfig-checker/releases +KIND_VERSION ?= v0.26.0 # https://github.com/kubernetes-sigs/kind/releases +CRD_REF_DOCS_VERSION ?= v0.1.0 # https://github.com/elastic/crd-ref-docs/releases +GO_TEST_COVERAGE_VERSION ?= v2.11.4 # https://github.com/vladopajic/go-test-coverage/releases .PHONY: golangci-lint golangci-lint: $(GOLANGCI_LINT) From 67de4805aaed5402aa6e0a48b0fca2a687f9b97f Mon Sep 17 00:00:00 2001 From: Erica Hughberg Date: Sat, 8 Feb 2025 20:11:28 -0500 Subject: [PATCH 37/40] API Docs Styling Signed-off-by: Erica Hughberg --- Makefile | 22 +- api/v1alpha1/api.go | 7 +- ...gateway.envoyproxy.io_aigatewayroutes.yaml | 8 +- .../{config.yaml => config-core.yaml} | 3 + site/crd-ref-docs/templates/gv_details.tpl | 53 +- site/crd-ref-docs/templates/gv_list.tpl | 9 +- site/crd-ref-docs/templates/type.tpl | 77 +- site/crd-ref-docs/templates/type_members.tpl | 2 +- site/docs/api.md | 759 --------------- site/docs/api/api.mdx | 833 +++++++++++++++++ site/docs/api/core.mdx | 877 ++++++++++++++++++ site/src/components/ApiField.tsx | 93 ++ site/src/css/custom.css | 465 +++++++++- site/src/theme/MDXComponents.tsx | 8 + 14 files changed, 2353 insertions(+), 863 deletions(-) rename site/crd-ref-docs/{config.yaml => config-core.yaml} (92%) delete mode 100644 site/docs/api.md create mode 100644 site/docs/api/api.mdx create mode 100644 site/docs/api/core.mdx create mode 100644 site/src/components/ApiField.tsx create mode 100644 site/src/theme/MDXComponents.tsx diff --git a/Makefile b/Makefile index 8e94a7d54..b7bf05225 100644 --- a/Makefile +++ b/Makefile @@ -86,20 +86,22 @@ apigen: controller-gen @$(CONTROLLER_GEN) object crd paths="./api/v1alpha1/..." output:dir=./api/v1alpha1 output:crd:dir=./manifests/charts/ai-gateway-helm/crds # This generates the API documentation for the API defined in the api/v1alpha1 directory. -.PHONY: apidoc -apidoc: crd-ref-docs +.PHONY: apidoc-all +apidoc-all: apidoc-core + +.PHONY: apidoc-core +apidoc-core: crd-ref-docs @$(CRD_REF_DOCS) \ - --source-path=api/v1alpha1 \ - --config=site/crd-ref-docs/config.yaml \ - --templates-dir=site/crd-ref-docs/templates \ - --output-path=API.md \ - --max-depth 20 \ - --output-path site/docs/api.md \ - --renderer=markdown + --source-path=api/v1alpha1 \ + --config=site/crd-ref-docs/config-core.yaml \ + --templates-dir=site/crd-ref-docs/templates \ + --max-depth 20 \ + --output-path site/docs/api/api.mdx \ + --renderer=markdown # This runs all necessary steps to prepare for a commit. .PHONY: precommit -precommit: tidy codespell apigen apidoc format lint editorconfig yamllint helm-lint +precommit: tidy codespell apigen apidoc-all format lint editorconfig yamllint helm-lint # This runs precommit and checks for any differences in the codebase, failing if there are any. .PHONY: check diff --git a/api/v1alpha1/api.go b/api/v1alpha1/api.go index 2244c3567..e54160674 100644 --- a/api/v1alpha1/api.go +++ b/api/v1alpha1/api.go @@ -99,7 +99,7 @@ type AIGatewayRouteSpec struct { // metadata per HTTP request. The namespaced key is "io.envoy.ai_gateway", // // For example, let's say we have the following LLMRequestCosts configuration: - // + // ```yaml // llmRequestCosts: // - metadataKey: llm_input_token // type: InputToken @@ -107,12 +107,13 @@ type AIGatewayRouteSpec struct { // type: OutputToken // - metadataKey: llm_total_token // type: TotalToken - // + // ``` // Then, with the following BackendTrafficPolicy of Envoy Gateway, you can have three // rate limit buckets for each unique x-user-id header value. One bucket is for the input token, // the other is for the output token, and the last one is for the total token. // Each bucket will be reduced by the corresponding token usage captured by the AI Gateway filter. // + // ```yaml // apiVersion: gateway.envoyproxy.io/v1alpha1 // kind: BackendTrafficPolicy // metadata: @@ -182,7 +183,7 @@ type AIGatewayRouteSpec struct { // metadata: // namespace: io.envoy.ai_gateway // key: llm_total_token - // + // ``` // +optional // +kubebuilder:validation:MaxItems=36 LLMRequestCosts []LLMRequestCost `json:"llmRequestCosts,omitempty"` diff --git a/manifests/charts/ai-gateway-helm/crds/aigateway.envoyproxy.io_aigatewayroutes.yaml b/manifests/charts/ai-gateway-helm/crds/aigateway.envoyproxy.io_aigatewayroutes.yaml index bcb071f12..91e32922d 100644 --- a/manifests/charts/ai-gateway-helm/crds/aigateway.envoyproxy.io_aigatewayroutes.yaml +++ b/manifests/charts/ai-gateway-helm/crds/aigateway.envoyproxy.io_aigatewayroutes.yaml @@ -164,15 +164,15 @@ spec: the LLM-related request, notably the token usage.\nThe AI Gateway filter will capture each specified number and store it in the Envoy's dynamic\nmetadata per HTTP request. The namespaced key is \"io.envoy.ai_gateway\",\n\nFor - example, let's say we have the following LLMRequestCosts configuration:\n\n\tllmRequestCosts:\n\t- + example, let's say we have the following LLMRequestCosts configuration:\n```yaml\n\tllmRequestCosts:\n\t- metadataKey: llm_input_token\n\t type: InputToken\n\t- metadataKey: llm_output_token\n\t type: OutputToken\n\t- metadataKey: llm_total_token\n\t - \ type: TotalToken\n\nThen, with the following BackendTrafficPolicy + \ type: TotalToken\n```\nThen, with the following BackendTrafficPolicy of Envoy Gateway, you can have three\nrate limit buckets for each unique x-user-id header value. One bucket is for the input token,\nthe other is for the output token, and the last one is for the total token.\nEach bucket will be reduced by the corresponding token usage - captured by the AI Gateway filter.\n\n\tapiVersion: gateway.envoyproxy.io/v1alpha1\n\tkind: + captured by the AI Gateway filter.\n\n```yaml\n\tapiVersion: gateway.envoyproxy.io/v1alpha1\n\tkind: BackendTrafficPolicy\n\tmetadata:\n\t name: some-example-token-rate-limit\n\t \ namespace: default\n\tspec:\n\t targetRefs:\n\t - group: gateway.networking.k8s.io\n\t \ kind: HTTPRoute\n\t name: usage-rate-limit\n\t rateLimit:\n\t @@ -204,7 +204,7 @@ spec: \ unit: Hour\n\t cost:\n\t request:\n\t \ from: Number\n\t number: 0\n\t response:\n\t \ from: Metadata\n\t metadata:\n\t namespace: - io.envoy.ai_gateway\n\t key: llm_total_token" + io.envoy.ai_gateway\n\t key: llm_total_token\n```" items: description: LLMRequestCost configures each request cost. properties: diff --git a/site/crd-ref-docs/config.yaml b/site/crd-ref-docs/config-core.yaml similarity index 92% rename from site/crd-ref-docs/config.yaml rename to site/crd-ref-docs/config-core.yaml index 5f16ff33f..2bde7044d 100644 --- a/site/crd-ref-docs/config.yaml +++ b/site/crd-ref-docs/config-core.yaml @@ -34,6 +34,9 @@ render: - name: ExtProc package: github.com/envoyproxy/gateway/api/v1alpha1 link: https://gateway.envoyproxy.io/docs/api/extension_types/#extproc + - name: OIDC + package: github.com/envoyproxy/gateway/api/v1alpha1 + link: https://gateway.envoyproxy.io/docs/api/extension_types/#oidc navigation: includeTOC: true diff --git a/site/crd-ref-docs/templates/gv_details.tpl b/site/crd-ref-docs/templates/gv_details.tpl index 235ef7728..2841d6f4a 100644 --- a/site/crd-ref-docs/templates/gv_details.tpl +++ b/site/crd-ref-docs/templates/gv_details.tpl @@ -1,22 +1,55 @@ {{- define "gvDetails" -}} {{- $gv := . -}} - -# {{ $gv.GroupVersionString }} +## {{ $gv.GroupVersionString }} {{ $gv.Doc }} -{{- if $gv.Kinds }} -## Resource Types +{{- if $gv.Kinds }} +## Resource Kinds + +### Available Kinds {{- range $gv.SortedKinds }} -- {{ markdownRenderTypeLink ($gv.TypeForKind .) }} +- {{ $gv.TypeForKind . | markdownRenderTypeLink }} {{- end }} -{{ end }} -{{ range $gv.SortedTypes }} -{{ template "type" . }} -{{ end }} +### Kind Definitions +{{- range $gv.SortedKinds }} +{{- $type := $gv.TypeForKind . }} +{{ template "type" $type }} +{{- end }} +{{- end }} -[Back to Packages](#api_references) +{{- if $gv.Types }} +## Supporting Types + +### Available Types +{{- range $gv.SortedTypes }} +{{- $type := . }} +{{- $isKind := false }} +{{- range $gv.Kinds }} +{{- if eq . $type.Name }} +{{- $isKind = true }} +{{- end }} +{{- end }} +{{- if not $isKind }} +- {{ markdownRenderTypeLink . }} +{{- end }} +{{- end }} + +### Type Definitions +{{- range $gv.SortedTypes }} +{{- $type := . }} +{{- $isKind := false }} +{{- range $gv.Kinds }} +{{- if eq . $type.Name }} +{{- $isKind = true }} +{{- end }} +{{- end }} +{{- if not $isKind }} +{{ template "type" . }} +{{- end }} +{{- end }} +{{- end }} {{- end -}} diff --git a/site/crd-ref-docs/templates/gv_list.tpl b/site/crd-ref-docs/templates/gv_list.tpl index 57a1e6d5c..c895a5bd2 100644 --- a/site/crd-ref-docs/templates/gv_list.tpl +++ b/site/crd-ref-docs/templates/gv_list.tpl @@ -1,17 +1,14 @@ {{- define "gvList" -}} {{- $groupVersions := . -}} + --- id: api_references title: API Reference +toc_min_heading_level: 2 +toc_max_heading_level: 4 --- - -# Packages -{{- range $groupVersions }} -- [{{ .GroupVersionString }}](#{{ lower (replace .GroupVersionString " " "-" ) }}) -{{- end }} - {{ range $groupVersions }} {{ template "gvDetails" . }} {{ end }} diff --git a/site/crd-ref-docs/templates/type.tpl b/site/crd-ref-docs/templates/type.tpl index 9611bdb1e..f2733778a 100644 --- a/site/crd-ref-docs/templates/type.tpl +++ b/site/crd-ref-docs/templates/type.tpl @@ -2,48 +2,65 @@ {{- $type := . -}} {{- if markdownShouldRenderType $type -}} -### {{ $type.Name }} +#### {{ $type.Name }} -{{ if $type.IsAlias }}_Underlying type:_ _{{ markdownRenderTypeLink $type.UnderlyingType }}_{{ end }} - -{{ $type.Doc }} - -{{ if $type.References -}} -_Appears in:_ +{{ if $type.IsAlias }}**Underlying type:** {{ markdownRenderTypeLink $type.UnderlyingType }}{{ end }} +{{ if $type.References }} +**Appears in** {{- range $type.SortedReferences }} - {{ markdownRenderTypeLink . }} {{- end }} {{- end }} +{{ $type.Doc }} + {{ if $type.Members -}} + +##### Fields + {{ if $type.GVK -}} -- **apiVersion** - - **Type:** _string_ - - **Value:** `{{ $type.GVK.Group }}/{{ $type.GVK.Version }}` -- **kind** - - **Type:** _string_ - - **Value:** `{{ $type.GVK.Kind }}` -{{ end -}} + + + +{{- end }} + {{ range $type.Members -}} -{{- with .Markers.notImplementedHide -}} -{{- else -}} -- **{{ .Name }}** - - **Type:** _{{ markdownRenderType .Type }}_ - - **Required:** {{ if .Markers.optional }}No{{ else }}Yes{{ end }} - {{- if .Doc }} - - **Description:** {{ .Doc }} +{{- if not .Markers.notImplementedHide -}} + +{{- end }} +{{- end }} +{{- end }} {{ if $type.EnumValues -}} -| Value | Description | -| ----- | ----------- | +##### Possible Values + {{ range $type.EnumValues -}} -| `{{ .Name }}` | {{ markdownRenderFieldDoc .Doc }} | -{{ end -}} -{{- end -}} + +{{- end }} +{{- end }} -{{- end -}} +{{- end }} {{- end -}} diff --git a/site/crd-ref-docs/templates/type_members.tpl b/site/crd-ref-docs/templates/type_members.tpl index 041758a87..e372c3f06 100644 --- a/site/crd-ref-docs/templates/type_members.tpl +++ b/site/crd-ref-docs/templates/type_members.tpl @@ -3,6 +3,6 @@ {{- if eq $field.Name "metadata" -}} Refer to Kubernetes API documentation for fields of `metadata`. {{- else -}} -{{ markdownRenderFieldDoc $field.Doc }} +{{ markdownRenderFieldDoc $field.Doc | replace "\"" "`" }} {{- end -}} {{- end -}} diff --git a/site/docs/api.md b/site/docs/api.md deleted file mode 100644 index 76bab9102..000000000 --- a/site/docs/api.md +++ /dev/null @@ -1,759 +0,0 @@ ---- -id: api_references -title: API Reference ---- - - -# Packages -- [aigateway.envoyproxy.io/v1alpha1](#-) - - - -# aigateway.envoyproxy.io/v1alpha1 - -Package v1alpha1 contains API schema definitions for the aigateway.envoyproxy.io -API group. - - -## Resource Types -- [AIGatewayRoute](#aigatewayroute) -- [AIGatewayRouteList](#aigatewayroutelist) -- [AIServiceBackend](#aiservicebackend) -- [AIServiceBackendList](#aiservicebackendlist) -- [BackendSecurityPolicy](#backendsecuritypolicy) -- [BackendSecurityPolicyList](#backendsecuritypolicylist) - - - -### AIGatewayFilterConfig - - - - - -_Appears in:_ -- [AIGatewayRouteSpec](#aigatewayroutespec) - -- **type** - - **Type:** _[AIGatewayFilterConfigType](#aigatewayfilterconfigtype)_ - - **Required:** Yes - - **Description:** Type specifies the type of the filter configuration. - - -Currently, only ExternalProcess is supported, and default is ExternalProcess. -- **externalProcess** - - **Type:** _[AIGatewayFilterConfigExternalProcess](#aigatewayfilterconfigexternalprocess)_ - - **Required:** No - - **Description:** ExternalProcess is the configuration for the external process filter. -This is optional, and if not set, the default values of Deployment spec will be used. - - -### AIGatewayFilterConfigExternalProcess - - - - - -_Appears in:_ -- [AIGatewayFilterConfig](#aigatewayfilterconfig) - -- **replicas** - - **Type:** _integer_ - - **Required:** No - - **Description:** Replicas is the number of desired pods of the external process deployment. -- **resources** - - **Type:** _[ResourceRequirements](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#resourcerequirements-v1-core)_ - - **Required:** No - - **Description:** Resources required by the external process container. -More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - - -### AIGatewayFilterConfigType - -_Underlying type:_ _string_ - -AIGatewayFilterConfigType specifies the type of the filter configuration. - -_Appears in:_ -- [AIGatewayFilterConfig](#aigatewayfilterconfig) - -| Value | Description | -| ----- | ----------- | -| `ExternalProcess` | | -| `DynamicModule` | | - - -### AIGatewayRoute - - - -AIGatewayRoute combines multiple AIServiceBackends and attaching them to Gateway(s) resources. - - -This serves as a way to define a "unified" AI API for a Gateway which allows downstream -clients to use a single schema API to interact with multiple AI backends. - - -The schema field is used to determine the structure of the requests that the Gateway will -receive. And then the Gateway will route the traffic to the appropriate AIServiceBackend based -on the output schema of the AIServiceBackend while doing the other necessary jobs like -upstream authentication, rate limit, etc. - - -For Advanced Users: Envoy AI Gateway will generate the following k8s resources corresponding to the AIGatewayRoute: - - - - Deployment, Service, and ConfigMap of the k8s API for the AI Gateway filter. - The name of these resources are `ai-eg-route-extproc-${name}`. - - HTTPRoute of the Gateway API as a top-level resource to bind all backends. - The name of the HTTPRoute is the same as the AIGatewayRoute. - - EnvoyExtensionPolicy of the Envoy Gateway API to attach the AI Gateway filter into the HTTPRoute. - The name of the EnvoyExtensionPolicy is `ai-eg-route-extproc-${name}` which is the same as the Deployment, etc. - - HTTPRouteFilter of the Envoy Gateway API per namespace for automatic hostname rewrite. - The name of the HTTPRouteFilter is `ai-eg-host-rewrite`. - - -All of these resources are created in the same namespace as the AIGatewayRoute. Note that this is the implementation -detail subject to change. If you want to customize the default behavior of the Envoy AI Gateway, you can use these -resources as a reference and create your own resources. Alternatively, you can use EnvoyPatchPolicy API of the Envoy -Gateway to patch the generated resources. For example, you can insert a custom filter into the filter chain. - -_Appears in:_ -- [AIGatewayRouteList](#aigatewayroutelist) - -- **apiVersion** - - **Type:** _string_ - - **Value:** `aigateway.envoyproxy.io/v1alpha1` -- **kind** - - **Type:** _string_ - - **Value:** `AIGatewayRoute` -- **metadata** - - **Type:** _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#objectmeta-v1-meta)_ - - **Required:** Yes -- **spec** - - **Type:** _[AIGatewayRouteSpec](#aigatewayroutespec)_ - - **Required:** Yes - - **Description:** Spec defines the details of the AIGatewayRoute. - - -### AIGatewayRouteList - - - -AIGatewayRouteList contains a list of AIGatewayRoute. - - - -- **apiVersion** - - **Type:** _string_ - - **Value:** `aigateway.envoyproxy.io/v1alpha1` -- **kind** - - **Type:** _string_ - - **Value:** `AIGatewayRouteList` -- **metadata** - - **Type:** _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#listmeta-v1-meta)_ - - **Required:** Yes -- **items** - - **Type:** _[AIGatewayRoute](#aigatewayroute) array_ - - **Required:** Yes - - -### AIGatewayRouteRule - - - -AIGatewayRouteRule is a rule that defines the routing behavior of the AIGatewayRoute. - -_Appears in:_ -- [AIGatewayRouteSpec](#aigatewayroutespec) - -- **backendRefs** - - **Type:** _[AIGatewayRouteRuleBackendRef](#aigatewayrouterulebackendref) array_ - - **Required:** No - - **Description:** BackendRefs is the list of AIServiceBackend that this rule will route the traffic to. -Each backend can have a weight that determines the traffic distribution. - - -The namespace of each backend is "local", i.e. the same namespace as the AIGatewayRoute. -- **matches** - - **Type:** _[AIGatewayRouteRuleMatch](#aigatewayrouterulematch) array_ - - **Required:** No - - **Description:** Matches is the list of AIGatewayRouteMatch that this rule will match the traffic to. -This is a subset of the HTTPRouteMatch in the Gateway API. See for the details: -https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io%2fv1.HTTPRouteMatch - - -### AIGatewayRouteRuleBackendRef - - - -AIGatewayRouteRuleBackendRef is a reference to a AIServiceBackend with a weight. - -_Appears in:_ -- [AIGatewayRouteRule](#aigatewayrouterule) - -- **name** - - **Type:** _string_ - - **Required:** Yes - - **Description:** Name is the name of the AIServiceBackend. -- **weight** - - **Type:** _integer_ - - **Required:** No - - **Description:** Weight is the weight of the AIServiceBackend. This is exactly the same as the weight in -the BackendRef in the Gateway API. See for the details: -https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io%2fv1.BackendRef - - -Default is 1. - - -### AIGatewayRouteRuleMatch - - - - - -_Appears in:_ -- [AIGatewayRouteRule](#aigatewayrouterule) - -- **headers** - - **Type:** _HTTPHeaderMatch array_ - - **Required:** No - - **Description:** Headers specifies HTTP request header matchers. See HeaderMatch in the Gateway API for the details: -https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io%2fv1.HTTPHeaderMatch - - -Currently, only the exact header matching is supported. - - -### AIGatewayRouteSpec - - - -AIGatewayRouteSpec details the AIGatewayRoute configuration. - -_Appears in:_ -- [AIGatewayRoute](#aigatewayroute) - -- **targetRefs** - - **Type:** _[LocalPolicyTargetReferenceWithSectionName](https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io/v1alpha2.LocalPolicyTargetReferenceWithSectionName) array_ - - **Required:** Yes - - **Description:** TargetRefs are the names of the Gateway resources this AIGatewayRoute is being attached to. -- **schema** - - **Type:** _[VersionedAPISchema](#versionedapischema)_ - - **Required:** Yes - - **Description:** APISchema specifies the API schema of the input that the target Gateway(s) will receive. -Based on this schema, the ai-gateway will perform the necessary transformation to the -output schema specified in the selected AIServiceBackend during the routing process. - - -Currently, the only supported schema is OpenAI as the input schema. -- **rules** - - **Type:** _[AIGatewayRouteRule](#aigatewayrouterule) array_ - - **Required:** Yes - - **Description:** Rules is the list of AIGatewayRouteRule that this AIGatewayRoute will match the traffic to. -Each rule is a subset of the HTTPRoute in the Gateway API (https://gateway-api.sigs.k8s.io/api-types/httproute/). - - -AI Gateway controller will generate a HTTPRoute based on the configuration given here with the additional -modifications to achieve the necessary jobs, notably inserting the AI Gateway filter responsible for -the transformation of the request and response, etc. - - -In the matching conditions in the AIGatewayRouteRule, `x-ai-eg-model` header is available -if we want to describe the routing behavior based on the model name. The model name is extracted -from the request content before the routing decision. - - -How multiple rules are matched is the same as the Gateway API. See for the details: -https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io%2fv1.HTTPRoute -- **filterConfig** - - **Type:** _[AIGatewayFilterConfig](#aigatewayfilterconfig)_ - - **Required:** Yes - - **Description:** FilterConfig is the configuration for the AI Gateway filter inserted in the generated HTTPRoute. - - -An AI Gateway filter is responsible for the transformation of the request and response -as well as the routing behavior based on the model name extracted from the request content, etc. - - -Currently, the filter is only implemented as an external process filter, which might be -extended to other types of filters in the future. See https://github.com/envoyproxy/ai-gateway/issues/90 -- **llmRequestCosts** - - **Type:** _[LLMRequestCost](#llmrequestcost) array_ - - **Required:** No - - **Description:** LLMRequestCosts specifies how to capture the cost of the LLM-related request, notably the token usage. -The AI Gateway filter will capture each specified number and store it in the Envoy's dynamic -metadata per HTTP request. The namespaced key is "io.envoy.ai_gateway", - - -For example, let's say we have the following LLMRequestCosts configuration: - - - llmRequestCosts: - - metadataKey: llm_input_token - type: InputToken - - metadataKey: llm_output_token - type: OutputToken - - metadataKey: llm_total_token - type: TotalToken - - -Then, with the following BackendTrafficPolicy of Envoy Gateway, you can have three -rate limit buckets for each unique x-user-id header value. One bucket is for the input token, -the other is for the output token, and the last one is for the total token. -Each bucket will be reduced by the corresponding token usage captured by the AI Gateway filter. - - - apiVersion: gateway.envoyproxy.io/v1alpha1 - kind: BackendTrafficPolicy - metadata: - name: some-example-token-rate-limit - namespace: default - spec: - targetRefs: - - group: gateway.networking.k8s.io - kind: HTTPRoute - name: usage-rate-limit - rateLimit: - type: Global - global: - rules: - - clientSelectors: - # Do the rate limiting based on the x-user-id header. - - headers: - - name: x-user-id - type: Distinct - limit: - # Configures the number of "tokens" allowed per hour. - requests: 10000 - unit: Hour - cost: - request: - from: Number - # Setting the request cost to zero allows to only check the rate limit budget, - # and not consume the budget on the request path. - number: 0 - # This specifies the cost of the response retrieved from the dynamic metadata set by the AI Gateway filter. - # The extracted value will be used to consume the rate limit budget, and subsequent requests will be rate limited - # if the budget is exhausted. - response: - from: Metadata - metadata: - namespace: io.envoy.ai_gateway - key: llm_input_token - - clientSelectors: - - headers: - - name: x-user-id - type: Distinct - limit: - requests: 10000 - unit: Hour - cost: - request: - from: Number - number: 0 - response: - from: Metadata - metadata: - namespace: io.envoy.ai_gateway - key: llm_output_token - - clientSelectors: - - headers: - - name: x-user-id - type: Distinct - limit: - requests: 10000 - unit: Hour - cost: - request: - from: Number - number: 0 - response: - from: Metadata - metadata: - namespace: io.envoy.ai_gateway - key: llm_total_token - - -### AIServiceBackend - - - -AIServiceBackend is a resource that represents a single backend for AIGatewayRoute. -A backend is a service that handles traffic with a concrete API specification. - - -A AIServiceBackend is "attached" to a Backend which is either a k8s Service or a Backend resource of the Envoy Gateway. - - -When a backend with an attached AIServiceBackend is used as a routing target in the AIGatewayRoute (more precisely, the -HTTPRouteSpec defined in the AIGatewayRoute), the ai-gateway will generate the necessary configuration to do -the backend specific logic in the final HTTPRoute. - -_Appears in:_ -- [AIServiceBackendList](#aiservicebackendlist) - -- **apiVersion** - - **Type:** _string_ - - **Value:** `aigateway.envoyproxy.io/v1alpha1` -- **kind** - - **Type:** _string_ - - **Value:** `AIServiceBackend` -- **metadata** - - **Type:** _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#objectmeta-v1-meta)_ - - **Required:** Yes -- **spec** - - **Type:** _[AIServiceBackendSpec](#aiservicebackendspec)_ - - **Required:** Yes - - **Description:** Spec defines the details of AIServiceBackend. - - -### AIServiceBackendList - - - -AIServiceBackendList contains a list of AIServiceBackends. - - - -- **apiVersion** - - **Type:** _string_ - - **Value:** `aigateway.envoyproxy.io/v1alpha1` -- **kind** - - **Type:** _string_ - - **Value:** `AIServiceBackendList` -- **metadata** - - **Type:** _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#listmeta-v1-meta)_ - - **Required:** Yes -- **items** - - **Type:** _[AIServiceBackend](#aiservicebackend) array_ - - **Required:** Yes - - -### AIServiceBackendSpec - - - -AIServiceBackendSpec details the AIServiceBackend configuration. - -_Appears in:_ -- [AIServiceBackend](#aiservicebackend) - -- **schema** - - **Type:** _[VersionedAPISchema](#versionedapischema)_ - - **Required:** Yes - - **Description:** APISchema specifies the API schema of the output format of requests from -Envoy that this AIServiceBackend can accept as incoming requests. -Based on this schema, the ai-gateway will perform the necessary transformation for -the pair of AIGatewayRouteSpec.APISchema and AIServiceBackendSpec.APISchema. - - -This is required to be set. -- **backendRef** - - **Type:** _[BackendObjectReference](https://gateway-api.sigs.k8s.io/references/spec/#gateway.networking.k8s.io/v1.BackendObjectReference)_ - - **Required:** Yes - - **Description:** BackendRef is the reference to the Backend resource that this AIServiceBackend corresponds to. - - -A backend can be of either k8s Service or Backend resource of Envoy Gateway. - - -This is required to be set. -- **backendSecurityPolicyRef** - - **Type:** _[LocalObjectReference](#localobjectreference)_ - - **Required:** No - - **Description:** BackendSecurityPolicyRef is the name of the BackendSecurityPolicy resources this backend -is being attached to. - - -### APISchema - -_Underlying type:_ _string_ - -APISchema defines the API schema. - -_Appears in:_ -- [VersionedAPISchema](#versionedapischema) - -| Value | Description | -| ----- | ----------- | -| `OpenAI` | APISchemaOpenAI is the OpenAI schema.
https://github.com/openai/openai-openapi
| -| `AWSBedrock` | APISchemaAWSBedrock is the AWS Bedrock schema.
https://docs.aws.amazon.com/bedrock/latest/APIReference/API_Operations_Amazon_Bedrock_Runtime.html
| - - -### AWSCredentialsFile - - - -AWSCredentialsFile specifies the credentials file to use for the AWS provider. -Envoy reads the secret file, and the profile to use is specified by the Profile field. - -_Appears in:_ -- [BackendSecurityPolicyAWSCredentials](#backendsecuritypolicyawscredentials) - -- **secretRef** - - **Type:** _[SecretObjectReference](https://gateway-api.sigs.k8s.io/references/spec/#gateway.networking.k8s.io/v1.SecretObjectReference)_ - - **Required:** Yes - - **Description:** SecretRef is the reference to the credential file. - - -The secret should contain the AWS credentials file keyed on "credentials". -- **profile** - - **Type:** _string_ - - **Required:** Yes - - **Description:** Profile is the profile to use in the credentials file. - - -### AWSOIDCExchangeToken - - - -AWSOIDCExchangeToken specifies credentials to obtain oidc token from a sso server. -For AWS, the controller will query STS to obtain AWS AccessKeyId, SecretAccessKey, and SessionToken, -and store them in a temporary credentials file. - -_Appears in:_ -- [BackendSecurityPolicyAWSCredentials](#backendsecuritypolicyawscredentials) - -- **oidc** - - **Type:** _[OIDC](#oidc)_ - - **Required:** Yes - - **Description:** OIDC is used to obtain oidc tokens via an SSO server which will be used to exchange for temporary AWS credentials. -- **grantType** - - **Type:** _string_ - - **Required:** No - - **Description:** GrantType is the method application gets access token. -- **aud** - - **Type:** _string_ - - **Required:** No - - **Description:** Aud defines the audience that this ID Token is intended for. -- **awsRoleArn** - - **Type:** _string_ - - **Required:** Yes - - **Description:** AwsRoleArn is the AWS IAM Role with the permission to use specific resources in AWS account -which maps to the temporary AWS security credentials exchanged using the authentication token issued by OIDC provider. - - -### BackendSecurityPolicy - - - -BackendSecurityPolicy specifies configuration for authentication and authorization rules on the traffic -exiting the gateway to the backend. - -_Appears in:_ -- [BackendSecurityPolicyList](#backendsecuritypolicylist) - -- **apiVersion** - - **Type:** _string_ - - **Value:** `aigateway.envoyproxy.io/v1alpha1` -- **kind** - - **Type:** _string_ - - **Value:** `BackendSecurityPolicy` -- **metadata** - - **Type:** _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#objectmeta-v1-meta)_ - - **Required:** Yes -- **spec** - - **Type:** _[BackendSecurityPolicySpec](#backendsecuritypolicyspec)_ - - **Required:** Yes - - -### BackendSecurityPolicyAPIKey - - - -BackendSecurityPolicyAPIKey specifies the API key. - -_Appears in:_ -- [BackendSecurityPolicySpec](#backendsecuritypolicyspec) - -- **secretRef** - - **Type:** _[SecretObjectReference](https://gateway-api.sigs.k8s.io/references/spec/#gateway.networking.k8s.io/v1.SecretObjectReference)_ - - **Required:** Yes - - **Description:** SecretRef is the reference to the secret containing the API key. -ai-gateway must be given the permission to read this secret. -The key of the secret should be "apiKey". - - -### BackendSecurityPolicyAWSCredentials - - - -BackendSecurityPolicyAWSCredentials contains the supported authentication mechanisms to access aws - -_Appears in:_ -- [BackendSecurityPolicySpec](#backendsecuritypolicyspec) - -- **region** - - **Type:** _string_ - - **Required:** Yes - - **Description:** Region specifies the AWS region associated with the policy. -- **credentialsFile** - - **Type:** _[AWSCredentialsFile](#awscredentialsfile)_ - - **Required:** No - - **Description:** CredentialsFile specifies the credentials file to use for the AWS provider. -- **oidcExchangeToken** - - **Type:** _[AWSOIDCExchangeToken](#awsoidcexchangetoken)_ - - **Required:** No - - **Description:** OIDCExchangeToken specifies the oidc configurations used to obtain an oidc token. The oidc token will be -used to obtain temporary credentials to access AWS. - - -### BackendSecurityPolicyList - - - -BackendSecurityPolicyList contains a list of BackendSecurityPolicy - - - -- **apiVersion** - - **Type:** _string_ - - **Value:** `aigateway.envoyproxy.io/v1alpha1` -- **kind** - - **Type:** _string_ - - **Value:** `BackendSecurityPolicyList` -- **metadata** - - **Type:** _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#listmeta-v1-meta)_ - - **Required:** Yes -- **items** - - **Type:** _[BackendSecurityPolicy](#backendsecuritypolicy) array_ - - **Required:** Yes - - -### BackendSecurityPolicySpec - - - -BackendSecurityPolicySpec specifies authentication rules on access the provider from the Gateway. -Only one mechanism to access a backend(s) can be specified. - - -Only one type of BackendSecurityPolicy can be defined. - -_Appears in:_ -- [BackendSecurityPolicy](#backendsecuritypolicy) - -- **type** - - **Type:** _[BackendSecurityPolicyType](#backendsecuritypolicytype)_ - - **Required:** Yes - - **Description:** Type specifies the auth mechanism used to access the provider. Currently, only "APIKey", AND "AWSCredentials" are supported. -- **apiKey** - - **Type:** _[BackendSecurityPolicyAPIKey](#backendsecuritypolicyapikey)_ - - **Required:** No - - **Description:** APIKey is a mechanism to access a backend(s). The API key will be injected into the Authorization header. -- **awsCredentials** - - **Type:** _[BackendSecurityPolicyAWSCredentials](#backendsecuritypolicyawscredentials)_ - - **Required:** No - - **Description:** AWSCredentials is a mechanism to access a backend(s). AWS specific logic will be applied. - - -### BackendSecurityPolicyType - -_Underlying type:_ _string_ - -BackendSecurityPolicyType specifies the type of auth mechanism used to access a backend. - -_Appears in:_ -- [BackendSecurityPolicySpec](#backendsecuritypolicyspec) - -| Value | Description | -| ----- | ----------- | -| `APIKey` | | -| `AWSCredentials` | | - - -### LLMRequestCost - - - -LLMRequestCost configures each request cost. - -_Appears in:_ -- [AIGatewayRouteSpec](#aigatewayroutespec) - -- **metadataKey** - - **Type:** _string_ - - **Required:** Yes - - **Description:** MetadataKey is the key of the metadata to store this cost of the request. -- **type** - - **Type:** _[LLMRequestCostType](#llmrequestcosttype)_ - - **Required:** Yes - - **Description:** Type specifies the type of the request cost. The default is "OutputToken", -and it uses "output token" as the cost. The other types are "InputToken", "TotalToken", -and "CEL". -- **celExpression** - - **Type:** _string_ - - **Required:** No - - **Description:** CELExpression is the CEL expression to calculate the cost of the request. -The CEL expression must return a signed or unsigned integer. If the -return value is negative, it will be error. - - -The expression can use the following variables: - - - * model: the model name extracted from the request content. Type: string. - * backend: the backend name in the form of "name.namespace". Type: string. - * input_tokens: the number of input tokens. Type: unsigned integer. - * output_tokens: the number of output tokens. Type: unsigned integer. - * total_tokens: the total number of tokens. Type: unsigned integer. - - -For example, the following expressions are valid: - - - * "model == 'llama' ? input_tokens + output_token * 0.5 : total_tokens" - * "backend == 'foo.default' ? input_tokens + output_tokens : total_tokens" - * "input_tokens + output_tokens + total_tokens" - * "input_tokens * output_tokens" - - -### LLMRequestCostType - -_Underlying type:_ _string_ - -LLMRequestCostType specifies the type of the LLMRequestCost. - -_Appears in:_ -- [LLMRequestCost](#llmrequestcost) - -| Value | Description | -| ----- | ----------- | -| `InputToken` | LLMRequestCostTypeInputToken is the cost type of the input token.
| -| `OutputToken` | LLMRequestCostTypeOutputToken is the cost type of the output token.
| -| `TotalToken` | LLMRequestCostTypeTotalToken is the cost type of the total token.
| -| `CEL` | LLMRequestCostTypeCEL is for calculating the cost using the CEL expression.
| - - -### VersionedAPISchema - - - -VersionedAPISchema defines the API schema of either AIGatewayRoute (the input) or AIServiceBackend (the output). - - -This allows the ai-gateway to understand the input and perform the necessary transformation -depending on the API schema pair (input, output). - - -Note that this is vendor specific, and the stability of the API schema is not guaranteed by -the ai-gateway, but by the vendor via proper versioning. - -_Appears in:_ -- [AIGatewayRouteSpec](#aigatewayroutespec) -- [AIServiceBackendSpec](#aiservicebackendspec) - -- **name** - - **Type:** _[APISchema](#apischema)_ - - **Required:** Yes - - **Description:** Name is the name of the API schema of the AIGatewayRoute or AIServiceBackend. -- **version** - - **Type:** _string_ - - **Required:** Yes - - **Description:** Version is the version of the API schema. - - - -[Back to Packages](#api_references) diff --git a/site/docs/api/api.mdx b/site/docs/api/api.mdx new file mode 100644 index 000000000..fc3b3e724 --- /dev/null +++ b/site/docs/api/api.mdx @@ -0,0 +1,833 @@ +--- +id: api_references +title: API Reference +toc_min_heading_level: 2 +toc_max_heading_level: 4 +--- + + +## aigateway.envoyproxy.io/v1alpha1 + +Package v1alpha1 contains API schema definitions for the aigateway.envoyproxy.io +API group. + + +## Resource Kinds + +### Available Kinds +- [AIGatewayRoute](#aigatewayroute) +- [AIGatewayRouteList](#aigatewayroutelist) +- [AIServiceBackend](#aiservicebackend) +- [AIServiceBackendList](#aiservicebackendlist) +- [BackendSecurityPolicy](#backendsecuritypolicy) +- [BackendSecurityPolicyList](#backendsecuritypolicylist) + +### Kind Definitions +#### AIGatewayRoute + + + +**Appears in** +- [AIGatewayRouteList](#aigatewayroutelist) + +AIGatewayRoute combines multiple AIServiceBackends and attaching them to Gateway(s) resources. + + +This serves as a way to define a "unified" AI API for a Gateway which allows downstream +clients to use a single schema API to interact with multiple AI backends. + + +The schema field is used to determine the structure of the requests that the Gateway will +receive. And then the Gateway will route the traffic to the appropriate AIServiceBackend based +on the output schema of the AIServiceBackend while doing the other necessary jobs like +upstream authentication, rate limit, etc. + + +For Advanced Users: Envoy AI Gateway will generate the following k8s resources corresponding to the AIGatewayRoute: + + + - Deployment, Service, and ConfigMap of the k8s API for the AI Gateway filter. + The name of these resources are `ai-eg-route-extproc-${name}`. + - HTTPRoute of the Gateway API as a top-level resource to bind all backends. + The name of the HTTPRoute is the same as the AIGatewayRoute. + - EnvoyExtensionPolicy of the Envoy Gateway API to attach the AI Gateway filter into the HTTPRoute. + The name of the EnvoyExtensionPolicy is `ai-eg-route-extproc-${name}` which is the same as the Deployment, etc. + - HTTPRouteFilter of the Envoy Gateway API per namespace for automatic hostname rewrite. + The name of the HTTPRouteFilter is `ai-eg-host-rewrite`. + + +All of these resources are created in the same namespace as the AIGatewayRoute. Note that this is the implementation +detail subject to change. If you want to customize the default behavior of the Envoy AI Gateway, you can use these +resources as a reference and create your own resources. Alternatively, you can use EnvoyPatchPolicy API of the Envoy +Gateway to patch the generated resources. For example, you can insert a custom filter into the filter chain. + +##### Fields + + + + + + + + +#### AIGatewayRouteList + + + + +AIGatewayRouteList contains a list of AIGatewayRoute. + +##### Fields + + + + + + + + +#### AIServiceBackend + + + +**Appears in** +- [AIServiceBackendList](#aiservicebackendlist) + +AIServiceBackend is a resource that represents a single backend for AIGatewayRoute. +A backend is a service that handles traffic with a concrete API specification. + + +A AIServiceBackend is "attached" to a Backend which is either a k8s Service or a Backend resource of the Envoy Gateway. + + +When a backend with an attached AIServiceBackend is used as a routing target in the AIGatewayRoute (more precisely, the +HTTPRouteSpec defined in the AIGatewayRoute), the ai-gateway will generate the necessary configuration to do +the backend specific logic in the final HTTPRoute. + +##### Fields + + + + + + + + +#### AIServiceBackendList + + + + +AIServiceBackendList contains a list of AIServiceBackends. + +##### Fields + + + + + + + + +#### BackendSecurityPolicy + + + +**Appears in** +- [BackendSecurityPolicyList](#backendsecuritypolicylist) + +BackendSecurityPolicy specifies configuration for authentication and authorization rules on the traffic +exiting the gateway to the backend. + +##### Fields + + + + + + + + +#### BackendSecurityPolicyList + + + + +BackendSecurityPolicyList contains a list of BackendSecurityPolicy + +##### Fields + + + + + + + + +## Supporting Types + +### Available Types +- [AIGatewayFilterConfig](#aigatewayfilterconfig) +- [AIGatewayFilterConfigExternalProcess](#aigatewayfilterconfigexternalprocess) +- [AIGatewayFilterConfigType](#aigatewayfilterconfigtype) +- [AIGatewayRouteRule](#aigatewayrouterule) +- [AIGatewayRouteRuleBackendRef](#aigatewayrouterulebackendref) +- [AIGatewayRouteRuleMatch](#aigatewayrouterulematch) +- [AIGatewayRouteSpec](#aigatewayroutespec) +- [AIServiceBackendSpec](#aiservicebackendspec) +- [APISchema](#apischema) +- [AWSCredentialsFile](#awscredentialsfile) +- [AWSOIDCExchangeToken](#awsoidcexchangetoken) +- [BackendSecurityPolicyAPIKey](#backendsecuritypolicyapikey) +- [BackendSecurityPolicyAWSCredentials](#backendsecuritypolicyawscredentials) +- [BackendSecurityPolicySpec](#backendsecuritypolicyspec) +- [BackendSecurityPolicyType](#backendsecuritypolicytype) +- [LLMRequestCost](#llmrequestcost) +- [LLMRequestCostType](#llmrequestcosttype) +- [VersionedAPISchema](#versionedapischema) + +### Type Definitions +#### AIGatewayFilterConfig + + + +**Appears in** +- [AIGatewayRouteSpec](#aigatewayroutespec) + + + +##### Fields + + + + + + +#### AIGatewayFilterConfigExternalProcess + + + +**Appears in** +- [AIGatewayFilterConfig](#aigatewayfilterconfig) + + + +##### Fields + + + + + + +#### AIGatewayFilterConfigType + +**Underlying type:** string + +**Appears in** +- [AIGatewayFilterConfig](#aigatewayfilterconfig) + +AIGatewayFilterConfigType specifies the type of the filter configuration. + + + +##### Possible Values + + +#### AIGatewayRouteRule + + + +**Appears in** +- [AIGatewayRouteSpec](#aigatewayroutespec) + +AIGatewayRouteRule is a rule that defines the routing behavior of the AIGatewayRoute. + +##### Fields + + + + + + +#### AIGatewayRouteRuleBackendRef + + + +**Appears in** +- [AIGatewayRouteRule](#aigatewayrouterule) + +AIGatewayRouteRuleBackendRef is a reference to a AIServiceBackend with a weight. + +##### Fields + + + + + + +#### AIGatewayRouteRuleMatch + + + +**Appears in** +- [AIGatewayRouteRule](#aigatewayrouterule) + + + +##### Fields + + + + + + +#### AIGatewayRouteSpec + + + +**Appears in** +- [AIGatewayRoute](#aigatewayroute) + +AIGatewayRouteSpec details the AIGatewayRoute configuration. + +##### Fields + + + + + + +#### AIServiceBackendSpec + + + +**Appears in** +- [AIServiceBackend](#aiservicebackend) + +AIServiceBackendSpec details the AIServiceBackend configuration. + +##### Fields + + + + + + +#### APISchema + +**Underlying type:** string + +**Appears in** +- [VersionedAPISchema](#versionedapischema) + +APISchema defines the API schema. + + + +##### Possible Values + + +#### AWSCredentialsFile + + + +**Appears in** +- [BackendSecurityPolicyAWSCredentials](#backendsecuritypolicyawscredentials) + +AWSCredentialsFile specifies the credentials file to use for the AWS provider. +Envoy reads the secret file, and the profile to use is specified by the Profile field. + +##### Fields + + + + + + +#### AWSOIDCExchangeToken + + + +**Appears in** +- [BackendSecurityPolicyAWSCredentials](#backendsecuritypolicyawscredentials) + +AWSOIDCExchangeToken specifies credentials to obtain oidc token from a sso server. +For AWS, the controller will query STS to obtain AWS AccessKeyId, SecretAccessKey, and SessionToken, +and store them in a temporary credentials file. + +##### Fields + + + + + + +#### BackendSecurityPolicyAPIKey + + + +**Appears in** +- [BackendSecurityPolicySpec](#backendsecuritypolicyspec) + +BackendSecurityPolicyAPIKey specifies the API key. + +##### Fields + + + + + + +#### BackendSecurityPolicyAWSCredentials + + + +**Appears in** +- [BackendSecurityPolicySpec](#backendsecuritypolicyspec) + +BackendSecurityPolicyAWSCredentials contains the supported authentication mechanisms to access aws + +##### Fields + + + + + + +#### BackendSecurityPolicySpec + + + +**Appears in** +- [BackendSecurityPolicy](#backendsecuritypolicy) + +BackendSecurityPolicySpec specifies authentication rules on access the provider from the Gateway. +Only one mechanism to access a backend(s) can be specified. + + +Only one type of BackendSecurityPolicy can be defined. + +##### Fields + + + + + + +#### BackendSecurityPolicyType + +**Underlying type:** string + +**Appears in** +- [BackendSecurityPolicySpec](#backendsecuritypolicyspec) + +BackendSecurityPolicyType specifies the type of auth mechanism used to access a backend. + + + +##### Possible Values + + +#### LLMRequestCost + + + +**Appears in** +- [AIGatewayRouteSpec](#aigatewayroutespec) + +LLMRequestCost configures each request cost. + +##### Fields + + + + + + +#### LLMRequestCostType + +**Underlying type:** string + +**Appears in** +- [LLMRequestCost](#llmrequestcost) + +LLMRequestCostType specifies the type of the LLMRequestCost. + + + +##### Possible Values + + +#### VersionedAPISchema + + + +**Appears in** +- [AIGatewayRouteSpec](#aigatewayroutespec) +- [AIServiceBackendSpec](#aiservicebackendspec) + +VersionedAPISchema defines the API schema of either AIGatewayRoute (the input) or AIServiceBackend (the output). + + +This allows the ai-gateway to understand the input and perform the necessary transformation +depending on the API schema pair (input, output). + + +Note that this is vendor specific, and the stability of the API schema is not guaranteed by +the ai-gateway, but by the vendor via proper versioning. + +##### Fields + + + + + + diff --git a/site/docs/api/core.mdx b/site/docs/api/core.mdx new file mode 100644 index 000000000..7a1c64b64 --- /dev/null +++ b/site/docs/api/core.mdx @@ -0,0 +1,877 @@ +--- +id: api_references +title: API Reference +toc_min_heading_level: 2 +toc_max_heading_level: 4 +--- + + +## aigateway.envoyproxy.io/v1alpha1 + +Package v1alpha1 contains API schema definitions for the aigateway.envoyproxy.io +API group. + + +## Resource Kinds + +### Available Kinds +- [AIGatewayRoute](#aigatewayroute) +- [AIGatewayRouteList](#aigatewayroutelist) +- [AIServiceBackend](#aiservicebackend) +- [AIServiceBackendList](#aiservicebackendlist) +- [BackendSecurityPolicy](#backendsecuritypolicy) +- [BackendSecurityPolicyList](#backendsecuritypolicylist) + +### Kind Definitions +#### AIGatewayRoute + + + +**Appears in** +- [AIGatewayRouteList](#aigatewayroutelist) + +AIGatewayRoute combines multiple AIServiceBackends and attaching them to Gateway(s) resources. + + +This serves as a way to define a "unified" AI API for a Gateway which allows downstream +clients to use a single schema API to interact with multiple AI backends. + + +The schema field is used to determine the structure of the requests that the Gateway will +receive. And then the Gateway will route the traffic to the appropriate AIServiceBackend based +on the output schema of the AIServiceBackend while doing the other necessary jobs like +upstream authentication, rate limit, etc. + + +For Advanced Users: Envoy AI Gateway will generate the following k8s resources corresponding to the AIGatewayRoute: + + + - Deployment, Service, and ConfigMap of the k8s API for the AI Gateway filter. + The name of these resources are `ai-eg-route-extproc-${name}`. + - HTTPRoute of the Gateway API as a top-level resource to bind all backends. + The name of the HTTPRoute is the same as the AIGatewayRoute. + - EnvoyExtensionPolicy of the Envoy Gateway API to attach the AI Gateway filter into the HTTPRoute. + The name of the EnvoyExtensionPolicy is `ai-eg-route-extproc-${name}` which is the same as the Deployment, etc. + - HTTPRouteFilter of the Envoy Gateway API per namespace for automatic hostname rewrite. + The name of the HTTPRouteFilter is `ai-eg-host-rewrite`. + + +All of these resources are created in the same namespace as the AIGatewayRoute. Note that this is the implementation +detail subject to change. If you want to customize the default behavior of the Envoy AI Gateway, you can use these +resources as a reference and create your own resources. Alternatively, you can use EnvoyPatchPolicy API of the Envoy +Gateway to patch the generated resources. For example, you can insert a custom filter into the filter chain. + +##### Fields + + + + + + + + +#### AIGatewayRouteList + + + + +AIGatewayRouteList contains a list of AIGatewayRoute. + +##### Fields + + + + + + + + +#### AIServiceBackend + + + +**Appears in** +- [AIServiceBackendList](#aiservicebackendlist) + +AIServiceBackend is a resource that represents a single backend for AIGatewayRoute. +A backend is a service that handles traffic with a concrete API specification. + + +A AIServiceBackend is "attached" to a Backend which is either a k8s Service or a Backend resource of the Envoy Gateway. + + +When a backend with an attached AIServiceBackend is used as a routing target in the AIGatewayRoute (more precisely, the +HTTPRouteSpec defined in the AIGatewayRoute), the ai-gateway will generate the necessary configuration to do +the backend specific logic in the final HTTPRoute. + +##### Fields + + + + + + + + +#### AIServiceBackendList + + + + +AIServiceBackendList contains a list of AIServiceBackends. + +##### Fields + + + + + + + + +#### BackendSecurityPolicy + + + +**Appears in** +- [BackendSecurityPolicyList](#backendsecuritypolicylist) + +BackendSecurityPolicy specifies configuration for authentication and authorization rules on the traffic +exiting the gateway to the backend. + +##### Fields + + + + + + + + +#### BackendSecurityPolicyList + + + + +BackendSecurityPolicyList contains a list of BackendSecurityPolicy + +##### Fields + + + + + + + + +## Supporting Types + +### Available Types +- [AIGatewayFilterConfig](#aigatewayfilterconfig) +- [AIGatewayFilterConfigExternalProcess](#aigatewayfilterconfigexternalprocess) +- [AIGatewayFilterConfigType](#aigatewayfilterconfigtype) +- [AIGatewayRouteRule](#aigatewayrouterule) +- [AIGatewayRouteRuleBackendRef](#aigatewayrouterulebackendref) +- [AIGatewayRouteRuleMatch](#aigatewayrouterulematch) +- [AIGatewayRouteSpec](#aigatewayroutespec) +- [AIServiceBackendSpec](#aiservicebackendspec) +- [APISchema](#apischema) +- [AWSCredentialsFile](#awscredentialsfile) +- [AWSOIDCExchangeToken](#awsoidcexchangetoken) +- [BackendSecurityPolicyAPIKey](#backendsecuritypolicyapikey) +- [BackendSecurityPolicyAWSCredentials](#backendsecuritypolicyawscredentials) +- [BackendSecurityPolicySpec](#backendsecuritypolicyspec) +- [BackendSecurityPolicyType](#backendsecuritypolicytype) +- [LLMRequestCost](#llmrequestcost) +- [LLMRequestCostType](#llmrequestcosttype) +- [VersionedAPISchema](#versionedapischema) + +### Type Definitions +#### AIGatewayFilterConfig + + + +**Appears in** +- [AIGatewayRouteSpec](#aigatewayroutespec) + + + +##### Fields + + + + + + +#### AIGatewayFilterConfigExternalProcess + + + +**Appears in** +- [AIGatewayFilterConfig](#aigatewayfilterconfig) + + + +##### Fields + + + + + + +#### AIGatewayFilterConfigType + +**Underlying type:** string + +**Appears in** +- [AIGatewayFilterConfig](#aigatewayfilterconfig) + +AIGatewayFilterConfigType specifies the type of the filter configuration. + + + +##### Possible Values + + +#### AIGatewayRouteRule + + + +**Appears in** +- [AIGatewayRouteSpec](#aigatewayroutespec) + +AIGatewayRouteRule is a rule that defines the routing behavior of the AIGatewayRoute. + +##### Fields + + + + + + +#### AIGatewayRouteRuleBackendRef + + + +**Appears in** +- [AIGatewayRouteRule](#aigatewayrouterule) + +AIGatewayRouteRuleBackendRef is a reference to a AIServiceBackend with a weight. + +##### Fields + + + + + + +#### AIGatewayRouteRuleMatch + + + +**Appears in** +- [AIGatewayRouteRule](#aigatewayrouterule) + + + +##### Fields + + + + + + +#### AIGatewayRouteSpec + + + +**Appears in** +- [AIGatewayRoute](#aigatewayroute) + +AIGatewayRouteSpec details the AIGatewayRoute configuration. + +##### Fields + + + + + + +#### AIServiceBackendSpec + + + +**Appears in** +- [AIServiceBackend](#aiservicebackend) + +AIServiceBackendSpec details the AIServiceBackend configuration. + +##### Fields + + + + + + +#### APISchema + +**Underlying type:** string + +**Appears in** +- [VersionedAPISchema](#versionedapischema) + +APISchema defines the API schema. + + + +##### Possible Values + + +#### AWSCredentialsFile + + + +**Appears in** +- [BackendSecurityPolicyAWSCredentials](#backendsecuritypolicyawscredentials) + +AWSCredentialsFile specifies the credentials file to use for the AWS provider. +Envoy reads the secret file, and the profile to use is specified by the Profile field. + +##### Fields + + + + + + +#### AWSOIDCExchangeToken + + + +**Appears in** +- [BackendSecurityPolicyAWSCredentials](#backendsecuritypolicyawscredentials) + +AWSOIDCExchangeToken specifies credentials to obtain oidc token from a sso server. +For AWS, the controller will query STS to obtain AWS AccessKeyId, SecretAccessKey, and SessionToken, +and store them in a temporary credentials file. + +##### Fields + + + + + + +#### BackendSecurityPolicyAPIKey + + + +**Appears in** +- [BackendSecurityPolicySpec](#backendsecuritypolicyspec) + +BackendSecurityPolicyAPIKey specifies the API key. + +##### Fields + + + + + + +#### BackendSecurityPolicyAWSCredentials + + + +**Appears in** +- [BackendSecurityPolicySpec](#backendsecuritypolicyspec) + +BackendSecurityPolicyAWSCredentials contains the supported authentication mechanisms to access aws + +##### Fields + + + + + + +#### BackendSecurityPolicySpec + + + +**Appears in** +- [BackendSecurityPolicy](#backendsecuritypolicy) + +BackendSecurityPolicySpec specifies authentication rules on access the provider from the Gateway. +Only one mechanism to access a backend(s) can be specified. + + +Only one type of BackendSecurityPolicy can be defined. + +##### Fields + + + + + + +#### BackendSecurityPolicyType + +**Underlying type:** string + +**Appears in** +- [BackendSecurityPolicySpec](#backendsecuritypolicyspec) + +BackendSecurityPolicyType specifies the type of auth mechanism used to access a backend. + + + +##### Possible Values + + +#### LLMRequestCost + + + +**Appears in** +- [AIGatewayRouteSpec](#aigatewayroutespec) + +LLMRequestCost configures each request cost. + +##### Fields + + + + + + +#### LLMRequestCostType + +**Underlying type:** string + +**Appears in** +- [LLMRequestCost](#llmrequestcost) + +LLMRequestCostType specifies the type of the LLMRequestCost. + + + +##### Possible Values + + +#### VersionedAPISchema + + + +**Appears in** +- [AIGatewayRouteSpec](#aigatewayroutespec) +- [AIServiceBackendSpec](#aiservicebackendspec) + +VersionedAPISchema defines the API schema of either AIGatewayRoute (the input) or AIServiceBackend (the output). + + +This allows the ai-gateway to understand the input and perform the necessary transformation +depending on the API schema pair (input, output). + + +Note that this is vendor specific, and the stability of the API schema is not guaranteed by +the ai-gateway, but by the vendor via proper versioning. + +##### Fields + + + + + + diff --git a/site/src/components/ApiField.tsx b/site/src/components/ApiField.tsx new file mode 100644 index 000000000..8e7d8571e --- /dev/null +++ b/site/src/components/ApiField.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import clsx from 'clsx'; +import MDXContent from '@theme/MDXContent'; + +interface ApiFieldProps { + name: string; + type: string; + required: string; + description?: string; + enumValues?: string[]; + defaultValue?: string; +} + +// Helper function to convert code content to HTML while preserving existing HTML +const processDescription = (description: string): string => { + // First handle triple backtick code blocks with optional language + let processed = description.replace(/```(\w+)?\s*([\s\S]*?)```/g, (match, lang, codeContent) => { + const language = lang || ''; + const languageClass = language ? ` language-${language}` : ''; + return `