From dddeebd60f26bccd11083de7a2d0bc4929d80de9 Mon Sep 17 00:00:00 2001 From: yxia216 Date: Tue, 16 Jun 2026 12:13:08 -0400 Subject: [PATCH 01/29] update the usage-report Signed-off-by: yxia216 --- internal/translator/anthropic_helper.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/internal/translator/anthropic_helper.go b/internal/translator/anthropic_helper.go index f454c2433f..5c3be352d6 100644 --- a/internal/translator/anthropic_helper.go +++ b/internal/translator/anthropic_helper.go @@ -1053,15 +1053,11 @@ func (p *anthropicStreamParser) handleAnthropicStreamEvent(eventType []byte, dat if output, ok := usage.OutputTokens(); ok { p.tokenUsage.AddOutputTokens(output) } - // Update input tokens to include any cache tokens from delta + // Update cache token details from delta (don't add to InputTokens — already included in message_start) if cached, ok := usage.CachedInputTokens(); ok { - p.tokenUsage.AddInputTokens(cached) - // Accumulate any additional cache tokens from delta p.tokenUsage.AddCachedInputTokens(cached) } if cacheCreation, ok := usage.CacheCreationInputTokens(); ok { - p.tokenUsage.AddInputTokens(cacheCreation) - // Accumulate cache creation tokens p.tokenUsage.AddCacheCreationInputTokens(cacheCreation) } if event.Delta.StopReason != "" { From 8c758f48cb806d93819228d4449e72c2b1c86e15 Mon Sep 17 00:00:00 2001 From: Aaron Choo Date: Tue, 9 Jun 2026 15:29:54 -0400 Subject: [PATCH 02/29] docs: update compatibility matrix EG -> V1.8.1 (#2215) **Description** Update the compatibility matrix to specify EG v1.8.1+. Signed-off-by: Aaron Choo Signed-off-by: yxia216 --- site/docs/compatibility.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/docs/compatibility.md b/site/docs/compatibility.md index ea4cc4f378..84d9836d02 100644 --- a/site/docs/compatibility.md +++ b/site/docs/compatibility.md @@ -10,7 +10,7 @@ This document provides compatibility information for Envoy AI Gateway releases w | AI Gateway | Envoy Gateway | Kubernetes | Gateway API | Support Status | | ---------- | ----------------------------- | ---------- | ----------- | -------------- | -| main | v1.7.x+ (Envoy Proxy v1.37.x) | v1.32+ | v1.4.x | Development | +| main | v1.8.1+ (Envoy Proxy v1.38.1) | v1.32+ | v1.4.x | Development | | v0.6.x | v1.7.x+ (Envoy Proxy v1.37.x) | v1.32+ | v1.4.x | Supported | | v0.5.x | v1.6.x+ (Envoy Proxy v1.35.x) | v1.32+ | v1.4.x | Supported | | others | N/A | N/A | N/A | End of Life | From 8073ccdaf31637cfb555f65ab26547395da83a73 Mon Sep 17 00:00:00 2001 From: Anurag Aggarwal Date: Wed, 10 Jun 2026 16:11:06 +0530 Subject: [PATCH 03/29] fix: set SchemeHeaderTransformation.MatchUpstream on AI Gateway listeners (#2194) **Description** Azure is strict about `scheme` header to be same as the TLS transport on which the request is being made. If the listener is an http listener, envoy forwards a request with `scheme: http` instead of `https`. This leads to requests getting dropped. The simple solution is to use `matchUpstream` which is what is being done in MCP backend listeners but missed in regular AI gateway route listeners. **Related Issues/PRs (if applicable)** Fixes https://github.com/envoyproxy/ai-gateway/issues/2095 Signed-off-by: Anurag Aggarwal Signed-off-by: yxia216 --- .../extensionserver/post_translate_modify.go | 4 ++++ .../post_translate_modify_test.go | 23 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/internal/extensionserver/post_translate_modify.go b/internal/extensionserver/post_translate_modify.go index 89689e1b3d..31edc9b1e0 100644 --- a/internal/extensionserver/post_translate_modify.go +++ b/internal/extensionserver/post_translate_modify.go @@ -676,6 +676,10 @@ func (s *Server) insertRouterLevelAIGatewayExtProc(listener *listenerv3.Listener if err = insertAIGatewayExtProcFilter(httpConManager, extProcFilter); err != nil { return fmt.Errorf("failed to insert AI Gateway extproc filter: %w", err) } + // Match the :scheme pseudo-header to the upstream transport protocol. + httpConManager.SchemeHeaderTransformation = &corev3.SchemeHeaderTransformation{ + MatchUpstream: true, + } hcAny, err := toAny(httpConManager) if err != nil { return fmt.Errorf("failed to marshal updated HCM to Any: %w", err) diff --git a/internal/extensionserver/post_translate_modify_test.go b/internal/extensionserver/post_translate_modify_test.go index 00ea2c718d..f7babc8de4 100644 --- a/internal/extensionserver/post_translate_modify_test.go +++ b/internal/extensionserver/post_translate_modify_test.go @@ -340,6 +340,29 @@ func Test_shouldAIGatewayExtProcBeInserted(t *testing.T) { } } +func TestServer_insertRouterLevelAIGatewayExtProc_setsSchemeHeaderTransformation(t *testing.T) { + hcm := &httpconnectionmanagerv3.HttpConnectionManager{ + HttpFilters: []*httpconnectionmanagerv3.HttpFilter{{Name: wellknown.Router}}, + } + listener := &listenerv3.Listener{ + DefaultFilterChain: &listenerv3.FilterChain{ + Filters: []*listenerv3.Filter{ + { + Name: wellknown.HTTPConnectionManager, + ConfigType: &listenerv3.Filter_TypedConfig{TypedConfig: mustToAny(t, hcm)}, + }, + }, + }, + } + s := &Server{log: zap.New()} + require.NoError(t, s.insertRouterLevelAIGatewayExtProc(listener)) + + updatedHCM, _, err := findHCM(listener.DefaultFilterChain) + require.NoError(t, err) + require.True(t, updatedHCM.GetSchemeHeaderTransformation().GetMatchUpstream(), + "SchemeHeaderTransformation.MatchUpstream must be true so :scheme matches upstream TLS transport") +} + func Test_findListenerRouteConfigs(t *testing.T) { newHCM := func(name string) *httpconnectionmanagerv3.HttpConnectionManager { return &httpconnectionmanagerv3.HttpConnectionManager{ From 699c72cf314bc7cade39217b9d03dde4e4dd9c57 Mon Sep 17 00:00:00 2001 From: aishwaryaraimule21 Date: Wed, 10 Jun 2026 21:39:54 +0530 Subject: [PATCH 04/29] fix: ensure that plaintext API key secret ref is not exposed in MCP backend HTTPRoute (#2134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Description** When an `MCPRoute` has a `backendRef` with `securityPolicy.apiKey` configured (either via `secretRef` or `inline`), the controller resolves the secret and embeds the **plaintext API key** directly into the generated `HTTPRoute` resource. This happens in two places: 1. **Header injection** — the key is written as a literal value in an `HTTPRouteFilter` of type `RequestHeaderModifier` (e.g., `Authorization: Bearer `). 2. **Query parameter injection** — the key is appended to the URL path in a `URLRewrite` filter (e.g., `/mcp?api_key=`). fixes https://github.com/envoyproxy/ai-gateway/issues/2141 **Possible Solutions** 1. This can be solved for Header injection by using https://gateway.envoyproxy.io/docs/api/extension_types/#httpcredentialinjectionfilter. However, APIKey for `QueryParams` will still continue to be stored in plaintext. **Note: This PR implements this approach.** 2. By contrast, the existing `BackendSecurityPolicy` (used by `AIGatewayRoute` / `AIServiceBackend`) stores the resolved credential inside a Kubernetes **Secret** (the filter config secret consumed by extproc), which benefits from RBAC, encryption at rest, and audit logging. MCPRoute should adopt a similar approach. 3. Explore if we can add support for QueryParams in CredentialInjectionFilter in Envoy Gateway. **Affected code:** - internal/controller/mcp_route.go` **Testing** **Created MCPRoute with credential injection into Authorization header.** Credential got created and HTTPRouteFilter with Credential Injection filter got created. List tools and tool calls work as expected. Credential and filter got deleted on removing backend and deleting MCPRoute. **Created MCPRoute with credential injection into a custom header.** Credential got created and HTTPRouteFilter with Credential Injection filter got created. List tools and tool calls work as expected. Credential and filter got deleted on removing backend and deleting MCPRoute. **Created MCPRoute with inline API Key into Authorization header.** No change in behaviour. **Created MCPRoute with API Key injected into query param** No change in behaviour. --------- Signed-off-by: Aishwarya Signed-off-by: aishwaryaraimule21 Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: yxia216 --- api/v1beta1/mcp_route.go | 3 + internal/controller/mcp_route.go | 227 +++++++++---- internal/controller/mcp_route_test.go | 321 ++++++++++++++++-- internal/internalapi/internalapi.go | 2 + .../aigateway.envoyproxy.io_mcproutes.yaml | 3 + site/docs/api/api.mdx | 2 +- 6 files changed, 462 insertions(+), 96 deletions(-) diff --git a/api/v1beta1/mcp_route.go b/api/v1beta1/mcp_route.go index 120a69186d..895674b46a 100644 --- a/api/v1beta1/mcp_route.go +++ b/api/v1beta1/mcp_route.go @@ -232,6 +232,9 @@ type MCPBackendAPIKey struct { // // Either one of Header or QueryParam can be specified to inject the API key. // + // Note: Embedding credentials in URLs (including query parameters) is generally not recommended because URLs can be exposed in logs + // and intermediary systems; prefer header-based injection when possible. + // // +kubebuilder:validation:Optional // +kubebuilder:validation:MinLength=1 // +optional diff --git a/internal/controller/mcp_route.go b/internal/controller/mcp_route.go index a4908d2d39..fe10b1b92c 100644 --- a/internal/controller/mcp_route.go +++ b/internal/controller/mcp_route.go @@ -12,6 +12,7 @@ import ( egv1a1 "github.com/envoyproxy/gateway/api/v1alpha1" "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" @@ -236,7 +237,7 @@ func (c *MCPRouteController) listExistingPerBackendHTTPRoutes(ctx context.Contex } // deleteOrphanedPerBackendResources deletes per-backend HTTPRoutes and their corresponding -// HTTPRouteFilters that are no longer referenced by any backendRef in the MCPRoute spec. +// HTTPRouteFilters and credential Secrets that are no longer referenced by any backendRef in the MCPRoute spec. func (c *MCPRouteController) deleteOrphanedPerBackendResources(ctx context.Context, mcpRoute *aigv1b1.MCPRoute, orphaned map[string]*gwapiv1.HTTPRoute) error { for name, route := range orphaned { c.logger.Info("Deleting orphaned per-backend HTTPRoute", "namespace", route.Namespace, "name", name) @@ -250,6 +251,17 @@ func (c *MCPRouteController) deleteOrphanedPerBackendResources(ctx context.Conte if err := c.client.Delete(ctx, filter); err != nil && !apierrors.IsNotFound(err) { return fmt.Errorf("failed to delete orphaned HTTPRouteFilter %s: %w", filterName, err) } + + // Credential secrets are only created for backends with secretRef-based API keys, but we + // unconditionally attempt the delete to avoid an extra GET call. + credSecretName := internalapi.MCPPerBackendCredentialSecretPrefix + strings.TrimPrefix(name, internalapi.MCPPerBackendRefHTTPRoutePrefix) + if err := c.kube.CoreV1().Secrets(mcpRoute.Namespace).Delete(ctx, credSecretName, metav1.DeleteOptions{}); err != nil { + if !apierrors.IsNotFound(err) { + return fmt.Errorf("failed to delete orphaned credential secret %s: %w", credSecretName, err) + } + } else { + c.logger.Info("Deleted orphaned credential secret", "namespace", mcpRoute.Namespace, "name", credSecretName) + } } return nil } @@ -549,87 +561,102 @@ func mcpBackendRefFilterName(mcpRoute *aigv1b1.MCPRoute, backendName gwapiv1.Obj return fmt.Sprintf("%s%s-%s", internalapi.MCPPerBackendHTTPRouteFilterPrefix, mcpRoute.Name, backendName) } -// mcpBackendRefToHTTPRouteRule creates an HTTPRouteRule for the given MCPRouteBackendRef. +func mcpCredentialSecretName(mcpRoute *aigv1b1.MCPRoute, backendName gwapiv1.ObjectName) string { + return fmt.Sprintf("%s%s-%s", internalapi.MCPPerBackendCredentialSecretPrefix, mcpRoute.Name, backendName) +} + +// mcpBackendRefToHTTPRouteRule creates a HTTPRouteRule for the given MCPRouteBackendRef. // The rule routes requests to the specified backend using internalapi.MCPBackendHeader, // which is set by the MCP proxy based on its routing logic. // This route rule will eventually be moved to the backend listener in the extension server. func (c *MCPRouteController) mcpBackendRefToHTTPRouteRule(ctx context.Context, mcpRoute *aigv1b1.MCPRoute, ref *aigv1b1.MCPRouteBackendRef) (gwapiv1.HTTPRouteRule, error) { - // Ensure the HTTPRouteFilter for this backend with its optional security configuration. egFilterName := mcpBackendRefFilterName(mcpRoute, ref.Name) - err := c.ensureMCPBackendRefHTTPFilter(ctx, egFilterName, mcpRoute) - if err != nil { - return gwapiv1.HTTPRouteRule{}, fmt.Errorf("failed to ensure MCP backend API key HTTP filter: %w", err) - } - filters := []gwapiv1.HTTPRouteFilter{ - { - Type: gwapiv1.HTTPRouteFilterExtensionRef, - ExtensionRef: &gwapiv1.LocalObjectReference{ - Group: "gateway.envoyproxy.io", - Kind: "HTTPRouteFilter", - Name: gwapiv1.ObjectName(egFilterName), - }, - }, - } + // Determine credential handling from the backend's security policy. + // - inline API keys: use RequestHeaderModifier (no security benefit from a Secret since + // the plaintext is already in the MCPRoute CRD manifest). + // - secretRef API keys: use credentialInjection via a managed Secret to keep the + // plaintext confined to Secret resources only. + // - query param API keys: embed in the URL rewrite path (There is no route filter support for query params). + var credentialSecretName string + var credentialHeader *string + var inlineHeaderFilter *gwapiv1.HTTPRouteFilter fullPathPtr := ptr.Deref(ref.Path, defaultMCPPath) - // Add credential injection if apiKey is specified. if ref.SecurityPolicy != nil && ref.SecurityPolicy.APIKey != nil { apiKey := ref.SecurityPolicy.APIKey - apiKeyLiteral, err := c.readAPIKey(ctx, mcpRoute.Namespace, apiKey) - if err != nil { - return gwapiv1.HTTPRouteRule{}, fmt.Errorf("failed to read API key for backend %s: %w", ref.Name, err) - } switch { case apiKey.QueryParam != nil: + // Query parameter injection cannot use Envoy Gateway's credentialInjection filter; + // embed directly in the URL rewrite path. + // TODO: evaluate alternatives to avoid embedding the secret in the HTTPRoute manifest. + apiKeyLiteral, err := c.readAPIKey(ctx, mcpRoute.Namespace, apiKey) + if err != nil { + return gwapiv1.HTTPRouteRule{}, fmt.Errorf("failed to read API key for backend %s: %w", ref.Name, err) + } fullPathPtr = fmt.Sprintf("%s?%s=%s", fullPathPtr, *apiKey.QueryParam, apiKeyLiteral) - case apiKey.Header != nil: - header := *apiKey.Header + case apiKey.Inline != nil: + // Inline API key: inject via RequestHeaderModifier directly. The value is already + // visible in the MCPRoute manifest, so a separate Secret adds no security benefit. + header := ptr.Deref(apiKey.Header, "Authorization") + value := *apiKey.Inline if header == "Authorization" { - apiKeyLiteral = "Bearer " + apiKeyLiteral + value = "Bearer " + value } - filters = append(filters, - gwapiv1.HTTPRouteFilter{ - Type: gwapiv1.HTTPRouteFilterRequestHeaderModifier, - RequestHeaderModifier: &gwapiv1.HTTPHeaderFilter{ - Set: []gwapiv1.HTTPHeader{ - {Name: gwapiv1.HTTPHeaderName(header), Value: apiKeyLiteral}, - }, - }, - }, - ) - default: - filters = append(filters, - gwapiv1.HTTPRouteFilter{ - Type: gwapiv1.HTTPRouteFilterRequestHeaderModifier, - RequestHeaderModifier: &gwapiv1.HTTPHeaderFilter{ - Set: []gwapiv1.HTTPHeader{ - {Name: "Authorization", Value: "Bearer " + apiKeyLiteral}, - }, + inlineHeaderFilter = &gwapiv1.HTTPRouteFilter{ + Type: gwapiv1.HTTPRouteFilterRequestHeaderModifier, + RequestHeaderModifier: &gwapiv1.HTTPHeaderFilter{ + Set: []gwapiv1.HTTPHeader{ + {Name: gwapiv1.HTTPHeaderName(header), Value: value}, }, }, - ) + } + case apiKey.SecretRef != nil: + // SecretRef API key: create a managed credential Secret and use the + // HTTPRouteFilter's credentialInjection to keep plaintext out of non-Secret resources. + credSecretName := mcpCredentialSecretName(mcpRoute, ref.Name) + if err := c.ensureCredentialSecret(ctx, credSecretName, mcpRoute, apiKey); err != nil { + return gwapiv1.HTTPRouteRule{}, fmt.Errorf("failed to ensure credential secret for backend %s: %w", ref.Name, err) + } + credentialSecretName = credSecretName + credentialHeader = apiKey.Header } } - filters = append(filters, - gwapiv1.HTTPRouteFilter{ - Type: gwapiv1.HTTPRouteFilterURLRewrite, - URLRewrite: &gwapiv1.HTTPURLRewriteFilter{ - Path: &gwapiv1.HTTPPathModifier{ - Type: gwapiv1.FullPathHTTPPathModifier, - ReplaceFullPath: ptr.To(fullPathPtr), - }, + // Ensure the HTTPRouteFilter for this backend with URL rewrite and optional credential injection. + if err := c.ensureMCPBackendRefHTTPFilter(ctx, egFilterName, mcpRoute, credentialSecretName, credentialHeader); err != nil { + return gwapiv1.HTTPRouteRule{}, fmt.Errorf("failed to ensure MCP backend HTTP filter: %w", err) + } + + filters := []gwapiv1.HTTPRouteFilter{ + { + Type: gwapiv1.HTTPRouteFilterExtensionRef, + ExtensionRef: &gwapiv1.LocalObjectReference{ + Group: "gateway.envoyproxy.io", + Kind: "HTTPRouteFilter", + Name: gwapiv1.ObjectName(egFilterName), }, }, - ) + } + if inlineHeaderFilter != nil { + filters = append(filters, *inlineHeaderFilter) + } + filters = append(filters, gwapiv1.HTTPRouteFilter{ + Type: gwapiv1.HTTPRouteFilterURLRewrite, + URLRewrite: &gwapiv1.HTTPURLRewriteFilter{ + Path: &gwapiv1.HTTPPathModifier{ + Type: gwapiv1.FullPathHTTPPathModifier, + ReplaceFullPath: ptr.To(fullPathPtr), + }, + }, + }) + return gwapiv1.HTTPRouteRule{ Matches: []gwapiv1.HTTPRouteMatch{ { Path: &gwapiv1.HTTPPathMatch{Type: ptr.To(gwapiv1.PathMatchPathPrefix), Value: ptr.To("/")}, Headers: []gwapiv1.HTTPHeaderMatch{ - // MCPRoute doesn't support cross-namespace backend reference so just use the name. {Name: internalapi.MCPBackendHeader, Value: string(ref.Name)}, {Name: internalapi.MCPRouteHeader, Value: mcpRouteHeaderValue(mcpRoute)}, }, @@ -648,7 +675,6 @@ func (c *MCPRouteController) mcpBackendRefToHTTPRouteRule(ctx context.Context, m }, }}, Timeouts: &gwapiv1.HTTPRouteTimeouts{ - // TODO: make it configurable via MCPRoute.Spec? Request: ptr.To(gwapiv1.Duration("30m")), BackendRequest: ptr.To(gwapiv1.Duration("30m")), }, @@ -660,16 +686,20 @@ func mcpRouteHeaderValue(mcpRoute *aigv1b1.MCPRoute) string { } // ensureMCPBackendRefHTTPFilter ensures that an HTTPRouteFilter exists for the given backend reference in the MCPRoute. -func (c *MCPRouteController) ensureMCPBackendRefHTTPFilter(ctx context.Context, filterName string, mcpRoute *aigv1b1.MCPRoute) error { - // Rewrite the hostname to the backend service name. - // This allows Envoy to route to public MCP services with SNI matching the service name. - // This could be a standalone filter and moved to the main mcp gateway route logic. +// When credentialSecretName is non-empty, the filter is configured with credential injection referencing +// the given secret (which must store the credential under the InjectedCredentialKey key). When empty, only URL +// hostname rewrite is configured. +func (c *MCPRouteController) ensureMCPBackendRefHTTPFilter(ctx context.Context, filterName string, mcpRoute *aigv1b1.MCPRoute, + credentialSecretName string, credentialHeader *string, +) error { filter := &egv1a1.HTTPRouteFilter{ ObjectMeta: metav1.ObjectMeta{ Name: filterName, Namespace: mcpRoute.Namespace, }, Spec: egv1a1.HTTPRouteFilterSpec{ + // Rewrite the hostname to the backend service name. + // This allows Envoy to route to public MCP services with SNI matching the service name. URLRewrite: &egv1a1.HTTPURLRewriteFilter{ Hostname: &egv1a1.HTTPHostnameModifier{ Type: egv1a1.BackendHTTPHostnameModifier, @@ -677,6 +707,19 @@ func (c *MCPRouteController) ensureMCPBackendRefHTTPFilter(ctx context.Context, }, }, } + + if credentialSecretName != "" { + filter.Spec.CredentialInjection = &egv1a1.HTTPCredentialInjectionFilter{ + Overwrite: ptr.To(true), + Header: credentialHeader, + Credential: egv1a1.InjectedCredential{ + ValueRef: gwapiv1.SecretObjectReference{ + Name: gwapiv1.ObjectName(credentialSecretName), + }, + }, + } + } + if err := ctrlutil.SetControllerReference(mcpRoute, filter, c.client.Scheme()); err != nil { return fmt.Errorf("failed to set controller reference for HTTPRouteFilter: %w", err) } @@ -693,6 +736,18 @@ func (c *MCPRouteController) ensureMCPBackendRefHTTPFilter(ctx context.Context, return fmt.Errorf("failed to create HTTPRouteFilter: %w", err) } } else { + previousCredentialSecretName := "" + if existingFilter.Spec.CredentialInjection != nil { + previousCredentialSecretName = string(existingFilter.Spec.CredentialInjection.Credential.ValueRef.Name) + } + // Delete only on credential-injection transitions to avoid per-reconcile Delete calls. + if previousCredentialSecretName != "" && previousCredentialSecretName != credentialSecretName { + deleteErr := c.kube.CoreV1().Secrets(mcpRoute.Namespace).Delete(ctx, previousCredentialSecretName, metav1.DeleteOptions{}) + if deleteErr != nil && !apierrors.IsNotFound(deleteErr) { + return fmt.Errorf("failed to delete stale credential secret %s: %w", previousCredentialSecretName, deleteErr) + } + } + // Update existing filter unconditionally to ensure it matches the desired state. existingFilter.Spec = filter.Spec c.logger.Info("Updating HTTPRouteFilter", "namespace", existingFilter.Namespace, "name", existingFilter.Name) @@ -703,6 +758,60 @@ func (c *MCPRouteController) ensureMCPBackendRefHTTPFilter(ctx context.Context, return nil } +// ensureCredentialSecret creates or updates a Kubernetes Secret that holds the formatted credential +// value under the InjectedCredentialKey key. This secret is referenced by the HTTPRouteFilter's credentialInjection, +// keeping the plaintext API key out of the HTTPRoute manifest. +func (c *MCPRouteController) ensureCredentialSecret(ctx context.Context, secretName string, mcpRoute *aigv1b1.MCPRoute, apiKey *aigv1b1.MCPBackendAPIKey) error { + apiKeyLiteral, err := c.readAPIKey(ctx, mcpRoute.Namespace, apiKey) + if err != nil { + return fmt.Errorf("failed to read API key: %w", err) + } + + // Format the credential value. The credentialInjection filter injects + // this verbatim into the target header. + credentialValue := apiKeyLiteral + header := ptr.Deref(apiKey.Header, "Authorization") + if header == "Authorization" { + credentialValue = "Bearer " + apiKeyLiteral + } + + desired := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: mcpRoute.Namespace, + }, + Data: map[string][]byte{ + egv1a1.InjectedCredentialKey: []byte(credentialValue), + }, + } + setRefErr := ctrlutil.SetControllerReference(mcpRoute, desired, c.client.Scheme()) + if setRefErr != nil { + return fmt.Errorf("failed to set controller reference for credential secret: %w", setRefErr) + } + + existing, err := c.kube.CoreV1().Secrets(mcpRoute.Namespace).Get(ctx, secretName, metav1.GetOptions{}) + if err != nil { + if !apierrors.IsNotFound(err) { + return fmt.Errorf("failed to get credential secret: %w", err) + } + c.logger.Info("Creating credential secret", "namespace", mcpRoute.Namespace, "name", secretName) + if _, err = c.kube.CoreV1().Secrets(mcpRoute.Namespace).Create(ctx, desired, metav1.CreateOptions{}); err != nil { + return fmt.Errorf("failed to create credential secret: %w", err) + } + return nil + } + + // Update if the credential value changed. + if string(existing.Data[egv1a1.InjectedCredentialKey]) != credentialValue { + existing.Data = desired.Data + c.logger.Info("Updating credential secret", "namespace", mcpRoute.Namespace, "name", secretName) + if _, err = c.kube.CoreV1().Secrets(mcpRoute.Namespace).Update(ctx, existing, metav1.UpdateOptions{}); err != nil { + return fmt.Errorf("failed to update credential secret: %w", err) + } + } + return nil +} + func (c *MCPRouteController) readAPIKey(ctx context.Context, namespace string, apiKey *aigv1b1.MCPBackendAPIKey) (string, error) { key := ptr.Deref(apiKey.Inline, "") if key == "" { diff --git a/internal/controller/mcp_route_test.go b/internal/controller/mcp_route_test.go index 68f2e270c8..40d0b574f2 100644 --- a/internal/controller/mcp_route_test.go +++ b/internal/controller/mcp_route_test.go @@ -285,42 +285,55 @@ func TestMCPRouteController_mcpRuleWithAPIKeyBackendSecurity(t *testing.T) { ctrlr := NewMCPRouteController(c, kubeClient, logr.Discard(), eventCh.Ch) tests := []struct { - name string - key *aigv1b1.MCPBackendAPIKey - expRequestHeader *internalapi.Header - refPath *string - expPath string + name string + key *aigv1b1.MCPBackendAPIKey + // expCredentialValue is the expected value stored in the credential secret's InjectedCredentialKey key. + // When set, the HTTPRouteFilter is expected to have credentialInjection configured (secretRef path). + expCredentialValue string + // expCredentialHeader is the expected header for the credential injection filter (secretRef path). + expCredentialHeader *string + // expInlineHeader is the expected RequestHeaderModifier header/value (inline path). + expInlineHeader *internalapi.Header + // expFilterCount is the expected number of filters on the HTTPRouteRule. + expFilterCount int + refPath *string + expPath string }{ { - name: "inline API key default header", - key: &aigv1b1.MCPBackendAPIKey{Inline: ptr.To("inline-key")}, - expRequestHeader: &internalapi.Header{"Authorization", "Bearer inline-key"}, - expPath: "/mcp", + name: "inline API key default header", + key: &aigv1b1.MCPBackendAPIKey{Inline: ptr.To("inline-key")}, + expInlineHeader: &internalapi.Header{"Authorization", "Bearer inline-key"}, + expFilterCount: 3, + expPath: "/mcp", }, { - name: "inline API key custom header", - key: &aigv1b1.MCPBackendAPIKey{Inline: ptr.To("inline-key"), Header: ptr.To("X-API-KEY")}, - expRequestHeader: &internalapi.Header{"X-API-KEY", "inline-key"}, - expPath: "/mcp", + name: "inline API key custom header", + key: &aigv1b1.MCPBackendAPIKey{Inline: ptr.To("inline-key"), Header: ptr.To("X-API-KEY")}, + expInlineHeader: &internalapi.Header{"X-API-KEY", "inline-key"}, + expFilterCount: 3, + expPath: "/mcp", }, { - name: "secret ref API key default header", - key: &aigv1b1.MCPBackendAPIKey{SecretRef: &gwapiv1.SecretObjectReference{Name: "some-secret"}}, - expRequestHeader: &internalapi.Header{"Authorization", "Bearer secretvalue"}, - refPath: ptr.To("/some/path"), - expPath: "/some/path", + name: "secret ref API key default header", + key: &aigv1b1.MCPBackendAPIKey{SecretRef: &gwapiv1.SecretObjectReference{Name: "some-secret"}}, + expCredentialValue: "Bearer secretvalue", + expFilterCount: 2, + refPath: ptr.To("/some/path"), + expPath: "/some/path", }, { - name: "query param API key", - key: &aigv1b1.MCPBackendAPIKey{Inline: ptr.To("inline-key"), QueryParam: ptr.To("api_key")}, - expPath: "/mcp?api_key=inline-key", + name: "query param API key", + key: &aigv1b1.MCPBackendAPIKey{Inline: ptr.To("inline-key"), QueryParam: ptr.To("api_key")}, + expFilterCount: 2, + expPath: "/mcp?api_key=inline-key", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + mcpRoute := &aigv1b1.MCPRoute{ObjectMeta: metav1.ObjectMeta{Name: "route-a", Namespace: "default"}} httpRule, err := ctrlr.mcpBackendRefToHTTPRouteRule(t.Context(), - &aigv1b1.MCPRoute{ObjectMeta: metav1.ObjectMeta{Name: "route-a", Namespace: "default"}}, + mcpRoute, &aigv1b1.MCPRouteBackendRef{ BackendObjectReference: gwapiv1.BackendObjectReference{ Name: "svc-a", @@ -340,7 +353,9 @@ func TestMCPRouteController_mcpRuleWithAPIKeyBackendSecurity(t *testing.T) { require.Equal(t, internalapi.MCPRouteHeader, string(headers[1].Name)) require.Contains(t, headers[1].Value, "route-a") - // The first filter is the EG extension ref filter for URL host rewrite. + require.Len(t, httpRule.Filters, tt.expFilterCount) + + // The first filter is always the EG extension ref filter for URL host rewrite. egFilter := httpRule.Filters[0] require.Equal(t, gwapiv1.HTTPRouteFilterExtensionRef, egFilter.Type) require.NotNil(t, egFilter.ExtensionRef) @@ -354,23 +369,54 @@ func TestMCPRouteController_mcpRuleWithAPIKeyBackendSecurity(t *testing.T) { require.NotNil(t, httpFilter.Spec.URLRewrite.Hostname) require.Equal(t, egv1a1.BackendHTTPHostnameModifier, httpFilter.Spec.URLRewrite.Hostname.Type) - if tt.expRequestHeader != nil { - // The second filter is the request header modifier for API key injection. + switch { + case tt.expCredentialValue != "": + // SecretRef path: credentialInjection on HTTPRouteFilter, no RequestHeaderModifier. + require.NotNil(t, httpFilter.Spec.CredentialInjection, "expected credentialInjection on the HTTPRouteFilter") + credSecretName := string(httpFilter.Spec.CredentialInjection.Credential.ValueRef.Name) + require.Equal(t, mcpCredentialSecretName(mcpRoute, "svc-a"), credSecretName) + require.Equal(t, tt.expCredentialHeader, httpFilter.Spec.CredentialInjection.Header) + require.True(t, *httpFilter.Spec.CredentialInjection.Overwrite) + + credSecret, getSecretErr := kubeClient.CoreV1().Secrets("default").Get(t.Context(), credSecretName, metav1.GetOptions{}) + require.NoError(t, getSecretErr) + require.Equal(t, tt.expCredentialValue, string(credSecret.Data[egv1a1.InjectedCredentialKey])) + + // No plaintext should appear in any RequestHeaderModifier filter. + for _, f := range httpRule.Filters { + if f.RequestHeaderModifier != nil { + for _, h := range f.RequestHeaderModifier.Set { + require.NotContains(t, h.Value, "secretvalue", "plaintext API key must not appear in HTTPRoute") + } + } + } + case tt.expInlineHeader != nil: + // Inline path: RequestHeaderModifier, no credentialInjection, no credential Secret. + require.Nil(t, httpFilter.Spec.CredentialInjection, "inline key should not use credentialInjection") + reqHeaderFilter := httpRule.Filters[1] require.Equal(t, gwapiv1.HTTPRouteFilterRequestHeaderModifier, reqHeaderFilter.Type) require.NotNil(t, reqHeaderFilter.RequestHeaderModifier) found := false for _, set := range reqHeaderFilter.RequestHeaderModifier.Set { - if set.Name == gwapiv1.HTTPHeaderName(tt.expRequestHeader.Key()) && - set.Value == tt.expRequestHeader.Value() { + if set.Name == gwapiv1.HTTPHeaderName(tt.expInlineHeader.Key()) && + set.Value == tt.expInlineHeader.Value() { found = true break } } - require.Truef(t, found, "Expected request header modifier not found in %v", reqHeaderFilter.RequestHeaderModifier.Set) + require.Truef(t, found, "expected header %v in %v", tt.expInlineHeader, reqHeaderFilter.RequestHeaderModifier.Set) + + // No credential Secret should be created for inline keys. + credSecretName := mcpCredentialSecretName(mcpRoute, "svc-a") + _, err = kubeClient.CoreV1().Secrets("default").Get(t.Context(), credSecretName, metav1.GetOptions{}) + require.True(t, apierrors.IsNotFound(err), "no credential secret should exist for inline API key") + default: + // Query param path: no credentialInjection, no RequestHeaderModifier. + require.Nil(t, httpFilter.Spec.CredentialInjection, "expected no credentialInjection for query param case") } - // Verify the last filter is the path rewrite filter. + // The last filter is always the path rewrite filter. pathRewriteFilter := httpRule.Filters[len(httpRule.Filters)-1] require.Equal(t, gwapiv1.HTTPRouteFilterURLRewrite, pathRewriteFilter.Type) require.NotNil(t, pathRewriteFilter.URLRewrite) @@ -381,15 +427,61 @@ func TestMCPRouteController_mcpRuleWithAPIKeyBackendSecurity(t *testing.T) { } } +func TestMCPRouteController_staleCredentialSecretCleanup(t *testing.T) { + c := requireNewFakeClientWithIndexesForMCP(t) + eventCh := internaltesting.NewControllerEventChan[*gwapiv1.Gateway]() + kubeClient := fakekube.NewClientset(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "api-secret", Namespace: "default"}, + Data: map[string][]byte{"apiKey": []byte("my-secret-key")}, + }) + ctrlr := NewMCPRouteController(c, kubeClient, logr.Discard(), eventCh.Ch) + + mcpRoute := &aigv1b1.MCPRoute{ObjectMeta: metav1.ObjectMeta{Name: "route-cleanup", Namespace: "default"}} + backendRef := &aigv1b1.MCPRouteBackendRef{ + BackendObjectReference: gwapiv1.BackendObjectReference{ + Name: "svc-b", + Namespace: ptr.To(gwapiv1.Namespace("default")), + }, + SecurityPolicy: &aigv1b1.MCPBackendSecurityPolicy{ + APIKey: &aigv1b1.MCPBackendAPIKey{SecretRef: &gwapiv1.SecretObjectReference{Name: "api-secret"}}, + }, + } + + // Step 1: Create an HTTPRouteRule with secretRef-based credential injection. + _, err := ctrlr.mcpBackendRefToHTTPRouteRule(t.Context(), mcpRoute, backendRef) + require.NoError(t, err) + + // Verify the credential secret was created. + credSecretName := mcpCredentialSecretName(mcpRoute, "svc-b") + credSecret, err := kubeClient.CoreV1().Secrets("default").Get(t.Context(), credSecretName, metav1.GetOptions{}) + require.NoError(t, err) + require.Equal(t, "Bearer my-secret-key", string(credSecret.Data[egv1a1.InjectedCredentialKey])) + + // Step 2: Remove the security policy from the backend ref and reconcile again. + backendRef.SecurityPolicy = nil + _, err = ctrlr.mcpBackendRefToHTTPRouteRule(t.Context(), mcpRoute, backendRef) + require.NoError(t, err) + + // Verify the stale credential secret was deleted. + _, err = kubeClient.CoreV1().Secrets("default").Get(t.Context(), credSecretName, metav1.GetOptions{}) + require.True(t, apierrors.IsNotFound(err), "expected credential secret to be deleted, but got: %v", err) + + // Step 3: Verify that calling again without a security policy is idempotent (no error on missing secret). + backendRef.SecurityPolicy = nil + _, err = ctrlr.mcpBackendRefToHTTPRouteRule(t.Context(), mcpRoute, backendRef) + require.NoError(t, err) +} + func TestMCPRouteController_ensureMCPBackendRefHTTPFilter(t *testing.T) { c := requireNewFakeClientWithIndexesForMCP(t) eventCh := internaltesting.NewControllerEventChan[*gwapiv1.Gateway]() - ctrlr := NewMCPRouteController(c, fakekube.NewClientset( + kubeClient := fakekube.NewClientset( &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{Name: "test-secret", Namespace: "default"}, Data: map[string][]byte{"apiKey": []byte("test-api-key")}, }, - ), logr.Discard(), eventCh.Ch) + ) + ctrlr := NewMCPRouteController(c, kubeClient, logr.Discard(), eventCh.Ch) mcpRoute := &aigv1b1.MCPRoute{ ObjectMeta: metav1.ObjectMeta{Name: "test-route", Namespace: "default"}, @@ -398,13 +490,170 @@ func TestMCPRouteController_ensureMCPBackendRefHTTPFilter(t *testing.T) { require.NoError(t, err) filterName := mcpBackendRefFilterName(mcpRoute, "some-name") - err = ctrlr.ensureMCPBackendRefHTTPFilter(t.Context(), filterName, mcpRoute) - require.NoError(t, err) - // Verify HTTPRouteFilter was created. - var httpFilter egv1a1.HTTPRouteFilter - err = c.Get(t.Context(), types.NamespacedName{Namespace: "default", Name: filterName}, &httpFilter) + t.Run("without credential injection", func(t *testing.T) { + err = ctrlr.ensureMCPBackendRefHTTPFilter(t.Context(), filterName, mcpRoute, "", nil) + require.NoError(t, err) + + var httpFilter egv1a1.HTTPRouteFilter + err = c.Get(t.Context(), types.NamespacedName{Namespace: "default", Name: filterName}, &httpFilter) + require.NoError(t, err) + require.NotNil(t, httpFilter.Spec.URLRewrite) + require.Nil(t, httpFilter.Spec.CredentialInjection) + }) + + t.Run("with credential injection", func(t *testing.T) { + customHeader := "X-Custom-Key" + err = ctrlr.ensureMCPBackendRefHTTPFilter(t.Context(), filterName, mcpRoute, "managed-ref-primary", &customHeader) + require.NoError(t, err) + + var httpFilter egv1a1.HTTPRouteFilter + err = c.Get(t.Context(), types.NamespacedName{Namespace: "default", Name: filterName}, &httpFilter) + require.NoError(t, err) + require.NotNil(t, httpFilter.Spec.URLRewrite) + require.NotNil(t, httpFilter.Spec.CredentialInjection) + require.Equal(t, &customHeader, httpFilter.Spec.CredentialInjection.Header) + require.True(t, *httpFilter.Spec.CredentialInjection.Overwrite) + require.Equal(t, gwapiv1.ObjectName("managed-ref-primary"), httpFilter.Spec.CredentialInjection.Credential.ValueRef.Name) + }) + + t.Run("deletes stale secret when transitioning away from credential injection", func(t *testing.T) { + staleRefName := "stale-managed-ref" + _, createErr := kubeClient.CoreV1().Secrets("default").Create(t.Context(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: staleRefName, Namespace: "default"}, + Data: map[string][]byte{egv1a1.InjectedCredentialKey: []byte("stale")}, + }, metav1.CreateOptions{}) + require.NoError(t, createErr) + + authorizationHeader := "Authorization" + err = ctrlr.ensureMCPBackendRefHTTPFilter(t.Context(), filterName, mcpRoute, staleRefName, &authorizationHeader) + require.NoError(t, err) + + err = ctrlr.ensureMCPBackendRefHTTPFilter(t.Context(), filterName, mcpRoute, "", nil) + require.NoError(t, err) + + _, getErr := kubeClient.CoreV1().Secrets("default").Get(t.Context(), staleRefName, metav1.GetOptions{}) + require.True(t, apierrors.IsNotFound(getErr), "expected stale credential secret to be deleted") + }) + + t.Run("deletes old secret when rotating credential injection secret", func(t *testing.T) { + oldRefName := "old-managed-ref" + newRefName := "new-managed-ref" + _, createErr := kubeClient.CoreV1().Secrets("default").Create(t.Context(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: oldRefName, Namespace: "default"}, + Data: map[string][]byte{egv1a1.InjectedCredentialKey: []byte("old")}, + }, metav1.CreateOptions{}) + require.NoError(t, createErr) + + customHeader := "X-Custom-Key" + err = ctrlr.ensureMCPBackendRefHTTPFilter(t.Context(), filterName, mcpRoute, oldRefName, &customHeader) + require.NoError(t, err) + + err = ctrlr.ensureMCPBackendRefHTTPFilter(t.Context(), filterName, mcpRoute, newRefName, &customHeader) + require.NoError(t, err) + + _, getErr := kubeClient.CoreV1().Secrets("default").Get(t.Context(), oldRefName, metav1.GetOptions{}) + require.True(t, apierrors.IsNotFound(getErr), "expected old credential secret to be deleted") + }) +} + +func TestMCPRouteController_credentialHelpers(t *testing.T) { + c := requireNewFakeClientWithIndexesForMCP(t) + eventCh := internaltesting.NewControllerEventChan[*gwapiv1.Gateway]() + kubeClient := fakekube.NewClientset( + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "api-secret", Namespace: "default"}, + Data: map[string][]byte{"apiKey": []byte("initial-key")}, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "missing-api-key", Namespace: "default"}, + Data: map[string][]byte{"other": []byte("value")}, + }, + ) + ctrlr := NewMCPRouteController(c, kubeClient, logr.Discard(), eventCh.Ch) + + mcpRoute := &aigv1b1.MCPRoute{ + ObjectMeta: metav1.ObjectMeta{Name: "test-route", Namespace: "default"}, + } + err := c.Create(t.Context(), mcpRoute) require.NoError(t, err) + + t.Run("ensureCredentialSecret", func(t *testing.T) { + secretRefKey := &aigv1b1.MCPBackendAPIKey{ + SecretRef: &gwapiv1.SecretObjectReference{Name: "api-secret"}, + } + + err = ctrlr.ensureCredentialSecret(t.Context(), "managed-ref-secret", mcpRoute, secretRefKey) + require.NoError(t, err) + + credSecret, getErr := kubeClient.CoreV1().Secrets("default").Get(t.Context(), "managed-ref-secret", metav1.GetOptions{}) + require.NoError(t, getErr) + require.Equal(t, "Bearer initial-key", string(credSecret.Data[egv1a1.InjectedCredentialKey])) + + _, updateErr := kubeClient.CoreV1().Secrets("default").Update(t.Context(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "api-secret", Namespace: "default"}, + Data: map[string][]byte{"apiKey": []byte("rotated-key")}, + }, metav1.UpdateOptions{}) + require.NoError(t, updateErr) + + err = ctrlr.ensureCredentialSecret(t.Context(), "managed-ref-secret", mcpRoute, secretRefKey) + require.NoError(t, err) + + credSecret, getErr = kubeClient.CoreV1().Secrets("default").Get(t.Context(), "managed-ref-secret", metav1.GetOptions{}) + require.NoError(t, getErr) + require.Equal(t, "Bearer rotated-key", string(credSecret.Data[egv1a1.InjectedCredentialKey])) + + header := "X-API-Key" + inlineKey := &aigv1b1.MCPBackendAPIKey{ + Inline: ptr.To("inline-key"), + Header: &header, + } + err = ctrlr.ensureCredentialSecret(t.Context(), "managed-ref-inline", mcpRoute, inlineKey) + require.NoError(t, err) + + credSecret, getErr = kubeClient.CoreV1().Secrets("default").Get(t.Context(), "managed-ref-inline", metav1.GetOptions{}) + require.NoError(t, getErr) + require.Equal(t, "inline-key", string(credSecret.Data[egv1a1.InjectedCredentialKey])) + }) + + t.Run("readAPIKey", func(t *testing.T) { + tests := []struct { + name string + keySpec *aigv1b1.MCPBackendAPIKey + wantKey string + wantError string + }{ + { + name: "inline key", + keySpec: &aigv1b1.MCPBackendAPIKey{Inline: ptr.To("inline-value")}, + wantKey: "inline-value", + }, + { + name: "secretRef key", + keySpec: &aigv1b1.MCPBackendAPIKey{SecretRef: &gwapiv1.SecretObjectReference{Name: "api-secret"}}, + wantKey: "rotated-key", + }, + { + name: "missing apiKey in secret", + keySpec: &aigv1b1.MCPBackendAPIKey{SecretRef: &gwapiv1.SecretObjectReference{Name: "missing-api-key"}}, + wantError: "does not contain 'apiKey' key", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotKey, readErr := ctrlr.readAPIKey(t.Context(), "default", tt.keySpec) + if tt.wantError != "" { + require.Error(t, readErr) + require.Contains(t, readErr.Error(), tt.wantError) + return + } + + require.NoError(t, readErr) + require.Equal(t, tt.wantKey, gotKey) + }) + } + }) } func TestMCPRouteController_syncGateways_NamespaceCrossReference(t *testing.T) { diff --git a/internal/internalapi/internalapi.go b/internal/internalapi/internalapi.go index 81739abffa..a42bd2bda8 100644 --- a/internal/internalapi/internalapi.go +++ b/internal/internalapi/internalapi.go @@ -45,6 +45,8 @@ const ( MCPPerBackendRefHTTPRoutePrefix = MCPGeneratedResourceCommonPrefix + "br-" // MCPPerBackendHTTPRouteFilterPrefix is the prefix for the HTTP route filter names for per-backend resources. MCPPerBackendHTTPRouteFilterPrefix = MCPGeneratedResourceCommonPrefix + "brf-" + // MCPPerBackendCredentialSecretPrefix is the prefix for the credential secrets created for per-backend credential injection. + MCPPerBackendCredentialSecretPrefix = MCPGeneratedResourceCommonPrefix + "cred-" // MCPMetadataHeaderPrefix is the prefix for special headers used to pass metadata in the filter metadata. // These headers are added internally to the requests to the upstream servers so they can be populated in the filter diff --git a/manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_mcproutes.yaml b/manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_mcproutes.yaml index d77a726f82..e7fe7102e0 100644 --- a/manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_mcproutes.yaml +++ b/manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_mcproutes.yaml @@ -5742,6 +5742,9 @@ spec: "?api_key=mysecretkey". Either one of Header or QueryParam can be specified to inject the API key. + + Note: Embedding credentials in URLs (including query parameters) is generally not recommended because URLs can be exposed in logs + and intermediary systems; prefer header-based injection when possible. minLength: 1 type: string secretRef: diff --git a/site/docs/api/api.mdx b/site/docs/api/api.mdx index 378c4cdad0..b3dc277b90 100644 --- a/site/docs/api/api.mdx +++ b/site/docs/api/api.mdx @@ -4243,7 +4243,7 @@ When both `header` and `queryParam` are unspecified, the API key will be injecte name="queryParam" type="string" required="false" - description="QueryParam is the HTTP query parameter to inject the API key into.
For example, if QueryParam is set to `api_key`, and the API key is `mysecretkey`, the request URL will be modified to include
`?api_key=mysecretkey`.
Either one of Header or QueryParam can be specified to inject the API key." + description="QueryParam is the HTTP query parameter to inject the API key into.
For example, if QueryParam is set to `api_key`, and the API key is `mysecretkey`, the request URL will be modified to include
`?api_key=mysecretkey`.
Either one of Header or QueryParam can be specified to inject the API key.
Note: Embedding credentials in URLs (including query parameters) is generally not recommended because URLs can be exposed in logs
and intermediary systems; prefer header-based injection when possible." /> From 91499c8df88261d1eb7b567af3c4f050b9bd46c6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:56:22 +0000 Subject: [PATCH 05/29] chore(deps): bump the go group across 1 directory with 8 updates (#2200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the go group with 7 updates in the / directory: | Package | From | To | | --- | --- | --- | | [github.com/anthropics/anthropic-sdk-go](https://github.com/anthropics/anthropic-sdk-go) | `1.43.0` | `1.45.0` | | [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) | `1.32.17` | `1.32.18` | | [github.com/modelcontextprotocol/go-sdk](https://github.com/modelcontextprotocol/go-sdk) | `1.6.0` | `1.6.1` | | [github.com/openai/openai-go/v3](https://github.com/openai/openai-go) | `3.35.0` | `3.37.0` | | [github.com/tetratelabs/func-e](https://github.com/tetratelabs/func-e) | `1.5.0` | `1.6.0` | | [google.golang.org/api](https://github.com/googleapis/google-api-go-client) | `0.279.0` | `0.280.0` | | [google.golang.org/genai](https://github.com/googleapis/go-genai) | `1.57.0` | `1.58.0` | Updates `github.com/anthropics/anthropic-sdk-go` from 1.43.0 to 1.45.0
Release notes

Sourced from github.com/anthropics/anthropic-sdk-go's releases.

v1.45.0

1.45.0 (2026-05-21)

Full Changelog: v1.44.1...v1.45.0

Features

  • api: Add support for thinking-token-count beta for estimated tokens in thinking block deltas when streaming (dedeb6d)

v1.44.1

1.44.1 (2026-05-19)

Full Changelog: v1.44.0...v1.44.1

Bug Fixes

  • runner: skip tool calls SessionToolRunner does not own (93afc65)

v1.44.0

1.44.0 (2026-05-19)

Full Changelog: v1.43.0...v1.44.0

Features

  • client: Add support for self-hosted sandboxes in CMA with sandbox helpers (34354c4)
Changelog

Sourced from github.com/anthropics/anthropic-sdk-go's changelog.

1.45.0 (2026-05-21)

Full Changelog: v1.44.1...v1.45.0

Features

  • api: Add support for thinking-token-count beta for estimated tokens in thinking block deltas when streaming (dedeb6d)

1.44.1 (2026-05-19)

Full Changelog: v1.44.0...v1.44.1

Bug Fixes

  • runner: skip tool calls SessionToolRunner does not own (93afc65)

1.44.0 (2026-05-19)

Full Changelog: v1.43.0...v1.44.0

Features

  • client: Add support for self-hosted sandboxes in CMA with sandbox helpers (34354c4)
Commits
  • 88310cc release: 1.45.0
  • 4eb28e3 feat(api): Add support for thinking-token-count beta for estimated tokens in ...
  • d138190 release: 1.44.1
  • d0a73a5 fix(runner): skip tool calls SessionToolRunner does not own
  • 2888573 release: 1.44.0 (#340)
  • See full diff in compare view

Updates `github.com/aws/aws-sdk-go-v2/config` from 1.32.17 to 1.32.18
Commits

Updates `github.com/modelcontextprotocol/go-sdk` from 1.6.0 to 1.6.1
Release notes

Sourced from github.com/modelcontextprotocol/go-sdk's releases.

v1.6.1

This release adds an MCPGODEBUG flag to opt out of the Content-Type check on POST requests.

Behavior Changes

Prior to v1.6.0 (v1.4.0...v1.5.0), the Content-Type check on POST requests was gated by the same disablecrossoriginprotection MCPGODEBUG flag as the cross-origin protection. In v1.6.0, the cross-origin protection was disabled by default (replaced by the opt-in enableoriginverification flag), but the Content-Type check was kept on unconditionally, leaving no way to disable it. This release restores an escape hatch for both the Streamable HTTP and SSE transports: setting MCPGODEBUG=disablecontenttypecheck=1 skips the Content-Type: application/json validation on POST requests. See #957.

What's Changed

Full Changelog: https://github.com/modelcontextprotocol/go-sdk/compare/v1.6.0...v1.6.1

Commits

Updates `github.com/openai/openai-go/v3` from 3.35.0 to 3.37.0
Release notes

Sourced from github.com/openai/openai-go/v3's releases.

v3.37.0

3.37.0 (2026-05-21)

Full Changelog: v3.36.0...v3.37.0

Features

  • api: api update (7f7416e)
  • api: manual updates (d646562)
  • api: update OpenAPI spec or Stainless config (b34b78a)
  • client: optimize json encoder for internal types (93adc6e)

Bug Fixes

  • go: format generated admin paths (1dd8f5e)
  • go: format generated project permission paths (b751c37)

Chores

v3.36.0

3.36.0 (2026-05-13)

Full Changelog: v3.35.0...v3.36.0

Features

  • api: add service_tier parameter to response compact method (bacd2c0)

Bug Fixes

  • go: avoid panic when http.DefaultTransport is wrapped (95a0250)
Changelog

Sourced from github.com/openai/openai-go/v3's changelog.

3.37.0 (2026-05-21)

Full Changelog: v3.36.0...v3.37.0

Features

  • api: api update (7f7416e)
  • api: manual updates (d646562)
  • api: update OpenAPI spec or Stainless config (b34b78a)
  • client: optimize json encoder for internal types (93adc6e)

Bug Fixes

  • go: format generated admin paths (1dd8f5e)
  • go: format generated project permission paths (b751c37)

Chores

3.36.0 (2026-05-13)

Full Changelog: v3.35.0...v3.36.0

Features

  • api: add service_tier parameter to response compact method (bacd2c0)

Bug Fixes

  • go: avoid panic when http.DefaultTransport is wrapped (95a0250)
Commits
  • 8f01a93 Merge pull request #676 from openai/release-please--branches--main--changes--...
  • 2db0d86 release: 3.37.0
  • ea245d8 Merge pull request #991 from stainless-sdks/dev/xuanqi/public-api-docs-enterp...
  • 1dd8f5e fix(go): format generated admin paths
  • 7f7416e feat(api): api update
  • b751c37 fix(go): format generated project permission paths
  • 08bc80e chore(api): docs updates
  • d646562 feat(api): manual updates
  • b34b78a feat(api): update OpenAPI spec or Stainless config
  • efa3cc0 codegen metadata
  • Additional commits viewable in compare view

Updates `github.com/tetratelabs/func-e` from 1.5.0 to 1.6.0
Release notes

Sourced from github.com/tetratelabs/func-e's releases.

v1.6.0

func-e 1.6.0 gives you yesterday's envoy main build via the "dev" version

Before, testing pre-release Envoy with func-e meant compiling it yourself or using Docker, which only worked on Linux. Now you can install and run dev builds on all platforms the same way you would any tagged version.

$ ENVOY_VERSION=dev func-e run --version
downloading
https://archive.tetratelabs.io/envoy/download/dev/envoy-dev-darwin-arm64.tar.xz
starting:
/Users/codefromthecrypt/.local/share/func-e/envoy-versions/dev/bin/envoy
with logs in
/Users/codefromthecrypt/.local/state/func-e/envoy-runs/20260518_170024_807

/Users/codefromthecrypt/.local/share/func-e/envoy-versions/dev/bin/envoy version: abbadd905fa486ff1085cf3bbe4f3b73eb6dd8e0/1.39.0-dev/Clean/RELEASE/BoringSSL

$ func-e versions -a
dev 2026-05-18 (a8d396eb)
1.38.0 2026-04-23
1.37.2 2026-04-10
-- snip--

This works because envoy's version manifest was recently updated with a special "dev" key backed by a daily pipeline job, which archives linux from envoy's docker image, and builds macos from the same SHA.

Updating the "dev" build with the "dev-latest" alias.

dev installs on demand like all other versions and stays put once pulled. The alias dev-latest compares the remote release date against the local install and re-downloads only when the build has changed. func-e use dev-latest persists "dev" in the version file, so it is a one-shot refresh, not a stored preference.

Why Use An Envoy "dev" Build?

Envoy releases are worth the wait, but they land about once a quarter. The dev build is for the gap between “merged” and “released”: you can try current Envoy mainline without building Envoy yourself or relying on a Linux-only Docker workflow.

For example, Envoy 1.39-dev already has new dynamic-module support for request-aware upstream selection. A dynamic module can parse a request, write selected_backend or selected_endpoint into request state, and have the cluster read it while choosing the upstream host. That removes the need to pass routing decisions through synthetic headers or route re-selection.

Use dev when you need to test an unreleased feature, validate upgrade impact early, or give feedback before the next Envoy release is cut. Use tagged versions for normal production runs.

Commits

Updates `google.golang.org/api` from 0.279.0 to 0.280.0
Release notes

Sourced from google.golang.org/api's releases.

v0.280.0

0.280.0 (2026-05-19)

Features

Changelog

Sourced from google.golang.org/api's changelog.

0.280.0 (2026-05-19)

Features

Commits

Updates `google.golang.org/genai` from 1.57.0 to 1.58.0
Release notes

Sourced from google.golang.org/genai's releases.

v1.58.0

1.58.0 (2026-05-21)

Features

  • add enable_prompt_injection_detection for Computer Use feature for the Gemini API. (19c2566)
  • add new fields (1608e80)
Changelog

Sourced from google.golang.org/genai's changelog.

1.58.0 (2026-05-21)

Features

  • add enable_prompt_injection_detection for Computer Use feature for the Gemini API. (19c2566)
  • add new fields (1608e80)
Commits
  • 97ea31f chore(main): release 1.58.0 (#793)
  • 19c2566 feat: add enable_prompt_injection_detection for Computer Use feature for th...
  • 1608e80 feat: add new fields
  • 843a665 chore: update comment in BatchJobOutputInfo to unblock javadoc generation
  • 8b28bf8 chore: Throw fatals() instead of errors() in the replay_api_client when the i...
  • See full diff in compare view

Updates `google.golang.org/genproto/googleapis/rpc` from 0.0.0-20260427160629-7cedc36a6bc4 to 0.0.0-20260511170946-3700d4141b60
Commits

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Ignasi Barrera Signed-off-by: yxia216 --- go.mod | 20 ++++++++++---------- go.sum | 40 ++++++++++++++++++++-------------------- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/go.mod b/go.mod index 67bce5632b..649c13301d 100644 --- a/go.mod +++ b/go.mod @@ -9,10 +9,10 @@ require ( github.com/a8m/envsubst v1.4.3 github.com/alecthomas/kong v1.15.0 github.com/andybalholm/brotli v1.2.1 - github.com/anthropics/anthropic-sdk-go v1.43.0 + github.com/anthropics/anthropic-sdk-go v1.45.0 github.com/aws/aws-sdk-go-v2 v1.41.7 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 - github.com/aws/aws-sdk-go-v2/config v1.32.17 + github.com/aws/aws-sdk-go-v2/config v1.32.18 github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 github.com/bytedance/sonic v1.15.1 github.com/cenkalti/backoff/v4 v4.3.0 @@ -29,15 +29,15 @@ require ( github.com/google/jsonschema-go v0.4.3 github.com/google/uuid v1.6.0 github.com/moby/moby/api v1.54.2 - github.com/modelcontextprotocol/go-sdk v1.6.0 + github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/openai/openai-go v1.12.0 - github.com/openai/openai-go/v3 v3.35.0 + github.com/openai/openai-go/v3 v3.37.0 github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_model v0.6.2 github.com/prometheus/common v0.67.5 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.42.0 - github.com/tetratelabs/func-e v1.5.0 + github.com/tetratelabs/func-e v1.6.0 github.com/tidwall/gjson v1.19.0 github.com/tidwall/sjson v1.2.6-0.20251103175603-13f6455cf849 go.opentelemetry.io/contrib/exporters/autoexport v0.68.0 @@ -57,9 +57,9 @@ require ( golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.20.0 golang.org/x/tools v0.45.0 - google.golang.org/api v0.279.0 - google.golang.org/genai v1.57.0 - google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 + google.golang.org/api v0.280.0 + google.golang.org/genai v1.58.0 + google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 google.golang.org/grpc v1.81.1 google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af gopkg.in/dnaeon/go-vcr.v4 v4.0.6 @@ -90,7 +90,7 @@ require ( github.com/NYTimes/gziphandler v1.1.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/avast/retry-go/v5 v5.0.0 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.17 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect @@ -99,7 +99,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0 // indirect github.com/aws/smithy-go v1.25.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect diff --git a/go.sum b/go.sum index 2524e427f7..29b5271c7e 100644 --- a/go.sum +++ b/go.sum @@ -43,8 +43,8 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/anthropics/anthropic-sdk-go v1.43.0 h1:ShY3C7lafzHP0ze1dCxL3ZFZzvkGfXJN91DfZTG8zLM= -github.com/anthropics/anthropic-sdk-go v1.43.0/go.mod h1:5cEaslQ6A9ajdL5YUvhNW57LKxEz0OAZ7WEzgZWLD7k= +github.com/anthropics/anthropic-sdk-go v1.45.0 h1:rWnpyBpm9OAm97jyH5bi6W4SRCwJeNY/RyhaJ7CHSUI= +github.com/anthropics/anthropic-sdk-go v1.45.0/go.mod h1:bx5vWuHFuGPkELH8Z4KUiNSohFnUwScdpTyr+50myPo= 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/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= @@ -55,10 +55,10 @@ github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6t github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY= -github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU= -github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE= -github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= -github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= +github.com/aws/aws-sdk-go-v2/config v1.32.18 h1:Hcia46bxhGgF3BaSnG8nSNCWmqTK6bj9xN9/FJ3WK6Q= +github.com/aws/aws-sdk-go-v2/config v1.32.18/go.mod h1:zEjCAYmxqDadH1WX8CdBvmLKhUEUVFgKRQG38zjDmrY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.17 h1:gP2nkGsS+KMvF/jfFz2Vv2qiiOqWKyPACSzPsqHgoW8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.17/go.mod h1:Bsew3S/moG5iT77giPj1q8wb/s0RE5/QfH+ASjYtuQc= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= @@ -75,8 +75,8 @@ github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VX github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0 h1:nDARhv/oF55bcxF7rCI/4PDxOKnVXVWwDuDwCs2I2SQ= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= @@ -341,8 +341,8 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= -github.com/modelcontextprotocol/go-sdk v1.6.0 h1:PPLS3kn7WtOEnR+Af4X5H96SG0qSab8R/ZQT/HkhPkY= -github.com/modelcontextprotocol/go-sdk v1.6.0/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= +github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= +github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -362,8 +362,8 @@ github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0= github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= -github.com/openai/openai-go/v3 v3.35.0 h1:109x3epXMSE423KW2euR506GGFezcEt0s87MoWejpH0= -github.com/openai/openai-go/v3 v3.35.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +github.com/openai/openai-go/v3 v3.37.0 h1:4OG68yZgnxZpwzebO+ZDUNkFJKKwKgzilMQq30nsouE= +github.com/openai/openai-go/v3 v3.37.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -433,8 +433,8 @@ github.com/telepresenceio/watchable v0.0.0-20220726211108-9bb86f92afa7 h1:GMw3nE github.com/telepresenceio/watchable v0.0.0-20220726211108-9bb86f92afa7/go.mod h1:ihJ97e2gsd8GuzFF/I3B1qcik3XZLpXjumQifXi8Slg= github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= -github.com/tetratelabs/func-e v1.5.0 h1:usqnwqIlLereQdua/BYLwFNfnNX7qGOFUGPihp8KMo0= -github.com/tetratelabs/func-e v1.5.0/go.mod h1:9d/4Wne/HSg8pM+6fNhUePCsbQLeDrajn+wwbiN5WS4= +github.com/tetratelabs/func-e v1.6.0 h1:TlTVVCSX/I+SBg6NWtU8On7EjrGz2/kxHQUtOo5tl1U= +github.com/tetratelabs/func-e v1.6.0/go.mod h1:9d/4Wne/HSg8pM+6fNhUePCsbQLeDrajn+wwbiN5WS4= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= @@ -622,16 +622,16 @@ gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0 gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.279.0 h1:hsx2M2OaRcaKtVYK6vXEUnQvdjnend7ZYES+lYaot74= -google.golang.org/api v0.279.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ= -google.golang.org/genai v1.57.0 h1:qTyG2ynz5dQy2jF4CvZdLHHVslhR0heMue+zM1a4GNM= -google.golang.org/genai v1.57.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= +google.golang.org/api v0.280.0 h1:F4OfEHZhZh6a7uTufJAXXVd/2TQ8EjM4vZH+jX/vFYk= +google.golang.org/api v0.280.0/go.mod h1:oGKmPZRDoD3vdkf6MA7F4VNkR1rxCiuaPSkhsf3EolU= +google.golang.org/genai v1.58.0 h1:MNA3ZkRyr7MnRwZ9RNZ60p4+UMKV3yYRw6pyHq4pp0U= +google.golang.org/genai v1.58.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d h1:/aDRtSZJjyLQzm75d+a1wOJaqyKBMvIAfeQmoa3ORiI= google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:etfGUgejTiadZAUaEP14NP97xi1RGeawqkjDARA/UOs= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 h1:tEkOQcXgF6dH1G+MVKZrfpYvozGrzb91k6ha7jireSM= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/grpc/examples v0.0.0-20250407062114-b368379ef8f6 h1:ExN12ndbJ608cboPYflpTny6mXSzPrDLh0iTaVrRrds= From 7f630d3d9c9a8b378a16e461ff6d19f09adfec6a Mon Sep 17 00:00:00 2001 From: Hritik Raj Date: Wed, 10 Jun 2026 22:51:29 +0530 Subject: [PATCH 06/29] fix: status of routes when Gateway is not found (#2180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Description** When an MCPRoute/AIGW ROute references a non-existent Gateway in its spec.parentRefs, the controller still marks the route as "Accepted" with the message "Gateway Route reconciled successfully". The controller logs "Gateway not found" but does not propagate this as an error, so the reconciliation appears successful. Root Cause: In internal/controller/mcp_route.go, the syncGateway function was a void function that silently returned on errors — it logged "Gateway not found" but never returned an error to the caller. Since syncGateways (which calls syncGateway for each parentRef) always returned nil, the syncMCPRoute function completed successfully, and Reconcile marked the status as ConditionTypeAccepted. - and same goes for ai gateway route controller Fix: Changed syncGateway to return an error. When the referenced Gateway is not found, it now returns a descriptive error ("gateway / not found"). This error propagates through syncGateways → syncRoute → Reconcile, which then calls updateRouteStatus with ConditionTypeNotAccepted and the error message. During Route deletion (via the finalizer path), the error is still logged but does not block cleanup — handleFinalizer already handles onDeletionFn errors non-fatally. --------- Signed-off-by: Hritik003 Signed-off-by: yxia216 --- cmd/aigw/translate.go | 30 ++++- internal/controller/ai_gateway_route.go | 14 ++- internal/controller/ai_gateway_route_test.go | 106 ++++++++++++++++- internal/controller/mcp_route.go | 14 ++- internal/controller/mcp_route_test.go | 119 ++++++++++++++++++- 5 files changed, 268 insertions(+), 15 deletions(-) diff --git a/cmd/aigw/translate.go b/cmd/aigw/translate.go index 5a9859c812..12f11ecbf6 100644 --- a/cmd/aigw/translate.go +++ b/cmd/aigw/translate.go @@ -234,21 +234,31 @@ func translateCustomResourceObjects( userDefinedSecretKeys[fmt.Sprintf("%s/%s", s.Namespace, s.Name)] = struct{}{} } + // Use buffered event channels so that controllers can push gateway sync events without blocking, + // This mirrors the production code at controller/controller.go:128 + const eventChanBuffer = 100 bspC := controller.NewBackendSecurityPolicyController(fakeClient, fakeClientSet, logr.FromSlogHandler(logger.Handler()), - make(chan event.GenericEvent), make(chan event.GenericEvent)) + make(chan event.GenericEvent, eventChanBuffer), make(chan event.GenericEvent, eventChanBuffer)) aisbC := controller.NewAIServiceBackendController(fakeClient, fakeClientSet, logr.FromSlogHandler(logger.Handler()), - make(chan event.GenericEvent)) + make(chan event.GenericEvent, eventChanBuffer)) airC := controller.NewAIGatewayRouteController(fakeClient, fakeClientSet, logr.FromSlogHandler(logger.Handler()), - make(chan event.GenericEvent), "/", + make(chan event.GenericEvent, eventChanBuffer), "/", ) mcpC := controller.NewMCPRouteController(fakeClient, fakeClientSet, logr.FromSlogHandler(logger.Handler()), - make(chan event.GenericEvent), + make(chan event.GenericEvent, eventChanBuffer), ) gwC := controller.NewGatewayController(fakeClient, fakeClientSet, logr.FromSlogHandler(logger.Handler()), "docker.io/envoyproxy/ai-gateway-extproc:latest", "debug", true, func() string { return "aigw-translate" }, false, ) + // Pre-create Gateways (without reconciling) before reconciling resources so that + // syncGateways can resolve the parent Gateway via the fake client. + // Otherwise the reconcile would fail with "gateway not found" because the + // Gateway is not yet present in the cache at this point. + for _, gw := range gws { + mustCreate(ctx, fakeClient, gw, logger) + } // Create and reconcile the custom resources to store the translated objects. // Note that the order of creation is important as some objects depend on others. for _, btp := range backendTLSPolicies { @@ -267,7 +277,7 @@ func translateCustomResourceObjects( mustCreateAndReconcile(ctx, fakeClient, mcpRoute, mcpC, logger) } for _, gw := range gws { - mustCreateAndReconcile(ctx, fakeClient, gw, gwC, logger) + mustReconcile(ctx, gw, gwC, logger) } // Now you can retrieve the translated objects from the fake client. @@ -347,6 +357,16 @@ func mustCreateAndReconcile( logger *slog.Logger, ) { mustCreate(ctx, fakeClient, obj, logger) + mustReconcile(ctx, obj, c, logger) +} + +// mustReconcile reconciles the object using the provided reconciler. +func mustReconcile( + ctx context.Context, + obj client.Object, + c reconcile.TypedReconciler[reconcile.Request], + logger *slog.Logger, +) { logger.Info("Fake reconciling", "kind", obj.GetObjectKind().GroupVersionKind().Kind, "name", obj.GetName()) _, err := c.Reconcile(ctx, reconcile.Request{NamespacedName: types.NamespacedName{Namespace: obj.GetNamespace(), Name: obj.GetName()}}) if err != nil { diff --git a/internal/controller/ai_gateway_route.go b/internal/controller/ai_gateway_route.go index 970b4afe74..740f49e704 100644 --- a/internal/controller/ai_gateway_route.go +++ b/internal/controller/ai_gateway_route.go @@ -367,24 +367,30 @@ func (c *AIGatewayRouteController) syncGateways(ctx context.Context, aiGatewayRo if p.Namespace != nil { gwNamespace = string(*p.Namespace) } - c.syncGateway(ctx, gwNamespace, string(p.Name)) + if err := c.syncGateway(ctx, gwNamespace, string(p.Name)); err != nil { + if aiGatewayRoute.DeletionTimestamp != nil && apierrors.IsNotFound(err) { + continue + } + return err + } } return nil } // syncGateway is a helper function for syncGateways that sends one GenericEvent to the gateway controller. -func (c *AIGatewayRouteController) syncGateway(ctx context.Context, namespace, name string) { +func (c *AIGatewayRouteController) syncGateway(ctx context.Context, namespace, name string) error { var gw gwapiv1.Gateway if err := c.client.Get(ctx, client.ObjectKey{Name: name, Namespace: namespace}, &gw); err != nil { if apierrors.IsNotFound(err) { c.logger.Info("Gateway not found", "namespace", namespace, "name", name) - return + return fmt.Errorf("gateway %s/%s not found: %w", namespace, name, err) } c.logger.Error(err, "failed to get Gateway", "namespace", namespace, "name", name) - return + return fmt.Errorf("failed to get Gateway %s/%s: %w", namespace, name, err) } c.logger.Info("syncing Gateway", "namespace", gw.Namespace, "name", gw.Name) c.gatewayEventChan <- event.GenericEvent{Object: &gw} + return nil } func (c *AIGatewayRouteController) backend(ctx context.Context, namespace, name string) (*aigv1b1.AIServiceBackend, error) { diff --git a/internal/controller/ai_gateway_route_test.go b/internal/controller/ai_gateway_route_test.go index 29a3f53cea..e3a3d58069 100644 --- a/internal/controller/ai_gateway_route_test.go +++ b/internal/controller/ai_gateway_route_test.go @@ -13,6 +13,7 @@ import ( egv1a1 "github.com/envoyproxy/gateway/api/v1alpha1" "github.com/go-logr/logr" "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" fake2 "k8s.io/client-go/kubernetes/fake" @@ -420,7 +421,110 @@ func TestAIGatewayRouterController_syncGateway_notFound(t *testing.T) { // This kube := fake2.NewClientset() eventCh := internaltesting.NewControllerEventChan[*gwapiv1.Gateway]() s := NewAIGatewayRouteController(fakeClient, kube, logr.Discard(), eventCh.Ch, "/v1") - s.syncGateway(t.Context(), "ns", "non-exist") + err := s.syncGateway(context.Background(), "ns", "non-exist") + require.Error(t, err) + require.Contains(t, err.Error(), "not found") +} + +func TestAIGatewayRouteController_Reconcile_GatewayNotFound(t *testing.T) { + fakeClient := requireNewFakeClientWithIndexes(t) + kube := fake2.NewClientset() + eventCh := internaltesting.NewControllerEventChan[*gwapiv1.Gateway]() + c := NewAIGatewayRouteController(fakeClient, kube, ctrl.Log, eventCh.Ch, "/v1") + + // Create AIGatewayRoute referencing a non-existent gateway. + route := &aigv1b1.AIGatewayRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "broken-route", + Namespace: "default", + }, + Spec: aigv1b1.AIGatewayRouteSpec{ + ParentRefs: []gwapiv1.ParentReference{{Name: gwapiv1.ObjectName("non-existent")}}, + }, + } + err := fakeClient.Create(t.Context(), route) + require.NoError(t, err) + + // Reconcile should fail and mark status as NotAccepted. + _, err = c.Reconcile(t.Context(), reconcile.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: "broken-route"}}) + require.Error(t, err) + require.Contains(t, err.Error(), "non-existent") + + // Verify the AIGatewayRoute status is NotAccepted. + var current aigv1b1.AIGatewayRoute + err = fakeClient.Get(t.Context(), types.NamespacedName{Namespace: "default", Name: "broken-route"}, ¤t) + require.NoError(t, err) + require.Len(t, current.Status.Conditions, 1) + require.Equal(t, aigv1b1.ConditionTypeNotAccepted, current.Status.Conditions[0].Type) + require.Contains(t, current.Status.Conditions[0].Message, "not found") + + // create the gateway now so that the reconcile succeeds. + err = fakeClient.Create(t.Context(), &gwapiv1.Gateway{ObjectMeta: metav1.ObjectMeta{Name: "non-existent", Namespace: "default"}}) + require.NoError(t, err) + + // Reconcile should succeed. + _, err = c.Reconcile(t.Context(), reconcile.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: "broken-route"}}) + require.NoError(t, err) + + // Verify the AIGatewayRoute status is Accepted. + err = fakeClient.Get(t.Context(), types.NamespacedName{Namespace: "default", Name: "broken-route"}, ¤t) + require.NoError(t, err) + require.Len(t, current.Status.Conditions, 1) + require.Equal(t, aigv1b1.ConditionTypeAccepted, current.Status.Conditions[0].Type) + require.Contains(t, current.Status.Conditions[0].Message, "reconciled successfully") +} + +func TestAIGatewayRouteController_syncGateway_DeletionWithMissingGateway(t *testing.T) { + fakeClient := requireNewFakeClientWithIndexes(t) + kube := fake2.NewClientset() + eventCh := internaltesting.NewControllerEventChan[*gwapiv1.Gateway]() + c := NewAIGatewayRouteController(fakeClient, kube, ctrl.Log, eventCh.Ch, "/v1") + + // Create the gateway first so that the initial reconcile succeeds. + err := fakeClient.Create(t.Context(), &gwapiv1.Gateway{ObjectMeta: metav1.ObjectMeta{Name: "temp-gw", Namespace: "default"}}) + require.NoError(t, err) + + route := &aigv1b1.AIGatewayRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "route-to-delete", + Namespace: "default", + }, + Spec: aigv1b1.AIGatewayRouteSpec{ + ParentRefs: []gwapiv1.ParentReference{{Name: gwapiv1.ObjectName("temp-gw")}}, + }, + } + err = fakeClient.Create(t.Context(), route) + require.NoError(t, err) + + // Initial reconcile to add the finalizer. + _, err = c.Reconcile(t.Context(), reconcile.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: "route-to-delete"}}) + require.NoError(t, err) + + // Verify finalizer is present. + var current aigv1b1.AIGatewayRoute + err = fakeClient.Get(t.Context(), types.NamespacedName{Namespace: "default", Name: "route-to-delete"}, ¤t) + require.NoError(t, err) + require.Contains(t, current.Finalizers, aiGatewayControllerFinalizer) + + // Now delete the gateway (simulating it being removed before the AIGatewayRoute). + err = fakeClient.Delete(t.Context(), &gwapiv1.Gateway{ObjectMeta: metav1.ObjectMeta{Name: "temp-gw", Namespace: "default"}}) + require.NoError(t, err) + + // Delete the AIGatewayRoute. + err = fakeClient.Delete(t.Context(), ¤t) + require.NoError(t, err) + + // Reconcile the deletion — should succeed even though the gateway is gone. + _, err = c.Reconcile(t.Context(), reconcile.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: "route-to-delete"}}) + require.NoError(t, err) + + // Verify the AIGatewayRoute finalizer has been removed (object should be gone or have no finalizer). + err = fakeClient.Get(t.Context(), types.NamespacedName{Namespace: "default", Name: "route-to-delete"}, ¤t) + if err == nil { + require.NotContains(t, current.Finalizers, aiGatewayControllerFinalizer) + } else { + require.True(t, apierrors.IsNotFound(err)) + } } func Test_newHTTPRoute_InferencePool(t *testing.T) { diff --git a/internal/controller/mcp_route.go b/internal/controller/mcp_route.go index fe10b1b92c..94fbeeb96f 100644 --- a/internal/controller/mcp_route.go +++ b/internal/controller/mcp_route.go @@ -472,24 +472,30 @@ func (c *MCPRouteController) syncGateways(ctx context.Context, mcpRoute *aigv1b1 if p.Namespace != nil { gwNamespace = string(*p.Namespace) } - c.syncGateway(ctx, gwNamespace, string(p.Name)) + if err := c.syncGateway(ctx, gwNamespace, string(p.Name)); err != nil { + if mcpRoute.DeletionTimestamp != nil && apierrors.IsNotFound(err) { + continue + } + return err + } } return nil } // syncGateway is a helper function for syncGateways that sends one GenericEvent to the gateway controller. -func (c *MCPRouteController) syncGateway(ctx context.Context, namespace, name string) { +func (c *MCPRouteController) syncGateway(ctx context.Context, namespace, name string) error { var gw gwapiv1.Gateway if err := c.client.Get(ctx, client.ObjectKey{Name: name, Namespace: namespace}, &gw); err != nil { if apierrors.IsNotFound(err) { c.logger.Info("Gateway not found", "namespace", namespace, "name", name) - return + return fmt.Errorf("gateway %s/%s not found: %w", namespace, name, err) } c.logger.Error(err, "failed to get Gateway", "namespace", namespace, "name", name) - return + return fmt.Errorf("failed to get Gateway %s/%s: %w", namespace, name, err) } c.logger.Info("Syncing Gateway", "namespace", gw.Namespace, "name", gw.Name) c.gatewayEventChan <- event.GenericEvent{Object: &gw} + return nil } // updateMCPRouteStatus updates the status of the MCPRoute. diff --git a/internal/controller/mcp_route_test.go b/internal/controller/mcp_route_test.go index 40d0b574f2..6179d585c1 100644 --- a/internal/controller/mcp_route_test.go +++ b/internal/controller/mcp_route_test.go @@ -272,7 +272,9 @@ func TestMCPRouteController_syncGateway_notFound(t *testing.T) { // coverage for fakeClient := requireNewFakeClientWithIndexesForMCP(t) eventCh := internaltesting.NewControllerEventChan[*gwapiv1.Gateway]() s := NewMCPRouteController(fakeClient, fakekube.NewClientset(), logr.Discard(), eventCh.Ch) - s.syncGateway(context.Background(), "ns", "non-exist") + err := s.syncGateway(context.Background(), "ns", "non-exist") + require.Error(t, err) + require.Contains(t, err.Error(), "not found") } func TestMCPRouteController_mcpRuleWithAPIKeyBackendSecurity(t *testing.T) { @@ -696,3 +698,118 @@ func TestMCPRouteController_syncGateways_NamespaceCrossReference(t *testing.T) { require.Equal(t, "gateway2", gateways[1].Name) require.Equal(t, "other-ns", gateways[1].Namespace) } + +func TestMCPRouteController_Reconcile_GatewayNotFound(t *testing.T) { + fakeClient := requireNewFakeClientWithIndexesForMCP(t) + eventCh := internaltesting.NewControllerEventChan[*gwapiv1.Gateway]() + c := NewMCPRouteController(fakeClient, fakekube.NewClientset(), ctrl.Log, eventCh.Ch) + + // Create MCPRoute referencing a non-existent gateway. + route := &aigv1b1.MCPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "broken-route", + Namespace: "default", + }, + Spec: aigv1b1.MCPRouteSpec{ + ParentRefs: []gwapiv1.ParentReference{{Name: gwapiv1.ObjectName("non-existent")}}, + BackendRefs: []aigv1b1.MCPRouteBackendRef{ + { + BackendObjectReference: gwapiv1.BackendObjectReference{ + Name: "svc-a", + Namespace: ptr.To(gwapiv1.Namespace("default")), + }, + }, + }, + }, + } + err := fakeClient.Create(t.Context(), route) + require.NoError(t, err) + + // Reconcile should fail and mark status as NotAccepted. + _, err = c.Reconcile(t.Context(), reconcile.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: "broken-route"}}) + require.Error(t, err) + require.Contains(t, err.Error(), "non-existent") + + // Verify the MCPRoute status is NotAccepted. + var current aigv1b1.MCPRoute + err = fakeClient.Get(t.Context(), types.NamespacedName{Namespace: "default", Name: "broken-route"}, ¤t) + require.NoError(t, err) + require.Len(t, current.Status.Conditions, 1) + require.Equal(t, aigv1b1.ConditionTypeNotAccepted, current.Status.Conditions[0].Type) + require.Contains(t, current.Status.Conditions[0].Message, "not found") + + // create the gateway now so that the reconcile succeeds. + err = fakeClient.Create(t.Context(), &gwapiv1.Gateway{ObjectMeta: metav1.ObjectMeta{Name: "non-existent", Namespace: "default"}}) + require.NoError(t, err) + + // Reconcile should succeed. + _, err = c.Reconcile(t.Context(), reconcile.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: "broken-route"}}) + require.NoError(t, err) + + // Verify the MCPRoute status is Accepted. + err = fakeClient.Get(t.Context(), types.NamespacedName{Namespace: "default", Name: "broken-route"}, ¤t) + require.NoError(t, err) + require.Len(t, current.Status.Conditions, 1) + require.Equal(t, aigv1b1.ConditionTypeAccepted, current.Status.Conditions[0].Type) + require.Contains(t, current.Status.Conditions[0].Message, "reconciled successfully") +} + +func TestMCPRouteController_Reconcile_DeletionWithMissingGateway(t *testing.T) { + fakeClient := requireNewFakeClientWithIndexesForMCP(t) + eventCh := internaltesting.NewControllerEventChan[*gwapiv1.Gateway]() + c := NewMCPRouteController(fakeClient, fakekube.NewClientset(), ctrl.Log, eventCh.Ch) + + // Create the gateway first so that the initial reconcile succeeds. + err := fakeClient.Create(t.Context(), &gwapiv1.Gateway{ObjectMeta: metav1.ObjectMeta{Name: "temp-gw", Namespace: "default"}}) + require.NoError(t, err) + + route := &aigv1b1.MCPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "route-to-delete", + Namespace: "default", + }, + Spec: aigv1b1.MCPRouteSpec{ + ParentRefs: []gwapiv1.ParentReference{{Name: gwapiv1.ObjectName("temp-gw")}}, + BackendRefs: []aigv1b1.MCPRouteBackendRef{ + { + BackendObjectReference: gwapiv1.BackendObjectReference{ + Name: "svc-a", + Namespace: ptr.To(gwapiv1.Namespace("default")), + }, + }, + }, + }, + } + err = fakeClient.Create(t.Context(), route) + require.NoError(t, err) + + // Initial reconcile to add the finalizer. + _, err = c.Reconcile(t.Context(), reconcile.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: "route-to-delete"}}) + require.NoError(t, err) + + // Verify finalizer is present. + var current aigv1b1.MCPRoute + err = fakeClient.Get(t.Context(), types.NamespacedName{Namespace: "default", Name: "route-to-delete"}, ¤t) + require.NoError(t, err) + require.Contains(t, current.Finalizers, aiGatewayControllerFinalizer) + + // Now delete the gateway (simulating it being removed before the MCPRoute). + err = fakeClient.Delete(t.Context(), &gwapiv1.Gateway{ObjectMeta: metav1.ObjectMeta{Name: "temp-gw", Namespace: "default"}}) + require.NoError(t, err) + + // Delete the MCPRoute. + err = fakeClient.Delete(t.Context(), ¤t) + require.NoError(t, err) + + // Reconcile the deletion — should succeed even though the gateway is gone. + _, err = c.Reconcile(t.Context(), reconcile.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: "route-to-delete"}}) + require.NoError(t, err) + + // Verify the MCPRoute finalizer has been removed (object should be gone or have no finalizer). + err = fakeClient.Get(t.Context(), types.NamespacedName{Namespace: "default", Name: "route-to-delete"}, ¤t) + if err == nil { + require.NotContains(t, current.Finalizers, aiGatewayControllerFinalizer) + } else { + require.True(t, apierrors.IsNotFound(err)) + } +} From c304288a84171cd1c62a93e726ac9daeb637fce1 Mon Sep 17 00:00:00 2001 From: Linus Schlumberger Date: Wed, 10 Jun 2026 19:54:18 +0200 Subject: [PATCH 07/29] fix: promote role:system messages from messages array to system param in Bedrock translator (#2189) **Description** In the Anthropic-to-AWS-Bedrock translator, Claude Code with mid-conversation-system beta sends system prompts as {"role": "system"} in the messages array. The SDK has no system role constant so these hit the default error case. This promotes them to the top-level system param before conversion. Fixes #2206 Signed-off-by: Linus Schlumberger Co-authored-by: Ignasi Barrera Signed-off-by: yxia216 --- internal/translator/anthropic_awsbedrock.go | 39 ++++++- .../translator/anthropic_awsbedrock_test.go | 103 ++++++++++++++++++ 2 files changed, 139 insertions(+), 3 deletions(-) diff --git a/internal/translator/anthropic_awsbedrock.go b/internal/translator/anthropic_awsbedrock.go index 1266b920dd..5bcc532164 100644 --- a/internal/translator/anthropic_awsbedrock.go +++ b/internal/translator/anthropic_awsbedrock.go @@ -66,11 +66,16 @@ func (a *anthropicToAWSBedrockTranslator) RequestBody(_ []byte, body *anthropics var bedrockReq awsbedrock.ConverseInput + // Promote any role: "system" messages from the messages array to the top-level system field. + // This handles clients (e.g. Claude Code with mid-conversation-system beta) that send + // system prompts as messages rather than the top-level parameter. + messages := promoteAnthropicSystemMessagesToParam(body) + // Convert messages. - bedrockReq.Messages = make([]*awsbedrock.Message, 0, len(body.Messages)) - msgLen := len(body.Messages) + bedrockReq.Messages = make([]*awsbedrock.Message, 0, len(messages)) + msgLen := len(messages) for i := 0; i < msgLen; { - msg := &body.Messages[i] + msg := &messages[i] switch msg.Role { case anthropicschema.MessageRoleUser: bedrockMsg, convErr := a.convertUserMessage(msg) @@ -162,6 +167,34 @@ func (a *anthropicToAWSBedrockTranslator) RequestBody(_ []byte, body *anthropics return } +// promoteAnthropicSystemMessagesToParam promotes role: "system" messages from the messages +// array to the top-level system parameter. Returns the filtered messages (without system messages). +// This handles clients (e.g. Claude Code with mid-conversation-system beta) that send +// system prompts as messages rather than the top-level parameter. +func promoteAnthropicSystemMessagesToParam(body *anthropicschema.MessagesRequest) []anthropicschema.MessageParam { + var systemTexts []string + var filtered []anthropicschema.MessageParam + for _, msg := range body.Messages { + if msg.Role == "system" { + if msg.Content.Text != "" { + systemTexts = append(systemTexts, msg.Content.Text) + } + for i := range msg.Content.Array { + if msg.Content.Array[i].Text != nil && msg.Content.Array[i].Text.Text != "" { + systemTexts = append(systemTexts, msg.Content.Array[i].Text.Text) + } + } + } else { + filtered = append(filtered, msg) + } + } + if len(systemTexts) > 0 { + systemText := strings.Join(systemTexts, "\n") + body.System = &anthropicschema.SystemPrompt{Text: systemText} + } + return filtered +} + func hasToolResult(msg *anthropicschema.MessageParam) bool { if msg.Role != anthropicschema.MessageRoleUser { return false diff --git a/internal/translator/anthropic_awsbedrock_test.go b/internal/translator/anthropic_awsbedrock_test.go index c382d4e814..29f8cb92fb 100644 --- a/internal/translator/anthropic_awsbedrock_test.go +++ b/internal/translator/anthropic_awsbedrock_test.go @@ -1234,3 +1234,106 @@ func TestAnthropicToAWSBedrockTranslator_ResponseBody_StreamingToolUse(t *testin assert.Contains(t, bodyStr, `"input_json_delta"`) assert.Contains(t, bodyStr, `"tool_use"`) } + +func TestPromoteAnthropicSystemMessagesToParam(t *testing.T) { + t.Run("no system messages returns all messages", func(t *testing.T) { + body := &anthropicschema.MessagesRequest{ + Messages: []anthropicschema.MessageParam{ + {Role: anthropicschema.MessageRoleUser, Content: anthropicschema.MessageContent{Text: "Hello"}}, + }, + } + result := promoteAnthropicSystemMessagesToParam(body) + require.Len(t, result, 1) + require.Nil(t, body.System) + }) + + t.Run("single text system message is promoted", func(t *testing.T) { + body := &anthropicschema.MessagesRequest{ + Messages: []anthropicschema.MessageParam{ + {Role: "system", Content: anthropicschema.MessageContent{Text: "You are helpful."}}, + {Role: anthropicschema.MessageRoleUser, Content: anthropicschema.MessageContent{Text: "Hi"}}, + }, + } + result := promoteAnthropicSystemMessagesToParam(body) + require.Len(t, result, 1) + require.Equal(t, "user", string(result[0].Role)) + require.NotNil(t, body.System) + require.Equal(t, "You are helpful.", body.System.Text) + }) + + t.Run("system message with content blocks is promoted", func(t *testing.T) { + body := &anthropicschema.MessagesRequest{ + Messages: []anthropicschema.MessageParam{ + { + Role: "system", + Content: anthropicschema.MessageContent{ + Array: []anthropicschema.ContentBlockParam{ + {Text: &anthropicschema.TextBlockParam{Text: "You are Claude."}}, + }, + }, + }, + {Role: anthropicschema.MessageRoleUser, Content: anthropicschema.MessageContent{Text: "Hi"}}, + }, + } + result := promoteAnthropicSystemMessagesToParam(body) + require.Len(t, result, 1) + require.NotNil(t, body.System) + require.Equal(t, "You are Claude.", body.System.Text) + }) + + t.Run("multiple system messages are joined", func(t *testing.T) { + body := &anthropicschema.MessagesRequest{ + Messages: []anthropicschema.MessageParam{ + {Role: "system", Content: anthropicschema.MessageContent{Text: "Be concise."}}, + {Role: "system", Content: anthropicschema.MessageContent{Text: "Be accurate."}}, + {Role: anthropicschema.MessageRoleUser, Content: anthropicschema.MessageContent{Text: "Hello"}}, + }, + } + result := promoteAnthropicSystemMessagesToParam(body) + require.Len(t, result, 1) + require.NotNil(t, body.System) + require.Equal(t, "Be concise.\nBe accurate.", body.System.Text) + }) + + t.Run("system message is added to existing system param", func(t *testing.T) { + body := &anthropicschema.MessagesRequest{ + System: &anthropicschema.SystemPrompt{Text: "You are Claude."}, + Messages: []anthropicschema.MessageParam{ + {Role: "system", Content: anthropicschema.MessageContent{Text: "Be concise."}}, + {Role: anthropicschema.MessageRoleUser, Content: anthropicschema.MessageContent{Text: "Hi"}}, + }, + } + result := promoteAnthropicSystemMessagesToParam(body) + require.Len(t, result, 1) + require.NotNil(t, body.System) + // The existing system param is overwritten by the promoted messages. + require.Equal(t, "Be concise.", body.System.Text) + }) + + t.Run("integration with full request", func(t *testing.T) { + translator := NewAnthropicToAWSBedrockTranslator("") + req := &anthropicschema.MessagesRequest{ + Model: "anthropic.claude-3-sonnet-20240229-v1:0", + MaxTokens: 1024, + Messages: []anthropicschema.MessageParam{ + {Role: "system", Content: anthropicschema.MessageContent{Text: "You are helpful."}}, + {Role: anthropicschema.MessageRoleUser, Content: anthropicschema.MessageContent{Text: "Hello"}}, + }, + } + rawBody, err := json.Marshal(req) + require.NoError(t, err) + + _, body, err := translator.RequestBody(rawBody, req, false) + require.NoError(t, err) + + var bedrockReq awsbedrock.ConverseInput + err = json.Unmarshal(body, &bedrockReq) + require.NoError(t, err) + + require.Len(t, bedrockReq.Messages, 1) + assert.Equal(t, awsbedrock.ConversationRoleUser, bedrockReq.Messages[0].Role) + require.NotNil(t, bedrockReq.System) + require.Len(t, bedrockReq.System, 1) + assert.Equal(t, "You are helpful.", *bedrockReq.System[0].Text) + }) +} From 5d691db974a595f4c82e90f21f9f49ae81473a9c Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Jun 2026 18:13:40 +0000 Subject: [PATCH 08/29] =?UTF-8?q?translator:=20handle=20numeric=20OpenAI?= =?UTF-8?q?=20`error.code`=20in=20Anthropic=E2=86=92OpenAI=20error=20trans?= =?UTF-8?q?lation=20(#2161)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Description** Anthropic→OpenAI error translation could fail with an internal 500 when an OpenAI-compatible upstream returned a structured JSON error where `error.code` was numeric (e.g. `400`) instead of string. This change makes the error model tolerant to both representations so upstream 4xx diagnostics can still be translated and surfaced to Anthropic clients. - **Schema decoding hardening (`internal/apischema/openai/openai.go`)** - Added `UnmarshalJSON` for `openai.ErrorType`. - `error.code` now accepts: - JSON string (`"code":"400"`) - JSON number (`"code":400`) - `null` / omitted - Numeric values are normalized to the existing internal `*string` field. - **Translator regression coverage (`internal/translator/anthropic_openai_test.go`)** - Extended `TestAnthropicToOpenAITranslator_ResponseError` with a JSON error fixture containing numeric `code`. - Confirms response still maps to Anthropic error envelope with expected `type` and `message`. ```json { "type": "error", "error": { "type": "invalid_request_error", "message": "Bad request", "param": null, "code": 400 } } ``` **Related Issues/PRs (if applicable)** Fixes https://github.com/envoyproxy/ai-gateway/issues/2151 **Special notes for reviewers (if applicable)** N/A --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Ignasi Barrera Signed-off-by: yxia216 --- internal/apischema/openai/openai.go | 41 ++++++++++++++++++++ internal/translator/anthropic_openai_test.go | 7 ++++ 2 files changed, 48 insertions(+) diff --git a/internal/apischema/openai/openai.go b/internal/apischema/openai/openai.go index 865205d2ba..14fdbb6d41 100644 --- a/internal/apischema/openai/openai.go +++ b/internal/apischema/openai/openai.go @@ -1662,6 +1662,47 @@ type ErrorType struct { EventID *string `json:"event_id,omitempty"` } +// UnmarshalJSON allows OpenAI-compatible backends to provide `error.code` as either a JSON string or number. +func (e *ErrorType) UnmarshalJSON(data []byte) error { + type errorTypeAlias ErrorType + aux := struct { + errorTypeAlias + Code json.RawMessage `json:"code,omitempty"` + }{} + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + *e = ErrorType(aux.errorTypeAlias) + if len(aux.Code) == 0 || string(aux.Code) == "null" { + e.Code = nil + return nil + } + + var code string + if err := json.Unmarshal(aux.Code, &code); err == nil { + e.Code = &code + return nil + } + + var codeInt int64 + if err := json.Unmarshal(aux.Code, &codeInt); err == nil { + code = strconv.FormatInt(codeInt, 10) + e.Code = &code + return nil + } + + var codeFloat float64 + if err := json.Unmarshal(aux.Code, &codeFloat); err == nil { + code = strconv.FormatFloat(codeFloat, 'f', -1, 64) + e.Code = &code + return nil + } + + return fmt.Errorf("error.code must be string or number") +} + // ModelList is described in the OpenAI API documentation // https://platform.openai.com/docs/api-reference/models/list type ModelList struct { diff --git a/internal/translator/anthropic_openai_test.go b/internal/translator/anthropic_openai_test.go index 18fd4b3b77..b5ce7eb699 100644 --- a/internal/translator/anthropic_openai_test.go +++ b/internal/translator/anthropic_openai_test.go @@ -430,6 +430,13 @@ func TestAnthropicToOpenAITranslator_ResponseError(t *testing.T) { wantErrType: "invalid_request_error", wantErrMsg: "Bad request", }, + { + name: "JSON error with numeric code from OpenAI-compatible backend", + headers: map[string]string{contentTypeHeaderName: "application/json"}, + body: `{"type":"error","error":{"type":"invalid_request_error","message":"Bad request","param":null,"code":400}}`, + wantErrType: "invalid_request_error", + wantErrMsg: "Bad request", + }, { name: "non-JSON 400 error", headers: map[string]string{statusHeaderName: "400", contentTypeHeaderName: "text/plain"}, From c5b3f0d546ce7bfde91871a5c162991e7ff5d53e Mon Sep 17 00:00:00 2001 From: Ignasi Barrera Date: Wed, 10 Jun 2026 20:19:22 +0200 Subject: [PATCH 09/29] site: fix vars for 0.7 and main (#2217) **Description** Fix the vars for the 0.7 and main releases. **Related Issues/PRs (if applicable)** N/A **Special notes for reviewers (if applicable)** N/A Signed-off-by: Ignasi Barrera Signed-off-by: yxia216 --- site/docs/_vars.json | 2 +- site/docs/compatibility.md | 3 ++- site/versioned_docs/version-0.7/_vars.json | 6 +++--- site/versioned_docs/version-0.7/compatibility.md | 3 ++- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/site/docs/_vars.json b/site/docs/_vars.json index 8541486ead..9abc53f7bd 100644 --- a/site/docs/_vars.json +++ b/site/docs/_vars.json @@ -2,6 +2,6 @@ "aigwVersion": "0.0.0-latest", "aigwGitRef": "main", "egVersion": "0.0.0-latest", - "egMinVersion": "1.7.0", + "egMinVersion": "1.8.1", "k8sMinVersion": "1.32" } diff --git a/site/docs/compatibility.md b/site/docs/compatibility.md index 84d9836d02..68c73de85e 100644 --- a/site/docs/compatibility.md +++ b/site/docs/compatibility.md @@ -10,7 +10,8 @@ This document provides compatibility information for Envoy AI Gateway releases w | AI Gateway | Envoy Gateway | Kubernetes | Gateway API | Support Status | | ---------- | ----------------------------- | ---------- | ----------- | -------------- | -| main | v1.8.1+ (Envoy Proxy v1.38.1) | v1.32+ | v1.4.x | Development | +| main | v1.7.x+ (Envoy Proxy v1.38.x) | v1.32+ | v1.5.x | Development | +| v0.7.x | v1.8.x+ (Envoy Proxy v1.38.x) | v1.32+ | v1.5.x | Supported | | v0.6.x | v1.7.x+ (Envoy Proxy v1.37.x) | v1.32+ | v1.4.x | Supported | | v0.5.x | v1.6.x+ (Envoy Proxy v1.35.x) | v1.32+ | v1.4.x | Supported | | others | N/A | N/A | N/A | End of Life | diff --git a/site/versioned_docs/version-0.7/_vars.json b/site/versioned_docs/version-0.7/_vars.json index 8541486ead..8b01e6b7b6 100644 --- a/site/versioned_docs/version-0.7/_vars.json +++ b/site/versioned_docs/version-0.7/_vars.json @@ -1,7 +1,7 @@ { - "aigwVersion": "0.0.0-latest", + "aigwVersion": "0.7.0", "aigwGitRef": "main", - "egVersion": "0.0.0-latest", - "egMinVersion": "1.7.0", + "egVersion": "1.8.1", + "egMinVersion": "1.8.1", "k8sMinVersion": "1.32" } diff --git a/site/versioned_docs/version-0.7/compatibility.md b/site/versioned_docs/version-0.7/compatibility.md index ea4cc4f378..68c73de85e 100644 --- a/site/versioned_docs/version-0.7/compatibility.md +++ b/site/versioned_docs/version-0.7/compatibility.md @@ -10,7 +10,8 @@ This document provides compatibility information for Envoy AI Gateway releases w | AI Gateway | Envoy Gateway | Kubernetes | Gateway API | Support Status | | ---------- | ----------------------------- | ---------- | ----------- | -------------- | -| main | v1.7.x+ (Envoy Proxy v1.37.x) | v1.32+ | v1.4.x | Development | +| main | v1.7.x+ (Envoy Proxy v1.38.x) | v1.32+ | v1.5.x | Development | +| v0.7.x | v1.8.x+ (Envoy Proxy v1.38.x) | v1.32+ | v1.5.x | Supported | | v0.6.x | v1.7.x+ (Envoy Proxy v1.37.x) | v1.32+ | v1.4.x | Supported | | v0.5.x | v1.6.x+ (Envoy Proxy v1.35.x) | v1.32+ | v1.4.x | Supported | | others | N/A | N/A | N/A | End of Life | From 855df948d6d34d4ce86b67e04c67f20d88b3b8f3 Mon Sep 17 00:00:00 2001 From: hustxiayang Date: Wed, 10 Jun 2026 18:25:39 -0400 Subject: [PATCH 10/29] feat: add completion tokens details for anthropic models (#2199) **Description** Anthropic recently exposed the reasoning tokens via `output_tokens_details`: https://platform.claude.com/docs/en/api/python/messages/create. Thus we should also added this usage information for our users. --------- Signed-off-by: yxia216 Co-authored-by: Ignasi Barrera Signed-off-by: yxia216 --- go.mod | 2 +- go.sum | 4 ++-- internal/translator/anthropic_helper.go | 10 ++++++++++ internal/translator/openai_awsanthropic_test.go | 4 +++- internal/translator/openai_gcpanthropic_test.go | 7 ++++++- tests/data-plane/testupstream_test.go | 10 +++++----- 6 files changed, 27 insertions(+), 10 deletions(-) diff --git a/go.mod b/go.mod index 649c13301d..cd324a4e1e 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/a8m/envsubst v1.4.3 github.com/alecthomas/kong v1.15.0 github.com/andybalholm/brotli v1.2.1 - github.com/anthropics/anthropic-sdk-go v1.45.0 + github.com/anthropics/anthropic-sdk-go v1.46.0 github.com/aws/aws-sdk-go-v2 v1.41.7 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 github.com/aws/aws-sdk-go-v2/config v1.32.18 diff --git a/go.sum b/go.sum index 29b5271c7e..381df5b9b4 100644 --- a/go.sum +++ b/go.sum @@ -43,8 +43,8 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/anthropics/anthropic-sdk-go v1.45.0 h1:rWnpyBpm9OAm97jyH5bi6W4SRCwJeNY/RyhaJ7CHSUI= -github.com/anthropics/anthropic-sdk-go v1.45.0/go.mod h1:bx5vWuHFuGPkELH8Z4KUiNSohFnUwScdpTyr+50myPo= +github.com/anthropics/anthropic-sdk-go v1.46.0 h1:yl3n+el5ZfNgiCtQ7zQ7s/NXxB11YbrKXdc3uLPNWlU= +github.com/anthropics/anthropic-sdk-go v1.46.0/go.mod h1:bx5vWuHFuGPkELH8Z4KUiNSohFnUwScdpTyr+50myPo= 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/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= diff --git a/internal/translator/anthropic_helper.go b/internal/translator/anthropic_helper.go index 5c3be352d6..8d77a3d9b8 100644 --- a/internal/translator/anthropic_helper.go +++ b/internal/translator/anthropic_helper.go @@ -864,6 +864,7 @@ func (p *anthropicStreamParser) Process(body io.Reader, endOfStream bool, span t totalTokens, _ := p.tokenUsage.TotalTokens() cachedTokens, _ := p.tokenUsage.CachedInputTokens() cacheCreationTokens, _ := p.tokenUsage.CacheCreationInputTokens() + reasoningTokens, _ := p.tokenUsage.ReasoningTokens() finalChunk := openai.ChatCompletionResponseChunk{ ID: p.activeMessageID, Created: p.created, @@ -877,6 +878,9 @@ func (p *anthropicStreamParser) Process(body io.Reader, endOfStream bool, span t CachedTokens: int(cachedTokens), CacheCreationTokens: int(cacheCreationTokens), }, + CompletionTokensDetails: &openai.CompletionTokensDetails{ + ReasoningTokens: int(reasoningTokens), + }, }, Model: p.requestModel, } @@ -1060,6 +1064,7 @@ func (p *anthropicStreamParser) handleAnthropicStreamEvent(eventType []byte, dat if cacheCreation, ok := usage.CacheCreationInputTokens(); ok { p.tokenUsage.AddCacheCreationInputTokens(cacheCreation) } + p.tokenUsage.SetReasoningTokens(uint32(u.OutputTokensDetails.ThinkingTokens)) //nolint:gosec if event.Delta.StopReason != "" { p.stopReason = event.Delta.StopReason } @@ -1174,11 +1179,13 @@ func messageToChatCompletion(anthropicResp *anthropic.Message, responseModel int &usage.CacheReadInputTokens, &usage.CacheCreationInputTokens, ) + tokenUsage.SetReasoningTokens(uint32(usage.OutputTokensDetails.ThinkingTokens)) //nolint:gosec inputTokens, _ := tokenUsage.InputTokens() outputTokens, _ := tokenUsage.OutputTokens() totalTokens, _ := tokenUsage.TotalTokens() cachedTokens, _ := tokenUsage.CachedInputTokens() cacheCreationTokens, _ := tokenUsage.CacheCreationInputTokens() + reasoningTokens, _ := tokenUsage.ReasoningTokens() openAIResp.Usage = openai.Usage{ CompletionTokens: int(outputTokens), PromptTokens: int(inputTokens), @@ -1187,6 +1194,9 @@ func messageToChatCompletion(anthropicResp *anthropic.Message, responseModel int CachedTokens: int(cachedTokens), CacheCreationTokens: int(cacheCreationTokens), }, + CompletionTokensDetails: &openai.CompletionTokensDetails{ + ReasoningTokens: int(reasoningTokens), + }, } finishReason, err := anthropicToOpenAIFinishReason(anthropicResp.StopReason) diff --git a/internal/translator/openai_awsanthropic_test.go b/internal/translator/openai_awsanthropic_test.go index 2af9792147..5e87f449c9 100644 --- a/internal/translator/openai_awsanthropic_test.go +++ b/internal/translator/openai_awsanthropic_test.go @@ -333,6 +333,7 @@ func TestOpenAIToAWSAnthropicTranslatorV1ChatCompletion_ResponseBody(t *testing. PromptTokensDetails: &openai.PromptTokensDetails{ CachedTokens: 5, }, + CompletionTokensDetails: &openai.CompletionTokensDetails{}, }, Choices: []openai.ChatCompletionResponseChoice{ { @@ -367,6 +368,7 @@ func TestOpenAIToAWSAnthropicTranslatorV1ChatCompletion_ResponseBody(t *testing. PromptTokensDetails: &openai.PromptTokensDetails{ CachedTokens: 10, }, + CompletionTokensDetails: &openai.CompletionTokensDetails{}, }, Choices: []openai.ChatCompletionResponseChoice{ { @@ -420,7 +422,7 @@ func TestOpenAIToAWSAnthropicTranslatorV1ChatCompletion_ResponseBody(t *testing. int32(tt.expectedOpenAIResponse.Usage.PromptTokensDetails.CacheCreationTokens), // nolint:gosec int32(tt.expectedOpenAIResponse.Usage.CompletionTokens), // nolint:gosec int32(tt.expectedOpenAIResponse.Usage.TotalTokens), // nolint:gosec - -1, + int32(tt.expectedOpenAIResponse.Usage.CompletionTokensDetails.ReasoningTokens), // nolint:gosec ) require.Equal(t, expectedTokenUsage, usedToken) diff --git a/internal/translator/openai_gcpanthropic_test.go b/internal/translator/openai_gcpanthropic_test.go index ab18539510..769b2dd1fa 100644 --- a/internal/translator/openai_gcpanthropic_test.go +++ b/internal/translator/openai_gcpanthropic_test.go @@ -443,6 +443,7 @@ func TestOpenAIToGCPAnthropicTranslatorV1ChatCompletion_ResponseBody(t *testing. CachedTokens: 5, CacheCreationTokens: 3, }, + CompletionTokensDetails: &openai.CompletionTokensDetails{}, }, Choices: []openai.ChatCompletionResponseChoice{ { @@ -478,6 +479,7 @@ func TestOpenAIToGCPAnthropicTranslatorV1ChatCompletion_ResponseBody(t *testing. CachedTokens: 10, CacheCreationTokens: 7, }, + CompletionTokensDetails: &openai.CompletionTokensDetails{}, }, Choices: []openai.ChatCompletionResponseChoice{ { @@ -524,6 +526,7 @@ func TestOpenAIToGCPAnthropicTranslatorV1ChatCompletion_ResponseBody(t *testing. PromptTokensDetails: &openai.PromptTokensDetails{ CachedTokens: 2, }, + CompletionTokensDetails: &openai.CompletionTokensDetails{}, }, Choices: []openai.ChatCompletionResponseChoice{ { @@ -557,6 +560,7 @@ func TestOpenAIToGCPAnthropicTranslatorV1ChatCompletion_ResponseBody(t *testing. PromptTokensDetails: &openai.PromptTokensDetails{ CachedTokens: 3, }, + CompletionTokensDetails: &openai.CompletionTokensDetails{}, }, Choices: []openai.ChatCompletionResponseChoice{ { @@ -602,6 +606,7 @@ func TestOpenAIToGCPAnthropicTranslatorV1ChatCompletion_ResponseBody(t *testing. PromptTokensDetails: &openai.PromptTokensDetails{ CachedTokens: 1, }, + CompletionTokensDetails: &openai.CompletionTokensDetails{}, }, Choices: []openai.ChatCompletionResponseChoice{ { @@ -651,7 +656,7 @@ func TestOpenAIToGCPAnthropicTranslatorV1ChatCompletion_ResponseBody(t *testing. int32(tt.expectedOpenAIResponse.Usage.PromptTokensDetails.CacheCreationTokens), // nolint:gosec int32(tt.expectedOpenAIResponse.Usage.CompletionTokens), // nolint:gosec int32(tt.expectedOpenAIResponse.Usage.TotalTokens), // nolint:gosec - -1, + int32(tt.expectedOpenAIResponse.Usage.CompletionTokensDetails.ReasoningTokens), // nolint:gosec ) require.Equal(t, expectedTokenUsage, usedToken) diff --git a/tests/data-plane/testupstream_test.go b/tests/data-plane/testupstream_test.go index 5011c17f81..d73bd0aafc 100644 --- a/tests/data-plane/testupstream_test.go +++ b/tests/data-plane/testupstream_test.go @@ -290,7 +290,7 @@ func TestWithTestUpstream(t *testing.T) { requestBody: toolCallResultsRequestBody, expRequestBody: `{"max_tokens":1024,"messages":[{"content":[{"text":"List the files in the /tmp directory","type":"text"}],"role":"user"},{"content":[{"id":"call_abc123","input":{"path":"/tmp"},"name":"list_files","type":"tool_use"}],"role":"assistant"},{"content":[{"tool_use_id":"call_abc123","is_error":false,"content":[{"text":"[\"foo.txt\", \"bar.log\", \"data.csv\"]","type":"text"}],"type":"tool_result"}],"role":"user"}],"anthropic_version":"vertex-2023-10-16"}`, responseBody: `{"id":"msg_123","type":"message","role":"assistant","stop_reason": "end_turn", "content":[{"type":"text","text":"Hello from Anthropic!"}],"usage":{"input_tokens":10,"output_tokens":25,"cache_read_input_tokens":10}}`, - expResponseBody: `{"choices":[{"finish_reason":"stop","index":0,"message":{"content":"Hello from Anthropic!","role":"assistant"}}],"created":123, "id":"msg_123","model":"gpt-4-0613","object":"chat.completion","usage":{"completion_tokens":25,"prompt_tokens":20,"total_tokens":45,"prompt_tokens_details":{"cached_tokens":10}}}`, + expResponseBody: `{"choices":[{"finish_reason":"stop","index":0,"message":{"content":"Hello from Anthropic!","role":"assistant"}}],"created":123, "id":"msg_123","model":"gpt-4-0613","object":"chat.completion","usage":{"completion_tokens":25,"completion_tokens_details":{},"prompt_tokens":20,"total_tokens":45,"prompt_tokens_details":{"cached_tokens":10}}}`, expStatus: http.StatusOK, }, { @@ -358,7 +358,7 @@ func TestWithTestUpstream(t *testing.T) { responseStatus: strconv.Itoa(http.StatusOK), responseBody: `{"id":"msg_123","type":"message","role":"assistant","stop_reason": "end_turn", "content":[{"type":"text","text":"Hello from Anthropic!"}],"usage":{"input_tokens":10,"output_tokens":25}}`, expStatus: http.StatusOK, - expResponseBody: `{"choices":[{"finish_reason":"stop","index":0,"message":{"content":"Hello from Anthropic!","role":"assistant"}}],"created":123, "id":"msg_123","model":"claude-3-sonnet","object":"chat.completion","usage":{"completion_tokens":25,"prompt_tokens":10,"total_tokens":35,"prompt_tokens_details":{}}}`, + expResponseBody: `{"choices":[{"finish_reason":"stop","index":0,"message":{"content":"Hello from Anthropic!","role":"assistant"}}],"created":123, "id":"msg_123","model":"claude-3-sonnet","object":"chat.completion","usage":{"completion_tokens":25,"completion_tokens_details":{},"prompt_tokens":10,"total_tokens":35,"prompt_tokens_details":{}}}`, }, { name: "gcp-anthropicai - /v1/chat/completions - with cache", @@ -372,7 +372,7 @@ func TestWithTestUpstream(t *testing.T) { responseStatus: strconv.Itoa(http.StatusOK), responseBody: `{"id":"msg_123","type":"message","role":"assistant","stop_reason": "end_turn", "content":[{"type":"text","text":"Hello from cached Anthropic!"}],"usage":{"input_tokens":10,"output_tokens":25, "cache_read_input_tokens": 8}}`, expStatus: http.StatusOK, - expResponseBody: `{"choices":[{"finish_reason":"stop","index":0,"message":{"content":"Hello from cached Anthropic!","role":"assistant"}}], "created":123, "id":"msg_123", "model":"claude-3-sonnet","object":"chat.completion","usage":{"completion_tokens":25,"prompt_tokens":18,"total_tokens":43,"prompt_tokens_details":{"cached_tokens":8}}}`, + expResponseBody: `{"choices":[{"finish_reason":"stop","index":0,"message":{"content":"Hello from cached Anthropic!","role":"assistant"}}], "created":123, "id":"msg_123", "model":"claude-3-sonnet","object":"chat.completion","usage":{"completion_tokens":25,"completion_tokens_details":{},"prompt_tokens":18,"total_tokens":43,"prompt_tokens_details":{"cached_tokens":8}}}`, }, { name: "modelname-override - /v1/chat/completions", @@ -578,7 +578,7 @@ data: {"id":"msg_123","choices":[{"index":0,"delta":{"content":" due to Rayleigh data: {"id":"msg_123","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"created":123,"model":"claude-3-sonnet","object":"chat.completion.chunk"} -data: {"id":"msg_123","choices":[],"created":123,"model":"claude-3-sonnet","object":"chat.completion.chunk","usage":{"prompt_tokens":25,"completion_tokens":12,"total_tokens":37,"prompt_tokens_details":{"cached_tokens":10}}} +data: {"id":"msg_123","choices":[],"created":123,"model":"claude-3-sonnet","object":"chat.completion.chunk","usage":{"prompt_tokens":25,"completion_tokens":12,"total_tokens":37,"completion_tokens_details":{},"prompt_tokens_details":{"cached_tokens":10}}} data: [DONE] @@ -642,7 +642,7 @@ data: {"id":"msg_123","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"i data: {"id":"msg_123","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"created":123,"model":"claude-3-sonnet","object":"chat.completion.chunk"} -data: {"id":"msg_123","choices":[],"created":123,"model":"claude-3-sonnet","object":"chat.completion.chunk","usage":{"prompt_tokens":50,"completion_tokens":20,"total_tokens":70,"prompt_tokens_details":{}}} +data: {"id":"msg_123","choices":[],"created":123,"model":"claude-3-sonnet","object":"chat.completion.chunk","usage":{"prompt_tokens":50,"completion_tokens":20,"total_tokens":70,"completion_tokens_details":{},"prompt_tokens_details":{}}} data: [DONE] From 48f759f25f0f22683fabc8bbe97631cf4dd5716f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:45:39 +0200 Subject: [PATCH 11/29] chore(deps): bump the go group across 1 directory with 12 updates (#2220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the go group with 3 updates in the / directory: [go.opentelemetry.io/contrib/exporters/autoexport](https://github.com/open-telemetry/opentelemetry-go-contrib), [go.opentelemetry.io/contrib/propagators/autoprop](https://github.com/open-telemetry/opentelemetry-go-contrib) and [google.golang.org/api](https://github.com/googleapis/google-api-go-client). Updates `go.opentelemetry.io/contrib/exporters/autoexport` from 0.68.0 to 0.69.0
Release notes

Sourced from go.opentelemetry.io/contrib/exporters/autoexport's releases.

v1.44.0/v2.5.1/v0.69.0/v0.37.1/v0.24.0/v0.19.0/v0.16.1/v0.16.0

Added

  • Add error.type attribute to http.client.request.duration for transport failures in otelhttp. (#8801)
  • Add examples for prometheus compatibility document. (#8716)
  • Add support for cardinality_limits in PeriodicMetricReader in otelconf. (#8885)
  • Add Resource method to SDK in go.opentelemetry.io/contrib/otelconf/x to expose the resolved SDK resource from declarative configuration. (#8913)
  • Add go.opentelemetry.io/contrib/detectors/hetzner, a new resource detector for Hetzner Cloud servers, ported from github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor/internal/hetzner. Detects cloud.provider, cloud.platform, cloud.region, cloud.availability_zone, host.id, and host.name. (#8979)

Changed

  • Set error field as record.SetErr instead of a plain attribute in go.opentelemetry.io/contrib/bridges/otellogrus. (#8776)
  • Set the "error" field (e.g. created via zap.Error) as record.SetErr instead of a plain attribute in go.opentelemetry.io/contrib/bridges/otelzap. (#8719)
  • Set fields implementing error interface from slog records as record.SetErr instead of plain attributes in go.opentelemetry.io/contrib/bridges/otelslog. (#8774)
  • Set emitted errors in go.opentelemetry.io/contrib/bridges/otellogr as record errors (Record.SetErr) instead of exception.message attributes. (#8775)

Fixed

  • Fix header attributes lost when using sub-spans in go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace. (#8797)
  • Validate encoding configuration for OTLP HTTP exporters in go.opentelemetry.io/contrib/otelconf. (#8772)
  • Remove the custom body wrapper from the request's body after the request is processed to allow body type comparisons with the original type in go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp and go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux. (#6914)
  • Unknown or empty HTTP methods now report "_OTHER" instead of "GET" across all HTTP instrumentations to align with OpenTelemetry semantic conventions. (#8868)
  • The default span name formatter in go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp now conforms to the OpenTelemetry HTTP semantic conventions for server span names. (#8871)
    • The default span name is now {method} {route} (e.g. GET /foo/{id}) when a route pattern is available, or {method} (e.g. GET) otherwise.

Removed

  • Remove the deprecated WithSpanOptions option in go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc. (#8991)

What's Changed

... (truncated)

Changelog

Sourced from go.opentelemetry.io/contrib/exporters/autoexport's changelog.

[1.44.0/2.5.1/0.69.0/0.37.1/0.24.0/0.19.0/0.16.1/0.16.0] - 2026-05-28

Added

  • Add error.type attribute to http.client.request.duration for transport failures in otelhttp. (#8801)
  • Add examples for prometheus compatibility document. (#8716)
  • Add support for cardinality_limits in PeriodicMetricReader in otelconf. (#8885)
  • Add Resource method to SDK in go.opentelemetry.io/contrib/otelconf/x to expose the resolved SDK resource from declarative configuration. (#8913)
  • Add go.opentelemetry.io/contrib/detectors/hetzner, a new resource detector for Hetzner Cloud servers, ported from github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor/internal/hetzner. Detects cloud.provider, cloud.platform, cloud.region, cloud.availability_zone, host.id, and host.name. (#8979)

Changed

  • Set error field as record.SetErr instead of a plain attribute in go.opentelemetry.io/contrib/bridges/otellogrus. (#8776)
  • Set the "error" field (e.g. created via zap.Error) as record.SetErr instead of a plain attribute in go.opentelemetry.io/contrib/bridges/otelzap. (#8719)
  • Set fields implementing error interface from slog records as record.SetErr instead of plain attributes in go.opentelemetry.io/contrib/bridges/otelslog. (#8774)
  • Set emitted errors in go.opentelemetry.io/contrib/bridges/otellogr as record errors (Record.SetErr) instead of exception.message attributes. (#8775)

Fixed

  • Fix header attributes lost when using sub-spans in go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace. (#8797)
  • Validate encoding configuration for OTLP HTTP exporters in go.opentelemetry.io/contrib/otelconf. (#8772)
  • Remove the custom body wrapper from the request's body after the request is processed to allow body type comparisons with the original type in go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp and go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux. (#6914)
  • Unknown or empty HTTP methods now report "_OTHER" instead of "GET" across all HTTP instrumentations to align with OpenTelemetry semantic conventions. (#8868)
  • The default span name formatter in go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp now conforms to the OpenTelemetry HTTP semantic conventions for server span names. (#8871)
    • The default span name is now {method} {route} (e.g. GET /foo/{id}) when a route pattern is available, or {method} (e.g. GET) otherwise.

Removed

  • Remove the deprecated WithSpanOptions option in go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc. (#8991)
Commits
  • 03b2bcd Release v1.44.0/v2.5.1/v0.69.0/v0.37.1/v0.24.0/v0.19.0/v0.16.1/v0.16.0 (#9033)
  • 80c46d4 chore(deps): update module github.com/alecthomas/chroma/v2 to v2.26.0 (#9034)
  • 51f2921 fix(deps): update module github.com/hetznercloud/hcloud-go/v2 to v2.41.2 (#9026)
  • db82162 fix(deps): update aws-sdk-go-v2 monorepo (#9031)
  • 5a3e533 fix(deps): update module github.com/aws/smithy-go to v1.26.0 (#9032)
  • c67843c otelhttp: Remove custom wrapper after handling request (#6914)
  • c0a4135 docs(otelhttptrace): add performance guidance for WithoutSubSpans (#8785)
  • a51a867 otelconf: implement cardinality_limits support in PeriodicMetricReader (#8885)
  • dead6e5 chore(deps): update module go.yaml.in/yaml/v2 to v2.4.4 (#8994)
  • 979ce18 chore(deps): update module github.com/jgautheron/goconst to v1.10.2 (#9030)
  • Additional commits viewable in compare view

Updates `go.opentelemetry.io/contrib/propagators/autoprop` from 0.68.0 to 0.69.0
Release notes

Sourced from go.opentelemetry.io/contrib/propagators/autoprop's releases.

v1.44.0/v2.5.1/v0.69.0/v0.37.1/v0.24.0/v0.19.0/v0.16.1/v0.16.0

Added

  • Add error.type attribute to http.client.request.duration for transport failures in otelhttp. (#8801)
  • Add examples for prometheus compatibility document. (#8716)
  • Add support for cardinality_limits in PeriodicMetricReader in otelconf. (#8885)
  • Add Resource method to SDK in go.opentelemetry.io/contrib/otelconf/x to expose the resolved SDK resource from declarative configuration. (#8913)
  • Add go.opentelemetry.io/contrib/detectors/hetzner, a new resource detector for Hetzner Cloud servers, ported from github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor/internal/hetzner. Detects cloud.provider, cloud.platform, cloud.region, cloud.availability_zone, host.id, and host.name. (#8979)

Changed

  • Set error field as record.SetErr instead of a plain attribute in go.opentelemetry.io/contrib/bridges/otellogrus. (#8776)
  • Set the "error" field (e.g. created via zap.Error) as record.SetErr instead of a plain attribute in go.opentelemetry.io/contrib/bridges/otelzap. (#8719)
  • Set fields implementing error interface from slog records as record.SetErr instead of plain attributes in go.opentelemetry.io/contrib/bridges/otelslog. (#8774)
  • Set emitted errors in go.opentelemetry.io/contrib/bridges/otellogr as record errors (Record.SetErr) instead of exception.message attributes. (#8775)

Fixed

  • Fix header attributes lost when using sub-spans in go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace. (#8797)
  • Validate encoding configuration for OTLP HTTP exporters in go.opentelemetry.io/contrib/otelconf. (#8772)
  • Remove the custom body wrapper from the request's body after the request is processed to allow body type comparisons with the original type in go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp and go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux. (#6914)
  • Unknown or empty HTTP methods now report "_OTHER" instead of "GET" across all HTTP instrumentations to align with OpenTelemetry semantic conventions. (#8868)
  • The default span name formatter in go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp now conforms to the OpenTelemetry HTTP semantic conventions for server span names. (#8871)
    • The default span name is now {method} {route} (e.g. GET /foo/{id}) when a route pattern is available, or {method} (e.g. GET) otherwise.

Removed

  • Remove the deprecated WithSpanOptions option in go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc. (#8991)

What's Changed

... (truncated)

Changelog

Sourced from go.opentelemetry.io/contrib/propagators/autoprop's changelog.

[1.44.0/2.5.1/0.69.0/0.37.1/0.24.0/0.19.0/0.16.1/0.16.0] - 2026-05-28

Added

  • Add error.type attribute to http.client.request.duration for transport failures in otelhttp. (#8801)
  • Add examples for prometheus compatibility document. (#8716)
  • Add support for cardinality_limits in PeriodicMetricReader in otelconf. (#8885)
  • Add Resource method to SDK in go.opentelemetry.io/contrib/otelconf/x to expose the resolved SDK resource from declarative configuration. (#8913)
  • Add go.opentelemetry.io/contrib/detectors/hetzner, a new resource detector for Hetzner Cloud servers, ported from github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor/internal/hetzner. Detects cloud.provider, cloud.platform, cloud.region, cloud.availability_zone, host.id, and host.name. (#8979)

Changed

  • Set error field as record.SetErr instead of a plain attribute in go.opentelemetry.io/contrib/bridges/otellogrus. (#8776)
  • Set the "error" field (e.g. created via zap.Error) as record.SetErr instead of a plain attribute in go.opentelemetry.io/contrib/bridges/otelzap. (#8719)
  • Set fields implementing error interface from slog records as record.SetErr instead of plain attributes in go.opentelemetry.io/contrib/bridges/otelslog. (#8774)
  • Set emitted errors in go.opentelemetry.io/contrib/bridges/otellogr as record errors (Record.SetErr) instead of exception.message attributes. (#8775)

Fixed

  • Fix header attributes lost when using sub-spans in go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace. (#8797)
  • Validate encoding configuration for OTLP HTTP exporters in go.opentelemetry.io/contrib/otelconf. (#8772)
  • Remove the custom body wrapper from the request's body after the request is processed to allow body type comparisons with the original type in go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp and go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux. (#6914)
  • Unknown or empty HTTP methods now report "_OTHER" instead of "GET" across all HTTP instrumentations to align with OpenTelemetry semantic conventions. (#8868)
  • The default span name formatter in go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp now conforms to the OpenTelemetry HTTP semantic conventions for server span names. (#8871)
    • The default span name is now {method} {route} (e.g. GET /foo/{id}) when a route pattern is available, or {method} (e.g. GET) otherwise.

Removed

  • Remove the deprecated WithSpanOptions option in go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc. (#8991)
Commits
  • 03b2bcd Release v1.44.0/v2.5.1/v0.69.0/v0.37.1/v0.24.0/v0.19.0/v0.16.1/v0.16.0 (#9033)
  • 80c46d4 chore(deps): update module github.com/alecthomas/chroma/v2 to v2.26.0 (#9034)
  • 51f2921 fix(deps): update module github.com/hetznercloud/hcloud-go/v2 to v2.41.2 (#9026)
  • db82162 fix(deps): update aws-sdk-go-v2 monorepo (#9031)
  • 5a3e533 fix(deps): update module github.com/aws/smithy-go to v1.26.0 (#9032)
  • c67843c otelhttp: Remove custom wrapper after handling request (#6914)
  • c0a4135 docs(otelhttptrace): add performance guidance for WithoutSubSpans (#8785)
  • a51a867 otelconf: implement cardinality_limits support in PeriodicMetricReader (#8885)
  • dead6e5 chore(deps): update module go.yaml.in/yaml/v2 to v2.4.4 (#8994)
  • 979ce18 chore(deps): update module github.com/jgautheron/goconst to v1.10.2 (#9030)
  • Additional commits viewable in compare view

Updates `go.opentelemetry.io/otel` from 1.43.0 to 1.44.0
Changelog

Sourced from go.opentelemetry.io/otel's changelog.

[1.44.0/0.66.0/0.20.0/0.0.17] 2026-05-27

Added

  • Add ByteSlice and ByteSliceValue functions for new BYTESLICE attribute type in go.opentelemetry.io/otel/attribute. (#7948)
  • Apply attribute value limit to the KindBytes attribute type in go.opentelemetry.io/otel/sdk/log. (#7990)
  • Apply attribute value limit to the BYTESLICE attribute type in go.opentelemetry.io/otel/sdk/trace. (#7990)
  • Support BYTESLICE attributes in go.opentelemetry.io/otel/trace. (#8153)
  • Support BYTESLICE attributes in go.opentelemetry.io/otel/exporters/otlp/otlptrace. (#8153)
  • Support BYTESLICE attributes in go.opentelemetry.io/otel/exporters/otlp/otlplog. (#8153)
  • Support BYTESLICE attributes in go.opentelemetry.io/otel/exporters/otlp/otlpmetric. (#8153)
  • Support BYTESLICE attributes in go.opentelemetry.io/otel/exporters/zipkin. (#8153)
  • Add String method for Value type in go.opentelemetry.io/otel/attribute. (#8142)
  • Add Slice and SliceValue functions for new SLICE attribute type in go.opentelemetry.io/otel/attribute. (#8166)
  • Support SLICE attributes in go.opentelemetry.io/otel/exporters/otlp/otlptrace. (#8216)
  • Support SLICE attributes in go.opentelemetry.io/otel/exporters/otlp/otlplog. (#8216)
  • Support SLICE attributes in go.opentelemetry.io/otel/exporters/otlp/otlpmetric. (#8216)
  • Support SLICE attributes in go.opentelemetry.io/otel/exporters/zipkin. (#8216)
  • Apply AttributeValueLengthLimit to attribute.SLICE type attribute values in go.opentelemetry.io/otel/sdk/trace, recursively truncating contained string values. (#8217)
  • Add Error field on Record type in go.opentelemetry.io/otel/log/logtest. (#8148)
  • Add WithMaxRequestSize option in go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc. (#8157)
  • Add WithMaxRequestSize option in go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp. (#8157)
  • Add WithMaxRequestSize option in go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc. (#8157)
  • Add WithMaxRequestSize option in go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp. (#8157)
  • Add WithMaxRequestSize option in go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc. (#8157)
  • Add WithMaxRequestSize option in go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp. (#8157)
  • Add Settable to go.opentelemetry.io/otel/metric/x to allow reusing attribute options. (#8178)
  • Add experimental support for splitting metric data across multiple batches in go.opentelemetry.io/otel/sdk/metric. Set OTEL_GO_X_METRIC_EXPORT_BATCH_SIZE=<max_size> to enable for all periodic readers. See go.opentelemetry.io/otel/sdk/metric/internal/x for feature documentation. (#8071)
  • Add experimental self-observability metrics in go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc. Enable with OTEL_GO_X_SELF_OBSERVABILITY=true environment variable. See go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc/internal/x for feature documentation. (#8192)
  • Add experimental self-observability metrics in go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp. Enable with OTEL_GO_X_SELF_OBSERVABILITY=true environment variable. See go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp/internal/x for feature documentation. (#8194)
  • Add experimental self-observability metrics in go.opentelemetry.io/otel/exporters/stdout/stdoutlog. Enable with OTEL_GO_X_SELF_OBSERVABILITY=true environment variable. See go.opentelemetry.io/otel/stdout/stdoutlog/internal/x for feature documentation. (#8263)
  • Add WithDefaultAttributes to go.opentelemetry.io/otel/metric/x to support setting default attributes on instruments. (#8135)
  • Add go.opentelemetry.io/otel/semconv/v1.41.0 package. The package contains semantic conventions from the v1.41.0 version of the OpenTelemetry Semantic Conventions. See the migration documentation for information on how to upgrade from go.opentelemetry.io/otel/semconv/v1.40.0. (#8324)
  • Add Observable variants of instruments to go.opentelemetry.io/otel/semconv/v1.41.0 package. (#8350)
  • Generate explicit histogram bucket boundaries from weaver configuration for HTTP and RPC duration instruments in go.opentelemetry.io/otel/semconv/v1.41.0. (#8002)

Changed

  • ⚠️ Breaking Change: go.opentelemetry.io/otel/sdk/metric now applies a default cardinality limit of 2000 to comply with the Metrics SDK specification recommendation. New attribute sets are dropped when the cardinality limit is reached. The measurement of these sets are aggregated into a special attribute set containing attribute.Bool("otel.metric.overflow", true).

... (truncated)

Commits
  • b62d928 Release 1.44.0 (#8376)
  • 94132a0 chore(deps): update golang.org/x/telemetry digest to 5997936 (#8379)
  • 6fdcf82 feat: add self-observability metrics to otlpmetricgrpc metric exporters (#8192)
  • 761bbfc fix(deps): update golang.org/x (#8377)
  • 3a91dc6 fix(deps): update googleapis to 3dc84a4 (#8375)
  • f593185 exporters/otlp: default max request size to 64 MiB (#8365)
  • f02feac Merge commit from fork
  • 36c2f1b semconvkit: add invariant test for histogram-exclusion rule (#8370)
  • d0b6cbd sdk/metric: document unit-sensitivity of DefaultAggregationSelector (#8224)
  • 9a68034 add self observability for stdout exporter (#8263)
  • Additional commits viewable in compare view

Updates `go.opentelemetry.io/otel/exporters/prometheus` from 0.65.0 to 0.66.0
Release notes

Sourced from go.opentelemetry.io/otel/exporters/prometheus's releases.

v1.44.0/v0.66.0/v0.20.0/v0.0.17

Added

  • Add ByteSlice and ByteSliceValue functions for new BYTESLICE attribute type in go.opentelemetry.io/otel/attribute. (#7948)
  • Apply attribute value limit to the KindBytes attribute type in go.opentelemetry.io/otel/sdk/log. (#7990)
  • Apply attribute value limit to the BYTESLICE attribute type in go.opentelemetry.io/otel/sdk/trace. (#7990)
  • Support BYTESLICE attributes in go.opentelemetry.io/otel/trace. (#8153)
  • Support BYTESLICE attributes in go.opentelemetry.io/otel/exporters/otlp/otlptrace. (#8153)
  • Support BYTESLICE attributes in go.opentelemetry.io/otel/exporters/otlp/otlplog. (#8153)
  • Support BYTESLICE attributes in go.opentelemetry.io/otel/exporters/otlp/otlpmetric. (#8153)
  • Support BYTESLICE attributes in go.opentelemetry.io/otel/exporters/zipkin. (#8153)
  • Add String method for Value type in go.opentelemetry.io/otel/attribute. (#8142)
  • Add Slice and SliceValue functions for new SLICE attribute type in go.opentelemetry.io/otel/attribute. (#8166)
  • Support SLICE attributes in go.opentelemetry.io/otel/exporters/otlp/otlptrace. (#8216)
  • Support SLICE attributes in go.opentelemetry.io/otel/exporters/otlp/otlplog. (#8216)
  • Support SLICE attributes in go.opentelemetry.io/otel/exporters/otlp/otlpmetric. (#8216)
  • Support SLICE attributes in go.opentelemetry.io/otel/exporters/zipkin. (#8216)
  • Apply AttributeValueLengthLimit to attribute.SLICE type attribute values in go.opentelemetry.io/otel/sdk/trace, recursively truncating contained string values. (#8217)
  • Add Error field on Record type in go.opentelemetry.io/otel/log/logtest. (#8148)
  • Add WithMaxRequestSize option in go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc. (#8157)
  • Add WithMaxRequestSize option in go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp. (#8157)
  • Add WithMaxRequestSize option in go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc. (#8157)
  • Add WithMaxRequestSize option in go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp. (#8157)
  • Add WithMaxRequestSize option in go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc. (#8157)
  • Add WithMaxRequestSize option in go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp. (#8157)
  • Add Settable to go.opentelemetry.io/otel/metric/x to allow reusing attribute options. (#8178)
  • Add experimental support for splitting metric data across multiple batches in go.opentelemetry.io/otel/sdk/metric. Set OTEL_GO_X_METRIC_EXPORT_BATCH_SIZE=<max_size> to enable for all periodic readers. See go.opentelemetry.io/otel/sdk/metric/internal/x for feature documentation. (#8071)
  • Add experimental self-observability metrics in go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc. Enable with OTEL_GO_X_SELF_OBSERVABILITY=true environment variable. See go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc/internal/x for feature documentation. (#8192)
  • Add experimental self-observability metrics in go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp. Enable with OTEL_GO_X_SELF_OBSERVABILITY=true environment variable. See go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp/internal/x for feature documentation. (#8194)
  • Add experimental self-observability metrics in go.opentelemetry.io/otel/exporters/stdout/stdoutlog. Enable with OTEL_GO_X_SELF_OBSERVABILITY=true environment variable. See go.opentelemetry.io/otel/stdout/stdoutlog/internal/x for feature documentation. (#8263)
  • Add WithDefaultAttributes to go.opentelemetry.io/otel/metric/x to support setting default attributes on instruments. (#8135)
  • Add go.opentelemetry.io/otel/semconv/v1.41.0 package. The package contains semantic conventions from the v1.41.0 version of the OpenTelemetry Semantic Conventions. See the migration documentation for information on how to upgrade from go.opentelemetry.io/otel/semconv/v1.40.0. (#8324)
  • Add Observable variants of instruments to go.opentelemetry.io/otel/semconv/v1.41.0 package. (#8350)
  • Generate explicit histogram bucket boundaries from weaver configuration for HTTP and RPC duration instruments in go.opentelemetry.io/otel/semconv/v1.41.0. (#8002)

Changed

  • ⚠️ Breaking Change: go.opentelemetry.io/otel/sdk/metric now applies a default cardinality limit of 2000 to comply with the Metrics SDK specification recommendation. New attribute sets are dropped when the cardinality limit is reached. The measurement of these sets are aggregated into a special attribute set containing attribute.Bool("otel.metric.overflow", true). This can break users who relied on the previous unlimited default.

... (truncated)

Changelog

Sourced from go.opentelemetry.io/otel/exporters/prometheus's changelog.

[1.44.0/0.66.0/0.20.0/0.0.17] 2026-05-27

Added

  • Add ByteSlice and ByteSliceValue functions for new BYTESLICE attribute type in go.opentelemetry.io/otel/attribute. (#7948)
  • Apply attribute value limit to the KindBytes attribute type in go.opentelemetry.io/otel/sdk/log. (#7990)
  • Apply attribute value limit to the BYTESLICE attribute type in go.opentelemetry.io/otel/sdk/trace. (#7990)
  • Support BYTESLICE attributes in go.opentelemetry.io/otel/trace. (#8153)
  • Support BYTESLICE attributes in go.opentelemetry.io/otel/exporters/otlp/otlptrace. (#8153)
  • Support BYTESLICE attributes in go.opentelemetry.io/otel/exporters/otlp/otlplog. (#8153)
  • Support BYTESLICE attributes in go.opentelemetry.io/otel/exporters/otlp/otlpmetric. (#8153)
  • Support BYTESLICE attributes in go.opentelemetry.io/otel/exporters/zipkin. (#8153)
  • Add String method for Value type in go.opentelemetry.io/otel/attribute. (#8142)
  • Add Slice and SliceValue functions for new SLICE attribute type in go.opentelemetry.io/otel/attribute. (#8166)
  • Support SLICE attributes in go.opentelemetry.io/otel/exporters/otlp/otlptrace. (#8216)
  • Support SLICE attributes in go.opentelemetry.io/otel/exporters/otlp/otlplog. (#8216)
  • Support SLICE attributes in go.opentelemetry.io/otel/exporters/otlp/otlpmetric. (#8216)
  • Support SLICE attributes in go.opentelemetry.io/otel/exporters/zipkin. (#8216)
  • Apply AttributeValueLengthLimit to attribute.SLICE type attribute values in go.opentelemetry.io/otel/sdk/trace, recursively truncating contained string values. (#8217)
  • Add Error field on Record type in go.opentelemetry.io/otel/log/logtest. (#8148)
  • Add WithMaxRequestSize option in go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc. (#8157)
  • Add WithMaxRequestSize option in go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp. (#8157)
  • Add WithMaxRequestSize option in go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc. (#8157)
  • Add WithMaxRequestSize option in go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp. (#8157)
  • Add WithMaxRequestSize option in go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc. (#8157)
  • Add WithMaxRequestSize option in go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp. (#8157)
  • Add Settable to go.opentelemetry.io/otel/metric/x to allow reusing attribute options. (#8178)
  • Add experimental support for splitting metric data across multiple batches in go.opentelemetry.io/otel/sdk/metric. Set OTEL_GO_X_METRIC_EXPORT_BATCH_SIZE=<max_size> to enable for all periodic readers. See go.opentelemetry.io/otel/sdk/metric/internal/x for feature documentation. ( Date: Thu, 11 Jun 2026 17:00:24 +0200 Subject: [PATCH 12/29] chore(deps): bump docker/setup-qemu-action from 4.0.0 to 4.1.0 in the github-actions group (#2221) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the github-actions group with 1 update: [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action). Updates `docker/setup-qemu-action` from 4.0.0 to 4.1.0
    Release notes

    Sourced from docker/setup-qemu-action's releases.

    v4.1.0

    Full Changelog: https://github.com/docker/setup-qemu-action/compare/v4.0.0...v4.1.0

    Commits
    • 0611638 Merge pull request #21 from crazy-max/uninst
    • ce59c81 chore: update generated content
    • 2ddad44 uninstall current emulators
    • 8c37cd6 Merge pull request #250 from docker/dependabot/npm_and_yarn/docker/actions-to...
    • d1a0ff3 chore: update generated content
    • 0a8f3dc build(deps): bump @​docker/actions-toolkit from 0.79.0 to 0.91.0
    • 9430f61 Merge pull request #291 from docker/dependabot/npm_and_yarn/tmp-0.2.6
    • 978bd77 chore: update generated content
    • 3479feb build(deps): bump tmp from 0.2.5 to 0.2.6
    • b113c26 Merge pull request #255 from docker/dependabot/npm_and_yarn/fast-xml-parser-5...
    • Additional commits viewable in compare view

    [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=docker/setup-qemu-action&package-manager=github_actions&previous-version=4.0.0&new-version=4.1.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Signed-off-by: yxia216 --- .github/workflows/docker_build_job.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker_build_job.yaml b/.github/workflows/docker_build_job.yaml index f465c63454..a38b44c69c 100644 --- a/.github/workflows/docker_build_job.yaml +++ b/.github/workflows/docker_build_job.yaml @@ -36,7 +36,7 @@ jobs: - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - name: Set up QEMU - uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 + uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 - name: Set up Docker buildx id: buildx From 2857986a6e3d1c29e6abc6bb6ba646b09308c9a7 Mon Sep 17 00:00:00 2001 From: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:58:15 +0100 Subject: [PATCH 13/29] docs(site): adds Stacklok to Adopters Page (#2224) **Description** Adds stacklok to adopters page --------- Signed-off-by: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Signed-off-by: yxia216 --- site/src/data/adopters/adopters.json | 6 ++++++ site/static/img/adopters/stacklok.png | Bin 0 -> 72035 bytes 2 files changed, 6 insertions(+) create mode 100644 site/static/img/adopters/stacklok.png diff --git a/site/src/data/adopters/adopters.json b/site/src/data/adopters/adopters.json index a8955b6a35..26ddc7c6e1 100644 --- a/site/src/data/adopters/adopters.json +++ b/site/src/data/adopters/adopters.json @@ -45,6 +45,12 @@ "logoUrl": "/img/adopters/Simplifai.png", "url": "https://simplifai.ai", "description": "Agentic AI platform for insurance" + }, + { + "name": "Stacklok", + "logoUrl": "/img/adopters/stacklok.png", + "url": "https://stacklok.com", + "description": "Agentic AI platform" }, { "name": "Your Logo Here", diff --git a/site/static/img/adopters/stacklok.png b/site/static/img/adopters/stacklok.png new file mode 100644 index 0000000000000000000000000000000000000000..7d983fe66b8a3333de8d29ab205ab4128dc879ab GIT binary patch literal 72035 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 literal 0 HcmV?d00001 From 7c254d4f1ee3c01e423bf38115baed7356bcda57 Mon Sep 17 00:00:00 2001 From: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Date: Fri, 12 Jun 2026 00:39:02 +0100 Subject: [PATCH 14/29] fix: use camelCase JSON tags for GCP Vertex AI request fields (#2225) **Description** The request body we send to GCP Vertex AI (the Gemini generateContent API) had a few field names in the wrong style. Vertex expects camelCase names like `generationConfig` and `systemInstruction`, but three of them were written in snake_case (`generation_config`, `system_instruction`, `tool_config`). The `safetySettings` field was already camelCase, so the struct was inconsistent with itself. This updates those three names to camelCase so they all match the format Vertex documents and the format the `google.golang.org/genai` library uses. Vertex does accept the snake_case versions as well, so this isn't fixing broken behaviour, but it removes the inconsistency and means we're no longer relying on that leniency. I also updated the tests that were asserting the old snake_case output. While I was in there, I replaced some hardcoded content-length values in the unit test with a check that the content-length header matches the actual body length. Those fixed numbers would have needed recalculating every time the body changed by even a byte, and the new check stays correct on its own. Testing: the existing unit tests and the data plane tests pass, including the GCP Vertex AI cases that exercise the request body end to end. Signed-off-by: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Signed-off-by: yxia216 --- internal/apischema/gcp/gcp.go | 6 +-- .../translator/openai_gcpvertexai_test.go | 49 +++++++++++-------- tests/data-plane/testupstream_test.go | 8 +-- 3 files changed, 35 insertions(+), 28 deletions(-) diff --git a/internal/apischema/gcp/gcp.go b/internal/apischema/gcp/gcp.go index 285225bc04..fc007b0073 100644 --- a/internal/apischema/gcp/gcp.go +++ b/internal/apischema/gcp/gcp.go @@ -24,17 +24,17 @@ type GenerateContentRequest struct { // This config is shared for all tools provided in the request. // // https://github.com/googleapis/go-genai/blob/6a8184fcaf8bf15f0c566616a7b356560309be9b/types.go#L1466 - ToolConfig *genai.ToolConfig `json:"tool_config,omitempty"` + ToolConfig *genai.ToolConfig `json:"toolConfig,omitempty"` // Optional. Generation config. // You can find API default values and more details at https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#generationconfig // and https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/content-generation-parameters. - GenerationConfig *genai.GenerationConfig `json:"generation_config,omitempty"` + GenerationConfig *genai.GenerationConfig `json:"generationConfig,omitempty"` // Optional. Instructions for the model to steer it toward better performance. // For example, "Answer as concisely as possible" or "Don't use technical // terms in your response". // // https://github.com/googleapis/go-genai/blob/6a8184fcaf8bf15f0c566616a7b356560309be9b/types.go#L858 - SystemInstruction *genai.Content `json:"system_instruction,omitempty"` + SystemInstruction *genai.Content `json:"systemInstruction,omitempty"` // Optional: Safety settings in the request to block unsafe content in the response. // // https://github.com/googleapis/go-genai/blob/6a8184fcaf8bf15f0c566616a7b356560309be9b/types.go#L1057 diff --git a/internal/translator/openai_gcpvertexai_test.go b/internal/translator/openai_gcpvertexai_test.go index 07ffe78315..d81fdca9f3 100644 --- a/internal/translator/openai_gcpvertexai_test.go +++ b/internal/translator/openai_gcpvertexai_test.go @@ -8,6 +8,7 @@ package translator import ( "bytes" "slices" + "strconv" "strings" "testing" @@ -79,12 +80,12 @@ func TestOpenAIToGCPVertexAITranslatorV1ChatCompletion_RequestBody(t *testing.T) } ], "tools": null, - "generation_config": { + "generationConfig": { "maxOutputTokens": 100, "stopSequences": ["stop1", "stop2"], "temperature": 0.1 }, - "system_instruction": { + "systemInstruction": { "parts": [ { "text": "You are a helpful assistant" @@ -129,8 +130,8 @@ func TestOpenAIToGCPVertexAITranslatorV1ChatCompletion_RequestBody(t *testing.T) ] } ], - "generation_config": {}, - "system_instruction": { + "generationConfig": {}, + "systemInstruction": { "parts": [ { "text": "You are a helpful assistant" @@ -169,7 +170,7 @@ func TestOpenAIToGCPVertexAITranslatorV1ChatCompletion_RequestBody(t *testing.T) ] } ], - "generation_config": { + "generationConfig": { "maxOutputTokens": 1024, "stopSequences": ["stop"], "temperature": 0.7, @@ -209,7 +210,7 @@ func TestOpenAIToGCPVertexAITranslatorV1ChatCompletion_RequestBody(t *testing.T) ] } ], - "generation_config": { + "generationConfig": { "maxOutputTokens": 1024, "temperature": 0.7 }, @@ -245,7 +246,7 @@ func TestOpenAIToGCPVertexAITranslatorV1ChatCompletion_RequestBody(t *testing.T) ] } ], - "generation_config": { + "generationConfig": { "maxOutputTokens": 1024, "mediaResolution": "high", "temperature": 0.7 @@ -281,7 +282,7 @@ func TestOpenAIToGCPVertexAITranslatorV1ChatCompletion_RequestBody(t *testing.T) ] } ], - "generation_config": { + "generationConfig": { "maxOutputTokens": 1024, "temperature": 0.7, "responseMimeType": "text/x.enum", @@ -324,7 +325,7 @@ func TestOpenAIToGCPVertexAITranslatorV1ChatCompletion_RequestBody(t *testing.T) ] } ], - "generation_config": { + "generationConfig": { "maxOutputTokens": 1024, "temperature": 0.7, "responseMimeType": "application/json", @@ -351,7 +352,7 @@ func TestOpenAIToGCPVertexAITranslatorV1ChatCompletion_RequestBody(t *testing.T) "enterpriseWebSearch": {} } ], - "generation_config": { + "generationConfig": { "maxOutputTokens": 1024, "temperature": 0.7 } @@ -400,7 +401,6 @@ func TestOpenAIToGCPVertexAITranslatorV1ChatCompletion_RequestBody(t *testing.T) // Since these are stub implementations, we expect nil mutations. wantHeaderMut: []internalapi.Header{ {":path", "publishers/google/models/gemini-pro:generateContent"}, - {"content-length", "258"}, }, wantBody: wantBdy, }, @@ -438,7 +438,6 @@ func TestOpenAIToGCPVertexAITranslatorV1ChatCompletion_RequestBody(t *testing.T) // Since these are stub implementations, we expect nil mutations. wantHeaderMut: []internalapi.Header{ {":path", "publishers/google/models/gemini-pro:streamGenerateContent?alt=sse"}, - {"content-length", "258"}, }, wantBody: wantBdy, }, @@ -477,7 +476,6 @@ func TestOpenAIToGCPVertexAITranslatorV1ChatCompletion_RequestBody(t *testing.T) // Since these are stub implementations, we expect nil mutations. wantHeaderMut: []internalapi.Header{ {":path", "publishers/google/models/gemini-flash:generateContent"}, - {"content-length", "258"}, }, wantBody: wantBdy, }, @@ -532,7 +530,6 @@ func TestOpenAIToGCPVertexAITranslatorV1ChatCompletion_RequestBody(t *testing.T) wantError: false, wantHeaderMut: []internalapi.Header{ {":path", "publishers/google/models/gemini-pro:generateContent"}, - {"content-length", "518"}, }, wantBody: wantBdyWithTools, }, @@ -582,7 +579,6 @@ func TestOpenAIToGCPVertexAITranslatorV1ChatCompletion_RequestBody(t *testing.T) wantError: false, wantHeaderMut: []internalapi.Header{ {":path", "publishers/google/models/gemini-1.5-pro:generateContent"}, - {"content-length", "396"}, }, wantBody: wantBdyWithVendorFields, }, @@ -630,7 +626,6 @@ func TestOpenAIToGCPVertexAITranslatorV1ChatCompletion_RequestBody(t *testing.T) wantError: false, wantHeaderMut: []internalapi.Header{ {":path", "publishers/google/models/gemini-1.5-pro:generateContent"}, - {"content-length", "395"}, }, wantBody: wantBdyWithSafetySettingFields, }, @@ -675,7 +670,6 @@ func TestOpenAIToGCPVertexAITranslatorV1ChatCompletion_RequestBody(t *testing.T) wantError: false, wantHeaderMut: []internalapi.Header{ {":path", "publishers/google/models/gemini-3-pro:generateContent"}, - {"content-length", "343"}, }, wantBody: wantBdyWithMediaResolutionFields, }, @@ -716,7 +710,6 @@ func TestOpenAIToGCPVertexAITranslatorV1ChatCompletion_RequestBody(t *testing.T) wantError: false, wantHeaderMut: []internalapi.Header{ {":path", "publishers/google/models/gemini-1.5-pro:generateContent"}, - {"content-length", "404"}, }, wantBody: wantBdyWithGuidedChoice, }, @@ -757,7 +750,6 @@ func TestOpenAIToGCPVertexAITranslatorV1ChatCompletion_RequestBody(t *testing.T) wantError: false, wantHeaderMut: []internalapi.Header{ {":path", "publishers/google/models/gemini-1.5-pro:generateContent"}, - {"content-length", "408"}, }, wantBody: wantBdyWithGuidedRegex, }, @@ -785,7 +777,6 @@ func TestOpenAIToGCPVertexAITranslatorV1ChatCompletion_RequestBody(t *testing.T) wantError: false, wantHeaderMut: []internalapi.Header{ {":path", "publishers/google/models/gemini-1.5-pro:generateContent"}, - {"content-length", "190"}, }, wantBody: wantBdyWithEnterpriseWebSearch, }, @@ -801,7 +792,23 @@ func TestOpenAIToGCPVertexAITranslatorV1ChatCompletion_RequestBody(t *testing.T) } require.NoError(t, err) - if diff := cmp.Diff(tc.wantHeaderMut, headerMut); diff != "" { + // Separate the content-length header from the others and assert it + // matches the serialized body length, rather than hardcoding a + // byte count that breaks whenever the body changes by a byte. + var gotHeaders []internalapi.Header + foundContentLength := false + for _, h := range headerMut { + if h.Key() == contentLengthHeaderName { + assert.Equal(t, strconv.Itoa(len(bodyMut)), h.Value(), + "content-length header should equal the serialized body length") + foundContentLength = true + continue + } + gotHeaders = append(gotHeaders, h) + } + assert.True(t, foundContentLength, "content-length header should be set") + + if diff := cmp.Diff(tc.wantHeaderMut, gotHeaders); diff != "" { t.Errorf("HeaderMutation mismatch (-want +got):\n%s", diff) } diff --git a/tests/data-plane/testupstream_test.go b/tests/data-plane/testupstream_test.go index d73bd0aafc..19bf7d503f 100644 --- a/tests/data-plane/testupstream_test.go +++ b/tests/data-plane/testupstream_test.go @@ -310,7 +310,7 @@ func TestWithTestUpstream(t *testing.T) { path: "/v1/chat/completions", method: http.MethodPost, requestBody: `{"model":"gemini-1.5-pro","messages":[{"role":"system","content":"You are a helpful assistant."}]}`, - expRequestBody: `{"contents":null,"tools":null,"generation_config":{},"system_instruction":{"parts":[{"text":"You are a helpful assistant."}]}}`, + expRequestBody: `{"contents":null,"tools":null,"generationConfig":{},"systemInstruction":{"parts":[{"text":"You are a helpful assistant."}]}}`, expPath: "/v1/projects/gcp-project-name/locations/gcp-region/publishers/google/models/gemini-1.5-pro:generateContent", expRequestHeaders: map[string]string{"Authorization": "Bearer " + fakeGCPAuthToken}, responseStatus: strconv.Itoa(http.StatusOK), @@ -324,7 +324,7 @@ func TestWithTestUpstream(t *testing.T) { path: "/v1/chat/completions", method: http.MethodPost, requestBody: `{"model":"gemini-1.5-pro","messages":[{"role":"system","content":"You are a helpful assistant."}]}`, - expRequestBody: `{"contents":null,"tools":null,"generation_config":{},"system_instruction":{"parts":[{"text":"You are a helpful assistant."}]}}`, + expRequestBody: `{"contents":null,"tools":null,"generationConfig":{},"systemInstruction":{"parts":[{"text":"You are a helpful assistant."}]}}`, expPath: "/v1/projects/gcp-project-name/locations/gcp-region/publishers/google/models/gemini-1.5-pro:generateContent", expRequestHeaders: map[string]string{"Authorization": "Bearer " + fakeGCPAuthToken}, responseStatus: strconv.Itoa(http.StatusOK), @@ -338,7 +338,7 @@ func TestWithTestUpstream(t *testing.T) { path: "/v1/chat/completions", method: http.MethodPost, requestBody: `{"model":"gemini-1.5-pro","messages":[{"role":"user","content":"tell me the delivery date for order 123"}],"tools":[{"type":"function","function":{"name":"get_delivery_date","description":"Get the delivery date for a customer's order. Call this whenever you need to know the delivery date, for example when a customer asks 'Where is my package'","parameters":{"type":"object","properties":{"order_id":{"type":"string","description":"The customer's order ID."}},"required":["order_id"]}}}]}`, - expRequestBody: `{"contents":[{"parts":[{"text":"tell me the delivery date for order 123"}],"role":"user"}],"tools":[{"functionDeclarations":[{"description":"Get the delivery date for a customer's order. Call this whenever you need to know the delivery date, for example when a customer asks 'Where is my package'","name":"get_delivery_date","parameters":{"properties":{"order_id":{"description":"The customer's order ID.","type":"string"}},"required":["order_id"],"type":"object"}}]}],"generation_config":{}}`, + expRequestBody: `{"contents":[{"parts":[{"text":"tell me the delivery date for order 123"}],"role":"user"}],"tools":[{"functionDeclarations":[{"description":"Get the delivery date for a customer's order. Call this whenever you need to know the delivery date, for example when a customer asks 'Where is my package'","name":"get_delivery_date","parameters":{"properties":{"order_id":{"description":"The customer's order ID.","type":"string"}},"required":["order_id"],"type":"object"}}]}],"generationConfig":{}}`, expPath: "/v1/projects/gcp-project-name/locations/gcp-region/publishers/google/models/gemini-1.5-pro:generateContent", expRequestHeaders: map[string]string{"Authorization": "Bearer " + fakeGCPAuthToken}, responseStatus: strconv.Itoa(http.StatusOK), @@ -507,7 +507,7 @@ data: [DONE] responseType: "sse", method: http.MethodPost, requestBody: `{"model":"gemini-1.5-pro","messages":[{"role":"system","content":"You are a helpful assistant."}], "stream": true}`, - expRequestBody: `{"contents":null,"tools":null,"generation_config":{},"system_instruction":{"parts":[{"text":"You are a helpful assistant."}]}}`, + expRequestBody: `{"contents":null,"tools":null,"generationConfig":{},"systemInstruction":{"parts":[{"text":"You are a helpful assistant."}]}}`, expPath: "/v1/projects/gcp-project-name/locations/gcp-region/publishers/google/models/gemini-1.5-pro:streamGenerateContent", expRawQuery: "alt=sse", expRequestHeaders: map[string]string{"Authorization": "Bearer " + fakeGCPAuthToken}, From 54e73bec580b8727646876de0a9a0e097df2a432 Mon Sep 17 00:00:00 2001 From: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Date: Fri, 12 Jun 2026 09:08:29 +0100 Subject: [PATCH 15/29] fix: prevent empty namespace from breaking RBAC name rendering in helm chart (#2226) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Description** The controller and inference-pool `ClusterRole`/`ClusterRoleBinding` names are built with `printf "%s:%s" .Release.Namespace` to keep them unique per-release (introduced in #1511). When `.Release.Namespace` is empty, this renders `name: :` — an unquoted trailing colon that strict YAML parsers reject with `mapping values are not allowed in this context`. The whole render fails and zero resources are produced. This doesn't trigger via `helm install` or `helm template` (the CLI always defaults the namespace), only where a renderer leaves it unset — e.g. the Helm SDK's `engine.Render` without `ReleaseOptions`, or Replicated's image-extraction render. The fix adds two independent layers of defense across the three affected helpers (`_helpers.tpl`) and their six call sites (`serviceaccount.yaml`, `envoy_gateway_cluster_role_for_inference_pool.yaml`): - Wrap the namespace in `default "default"` so the `printf` argument is never empty. No behavior change for real installs, where the namespace is always populated. - Quote the rendered `name` fields so a trailing colon is always unambiguous. Either layer fixes the failure on its own; both are included for robustness. **Special notes for reviewers (if applicable)** Verified with `helm lint` and `helm template` (default and explicit namespaces) — names render correctly as `:` with no change to existing behavior. Signed-off-by: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Signed-off-by: yxia216 --- manifests/charts/ai-gateway-helm/templates/_helpers.tpl | 6 +++--- .../envoy_gateway_cluster_role_for_inference_pool.yaml | 6 +++--- .../charts/ai-gateway-helm/templates/serviceaccount.yaml | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/manifests/charts/ai-gateway-helm/templates/_helpers.tpl b/manifests/charts/ai-gateway-helm/templates/_helpers.tpl index 357098eadb..0a44188449 100644 --- a/manifests/charts/ai-gateway-helm/templates/_helpers.tpl +++ b/manifests/charts/ai-gateway-helm/templates/_helpers.tpl @@ -76,7 +76,7 @@ Create the name of the cluster role to use {{- if $existing }} {{- (include "ai-gateway-helm.controller.serviceAccountName" .) }} {{- else }} -{{- printf "%s:%s" (include "ai-gateway-helm.controller.serviceAccountName" .) .Release.Namespace }} +{{- printf "%s:%s" (include "ai-gateway-helm.controller.serviceAccountName" .) (default "default" .Release.Namespace) }} {{- end }} {{- end }} @@ -85,7 +85,7 @@ Create the name of the cluster role to use {{- if $existing }} {{- "envoy-ai-gateway-inference-pool-reader" }} {{- else }} -{{- printf "%s:%s" "envoy-ai-gateway-inference-pool-reader" .Release.Namespace}} +{{- printf "%s:%s" "envoy-ai-gateway-inference-pool-reader" (default "default" .Release.Namespace)}} {{- end}} {{- end -}} @@ -94,7 +94,7 @@ Create the name of the cluster role to use {{- if $existing }} {{- "envoy-ai-gateway-inference-pool-reader-binding" }} {{- else }} -{{- printf "%s:%s" "envoy-ai-gateway-inference-pool-reader-binding" .Release.Namespace}} +{{- printf "%s:%s" "envoy-ai-gateway-inference-pool-reader-binding" (default "default" .Release.Namespace)}} {{- end}} {{- end -}} diff --git a/manifests/charts/ai-gateway-helm/templates/envoy_gateway_cluster_role_for_inference_pool.yaml b/manifests/charts/ai-gateway-helm/templates/envoy_gateway_cluster_role_for_inference_pool.yaml index 2378e5eaf3..023267bd64 100644 --- a/manifests/charts/ai-gateway-helm/templates/envoy_gateway_cluster_role_for_inference_pool.yaml +++ b/manifests/charts/ai-gateway-helm/templates/envoy_gateway_cluster_role_for_inference_pool.yaml @@ -10,7 +10,7 @@ apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: - name: {{ include "ai-gateway-helm.inference-pool.clusterRoleName" . }} + name: {{ include "ai-gateway-helm.inference-pool.clusterRoleName" . | quote }} rules: - apiGroups: - "inference.networking.k8s.io" @@ -24,11 +24,11 @@ rules: apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: - name: {{ include "ai-gateway-helm.inference-pool.clusterRoleBindingName" . }} + name: {{ include "ai-gateway-helm.inference-pool.clusterRoleBindingName" . | quote }} roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole - name: {{ include "ai-gateway-helm.inference-pool.clusterRoleName" . }} + name: {{ include "ai-gateway-helm.inference-pool.clusterRoleName" . | quote }} subjects: - kind: ServiceAccount # The service account name is hardcoded to "envoy-gateway": diff --git a/manifests/charts/ai-gateway-helm/templates/serviceaccount.yaml b/manifests/charts/ai-gateway-helm/templates/serviceaccount.yaml index a984ca1ccf..2c8fc7d6e8 100644 --- a/manifests/charts/ai-gateway-helm/templates/serviceaccount.yaml +++ b/manifests/charts/ai-gateway-helm/templates/serviceaccount.yaml @@ -19,7 +19,7 @@ metadata: apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: - name: {{ include "ai-gateway-helm.controller.clusterRoleName" . }} + name: {{ include "ai-gateway-helm.controller.clusterRoleName" . | quote }} rules: - apiGroups: [""] resources: @@ -103,11 +103,11 @@ rules: apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: - name: {{ include "ai-gateway-helm.controller.clusterRoleName" . }} + name: {{ include "ai-gateway-helm.controller.clusterRoleName" . | quote }} roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole - name: {{ include "ai-gateway-helm.controller.clusterRoleName" . }} + name: {{ include "ai-gateway-helm.controller.clusterRoleName" . | quote }} subjects: - kind: ServiceAccount name: {{ include "ai-gateway-helm.controller.serviceAccountName" . }} From 8e4b6d807268f69b854110b937a24f342ed72d08 Mon Sep 17 00:00:00 2001 From: Linus Schlumberger Date: Fri, 12 Jun 2026 10:46:26 +0200 Subject: [PATCH 16/29] fix: skip empty-content assistant messages in Bedrock translator (#2191) **Description** In the OpenAI-to-AWS-Bedrock message converter, Bedrock Converse rejects assistant messages with empty content arrays. Some clients send empty assistant messages. This skips them when they have no content blocks. Tool-call-only messages are preserved. Fixes #2208 Signed-off-by: Linus Schlumberger Co-authored-by: Ignasi Barrera Signed-off-by: yxia216 --- internal/translator/openai_awsbedrock.go | 6 +- internal/translator/openai_awsbedrock_test.go | 111 +++++++++++++++++- 2 files changed, 112 insertions(+), 5 deletions(-) diff --git a/internal/translator/openai_awsbedrock.go b/internal/translator/openai_awsbedrock.go index d5ddc9d168..39afb833af 100644 --- a/internal/translator/openai_awsbedrock.go +++ b/internal/translator/openai_awsbedrock.go @@ -521,7 +521,11 @@ func (o *openAIToAWSBedrockTranslatorV1ChatCompletion) openAIMessageToBedrockMes if err != nil { return err } - bedrockReq.Messages = append(bedrockReq.Messages, bedrockMessage) + // Some clients, like OpenCode, can send assistant messages with nil or empty string content and no tool calls, + // which would translate to an empty content array that Bedrock Converse rejects. + if len(bedrockMessage.Content) > 0 { + bedrockReq.Messages = append(bedrockReq.Messages, bedrockMessage) + } case msg.OfSystem != nil: if bedrockReq.System == nil { bedrockReq.System = make([]*awsbedrock.SystemContentBlock, 0) diff --git a/internal/translator/openai_awsbedrock_test.go b/internal/translator/openai_awsbedrock_test.go index c07479ea62..4cc83339f6 100644 --- a/internal/translator/openai_awsbedrock_test.go +++ b/internal/translator/openai_awsbedrock_test.go @@ -225,10 +225,6 @@ func TestOpenAIToAWSBedrockTranslatorV1ChatCompletion_RequestBody(t *testing.T) }, }, }, - { - Role: openai.ChatMessageRoleAssistant, - Content: []*awsbedrock.ContentBlock{}, - }, }, }, }, @@ -2582,3 +2578,110 @@ func TestCacheControlHelpers(t *testing.T) { require.Nil(t, cachePoint3) }) } + +func TestOpenAIToAWSBedrockTranslator_EmptyContentMessages(t *testing.T) { + t.Run("empty assistant message without tool calls is dropped", func(t *testing.T) { + for _, name := range []string{"empty string", "nil content"} { + t.Run(name, func(t *testing.T) { + assistantMsg := &openai.ChatCompletionAssistantMessageParam{ + Role: openai.ChatMessageRoleAssistant, + } + if name == "empty string" { + assistantMsg.Content = openai.StringOrAssistantRoleContentUnion{Value: ""} + } + + o := &openAIToAWSBedrockTranslatorV1ChatCompletion{} + req := openai.ChatCompletionRequest{ + Model: "anthropic.claude-3-5-sonnet-20241022-v2:0", + Messages: []openai.ChatCompletionMessageParamUnion{ + {OfUser: &openai.ChatCompletionUserMessageParam{Role: openai.ChatMessageRoleUser, Content: openai.StringOrUserRoleContentUnion{Value: "Hello"}}}, + {OfAssistant: assistantMsg}, + {OfUser: &openai.ChatCompletionUserMessageParam{Role: openai.ChatMessageRoleUser, Content: openai.StringOrUserRoleContentUnion{Value: "Can you help?"}}}, + }, + } + + _, newBody, err := o.RequestBody(nil, &req, false) + require.NoError(t, err) + + var awsReq awsbedrock.ConverseInput + require.NoError(t, json.Unmarshal(newBody, &awsReq)) + requireNoEmptyAssistantContent(t, awsReq.Messages) + }) + } + }) + + t.Run("assistant message with only tool calls and no content should work", func(t *testing.T) { + o := &openAIToAWSBedrockTranslatorV1ChatCompletion{} + req := openai.ChatCompletionRequest{ + Model: "anthropic.claude-3-5-sonnet-20241022-v2:0", + Messages: []openai.ChatCompletionMessageParamUnion{ + {OfUser: &openai.ChatCompletionUserMessageParam{Role: openai.ChatMessageRoleUser, Content: openai.StringOrUserRoleContentUnion{Value: "What is the weather?"}}}, + {OfAssistant: &openai.ChatCompletionAssistantMessageParam{ + Role: openai.ChatMessageRoleAssistant, + ToolCalls: []openai.ChatCompletionMessageToolCallParam{ + {ID: ptr.To("call_abc123"), Type: openai.ChatCompletionMessageToolCallTypeFunction, Function: openai.ChatCompletionMessageToolCallFunctionParam{Name: "get_weather", Arguments: `{"location":"NYC"}`}}, + }, + }}, + {OfTool: &openai.ChatCompletionToolMessageParam{Role: openai.ChatMessageRoleTool, ToolCallID: "call_abc123", Content: openai.ContentUnion{Value: `{"temp":72}`}}}, + }, + } + + _, newBody, err := o.RequestBody(nil, &req, false) + require.NoError(t, err) + + var awsReq awsbedrock.ConverseInput + require.NoError(t, json.Unmarshal(newBody, &awsReq)) + + var foundAssistant bool + for _, msg := range awsReq.Messages { + if msg.Role == openai.ChatMessageRoleAssistant { + foundAssistant = true + require.NotEmpty(t, msg.Content, "assistant message with tool calls should have non-empty content") + require.NotNil(t, msg.Content[0].ToolUse, "expected a ToolUse block") + require.Equal(t, "get_weather", msg.Content[0].ToolUse.Name) + } + } + require.True(t, foundAssistant, "should have found an assistant message") + }) + + t.Run("mixed messages with some empty assistant messages should handle correctly", func(t *testing.T) { + o := &openAIToAWSBedrockTranslatorV1ChatCompletion{} + req := openai.ChatCompletionRequest{ + Model: "anthropic.claude-3-5-sonnet-20241022-v2:0", + Messages: []openai.ChatCompletionMessageParamUnion{ + {OfUser: &openai.ChatCompletionUserMessageParam{Role: openai.ChatMessageRoleUser, Content: openai.StringOrUserRoleContentUnion{Value: "Hello"}}}, + {OfAssistant: &openai.ChatCompletionAssistantMessageParam{Role: openai.ChatMessageRoleAssistant, Content: openai.StringOrAssistantRoleContentUnion{Value: ""}}}, + {OfUser: &openai.ChatCompletionUserMessageParam{Role: openai.ChatMessageRoleUser, Content: openai.StringOrUserRoleContentUnion{Value: "Tell me more"}}}, + {OfAssistant: &openai.ChatCompletionAssistantMessageParam{Role: openai.ChatMessageRoleAssistant, Content: openai.StringOrAssistantRoleContentUnion{Value: "Here is the info"}}}, + }, + } + + _, newBody, err := o.RequestBody(nil, &req, false) + require.NoError(t, err) + + var awsReq awsbedrock.ConverseInput + require.NoError(t, json.Unmarshal(newBody, &awsReq)) + requireNoEmptyAssistantContent(t, awsReq.Messages) + + var assistantWithContent bool + for _, msg := range awsReq.Messages { + if msg.Role == openai.ChatMessageRoleAssistant { + for _, block := range msg.Content { + if block.Text != nil && *block.Text == "Here is the info" { + assistantWithContent = true + } + } + } + } + require.True(t, assistantWithContent, "should preserve assistant message with actual content") + }) +} + +func requireNoEmptyAssistantContent(t *testing.T, messages []*awsbedrock.Message) { + t.Helper() + for i, msg := range messages { + if msg.Role == openai.ChatMessageRoleAssistant && len(msg.Content) == 0 { + t.Errorf("message at index %d is an assistant message with empty content array, which Bedrock will reject", i) + } + } +} From 3326c0a2b60b2147863733f0ab012365250cbf81 Mon Sep 17 00:00:00 2001 From: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:33:26 +0100 Subject: [PATCH 17/29] extproc: skip CONTINUE_AND_REPLACE when no body change is needed (#2170) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Description** **Scope:** this PR fixes the narrow case where the upstream cluster ext_proc filter issued `CONTINUE_AND_REPLACE` even though *nothing in the request pipeline needed to mutate the body* — no translation, no model override, no retry, no backend `HTTPBodyMutation`. In that path, the upstream filter replayed `u.parent.originalRequestBodyRaw` (snapshotted at the router phase), which clobbered any body mutation applied by an earlier ext_proc filter in the listener chain. After this PR, that path emits `CONTINUE` and leaves the body alone. The broader question of "preserve an intermediate filter's body mutation across translation / retries / `HTTPBodyMutation`" is **not** addressed by this PR. When `wantBodyReplace == true`, the replacement body is still derived from the router-phase snapshot, so any intermediate filter's mutation is lost on those branches. That's an architectural property of the snapshot-at-router- phase design — the upstream filter has no view of Envoy's current body, by design, to avoid re-buffering through ext_proc at the upstream phase. Fixing it requires a separate design (either re-buffering at upstream, or propagating a diff via dynamic metadata) and should be its own PR. **Root cause of the narrow bug.** The existing safety net in `applyBodyMutation` looks like it covers the no-config case: if bodyMutator == nil { return bodyMutation } but it does not fire in practice. `bodymutator.NewBodyMutator(nil, ...)` returns a non-nil struct even when its `*filterapi.HTTPBodyMutation` argument is nil. So the early-return is dead code; the function falls through, calls `Mutate(originalRequestBodyRaw)` on a no-config mutator (which returns its input unchanged), wraps that in a `BodyMutation`, and the upstream filter sends `CONTINUE_AND_REPLACE` with the original body — even though nothing asked for a body replacement. **Fix:** * Add `BodyMutator.HasMutations()` predicate. `NewBodyMutator`'s contract is unchanged (it still returns non-nil); callers can ask whether the mutator actually has anything to do. * In `upstreamProcessor.ProcessRequestHeaders`, compute mutatorHasMutations := u.bodyMutator != nil && u.bodyMutator.HasMutations() wantBodyReplace := bodyMutation != nil || forceBodyMutation || mutatorHasMutations before calling `applyBodyMutation`. When false, skip the body-mutation work and emit `CONTINUE` instead of `CONTINUE_AND_REPLACE`, preserving translator header mutations (e.g. path rewrite), header-mutator output, and auth-handler header additions. Skip the content-length dynamic-metadata restamp on the `CONTINUE` branch since the body length did not change. All existing `CONTINUE_AND_REPLACE` callers stay on the existing path (these are the branches where this PR explicitly does not change behavior, including the loss-of-earlier-mutation property documented above): * Translator emits a body (AWS Bedrock/SigV4, OpenAI to Anthropic, OpenAI to Vertex, etc.) — `bodyMutation != nil`. * Backend `HTTPBodyMutation` configured — `HasMutations() == true`. * Retry — `forceBodyMutation == true` via `u.onRetry()`. * Streaming with `IncludeUsage=false` — `forceBodyMutation == true`. * Model override — translator emits `newBody` via `sjson.SetBytesOptions`, so `bodyMutation != nil`. **Tests:** * Added `Test_chatCompletionProcessorUpstreamFilter_ProcessRequestHeaders_BodyReplaceContract` with two subtests: * `no translator body, no mutator, no force -> CONTINUE` exercises the previously untested path. Asserts `Status == CONTINUE`, no `BodyMutation`, preserved translator and auth-handler header mutations, and `DynamicMetadata == nil` (no content-length restamp). * `translator body present -> CONTINUE_AND_REPLACE` pins the existing behavior so a future refactor cannot quietly flip the contract back. The coverage gap that let this ship: every existing `t.Run("ok", ...)` subtest under `Test_chatCompletionProcessorUpstreamFilter_ProcessRequestHeaders` set `retBodyMutation` on the mock translator, so the "translator returns nil body" path was never exercised. **Related Issues/PRs (if applicable)** None. **Special notes for reviewers (if applicable)** * The fix is request-side only. Response-side code is untouched. * `NewBodyMutator`'s contract is intentionally not changed (it still returns non-nil even when given a nil `*filterapi.HTTPBodyMutation`). A predicate is added instead, to keep the blast radius local to `ProcessRequestHeaders`. If reviewers prefer changing the constructor to return nil in the no-config case, that is a larger refactor and a separate PR. * The bug is in the generic `upstreamProcessor[...]`, so the fix applies to every endpoint spec (chat completions, messages, embeddings, completions, GCP, AWS Bedrock, etc.). The new tests exercise one endpoint spec for the same reason the existing `ProcessRequestHeaders` table-test does — locking the generic method's contract. Signed-off-by: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Co-authored-by: Ignasi Barrera Signed-off-by: yxia216 --- internal/bodymutator/body_mutator.go | 8 ++ internal/extproc/processor_impl.go | 31 +++++- internal/extproc/processor_impl_test.go | 122 ++++++++++++++++++++++++ 3 files changed, 159 insertions(+), 2 deletions(-) diff --git a/internal/bodymutator/body_mutator.go b/internal/bodymutator/body_mutator.go index ec9d3c6c97..f5613d7967 100644 --- a/internal/bodymutator/body_mutator.go +++ b/internal/bodymutator/body_mutator.go @@ -29,6 +29,14 @@ func NewBodyMutator(bodyMutations *filterapi.HTTPBodyMutation, originalBody []by } } +// HasMutations reports whether this BodyMutator was constructed with a +// non-nil HTTPBodyMutation config. When false, Mutate is a no-op that +// returns its input unchanged, so callers can short-circuit the +// upstream filter's body-replacement path entirely. +func (b *BodyMutator) HasMutations() bool { + return b != nil && b.bodyMutations != nil +} + // isJSONValue checks if a string represents a JSON value (not a plain string) func isJSONValue(value string) bool { value = strings.TrimSpace(value) diff --git a/internal/extproc/processor_impl.go b/internal/extproc/processor_impl.go index 546deafc54..abb6ac956b 100644 --- a/internal/extproc/processor_impl.go +++ b/internal/extproc/processor_impl.go @@ -366,8 +366,19 @@ func (u *upstreamProcessor[ReqT, RespT, RespChunkT, EndpointSpecT]) ProcessReque } } - // Apply body mutations from the route and also restore original body on retry. - bodyMutation = applyBodyMutation(u.bodyMutator, bodyMutation, u.parent.originalRequestBodyRaw, u.logger) + // Decide whether the upstream filter should replace the request body at + // all. If the translator emitted no body, no backend HTTPBodyMutation is + // configured, and we're not forcing body replay (retry or + // streaming-without-usage), then issuing CONTINUE_AND_REPLACE with the + // captured original body would clobber any body mutation applied by an + // earlier ext_proc filter in the chain. + mutatorHasMutations := u.bodyMutator != nil && u.bodyMutator.HasMutations() + wantBodyReplace := bodyMutation != nil || forceBodyMutation || mutatorHasMutations + + if wantBodyReplace { + // Apply body mutations from the route and also restore original body on retry. + bodyMutation = applyBodyMutation(u.bodyMutator, bodyMutation, u.parent.originalRequestBodyRaw, u.logger) + } // Ensure bodyMutation is not nil for subsequent processing if bodyMutation == nil { @@ -392,6 +403,22 @@ func (u *upstreamProcessor[ReqT, RespT, RespChunkT, EndpointSpecT]) ProcessReque } } + if !wantBodyReplace { + // No body change -> no content-length restamp; emit CONTINUE so Envoy + // keeps whatever body the previous filter in the chain produced. + return &extprocv3.ProcessingResponse{ + Response: &extprocv3.ProcessingResponse_RequestHeaders{ + RequestHeaders: &extprocv3.HeadersResponse{ + Response: &extprocv3.CommonResponse{ + HeaderMutation: headerMutation, + Status: extprocv3.CommonResponse_CONTINUE, + }, + }, + }, + DynamicMetadata: buildRequestHeaderDynamicMetadata(u.requestHeaders), + }, nil + } + var dm *structpb.Struct if bm := bodyMutation.GetBody(); bm != nil { dm = buildContentLengthDynamicMetadataOnRequest(len(bm)) diff --git a/internal/extproc/processor_impl_test.go b/internal/extproc/processor_impl_test.go index 719d5aae53..8eaccd65dc 100644 --- a/internal/extproc/processor_impl_test.go +++ b/internal/extproc/processor_impl_test.go @@ -26,6 +26,7 @@ import ( anthropicschema "github.com/envoyproxy/ai-gateway/internal/apischema/anthropic" "github.com/envoyproxy/ai-gateway/internal/apischema/openai" + "github.com/envoyproxy/ai-gateway/internal/bodymutator" "github.com/envoyproxy/ai-gateway/internal/endpointspec" "github.com/envoyproxy/ai-gateway/internal/filterapi" "github.com/envoyproxy/ai-gateway/internal/headermutator" @@ -744,6 +745,127 @@ func Test_messagesProcessorUpstreamFilter_ProcessRequestHeaders_AWSAnthropicBeta require.Equal(t, []any{"interleaved-thinking-2025-05-14", "context-1m-2025-08-07"}, betaValues) } +// Test_chatCompletionProcessorUpstreamFilter_ProcessRequestHeaders_BodyReplaceContract +// locks the contract for when the upstream filter must NOT replace the request +// body: when the translator returns no body, no backend HTTPBodyMutation is +// configured, and forceBodyMutation is false, the upstream filter must emit +// CONTINUE rather than CONTINUE_AND_REPLACE. Issuing CONTINUE_AND_REPLACE with +// the captured original body would clobber any body mutation applied by an +// earlier ext_proc filter in the chain. Header mutations and auth headers +// must still apply on the CONTINUE branch. +// +// The sibling subtest pins the opposite half of the contract: when the +// translator DID emit a body, the upstream filter must continue to issue +// CONTINUE_AND_REPLACE, so a future refactor cannot quietly flip the contract +// back. +func Test_chatCompletionProcessorUpstreamFilter_ProcessRequestHeaders_BodyReplaceContract(t *testing.T) { + t.Run("no translator body, no mutator, no force -> CONTINUE", func(t *testing.T) { + someBody := bodyFromModel(t, "some-model", false, nil) + headers := map[string]string{ + ":path": "/foo", + internalapi.ModelNameHeaderKeyDefault: "some-model", + } + var expBody openai.ChatCompletionRequest + require.NoError(t, json.Unmarshal(someBody, &expBody)) + + pathRewrite := []internalapi.Header{{":path", "/v1/chat/completions"}} + mt := &mockTranslator{ + t: t, + expRequestBody: &expBody, + retHeaderMutation: pathRewrite, + retBodyMutation: nil, + expForceRequestBodyMutation: false, + } + mm := &mockMetrics{} + p := &chatCompletionProcessorUpstreamFilter{ + parent: &chatCompletionProcessorRouterFilter{ + config: &filterapi.RuntimeConfig{}, + logger: slog.Default(), + originalRequestBodyRaw: someBody, + originalRequestBody: &expBody, + originalModel: "some-model", + stream: false, + forceBodyMutation: false, + }, + requestHeaders: headers, + metrics: mm, + translator: mt, + handler: &mockBackendAuthHandler{}, + // No-config body mutator: HasMutations() returns false. This mirrors + // SetBackend's call to bodymutator.NewBodyMutator(nil, ...) when the + // route has no HTTPBodyMutation. + bodyMutator: bodymutator.NewBodyMutator(nil, someBody), + } + + resp, err := p.ProcessRequestHeaders(t.Context(), nil) + require.NoError(t, err) + require.NotNil(t, resp) + + commonRes := resp.Response.(*extprocv3.ProcessingResponse_RequestHeaders).RequestHeaders.Response + require.Equal(t, extprocv3.CommonResponse_CONTINUE, commonRes.Status, + "must NOT issue CONTINUE_AND_REPLACE when nothing actually needs to mutate the body — that path silently replays the original body and clobbers earlier filters' mutations") + require.Nil(t, commonRes.BodyMutation, "no body mutation should ride on a CONTINUE response") + + require.NotNil(t, commonRes.HeaderMutation) + require.Len(t, commonRes.HeaderMutation.SetHeaders, 2, + "header mutations from the translator (path rewrite) and the auth handler must still apply on the CONTINUE branch") + require.Equal(t, ":path", commonRes.HeaderMutation.SetHeaders[0].Header.Key) + require.Equal(t, []byte("/v1/chat/completions"), commonRes.HeaderMutation.SetHeaders[0].Header.RawValue) + require.Equal(t, "foo", commonRes.HeaderMutation.SetHeaders[1].Header.Key) + require.Equal(t, "mock-auth-handler", string(commonRes.HeaderMutation.SetHeaders[1].Header.RawValue)) + + // No body change -> no content-length restamp. + // buildRequestHeaderDynamicMetadata returns nil when LogRequestHeaderAttributes is empty. + require.Nil(t, resp.DynamicMetadata, + "buildContentLengthDynamicMetadataOnRequest must not be called when the body is not replaced") + }) + + t.Run("translator body present -> CONTINUE_AND_REPLACE", func(t *testing.T) { + someBody := bodyFromModel(t, "some-model", false, nil) + headers := map[string]string{ + ":path": "/foo", + internalapi.ModelNameHeaderKeyDefault: "some-model", + } + var expBody openai.ChatCompletionRequest + require.NoError(t, json.Unmarshal(someBody, &expBody)) + + bodyMut := []byte("translator-emitted body") + mt := &mockTranslator{ + t: t, + expRequestBody: &expBody, + retHeaderMutation: []internalapi.Header{{":path", "/v1/chat/completions"}}, + retBodyMutation: bodyMut, + expForceRequestBodyMutation: false, + } + mm := &mockMetrics{} + p := &chatCompletionProcessorUpstreamFilter{ + parent: &chatCompletionProcessorRouterFilter{ + config: &filterapi.RuntimeConfig{}, + logger: slog.Default(), + originalRequestBodyRaw: someBody, + originalRequestBody: &expBody, + originalModel: "some-model", + stream: false, + forceBodyMutation: false, + }, + requestHeaders: headers, + metrics: mm, + translator: mt, + handler: &mockBackendAuthHandler{}, + bodyMutator: bodymutator.NewBodyMutator(nil, someBody), + } + + resp, err := p.ProcessRequestHeaders(t.Context(), nil) + require.NoError(t, err) + require.NotNil(t, resp) + + commonRes := resp.Response.(*extprocv3.ProcessingResponse_RequestHeaders).RequestHeaders.Response + require.Equal(t, extprocv3.CommonResponse_CONTINUE_AND_REPLACE, commonRes.Status, + "existing CONTINUE_AND_REPLACE behavior must be preserved when the translator produced a body") + require.Equal(t, bodyMut, commonRes.BodyMutation.GetBody()) + }) +} + func Test_chatCompletionProcessorUpstreamFilter_MergeWithTokenLatencyMetadata(t *testing.T) { t.Run("empty metadata", func(t *testing.T) { mm := &mockMetrics{} From 0e03257bd6e26012ed0c927a3fcbf2265d2478a9 Mon Sep 17 00:00:00 2001 From: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:07:14 +0100 Subject: [PATCH 18/29] fix: insert AI Gateway extproc after the buffer filter (#2227) **Description** **Problem** If a buffer filter (`envoy.filters.http.buffer`) is added to the chain, for example via an EnvoyPatchPolicy to raise the request buffer limit, the AI Gateway extproc could be placed ahead of it. The AI Gateway extproc runs with `RequestBodyMode: BUFFERED`, so when it runs before the buffer filter it buffers the body against Envoy Gateway's default 32 KiB downstream per-connection limit and returns `413 Payload Too Large` for larger bodies before the buffer filter's higher limit applies. The buffer filter raises the stream's buffer limit to `max_request_bytes`, but only for filters that decode after it. This was hit in practice with large request bodies such as Gemini CLI tool definitions. **Solution** When a buffer filter sits at or after the computed ext_proc insertion point, insert the AI Gateway extproc immediately after the last buffer filter. Envoy Gateway already orders the buffer filter before ext_proc, so this honors that order. Behavior is unchanged when no buffer filter is present. This works because EnvoyPatchPolicy patches are applied before the extension hook runs and Envoy Gateway does not re-sort filters afterward, so the buffer filter is visible to this code. The extension server only moves its own filter, not the user's buffer filter, so this fixes the 413 for AI Gateway's extproc specifically and not for other co-resident BUFFERED ext_procs. **Testing** Added table tests for three cases: ext_proc before buffer (inserted after buffer), buffer before ext_proc (inserted after buffer), and no buffer filter (unchanged). Signed-off-by: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Co-authored-by: Ignasi Barrera Signed-off-by: yxia216 --- .../extensionserver/post_translate_modify.go | 30 ++++++++++++- .../post_translate_modify_test.go | 42 +++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/internal/extensionserver/post_translate_modify.go b/internal/extensionserver/post_translate_modify.go index 31edc9b1e0..694ff060f0 100644 --- a/internal/extensionserver/post_translate_modify.go +++ b/internal/extensionserver/post_translate_modify.go @@ -854,9 +854,20 @@ func shouldAIGatewayExtProcBeInserted(filters []*httpconnectionmanagerv3.HttpFil // insertAIGatewayExtProcFilter inserts the AI Gateway extproc filter into the HTTP connection manager. // -// The order is simple: make sure that the AI Gateway extproc filter is the very first extproc filter in the standard -// Envoy Gateway order. See: +// The order is mostly simple: make sure that the AI Gateway extproc filter is the first extproc filter in the +// standard Envoy Gateway order. See: // https://github.com/envoyproxy/gateway/blob/f1e6dab770fabc70d175237380eedfc1f9b1a9e5/internal/xds/translator/httpfilters.go#L93 +// +// There is one exception: the HTTP buffer filter (envoy.filters.http.buffer). In Envoy Gateway's canonical +// filter order the buffer filter is placed *before* the ext_proc filters, so a user who raises the request +// buffer limit via the buffer filter (e.g. through an EnvoyPatchPolicy) expects that limit to apply to the AI +// Gateway extproc as well. The AI Gateway extproc runs with RequestBodyMode: BUFFERED, so inserting it ahead of +// the buffer filter would make it buffer the request body against the (smaller) default per-connection buffer +// limit and reject large request bodies with a 413 before the buffer filter's larger limit can take effect. To +// avoid this, when a buffer filter sits at or after the chosen insertion point we insert the AI Gateway extproc +// immediately after the last buffer filter instead. The AI Gateway extproc is then the first ext_proc filter to +// run after the buffer filter (not necessarily the first ext_proc filter overall), so a user-supplied ext_proc +// placed ahead of the buffer filter, e.g. api-key auth, still runs before it. func insertAIGatewayExtProcFilter(mgr *httpconnectionmanagerv3.HttpConnectionManager, filter *httpconnectionmanagerv3.HttpFilter) error { insertIndex := -1 outer: @@ -871,6 +882,21 @@ outer: if insertIndex == -1 { return errors.New("failed to find insertion point for AI Gateway extproc filter") } + + // Find the last buffer filter so we can insert the AI Gateway extproc after it (see the buffer-filter + // exception in the function doc above). + lastBufferIndex := -1 + for i, existingFilter := range mgr.HttpFilters { + if strings.HasPrefix(existingFilter.Name, egv1a1.EnvoyFilterBuffer.String()) { + lastBufferIndex = i + } + } + // When the buffer filter sits at or after the ext_proc insertion point, insert the AI Gateway extproc + // immediately after it instead. Behavior is unchanged when no buffer filter exists (lastBufferIndex == -1). + if lastBufferIndex >= insertIndex { + insertIndex = lastBufferIndex + 1 + } + mgr.HttpFilters = append(mgr.HttpFilters, filter) copy(mgr.HttpFilters[insertIndex+1:], mgr.HttpFilters[insertIndex:]) mgr.HttpFilters[insertIndex] = filter diff --git a/internal/extensionserver/post_translate_modify_test.go b/internal/extensionserver/post_translate_modify_test.go index f7babc8de4..78e71f2a8d 100644 --- a/internal/extensionserver/post_translate_modify_test.go +++ b/internal/extensionserver/post_translate_modify_test.go @@ -168,6 +168,48 @@ func TestInsertAIGatewayExtProcFilter(t *testing.T) { expectedPosition: 2, expectedFilterCount: 6, }, + { + // Mirrors the EKS setup where an api-key ext_proc and a buffer filter are added ahead of AI + // Gateway. The ext_proc at index 0 matches afterExtProcFilterPrefixes, but the buffer filter + // must still run first so its larger request buffer limit applies to AI Gateway's BUFFERED + // extproc. AI Gateway is inserted after the buffer filter (position 2). + name: "insert after buffer when ext_proc precedes buffer", + existingFilters: []*httpconnectionmanagerv3.HttpFilter{ + {Name: "envoy.filters.http.ext_proc.apikey"}, + {Name: "envoy.filters.http.buffer"}, + {Name: "envoy.filters.http.jwt_authn"}, + {Name: "envoy.filters.http.rbac"}, + {Name: "envoy.filters.http.router"}, + }, + expectedPosition: 2, + expectedFilterCount: 6, + }, + { + // When the buffer filter already precedes the first ext_proc filter, AI Gateway is inserted + // right after the buffer filter (position 1), preserving Envoy Gateway's buffer-before-extproc + // ordering. + name: "insert after buffer when buffer precedes ext_proc", + existingFilters: []*httpconnectionmanagerv3.HttpFilter{ + {Name: "envoy.filters.http.buffer"}, + {Name: "envoy.filters.http.ext_proc.apikey"}, + {Name: "envoy.filters.http.rbac"}, + {Name: "envoy.filters.http.router"}, + }, + expectedPosition: 1, + expectedFilterCount: 5, + }, + { + // Regression guard: with no buffer filter present, insertion behavior is unchanged and AI + // Gateway lands ahead of the first ext_proc filter (position 0). + name: "no buffer filter leaves ext_proc insertion unchanged", + existingFilters: []*httpconnectionmanagerv3.HttpFilter{ + {Name: "envoy.filters.http.ext_proc.apikey"}, + {Name: "envoy.filters.http.rbac"}, + {Name: "envoy.filters.http.router"}, + }, + expectedPosition: 0, + expectedFilterCount: 4, + }, } for _, tt := range tests { From a20022964cd438f8c33bf8c07c9e5ed9577197ae Mon Sep 17 00:00:00 2001 From: hustxiayang Date: Fri, 12 Jun 2026 15:05:43 -0400 Subject: [PATCH 19/29] fix: allow strict function definition in anthropic (#2232) Signed-off-by: yxia216 --- internal/translator/anthropic_helper.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/translator/anthropic_helper.go b/internal/translator/anthropic_helper.go index 8d77a3d9b8..8926f9bda9 100644 --- a/internal/translator/anthropic_helper.go +++ b/internal/translator/anthropic_helper.go @@ -140,6 +140,10 @@ func translateOpenAItoAnthropicTools(openAITools []openai.Tool, openAIToolChoice Description: anthropic.String(openAITool.Function.Description), } + if openAITool.Function.Strict { + toolParam.Strict = anthropic.Bool(true) + } + if isCacheEnabled(openAITool.Function.AnthropicContentFields) { toolParam.CacheControl = anthropic.NewCacheControlEphemeralParam() } From 8f98b4ba64d49e270b59ec4de5a052b7bd1fd14d Mon Sep 17 00:00:00 2001 From: Takeshi Yoneda Date: Sun, 14 Jun 2026 07:41:51 -0700 Subject: [PATCH 20/29] docs: update release information to v0.7 (#2233) **Description** This updates the homepage info Signed-off-by: Takeshi Yoneda Signed-off-by: yxia216 --- site/src/components/HomepageFeatures/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/components/HomepageFeatures/index.tsx b/site/src/components/HomepageFeatures/index.tsx index 57b57f8407..4cb996d3ac 100644 --- a/site/src/components/HomepageFeatures/index.tsx +++ b/site/src/components/HomepageFeatures/index.tsx @@ -19,11 +19,11 @@ const FeatureList: FeatureItem[] = [ ), }, { - title: 'v0.5 Release now available', + title: 'v0.7 Release now available', image: require('@site/static/img/3.png').default, description: ( <> - The v0.5 Release of Envoy AI Gateway is now available. See the release notes for more information. + The v0.7 Release of Envoy AI Gateway is now available. See the release notes for more information. ), }, From d34a29dfbe25f96729ba31750fbedc43e3ef4797 Mon Sep 17 00:00:00 2001 From: Ignasi Barrera Date: Tue, 16 Jun 2026 16:16:49 +0200 Subject: [PATCH 21/29] Merge commit from fork Signed-off-by: Ignasi Barrera Signed-off-by: yxia216 --- internal/extproc/processor_impl.go | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/internal/extproc/processor_impl.go b/internal/extproc/processor_impl.go index abb6ac956b..733b2ef974 100644 --- a/internal/extproc/processor_impl.go +++ b/internal/extproc/processor_impl.go @@ -267,12 +267,10 @@ func (r *routerProcessor[ReqT, RespT, RespChunkT, EndpointSpecT]) ProcessRequest Header: &corev3.HeaderValue{Key: internalapi.ModelNameHeaderKeyDefault, RawValue: []byte(originalModel)}, }) originalPath := r.requestHeaders[":path"] - if r.requestHeaders[originalPathHeader] == "" { - r.requestHeaders[originalPathHeader] = originalPath - additionalHeaders = append(additionalHeaders, &corev3.HeaderValueOption{ - Header: &corev3.HeaderValue{Key: originalPathHeader, RawValue: []byte(originalPath)}, - }) - } + r.requestHeaders[originalPathHeader] = originalPath + additionalHeaders = append(additionalHeaders, &corev3.HeaderValueOption{ + Header: &corev3.HeaderValue{Key: originalPathHeader, RawValue: []byte(originalPath)}, + }) if r.requestHeaders[internalapi.EnvoyOriginalPathHeader] == "" { r.requestHeaders[internalapi.EnvoyOriginalPathHeader] = originalPath additionalHeaders = append(additionalHeaders, &corev3.HeaderValueOption{ From fbdde207053278d8b941c2000c8366953a502772 Mon Sep 17 00:00:00 2001 From: Ignasi Barrera Date: Tue, 16 Jun 2026 16:44:34 +0200 Subject: [PATCH 22/29] mcp: limit request body size by default (#2238) **Description** Limit by default the size of MCP request payloads. **Related Issues/PRs (if applicable)** N/A **Special notes for reviewers (if applicable)** N/A Signed-off-by: Ignasi Barrera Signed-off-by: yxia216 --- internal/mcpproxy/config.go | 1 + internal/mcpproxy/handlers.go | 12 +++++++++++- internal/mcpproxy/handlers_test.go | 14 ++++++++++++++ internal/mcpproxy/mcpproxy.go | 17 +++++++++++++++++ internal/mcpproxy/mcpproxy_test.go | 19 +++++++++++++++++++ 5 files changed, 62 insertions(+), 1 deletion(-) diff --git a/internal/mcpproxy/config.go b/internal/mcpproxy/config.go index 777e6a2442..6a231c7ab2 100644 --- a/internal/mcpproxy/config.go +++ b/internal/mcpproxy/config.go @@ -31,6 +31,7 @@ type ( tracer tracingapi.MCPTracer client http.Client logRequestHeaderAttributes map[string]string + maxRequestBodySize int64 // maximum allowed POST body size in bytes } mcpProxyConfig struct { diff --git a/internal/mcpproxy/handlers.go b/internal/mcpproxy/handlers.go index 7d5dce7862..74dd528d4e 100644 --- a/internal/mcpproxy/handlers.go +++ b/internal/mcpproxy/handlers.go @@ -276,8 +276,18 @@ func (m *mcpRequestContext) servePOST(w http.ResponseWriter, r *http.Request) { } } - body, err := io.ReadAll(r.Body) + limit := m.maxRequestBodySize + if limit <= 0 { + limit = defaultMaxRequestBodySize + } + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, limit)) if err != nil { + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + errType = metrics.MCPErrorInternal + onErrorResponse(w, http.StatusRequestEntityTooLarge, "request body too large") + return + } errType = metrics.MCPErrorInternal onErrorResponse(w, http.StatusBadRequest, err.Error()) return diff --git a/internal/mcpproxy/handlers_test.go b/internal/mcpproxy/handlers_test.go index a20afa5b61..8b6a214d8c 100644 --- a/internal/mcpproxy/handlers_test.go +++ b/internal/mcpproxy/handlers_test.go @@ -168,6 +168,20 @@ func TestServePOST_InvalidJSONRPC(t *testing.T) { require.Contains(t, rr.Body.String(), "invalid JSON-RPC message") } +func TestServePOST_OversizedBody(t *testing.T) { + proxy := newTestMCPProxy() + proxy.maxRequestBodySize = 16 // tiny limit to exercise the guard without allocating a large buffer. + + body := strings.NewReader(`{"jsonrpc":"2.0","method":"initialize","id":1,"params":{}}`) // > 16 bytes + req := httptest.NewRequest(http.MethodPost, "/mcp", body) + rr := httptest.NewRecorder() + + proxy.servePOST(rr, req) + + require.Equal(t, http.StatusRequestEntityTooLarge, rr.Code) + require.Contains(t, rr.Body.String(), "request body too large") +} + func TestServePOST_InvalidSessionID(t *testing.T) { proxy := newTestMCPProxy() req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(`{"jsonrpc":"2.0","method":"tools/call","params":{"name":"test-tool"},"id":"1"}`)) diff --git a/internal/mcpproxy/mcpproxy.go b/internal/mcpproxy/mcpproxy.go index 354f233638..ea8c1ef733 100644 --- a/internal/mcpproxy/mcpproxy.go +++ b/internal/mcpproxy/mcpproxy.go @@ -14,6 +14,8 @@ import ( "log/slog" "maps" "net/http" + "os" + "strconv" "strings" "sync" "time" @@ -39,6 +41,20 @@ type mcpRequestContext struct { perBackendMetricsRecorded bool } +// defaultMaxRequestBodySize is the default maximum allowed POST body size in bytes (4 MiB). +const defaultMaxRequestBodySize = 4 * 1024 * 1024 + +// getMaxRequestBodySize returns the configured POST body limit from the environment variable, +// falling back to 4 MiB if the variable is unset or invalid. +func getMaxRequestBodySize() int64 { + if v, ok := os.LookupEnv("MCP_PROXY_MAX_REQUEST_BODY_SIZE"); ok { + if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 { + return n + } + } + return defaultMaxRequestBodySize +} + // NewMCPProxy creates a new MCPProxy instance. func NewMCPProxy(l *slog.Logger, mcpMetrics metrics.MCPMetrics, tracer tracingapi.MCPTracer, sessionCrypto SessionCrypto, logRequestHeaderAttributes map[string]string) (*ProxyConfig, *http.ServeMux, error) { toolChangeSignaler := newMultiWatcherSignaler() // used to signal changes to all active sessions. @@ -49,6 +65,7 @@ func NewMCPProxy(l *slog.Logger, mcpMetrics metrics.MCPMetrics, tracer tracingap l: l, client: http.Client{}, // No timeout as it's enforced at Envoy level. logRequestHeaderAttributes: maps.Clone(logRequestHeaderAttributes), + maxRequestBodySize: getMaxRequestBodySize(), } mux := http.NewServeMux() mux.HandleFunc( diff --git a/internal/mcpproxy/mcpproxy_test.go b/internal/mcpproxy/mcpproxy_test.go index 966b768eff..572ec779c9 100644 --- a/internal/mcpproxy/mcpproxy_test.go +++ b/internal/mcpproxy/mcpproxy_test.go @@ -60,6 +60,25 @@ func (f *fakeTracer) StartSpanAndInjectMeta(context.Context, *jsonrpc.Request, m var noopTracer = tracingapi.NoopMCPTracer{} +func TestGetMaxRequestBodySize(t *testing.T) { + for _, tc := range []struct { + name string + envValue string + want int64 + }{ + {name: "default when env var not set", envValue: "", want: defaultMaxRequestBodySize}, + {name: "custom value from env var", envValue: "1048576", want: 1048576}, + {name: "default when env var is not a number", envValue: "not-a-number", want: defaultMaxRequestBodySize}, + {name: "default when env var is zero", envValue: "0", want: defaultMaxRequestBodySize}, + {name: "default when env var is negative", envValue: "-1", want: defaultMaxRequestBodySize}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("MCP_PROXY_MAX_REQUEST_BODY_SIZE", tc.envValue) + require.Equal(t, tc.want, getMaxRequestBodySize()) + }) + } +} + func TestNewMCPProxy(t *testing.T) { l := slog.Default() proxy, mux, err := NewMCPProxy(l, stubMetrics{}, noopTracer, NewPBKDF2AesGcmSessionCrypto("test", 100), nil) From 9719b8f13c0529e8789e1fb7c7b0725f92b1fd22 Mon Sep 17 00:00:00 2001 From: yxia216 Date: Tue, 16 Jun 2026 12:19:50 -0400 Subject: [PATCH 23/29] cached-tokens Signed-off-by: yxia216 --- internal/translator/anthropic_helper.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/translator/anthropic_helper.go b/internal/translator/anthropic_helper.go index 8926f9bda9..fa6774109e 100644 --- a/internal/translator/anthropic_helper.go +++ b/internal/translator/anthropic_helper.go @@ -1061,12 +1061,12 @@ func (p *anthropicStreamParser) handleAnthropicStreamEvent(eventType []byte, dat if output, ok := usage.OutputTokens(); ok { p.tokenUsage.AddOutputTokens(output) } - // Update cache token details from delta (don't add to InputTokens — already included in message_start) + // Set (not add) cache token details from delta — message_start already set these once if cached, ok := usage.CachedInputTokens(); ok { - p.tokenUsage.AddCachedInputTokens(cached) + p.tokenUsage.SetCachedInputTokens(cached) } if cacheCreation, ok := usage.CacheCreationInputTokens(); ok { - p.tokenUsage.AddCacheCreationInputTokens(cacheCreation) + p.tokenUsage.SetCacheCreationInputTokens(cacheCreation) } p.tokenUsage.SetReasoningTokens(uint32(u.OutputTokensDetails.ThinkingTokens)) //nolint:gosec if event.Delta.StopReason != "" { From e0ae47e0ad5e4e648e0af7ab720fadc016f96368 Mon Sep 17 00:00:00 2001 From: yxia216 Date: Tue, 16 Jun 2026 12:26:00 -0400 Subject: [PATCH 24/29] fix-gemini-review-comment Signed-off-by: yxia216 --- internal/translator/anthropic_helper.go | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/internal/translator/anthropic_helper.go b/internal/translator/anthropic_helper.go index fa6774109e..f02753befe 100644 --- a/internal/translator/anthropic_helper.go +++ b/internal/translator/anthropic_helper.go @@ -1061,13 +1061,8 @@ func (p *anthropicStreamParser) handleAnthropicStreamEvent(eventType []byte, dat if output, ok := usage.OutputTokens(); ok { p.tokenUsage.AddOutputTokens(output) } - // Set (not add) cache token details from delta — message_start already set these once - if cached, ok := usage.CachedInputTokens(); ok { - p.tokenUsage.SetCachedInputTokens(cached) - } - if cacheCreation, ok := usage.CacheCreationInputTokens(); ok { - p.tokenUsage.SetCacheCreationInputTokens(cacheCreation) - } + // Cache token details are already set in message_start — don't touch them here + // since message_delta only contains incremental output_tokens. p.tokenUsage.SetReasoningTokens(uint32(u.OutputTokensDetails.ThinkingTokens)) //nolint:gosec if event.Delta.StopReason != "" { p.stopReason = event.Delta.StopReason From 7a7e107657e21eaecdc2bff022c074a90a5aa1d0 Mon Sep 17 00:00:00 2001 From: yxia216 Date: Tue, 16 Jun 2026 13:30:08 -0400 Subject: [PATCH 25/29] update-tests Signed-off-by: yxia216 --- .../translator/openai_gcpanthropic_test.go | 91 +++++++++++++++++++ tests/data-plane/testupstream_test.go | 4 +- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/internal/translator/openai_gcpanthropic_test.go b/internal/translator/openai_gcpanthropic_test.go index 769b2dd1fa..8e61037e7a 100644 --- a/internal/translator/openai_gcpanthropic_test.go +++ b/internal/translator/openai_gcpanthropic_test.go @@ -2097,3 +2097,94 @@ func TestOpenAIToGCPAnthropicTranslatorV1ChatCompletion_ResponseBody_WithSpanRec require.Len(t, span.recordedResponse.Choices, 1) require.Equal(t, "Hello!", *span.recordedResponse.Choices[0].Message.Content) } + +// TestOpenAIToGCPAnthropicTranslatorV1ChatCompletion_ResponseBody_StreamingCacheTokensNotDoubled +// verifies that cache tokens reported in both message_start and message_delta are not double-counted. +// This is a regression test for the bug where prompt_tokens and cached_tokens were doubled in streaming. +func TestOpenAIToGCPAnthropicTranslatorV1ChatCompletion_ResponseBody_StreamingCacheTokensNotDoubled(t *testing.T) { + translator := NewChatCompletionOpenAIToGCPAnthropicTranslator("", "").(*openAIToGCPAnthropicTranslatorV1ChatCompletion) + + // Initialize translator with stream=true + req := &openai.ChatCompletionRequest{ + Stream: true, + Model: "claude-sonnet-4-6", + MaxTokens: ptr.To(int64(100)), + Messages: []openai.ChatCompletionMessageParamUnion{ + { + OfUser: &openai.ChatCompletionUserMessageParam{ + Content: openai.StringOrUserRoleContentUnion{Value: "Hello"}, + Role: openai.ChatMessageRoleUser, + }, + }, + }, + } + reqBody, _ := json.Marshal(req) + _, _, err := translator.RequestBody(reqBody, req, false) + require.NoError(t, err) + + // Simulate streaming: message_start has input_tokens=678, cache_read=13363, cache_creation=0 + messageStartChunk := `event: message_start +data: {"type": "message_start", "message": {"id": "msg_abc123", "type": "message", "role": "assistant", "content": [], "model": "claude-sonnet-4-6", "usage": {"input_tokens": 678, "cache_read_input_tokens": 13363, "cache_creation_input_tokens": 0, "output_tokens": 0}}} + +` + contentBlockStartChunk := `event: content_block_start +data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}} + +` + contentBlockDeltaChunk := `event: content_block_delta +data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Hi"}} + +` + contentBlockStopChunk := `event: content_block_stop +data: {"type": "content_block_stop", "index": 0} + +` + // message_delta reports cache tokens again (same values as message_start) — this must NOT double-count + messageDeltaChunk := `event: message_delta +data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 1, "input_tokens": 678, "cache_read_input_tokens": 13363, "cache_creation_input_tokens": 0}} + +` + messageStopChunk := `event: message_stop +data: {"type": "message_stop"} + +` + + // Process all chunks sequentially (non-endOfStream until the last) + _, _, _, _, err = translator.ResponseBody(nil, bytes.NewBufferString(messageStartChunk), false, nil) + require.NoError(t, err) + _, _, _, _, err = translator.ResponseBody(nil, bytes.NewBufferString(contentBlockStartChunk), false, nil) + require.NoError(t, err) + _, _, _, _, err = translator.ResponseBody(nil, bytes.NewBufferString(contentBlockDeltaChunk), false, nil) + require.NoError(t, err) + _, _, _, _, err = translator.ResponseBody(nil, bytes.NewBufferString(contentBlockStopChunk), false, nil) + require.NoError(t, err) + _, _, _, _, err = translator.ResponseBody(nil, bytes.NewBufferString(messageDeltaChunk), false, nil) + require.NoError(t, err) + _, _, tokenUsage, _, err := translator.ResponseBody(nil, bytes.NewBufferString(messageStopChunk), true, nil) + require.NoError(t, err) + + // Verify: input_tokens should be 678 + 13363 = 14041, NOT doubled to 27404 + inputTokens, inputSet := tokenUsage.InputTokens() + require.True(t, inputSet) + require.Equal(t, uint32(14041), inputTokens, "prompt_tokens must not be doubled in streaming") + + // Verify: cached_tokens should be 13363, NOT doubled to 26726 + cachedTokens, cachedSet := tokenUsage.CachedInputTokens() + require.True(t, cachedSet) + require.Equal(t, uint32(13363), cachedTokens, "cached_tokens must not be doubled in streaming") + + // Verify: output_tokens = 1 + outputTokens, outputSet := tokenUsage.OutputTokens() + require.True(t, outputSet) + require.Equal(t, uint32(1), outputTokens) + + // Verify: total = input + output = 14042 + totalTokens, totalSet := tokenUsage.TotalTokens() + require.True(t, totalSet) + require.Equal(t, uint32(14042), totalTokens) + + // Verify: cache_creation_input_tokens = 0 + cacheCreation, cacheCreationSet := tokenUsage.CacheCreationInputTokens() + require.True(t, cacheCreationSet) + require.Equal(t, uint32(0), cacheCreation) +} diff --git a/tests/data-plane/testupstream_test.go b/tests/data-plane/testupstream_test.go index 19bf7d503f..8b76d4b0de 100644 --- a/tests/data-plane/testupstream_test.go +++ b/tests/data-plane/testupstream_test.go @@ -551,7 +551,7 @@ data: [DONE] expRequestHeaders: map[string]string{"Authorization": "Bearer " + fakeGCPAuthToken}, responseStatus: strconv.Itoa(http.StatusOK), responseBody: `event: message_start -data: {"type": "message_start", "message": {"id": "msg_123", "usage": {"input_tokens": 15}}} +data: {"type": "message_start", "message": {"id": "msg_123", "usage": {"input_tokens": 15, "cache_read_input_tokens": 10}}} event: content_block_start data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}} @@ -566,7 +566,7 @@ event: content_block_stop data: {"type": "content_block_stop", "index": 0} event: message_delta -data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 12, "cache_read_input_tokens":10}} +data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 12}} event: message_stop data: {"type": "message_stop"} From a02e6d7f0326e0af4a96a34ccd945d14fb12530d Mon Sep 17 00:00:00 2001 From: yxia216 Date: Tue, 16 Jun 2026 13:42:29 -0400 Subject: [PATCH 26/29] fix-the-tests Signed-off-by: yxia216 --- tests/data-plane/testupstream_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/data-plane/testupstream_test.go b/tests/data-plane/testupstream_test.go index 8b76d4b0de..8c923aa6b2 100644 --- a/tests/data-plane/testupstream_test.go +++ b/tests/data-plane/testupstream_test.go @@ -566,7 +566,7 @@ event: content_block_stop data: {"type": "content_block_stop", "index": 0} event: message_delta -data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 12}} +data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 12, "cache_read_input_tokens": 10}} event: message_stop data: {"type": "message_stop"} From bbdb1441627279845a93972a023cf7ff3f79f3a9 Mon Sep 17 00:00:00 2001 From: yxia216 Date: Tue, 16 Jun 2026 14:01:47 -0400 Subject: [PATCH 27/29] update-tests Signed-off-by: yxia216 --- internal/translator/anthropic_helper_test.go | 148 ++++++++++++++++++ .../translator/openai_gcpanthropic_test.go | 91 ----------- tests/data-plane/testupstream_test.go | 22 +-- 3 files changed, 159 insertions(+), 102 deletions(-) diff --git a/internal/translator/anthropic_helper_test.go b/internal/translator/anthropic_helper_test.go index eb39929731..c395094854 100644 --- a/internal/translator/anthropic_helper_test.go +++ b/internal/translator/anthropic_helper_test.go @@ -7,15 +7,18 @@ package translator import ( "fmt" + "strings" "testing" "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/shared/constant" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/utils/ptr" "github.com/envoyproxy/ai-gateway/internal/apischema/openai" "github.com/envoyproxy/ai-gateway/internal/internalapi" + "github.com/envoyproxy/ai-gateway/internal/metrics" ) // mockErrorReader is a helper for testing io.Reader failures. @@ -1157,3 +1160,148 @@ func TestBuildAnthropicParamsWithReasoningEffort(t *testing.T) { require.Equal(t, anthropic.OutputConfigEffort(""), params.OutputConfig.Effort) }) } + +func TestAnthropicStreamParser_StreamingTokenUsage(t *testing.T) { + tests := []struct { + name string + events string + expectedInputTokens uint32 + expectedOutputTokens uint32 + expectedTotalTokens uint32 + expectedCachedTokens uint32 + expectedCacheCreationTokens uint32 + }{ + { + name: "with cache tokens", + events: `event: message_start +data: {"type": "message_start", "message": {"id": "msg_abc123", "type": "message", "role": "assistant", "content": [], "model": "claude-sonnet-4-6", "usage": {"input_tokens": 678, "cache_read_input_tokens": 13363, "cache_creation_input_tokens": 0, "output_tokens": 1}}} + +event: content_block_start +data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}} + +event: content_block_delta +data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Hi"}} + +event: content_block_stop +data: {"type": "content_block_stop", "index": 0} + +event: message_delta +data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"input_tokens": 678, "cache_read_input_tokens": 13363, "cache_creation_input_tokens": 0, "output_tokens": 5}} + +event: message_stop +data: {"type": "message_stop"} + +`, + expectedInputTokens: 14041, // 678 + 13363 + 0 + expectedOutputTokens: 5, + expectedTotalTokens: 14046, // 14041 + 5 + expectedCachedTokens: 13363, + expectedCacheCreationTokens: 0, + }, + { + name: "without cache tokens", + events: `event: message_start +data: {"type": "message_start", "message": {"id": "msg_abc456", "type": "message", "role": "assistant", "content": [], "model": "claude-sonnet-4-6", "usage": {"input_tokens": 100, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0, "output_tokens": 1}}} + +event: content_block_start +data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}} + +event: content_block_delta +data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Hello"}} + +event: content_block_stop +data: {"type": "content_block_stop", "index": 0} + +event: message_delta +data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"input_tokens": 100, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0, "output_tokens": 10}} + +event: message_stop +data: {"type": "message_stop"} + +`, + expectedInputTokens: 100, + expectedOutputTokens: 10, + expectedTotalTokens: 110, + expectedCachedTokens: 0, + expectedCacheCreationTokens: 0, + }, + { + name: "with cache creation tokens", + events: `event: message_start +data: {"type": "message_start", "message": {"id": "msg_abc789", "type": "message", "role": "assistant", "content": [], "model": "claude-sonnet-4-6", "usage": {"input_tokens": 200, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 5000, "output_tokens": 1}}} + +event: content_block_start +data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}} + +event: content_block_delta +data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Response"}} + +event: content_block_stop +data: {"type": "content_block_stop", "index": 0} + +event: message_delta +data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"input_tokens": 200, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 5000, "output_tokens": 8}} + +event: message_stop +data: {"type": "message_stop"} + +`, + expectedInputTokens: 5200, // 200 + 5000 + 0 + expectedOutputTokens: 8, + expectedTotalTokens: 5208, // 5200 + 8 + expectedCachedTokens: 0, + expectedCacheCreationTokens: 5000, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parser := newAnthropicStreamParser("claude-sonnet-4-6") + + // Feed each event block separately (simulating chunked SSE delivery), + // with the last chunk marked as endOfStream. + chunks := splitSSEEvents(tt.events) + var tokenUsage metrics.TokenUsage + for i, chunk := range chunks { + endOfStream := i == len(chunks)-1 + _, _, usage, _, err := parser.Process(strings.NewReader(chunk), endOfStream, nil) + require.NoError(t, err) + if endOfStream { + tokenUsage = usage + } + } + + inputTokens, inputSet := tokenUsage.InputTokens() + assert.True(t, inputSet, "InputTokens should be set") + assert.Equal(t, tt.expectedInputTokens, inputTokens, "InputTokens mismatch") + + outputTokens, outputSet := tokenUsage.OutputTokens() + assert.True(t, outputSet, "OutputTokens should be set") + assert.Equal(t, tt.expectedOutputTokens, outputTokens, "OutputTokens mismatch") + + totalTokens, totalSet := tokenUsage.TotalTokens() + assert.True(t, totalSet, "TotalTokens should be set") + assert.Equal(t, tt.expectedTotalTokens, totalTokens, "TotalTokens mismatch") + + cachedTokens, cachedSet := tokenUsage.CachedInputTokens() + assert.True(t, cachedSet, "CachedInputTokens should be set") + assert.Equal(t, tt.expectedCachedTokens, cachedTokens, "CachedInputTokens mismatch") + + cacheCreation, cacheCreationSet := tokenUsage.CacheCreationInputTokens() + assert.True(t, cacheCreationSet, "CacheCreationInputTokens should be set") + assert.Equal(t, tt.expectedCacheCreationTokens, cacheCreation, "CacheCreationInputTokens mismatch") + }) + } +} + +func splitSSEEvents(data string) []string { + parts := strings.Split(data, "\n\n") + var events []string + for _, p := range parts { + trimmed := strings.TrimSpace(p) + if trimmed != "" { + events = append(events, p+"\n\n") + } + } + return events +} diff --git a/internal/translator/openai_gcpanthropic_test.go b/internal/translator/openai_gcpanthropic_test.go index 8e61037e7a..769b2dd1fa 100644 --- a/internal/translator/openai_gcpanthropic_test.go +++ b/internal/translator/openai_gcpanthropic_test.go @@ -2097,94 +2097,3 @@ func TestOpenAIToGCPAnthropicTranslatorV1ChatCompletion_ResponseBody_WithSpanRec require.Len(t, span.recordedResponse.Choices, 1) require.Equal(t, "Hello!", *span.recordedResponse.Choices[0].Message.Content) } - -// TestOpenAIToGCPAnthropicTranslatorV1ChatCompletion_ResponseBody_StreamingCacheTokensNotDoubled -// verifies that cache tokens reported in both message_start and message_delta are not double-counted. -// This is a regression test for the bug where prompt_tokens and cached_tokens were doubled in streaming. -func TestOpenAIToGCPAnthropicTranslatorV1ChatCompletion_ResponseBody_StreamingCacheTokensNotDoubled(t *testing.T) { - translator := NewChatCompletionOpenAIToGCPAnthropicTranslator("", "").(*openAIToGCPAnthropicTranslatorV1ChatCompletion) - - // Initialize translator with stream=true - req := &openai.ChatCompletionRequest{ - Stream: true, - Model: "claude-sonnet-4-6", - MaxTokens: ptr.To(int64(100)), - Messages: []openai.ChatCompletionMessageParamUnion{ - { - OfUser: &openai.ChatCompletionUserMessageParam{ - Content: openai.StringOrUserRoleContentUnion{Value: "Hello"}, - Role: openai.ChatMessageRoleUser, - }, - }, - }, - } - reqBody, _ := json.Marshal(req) - _, _, err := translator.RequestBody(reqBody, req, false) - require.NoError(t, err) - - // Simulate streaming: message_start has input_tokens=678, cache_read=13363, cache_creation=0 - messageStartChunk := `event: message_start -data: {"type": "message_start", "message": {"id": "msg_abc123", "type": "message", "role": "assistant", "content": [], "model": "claude-sonnet-4-6", "usage": {"input_tokens": 678, "cache_read_input_tokens": 13363, "cache_creation_input_tokens": 0, "output_tokens": 0}}} - -` - contentBlockStartChunk := `event: content_block_start -data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}} - -` - contentBlockDeltaChunk := `event: content_block_delta -data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Hi"}} - -` - contentBlockStopChunk := `event: content_block_stop -data: {"type": "content_block_stop", "index": 0} - -` - // message_delta reports cache tokens again (same values as message_start) — this must NOT double-count - messageDeltaChunk := `event: message_delta -data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 1, "input_tokens": 678, "cache_read_input_tokens": 13363, "cache_creation_input_tokens": 0}} - -` - messageStopChunk := `event: message_stop -data: {"type": "message_stop"} - -` - - // Process all chunks sequentially (non-endOfStream until the last) - _, _, _, _, err = translator.ResponseBody(nil, bytes.NewBufferString(messageStartChunk), false, nil) - require.NoError(t, err) - _, _, _, _, err = translator.ResponseBody(nil, bytes.NewBufferString(contentBlockStartChunk), false, nil) - require.NoError(t, err) - _, _, _, _, err = translator.ResponseBody(nil, bytes.NewBufferString(contentBlockDeltaChunk), false, nil) - require.NoError(t, err) - _, _, _, _, err = translator.ResponseBody(nil, bytes.NewBufferString(contentBlockStopChunk), false, nil) - require.NoError(t, err) - _, _, _, _, err = translator.ResponseBody(nil, bytes.NewBufferString(messageDeltaChunk), false, nil) - require.NoError(t, err) - _, _, tokenUsage, _, err := translator.ResponseBody(nil, bytes.NewBufferString(messageStopChunk), true, nil) - require.NoError(t, err) - - // Verify: input_tokens should be 678 + 13363 = 14041, NOT doubled to 27404 - inputTokens, inputSet := tokenUsage.InputTokens() - require.True(t, inputSet) - require.Equal(t, uint32(14041), inputTokens, "prompt_tokens must not be doubled in streaming") - - // Verify: cached_tokens should be 13363, NOT doubled to 26726 - cachedTokens, cachedSet := tokenUsage.CachedInputTokens() - require.True(t, cachedSet) - require.Equal(t, uint32(13363), cachedTokens, "cached_tokens must not be doubled in streaming") - - // Verify: output_tokens = 1 - outputTokens, outputSet := tokenUsage.OutputTokens() - require.True(t, outputSet) - require.Equal(t, uint32(1), outputTokens) - - // Verify: total = input + output = 14042 - totalTokens, totalSet := tokenUsage.TotalTokens() - require.True(t, totalSet) - require.Equal(t, uint32(14042), totalTokens) - - // Verify: cache_creation_input_tokens = 0 - cacheCreation, cacheCreationSet := tokenUsage.CacheCreationInputTokens() - require.True(t, cacheCreationSet) - require.Equal(t, uint32(0), cacheCreation) -} diff --git a/tests/data-plane/testupstream_test.go b/tests/data-plane/testupstream_test.go index 8c923aa6b2..c3801e27a4 100644 --- a/tests/data-plane/testupstream_test.go +++ b/tests/data-plane/testupstream_test.go @@ -551,7 +551,7 @@ data: [DONE] expRequestHeaders: map[string]string{"Authorization": "Bearer " + fakeGCPAuthToken}, responseStatus: strconv.Itoa(http.StatusOK), responseBody: `event: message_start -data: {"type": "message_start", "message": {"id": "msg_123", "usage": {"input_tokens": 15, "cache_read_input_tokens": 10}}} +data: {"type": "message_start", "message": {"id": "msg_123", "usage": {"input_tokens": 15, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 10, "output_tokens": 1}}} event: content_block_start data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}} @@ -566,7 +566,7 @@ event: content_block_stop data: {"type": "content_block_stop", "index": 0} event: message_delta -data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 12, "cache_read_input_tokens": 10}} +data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"input_tokens": 15, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 10, "output_tokens": 12}} event: message_stop data: {"type": "message_stop"} @@ -614,7 +614,7 @@ data: [DONE] expRequestHeaders: map[string]string{"Authorization": "Bearer " + fakeGCPAuthToken}, responseStatus: strconv.Itoa(http.StatusOK), responseBody: `event: message_start -data: {"type": "message_start", "message": {"id": "msg_123", "usage": {"input_tokens": 50}}} +data: {"type": "message_start", "message": {"id": "msg_123", "usage": {"input_tokens": 50, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 1}}} event: content_block_start data: {"type": "content_block_start", "index": 0, "content_block": {"type": "tool_use", "id": "toolu_abc123", "name": "get_weather", "input": {}}} @@ -629,7 +629,7 @@ event: content_block_stop data: {"type": "content_block_stop", "index": 0} event: message_delta -data: {"type": "message_delta", "delta": {"stop_reason": "tool_use"}, "usage": {"output_tokens": 20}} +data: {"type": "message_delta", "delta": {"stop_reason": "tool_use"}, "usage": {"input_tokens": 50, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 20}} event: message_stop data: {"type": "message_stop"}`, @@ -910,7 +910,7 @@ data: [DONE] expRequestHeaders: map[string]string{"Authorization": "Bearer " + fakeGCPAuthToken}, responseStatus: strconv.Itoa(http.StatusOK), responseBody: `event: message_start -data: {"type": "message_start", "message": {"id": "msg_789", "usage": {"input_tokens": 8}}} +data: {"type": "message_start", "message": {"id": "msg_789", "usage": {"input_tokens": 8, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 1}}} event: content_block_start data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}} @@ -922,7 +922,7 @@ event: content_block_stop data: {"type": "content_block_stop", "index": 0} event: message_delta -data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 15}} +data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"input_tokens": 8, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 15}} event: message_stop data: {"type": "message_stop"} @@ -930,7 +930,7 @@ data: {"type": "message_stop"} `, expStatus: http.StatusOK, expResponseBody: `event: message_start -data: {"type": "message_start", "message": {"id": "msg_789", "usage": {"input_tokens": 8}}} +data: {"type": "message_start", "message": {"id": "msg_789", "usage": {"input_tokens": 8, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 1}}} event: content_block_start data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}} @@ -942,7 +942,7 @@ event: content_block_stop data: {"type": "content_block_stop", "index": 0} event: message_delta -data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 15}} +data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"input_tokens": 8, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 15}} event: message_stop data: {"type": "message_stop"} @@ -1052,7 +1052,7 @@ event: content_block_stop data: {"type":"content_block_stop","index":0} event: message_delta -data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":9,"output_tokens":10}} +data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":9,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":10}} event: message_stop data: {"type":"message_stop"} @@ -1090,7 +1090,7 @@ data: {"type":"message_stop"} {"bytes":"eyJ0eXBlIjoiY29udGVudF9ibG9ja19kZWx0YSIsImluZGV4IjowLCJkZWx0YSI6eyJ0eXBlIjoidGV4dF9kZWx0YSIsInRleHQiOiLwn5GLIEhvdyJ9fQ==","p":"abcdefghijklmnopqrstuvwxyzABCDEFG"} {"bytes":"eyJ0eXBlIjoiY29udGVudF9ibG9ja19kZWx0YSIsImluZGV4IjowLCJkZWx0YSI6eyJ0eXBlIjoidGV4dF9kZWx0YSIsInRleHQiOiIgYXJlIHlvdSBkb2luZyB0b2RheT8ifX0=","p":"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234"} {"bytes":"eyJ0eXBlIjoiY29udGVudF9ibG9ja19zdG9wIiwiaW5kZXgiOjB9","p":"abcdefghijklmnopqrstuvwxyz"} -{"bytes":"eyJ0eXBlIjoibWVzc2FnZV9kZWx0YSIsImRlbHRhIjp7InN0b3BfcmVhc29uIjoiZW5kX3R1cm4iLCJzdG9wX3NlcXVlbmNlIjpudWxsfSwidXNhZ2UiOnsib3V0cHV0X3Rva2VucyI6MTV9fQ==","p":"abcdefghijklmnopqrstu"} +{"bytes":"eyJ0eXBlIjoibWVzc2FnZV9kZWx0YSIsImRlbHRhIjp7InN0b3BfcmVhc29uIjoiZW5kX3R1cm4iLCJzdG9wX3NlcXVlbmNlIjpudWxsfSwidXNhZ2UiOnsiaW5wdXRfdG9rZW5zIjoxMCwiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjowLCJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6MCwib3V0cHV0X3Rva2VucyI6MTV9fQ==","p":"abcdefghijklmnopqrstu"} {"bytes":"eyJ0eXBlIjoibWVzc2FnZV9zdG9wIiwiYW1hem9uLWJlZHJvY2staW52b2NhdGlvbk1ldHJpY3MiOnsiaW5wdXRUb2tlbkNvdW50IjoxMCwib3V0cHV0VG9rZW5Db3VudCI6MTUsImludm9jYXRpb25MYXRlbmN5IjoxNzk4LCJmaXJzdEJ5dGVMYXRlbmN5IjoxNTA3fX0=","p":"ab"} `, expStatus: http.StatusOK, @@ -1119,7 +1119,7 @@ event: content_block_stop data: {"type":"content_block_stop","index":0} event: message_delta -data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":15}} +data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":15}} event: message_stop data: {"type":"message_stop","amazon-bedrock-invocationMetrics":{"inputTokenCount":10,"outputTokenCount":15,"invocationLatency":1798,"firstByteLatency":1507}} From 224ea2224ad48f24c6e382e83bd7c58cd9f1f43d Mon Sep 17 00:00:00 2001 From: hustxiayang Date: Tue, 16 Jun 2026 14:12:09 -0400 Subject: [PATCH 28/29] Update internal/translator/anthropic_helper.go Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: hustxiayang --- internal/translator/anthropic_helper.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/translator/anthropic_helper.go b/internal/translator/anthropic_helper.go index f02753befe..127e3ace88 100644 --- a/internal/translator/anthropic_helper.go +++ b/internal/translator/anthropic_helper.go @@ -1062,7 +1062,7 @@ func (p *anthropicStreamParser) handleAnthropicStreamEvent(eventType []byte, dat p.tokenUsage.AddOutputTokens(output) } // Cache token details are already set in message_start — don't touch them here - // since message_delta only contains incremental output_tokens. + // to avoid double-counting, as message_delta may also contain them. p.tokenUsage.SetReasoningTokens(uint32(u.OutputTokensDetails.ThinkingTokens)) //nolint:gosec if event.Delta.StopReason != "" { p.stopReason = event.Delta.StopReason From da8daf962b6b628c80c966a94aebc3aa3b6b4e01 Mon Sep 17 00:00:00 2001 From: yxia216 Date: Wed, 17 Jun 2026 15:35:38 -0400 Subject: [PATCH 29/29] update-comments Signed-off-by: yxia216 --- internal/translator/anthropic_helper.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/internal/translator/anthropic_helper.go b/internal/translator/anthropic_helper.go index 127e3ace88..a852d1a678 100644 --- a/internal/translator/anthropic_helper.go +++ b/internal/translator/anthropic_helper.go @@ -966,8 +966,8 @@ func (p *anthropicStreamParser) handleAnthropicStreamEvent(eventType []byte, dat &u.CacheReadInputTokens, &u.CacheCreationInputTokens, ) - // For message_start, we store the initial usage but don't add to the accumulated - // The message_delta event will contain the final totals + // Set all input token counts (input, cache read, cache creation) from message_start. + // message_delta may also contain these fields but only output_tokens is used from it. if input, ok := usage.InputTokens(); ok { p.tokenUsage.SetInputTokens(input) } @@ -1061,8 +1061,6 @@ func (p *anthropicStreamParser) handleAnthropicStreamEvent(eventType []byte, dat if output, ok := usage.OutputTokens(); ok { p.tokenUsage.AddOutputTokens(output) } - // Cache token details are already set in message_start — don't touch them here - // to avoid double-counting, as message_delta may also contain them. p.tokenUsage.SetReasoningTokens(uint32(u.OutputTokensDetails.ThinkingTokens)) //nolint:gosec if event.Delta.StopReason != "" { p.stopReason = event.Delta.StopReason