From 99a504a0f3d42ed01ebc2dceb48bb2b027caceae Mon Sep 17 00:00:00 2001 From: Fede Ciliberti Date: Sun, 18 Oct 2026 18:38:54 -0300 Subject: [PATCH 01/17] feat(plugins): apply plugin upstream headers to provider calls --- internal/core/context.go | 30 ++++- internal/llmclient/client_request.go | 40 ++++++- internal/llmclient/plugin_headers_test.go | 105 ++++++++++++++++++ internal/plugins/request_state.go | 27 ++++- .../plugins/request_state_upstream_test.go | 41 +++++++ internal/server/plugin_chains.go | 7 ++ 6 files changed, 245 insertions(+), 5 deletions(-) create mode 100644 internal/llmclient/plugin_headers_test.go create mode 100644 internal/plugins/request_state_upstream_test.go diff --git a/internal/core/context.go b/internal/core/context.go index 255cf3dce..4907c1ccb 100644 --- a/internal/core/context.go +++ b/internal/core/context.go @@ -1,6 +1,9 @@ package core -import "context" +import ( + "context" + "net/http" +) // contextKey is a custom type for context keys to avoid collisions. type contextKey string @@ -69,6 +72,11 @@ const ( // credentialAllowedModelsKey stores the model allowlist bound to the // authenticated credential (a managed auth key). Empty means unrestricted. credentialAllowedModelsKey contextKey = "credential-allowed-models" //nolint:gosec // G101: context key name, not a credential. + + // pluginUpstreamHeadersKey stores the headers prompt-phase plugins wrote + // to Exchange.Headers.Upstream. The provider client merges them into every + // outgoing call, minus credential headers the provider itself sets. + pluginUpstreamHeadersKey contextKey = "plugin-upstream-headers" ) // RequestOrigin identifies whether a request came from an external caller or an @@ -396,3 +404,23 @@ func GetRequestOrigin(ctx context.Context) RequestOrigin { } return RequestOriginExternal } + +// WithPluginUpstreamHeaders returns a new context carrying the headers that +// prompt-phase plugins asked to add to provider calls. The headers are copied, +// so later edits to the source do not leak into the request. +func WithPluginUpstreamHeaders(ctx context.Context, headers http.Header) context.Context { + if len(headers) == 0 { + return ctx + } + return context.WithValue(ctx, pluginUpstreamHeadersKey, headers.Clone()) +} + +// PluginUpstreamHeaders returns the headers plugins asked to add to provider +// calls, or nil when no plugin set any. +func PluginUpstreamHeaders(ctx context.Context) http.Header { + if ctx == nil { + return nil + } + headers, _ := ctx.Value(pluginUpstreamHeadersKey).(http.Header) + return headers +} diff --git a/internal/llmclient/client_request.go b/internal/llmclient/client_request.go index fe4e77937..ee455d290 100644 --- a/internal/llmclient/client_request.go +++ b/internal/llmclient/client_request.go @@ -284,8 +284,10 @@ func (c *Client) buildRequest(ctx context.Context, req Request) (*http.Request, c.headerSetter(httpReq) } - // Apply request-specific headers - for key, values := range req.Headers { + // Apply request-specific headers, including the ones prompt-phase plugins + // asked to add. Plugin headers may not override provider credentials or + // transport-controlled headers: the provider always wins. + for key, values := range requestHeadersWithPluginOverrides(ctx, req.Headers) { httpReq.Header.Del(key) for _, value := range values { httpReq.Header.Add(key, value) @@ -305,6 +307,40 @@ func (c *Client) buildRequest(ctx context.Context, req Request) (*http.Request, return httpReq, nil } +// pluginDeniedUpstreamHeaders are never taken from a plugin: they carry +// provider credentials or control the transport, so a plugin must not be able +// to override what the provider client sets. +var pluginDeniedUpstreamHeaders = map[string]struct{}{ + "Authorization": {}, + "X-Goog-Api-Key": {}, + "X-Api-Key": {}, + "Cookie": {}, + "Host": {}, + "Content-Length": {}, +} + +// requestHeadersWithPluginOverrides merges the headers plugins wrote to +// Exchange.Headers.Upstream into the request headers. It returns reqHeaders +// unchanged when no plugin set any header. +func requestHeadersWithPluginOverrides(ctx context.Context, reqHeaders http.Header) http.Header { + extra := core.PluginUpstreamHeaders(ctx) + if len(extra) == 0 { + return reqHeaders + } + merged := reqHeaders.Clone() + if merged == nil { + merged = http.Header{} + } + for key, values := range extra { + canonical := http.CanonicalHeaderKey(key) + if _, denied := pluginDeniedUpstreamHeaders[canonical]; denied { + continue + } + merged[canonical] = append([]string(nil), values...) + } + return merged +} + func providerErrorStatusCode(err error) int { if isTimeoutError(err) { return http.StatusGatewayTimeout diff --git a/internal/llmclient/plugin_headers_test.go b/internal/llmclient/plugin_headers_test.go new file mode 100644 index 000000000..1adb025fb --- /dev/null +++ b/internal/llmclient/plugin_headers_test.go @@ -0,0 +1,105 @@ +package llmclient + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/enterpilot/gomodel/internal/core" +) + +func TestBuildRequestAppliesPluginUpstreamHeaders(t *testing.T) { + client := New(DefaultConfig("test", "http://upstream.test"), nil) + ctx := core.WithPluginUpstreamHeaders(context.Background(), http.Header{ + "X-Edge-Proxy-Token": {"edge-token"}, + }) + + for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodDelete} { + t.Run(method, func(t *testing.T) { + req, err := client.buildRequest(ctx, Request{Method: method, Endpoint: "/v1/models"}) + require.NoError(t, err) + assert.Equal(t, "edge-token", req.Header.Get("X-Edge-Proxy-Token")) + }) + } +} + +func TestBuildRequestPluginHeadersCannotOverrideCredentials(t *testing.T) { + client := New(DefaultConfig("test", "http://upstream.test"), func(req *http.Request) { + req.Header.Set("Authorization", "Bearer provider") + req.Header.Set("X-Goog-Api-Key", "provider-key") + }) + ctx := core.WithPluginUpstreamHeaders(context.Background(), http.Header{ + "Authorization": {"Bearer plugin"}, + "X-Goog-Api-Key": {"plugin-key"}, + "X-Api-Key": {"plugin-key"}, + "Cookie": {"session=plugin"}, + "Host": {"evil.test"}, + "Content-Length": {"999"}, + "X-Allowed-Header": {"allowed"}, + }) + + req, err := client.buildRequest(ctx, Request{Method: http.MethodGet, Endpoint: "/v1/models"}) + require.NoError(t, err) + + assert.Equal(t, "Bearer provider", req.Header.Get("Authorization")) + assert.Equal(t, "provider-key", req.Header.Get("X-Goog-Api-Key")) + assert.Empty(t, req.Header.Get("X-Api-Key")) + assert.Empty(t, req.Header.Get("Cookie")) + assert.Equal(t, "upstream.test", req.Host) + assert.Empty(t, req.Header.Get("Content-Length")) + assert.Equal(t, "allowed", req.Header.Get("X-Allowed-Header")) +} + +func TestBuildRequestPluginHeadersOverrideRequestHeaders(t *testing.T) { + client := New(DefaultConfig("test", "http://upstream.test"), nil) + ctx := core.WithPluginUpstreamHeaders(context.Background(), http.Header{ + "X-Edge-Proxy-Token": {"from-plugin"}, + }) + + req, err := client.buildRequest(ctx, Request{ + Method: http.MethodGet, + Endpoint: "/v1/models", + Headers: http.Header{"X-Edge-Proxy-Token": {"from-request"}}, + }) + require.NoError(t, err) + assert.Equal(t, "from-plugin", req.Header.Get("X-Edge-Proxy-Token")) +} + +func TestBuildRequestWithoutPluginHeadersIsUnchanged(t *testing.T) { + client := New(DefaultConfig("test", "http://upstream.test"), nil) + req, err := client.buildRequest(context.Background(), Request{ + Method: http.MethodGet, + Endpoint: "/v1/models", + Headers: http.Header{"X-Test": {"value"}}, + }) + require.NoError(t, err) + assert.Equal(t, "value", req.Header.Get("X-Test")) +} + +func TestDoAppliesPluginUpstreamHeaders(t *testing.T) { + var received http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + received = r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + client := New(DefaultConfig("test", server.URL), func(req *http.Request) { + req.Header.Set("Authorization", "Bearer provider") + }) + ctx := core.WithPluginUpstreamHeaders(context.Background(), http.Header{ + "X-Edge-Proxy-Token": {"edge-token"}, + "Authorization": {"Bearer plugin"}, + }) + + var result map[string]any + err := client.Do(ctx, Request{Method: http.MethodGet, Endpoint: "/v1/models"}, &result) + require.NoError(t, err) + assert.Equal(t, "edge-token", received.Get("X-Edge-Proxy-Token")) + assert.Equal(t, "Bearer provider", received.Get("Authorization")) +} diff --git a/internal/plugins/request_state.go b/internal/plugins/request_state.go index 91338090e..b30d071a8 100644 --- a/internal/plugins/request_state.go +++ b/internal/plugins/request_state.go @@ -4,6 +4,7 @@ import ( "context" "errors" "log/slog" + "maps" "net/http" "slices" "sync" @@ -29,6 +30,9 @@ type RequestState struct { requestHeaders http.Header originalHeaders http.Header promptEdits []PromptEdit + // upstreamHeaders are the headers plugins wrote to + // Exchange.Headers.Upstream; the provider client applies them. + upstreamHeaders http.Header } // PromptEdit is one instance's edit of the prompt, kept for the audit @@ -337,18 +341,37 @@ func (s *RequestState) ApplyRequestHeaders(dst http.Header) []string { } // Finish folds an exchange back into the state after a chain ran: upstream -// headers are logged (not applied in this version). +// headers are kept so the provider client can apply them to the outgoing +// call. func (s *RequestState) Finish(x *pluginapi.Exchange) { if s == nil || x == nil || x.Headers == nil || len(x.Headers.Upstream) == 0 { return } s.mu.Lock() + if s.upstreamHeaders == nil { + s.upstreamHeaders = http.Header{} + } + maps.Copy(s.upstreamHeaders, x.Headers.Upstream) logged := s.upstreamLogged s.upstreamLogged = true s.mu.Unlock() if !logged { - slog.Debug("plugin set upstream headers; not applied in this version", "request_id", x.Meta.RequestID, "count", len(x.Headers.Upstream)) + slog.Debug("plugin set upstream headers", "request_id", x.Meta.RequestID, "count", len(x.Headers.Upstream)) + } +} + +// UpstreamHeaders returns the headers plugins asked to add to provider calls, +// or nil when none were set. The caller gets a copy. +func (s *RequestState) UpstreamHeaders() http.Header { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + if len(s.upstreamHeaders) == 0 { + return nil } + return s.upstreamHeaders.Clone() } // credentialHeaders are never exposed to plugins. diff --git a/internal/plugins/request_state_upstream_test.go b/internal/plugins/request_state_upstream_test.go new file mode 100644 index 000000000..f268513b3 --- /dev/null +++ b/internal/plugins/request_state_upstream_test.go @@ -0,0 +1,41 @@ +package plugins + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/enterpilot/gomodel/pluginapi" +) + +func TestFinishKeepsPluginUpstreamHeaders(t *testing.T) { + state := NewRequestState() + x := state.NewExchange(context.Background(), pluginapi.Meta{}) + x.Headers.Upstream = http.Header{ + "X-Edge-Proxy-Token": {"edge-token"}, + } + + state.Finish(x) + + assert.Equal(t, http.Header{"X-Edge-Proxy-Token": {"edge-token"}}, state.UpstreamHeaders()) +} + +func TestFinishMergesUpstreamHeadersAcrossExchanges(t *testing.T) { + state := NewRequestState() + first := state.NewExchange(context.Background(), pluginapi.Meta{}) + first.Headers.Upstream = http.Header{"X-First": {"1"}} + state.Finish(first) + + second := state.NewExchange(context.Background(), pluginapi.Meta{}) + second.Headers.Upstream = http.Header{"X-Second": {"2"}} + state.Finish(second) + + assert.Equal(t, http.Header{"X-First": {"1"}, "X-Second": {"2"}}, state.UpstreamHeaders()) +} + +func TestUpstreamHeadersNilWithoutPlugins(t *testing.T) { + state := NewRequestState() + assert.Nil(t, state.UpstreamHeaders()) +} diff --git a/internal/server/plugin_chains.go b/internal/server/plugin_chains.go index 1bbc29481..37019c81f 100644 --- a/internal/server/plugin_chains.go +++ b/internal/server/plugin_chains.go @@ -57,6 +57,13 @@ func applyPluginRequestHeaders(c *echo.Context) { if changed := state.ApplyRequestHeaders(c.Request().Header); len(changed) > 0 { slog.Debug("plugins edited request headers", "request_id", core.GetRequestID(c.Request().Context()), "headers", changed) } + // Prompt-phase plugins may also add headers to provider calls. Carry them + // on the request context so every provider client of this request applies + // them to its outgoing calls. + if upstream := state.UpstreamHeaders(); len(upstream) > 0 { + req := c.Request() + *req = *req.WithContext(core.WithPluginUpstreamHeaders(req.Context(), upstream)) + } } // pluginDecisionDetail is the audit-visible summary of one plugin decision. From 5cbe37f16da5ccaa96e7c95874f2204c65d81417 Mon Sep 17 00:00:00 2001 From: Fede Ciliberti Date: Fri, 23 Oct 2026 11:05:16 -0300 Subject: [PATCH 02/17] fix(plugins): canonicalize upstream header names when merging Two prompt-phase plugins writing "x-foo" and "X-Foo" kept two keys in the request state. The provider client canonicalizes when it applies them, so which value reached the provider depended on map iteration order. Canonicalize on the way in. Later exchanges now replace earlier ones deterministically, whatever case each plugin used. Tests: `internal/plugins`. --- internal/plugins/request_state.go | 8 ++++++-- internal/plugins/request_state_upstream_test.go | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/internal/plugins/request_state.go b/internal/plugins/request_state.go index b30d071a8..80c1a449f 100644 --- a/internal/plugins/request_state.go +++ b/internal/plugins/request_state.go @@ -4,7 +4,6 @@ import ( "context" "errors" "log/slog" - "maps" "net/http" "slices" "sync" @@ -351,7 +350,12 @@ func (s *RequestState) Finish(x *pluginapi.Exchange) { if s.upstreamHeaders == nil { s.upstreamHeaders = http.Header{} } - maps.Copy(s.upstreamHeaders, x.Headers.Upstream) + // Canonicalize on the way in: two exchanges writing "x-foo" and "X-Foo" + // must replace each other instead of racing on map iteration order when + // the provider call is built. + for key, values := range x.Headers.Upstream { + s.upstreamHeaders[http.CanonicalHeaderKey(key)] = append([]string(nil), values...) + } logged := s.upstreamLogged s.upstreamLogged = true s.mu.Unlock() diff --git a/internal/plugins/request_state_upstream_test.go b/internal/plugins/request_state_upstream_test.go index f268513b3..93151e6a2 100644 --- a/internal/plugins/request_state_upstream_test.go +++ b/internal/plugins/request_state_upstream_test.go @@ -35,6 +35,20 @@ func TestFinishMergesUpstreamHeadersAcrossExchanges(t *testing.T) { assert.Equal(t, http.Header{"X-First": {"1"}, "X-Second": {"2"}}, state.UpstreamHeaders()) } +func TestFinishCanonicalizesUpstreamHeaderNames(t *testing.T) { + state := NewRequestState() + first := state.NewExchange(context.Background(), pluginapi.Meta{}) + first.Headers.Upstream = http.Header{"x-tenant": {"first"}} + state.Finish(first) + + second := state.NewExchange(context.Background(), pluginapi.Meta{}) + second.Headers.Upstream = http.Header{"X-Tenant": {"second"}} + state.Finish(second) + + // One canonical key, and the later exchange wins regardless of map order. + assert.Equal(t, http.Header{"X-Tenant": {"second"}}, state.UpstreamHeaders()) +} + func TestUpstreamHeadersNilWithoutPlugins(t *testing.T) { state := NewRequestState() assert.Nil(t, state.UpstreamHeaders()) From da62937b9b3fae7dfd2de05205bdb1341b487c52 Mon Sep 17 00:00:00 2001 From: Fede Ciliberti Date: Fri, 23 Oct 2026 11:05:22 -0300 Subject: [PATCH 03/17] refactor(plugins): use the shared credential-header list Three lists disagreed: core's audit-redaction list, the plugin request state's inbound redaction list, and the merge in `llmclient` that decides which plugin headers may reach a provider call. The merge list was the shortest one, so it let a plugin override Azure's `api-key` header and `X-Anthropic-Api-Key`, and it had no entry for `Proxy-Authorization`. `core.IsCredentialHeader` is now the one list. The request state and the header-editing plugin call it instead of keeping their own copy, and `llmclient` combines it with a short transport list (`Host`, `Content-Length`, `Transfer-Encoding`) that is about the connection, not credentials. Add `x-anthropic-api-key` to the shared list, which the header-editing plugin already rejected. Tests: `internal/core`, `internal/plugins`, `internal/plugins/builtin/headeredit`, `internal/llmclient` (including the Azure `api-key` case). --- internal/core/credential_headers.go | 1 + internal/core/credential_headers_test.go | 1 + internal/llmclient/client_request.go | 20 +++++++-------- internal/llmclient/plugin_headers_test.go | 25 +++++++++++++------ internal/plugins/builtin/headeredit/config.go | 16 ++---------- internal/plugins/request_state.go | 14 +++-------- 6 files changed, 34 insertions(+), 43 deletions(-) diff --git a/internal/core/credential_headers.go b/internal/core/credential_headers.go index fbae7d8c7..bfc03034f 100644 --- a/internal/core/credential_headers.go +++ b/internal/core/credential_headers.go @@ -13,6 +13,7 @@ var credentialHeaders = map[string]struct{}{ "x-api-key": {}, "api-key": {}, // Azure OpenAI credential header "x-goog-api-key": {}, // Google Gemini / Vertex credential header + "x-anthropic-api-key": {}, "x-auth-token": {}, "x-access-token": {}, "x-gomodel-key": {}, diff --git a/internal/core/credential_headers_test.go b/internal/core/credential_headers_test.go index 86a19ebf9..5d8d693d9 100644 --- a/internal/core/credential_headers_test.go +++ b/internal/core/credential_headers_test.go @@ -15,6 +15,7 @@ func TestIsCredentialHeader(t *testing.T) { {name: "Authorization", want: true}, {name: " X-API-Key ", want: true}, {name: "Set-Cookie", want: true}, + {name: "X-Anthropic-Api-Key", want: true}, {name: "x-gomodel-key", want: true}, {name: "X-Team", want: false}, {name: "Content-Type", want: false}, diff --git a/internal/llmclient/client_request.go b/internal/llmclient/client_request.go index ee455d290..aa0cc0360 100644 --- a/internal/llmclient/client_request.go +++ b/internal/llmclient/client_request.go @@ -307,16 +307,14 @@ func (c *Client) buildRequest(ctx context.Context, req Request) (*http.Request, return httpReq, nil } -// pluginDeniedUpstreamHeaders are never taken from a plugin: they carry -// provider credentials or control the transport, so a plugin must not be able -// to override what the provider client sets. -var pluginDeniedUpstreamHeaders = map[string]struct{}{ - "Authorization": {}, - "X-Goog-Api-Key": {}, - "X-Api-Key": {}, - "Cookie": {}, - "Host": {}, - "Content-Length": {}, +// pluginTransportHeaders control the transport rather than the provider call, +// so a plugin must not set them. Credential headers are refused separately, by +// the shared list in core, so the host, the request state and this merge cannot +// drift apart. +var pluginTransportHeaders = map[string]struct{}{ + "Host": {}, + "Content-Length": {}, + "Transfer-Encoding": {}, } // requestHeadersWithPluginOverrides merges the headers plugins wrote to @@ -333,7 +331,7 @@ func requestHeadersWithPluginOverrides(ctx context.Context, reqHeaders http.Head } for key, values := range extra { canonical := http.CanonicalHeaderKey(key) - if _, denied := pluginDeniedUpstreamHeaders[canonical]; denied { + if _, transport := pluginTransportHeaders[canonical]; transport || core.IsCredentialHeader(canonical) { continue } merged[canonical] = append([]string(nil), values...) diff --git a/internal/llmclient/plugin_headers_test.go b/internal/llmclient/plugin_headers_test.go index 1adb025fb..2c9bc7203 100644 --- a/internal/llmclient/plugin_headers_test.go +++ b/internal/llmclient/plugin_headers_test.go @@ -31,15 +31,21 @@ func TestBuildRequestPluginHeadersCannotOverrideCredentials(t *testing.T) { client := New(DefaultConfig("test", "http://upstream.test"), func(req *http.Request) { req.Header.Set("Authorization", "Bearer provider") req.Header.Set("X-Goog-Api-Key", "provider-key") + req.Header.Set("api-key", "azure-provider-key") }) ctx := core.WithPluginUpstreamHeaders(context.Background(), http.Header{ - "Authorization": {"Bearer plugin"}, - "X-Goog-Api-Key": {"plugin-key"}, - "X-Api-Key": {"plugin-key"}, - "Cookie": {"session=plugin"}, - "Host": {"evil.test"}, - "Content-Length": {"999"}, - "X-Allowed-Header": {"allowed"}, + "Authorization": {"Bearer plugin"}, + "Proxy-Authorization": {"Basic plugin"}, + "X-Goog-Api-Key": {"plugin-key"}, + "X-Api-Key": {"plugin-key"}, + "Api-Key": {"plugin-key"}, + "X-Anthropic-Api-Key": {"plugin-key"}, + "Cookie": {"session=plugin"}, + "Set-Cookie": {"session=plugin"}, + "Host": {"evil.test"}, + "Content-Length": {"999"}, + "Transfer-Encoding": {"chunked"}, + "X-Allowed-Header": {"allowed"}, }) req, err := client.buildRequest(ctx, Request{Method: http.MethodGet, Endpoint: "/v1/models"}) @@ -47,10 +53,15 @@ func TestBuildRequestPluginHeadersCannotOverrideCredentials(t *testing.T) { assert.Equal(t, "Bearer provider", req.Header.Get("Authorization")) assert.Equal(t, "provider-key", req.Header.Get("X-Goog-Api-Key")) + assert.Equal(t, "azure-provider-key", req.Header.Get("Api-Key")) + assert.Empty(t, req.Header.Get("Proxy-Authorization")) assert.Empty(t, req.Header.Get("X-Api-Key")) + assert.Empty(t, req.Header.Get("X-Anthropic-Api-Key")) assert.Empty(t, req.Header.Get("Cookie")) + assert.Empty(t, req.Header.Get("Set-Cookie")) assert.Equal(t, "upstream.test", req.Host) assert.Empty(t, req.Header.Get("Content-Length")) + assert.Empty(t, req.Header.Get("Transfer-Encoding")) assert.Equal(t, "allowed", req.Header.Get("X-Allowed-Header")) } diff --git a/internal/plugins/builtin/headeredit/config.go b/internal/plugins/builtin/headeredit/config.go index d20c1c87d..c34414b92 100644 --- a/internal/plugins/builtin/headeredit/config.go +++ b/internal/plugins/builtin/headeredit/config.go @@ -6,6 +6,7 @@ import ( "net/http" "strings" + "github.com/enterpilot/gomodel/internal/core" "github.com/enterpilot/gomodel/pluginapi" ) @@ -37,19 +38,6 @@ type config struct { UpstreamSet []string } -// credentialHeaders can never be set, added, or removed by this plugin. -var credentialHeaders = map[string]bool{ - "authorization": true, - "proxy-authorization": true, - "cookie": true, - "set-cookie": true, - "x-api-key": true, - "api-key": true, - "x-goog-api-key": true, - "x-anthropic-api-key": true, - "x-auth-token": true, -} - func decodeConfig(raw json.RawMessage) (config, error) { cfg, err := pluginapi.ParseConfig(Name, New().Manifest().ConfigSchema, raw) if err != nil { @@ -112,7 +100,7 @@ func checkName(name string, o op) error { } } lower := strings.ToLower(name) - if credentialHeaders[lower] { + if core.IsCredentialHeader(lower) { return fmt.Errorf("header %q carries credentials and cannot be edited", name) } if o != opRemove && (strings.Contains(lower, "secret") || strings.Contains(lower, "token")) { diff --git a/internal/plugins/request_state.go b/internal/plugins/request_state.go index 80c1a449f..e8adab949 100644 --- a/internal/plugins/request_state.go +++ b/internal/plugins/request_state.go @@ -317,7 +317,7 @@ func (s *RequestState) ApplyRequestHeaders(dst http.Header) []string { var changed []string for key, values := range s.requestHeaders { canonical := http.CanonicalHeaderKey(key) - if _, secret := credentialHeaders[canonical]; secret || slices.Equal(values, s.originalHeaders[canonical]) { + if core.IsCredentialHeader(canonical) || slices.Equal(values, s.originalHeaders[canonical]) { continue } if slices.Contains(values, "[redacted]") { @@ -327,7 +327,7 @@ func (s *RequestState) ApplyRequestHeaders(dst http.Header) []string { changed = append(changed, canonical) } for key := range s.originalHeaders { - if _, secret := credentialHeaders[key]; secret { + if core.IsCredentialHeader(key) { continue } if _, still := s.requestHeaders[key]; !still { @@ -378,14 +378,6 @@ func (s *RequestState) UpstreamHeaders() http.Header { return s.upstreamHeaders.Clone() } -// credentialHeaders are never exposed to plugins. -var credentialHeaders = map[string]struct{}{ - "Authorization": {}, - "X-Api-Key": {}, - "Proxy-Authorization": {}, - "Cookie": {}, -} - func inboundHeaders(ctx context.Context) http.Header { snapshot := core.GetRequestSnapshot(ctx) if snapshot == nil { @@ -395,7 +387,7 @@ func inboundHeaders(ctx context.Context) http.Header { out := make(http.Header, len(source)) for key, values := range source { canonical := http.CanonicalHeaderKey(key) - if _, secret := credentialHeaders[canonical]; secret { + if core.IsCredentialHeader(canonical) { out[canonical] = []string{"[redacted]"} continue } From 09c948911d4dc2f6e751d5238429b66d61febe09 Mon Sep 17 00:00:00 2001 From: Fede Ciliberti Date: Fri, 23 Oct 2026 11:05:25 -0300 Subject: [PATCH 04/17] fix(server): apply plugin upstream headers to utility calls Token counting and compaction run the prompt chain through `utilityRequest`, so a prompt-phase plugin can set `Headers.Upstream` on those requests too. The handler never attached the captured headers to the request context, so a provider behind an authenticating proxy accepted generation calls and rejected these two. Call `applyPluginRequestHeaders` after the chain runs, and re-read the context from the request afterwards: the provider call was using the context captured before the headers were attached. Tests: `TestResponsesUtilityRoutesCarryPluginUpstreamHeaders` drives both routes and fails without the call or without the context refresh. --- internal/server/handlers_test.go | 7 ++- internal/server/native_response_service.go | 12 ++++ .../native_response_utility_headers_test.go | 63 +++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 internal/server/native_response_utility_headers_test.go diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go index 9b7946afe..942ae920c 100644 --- a/internal/server/handlers_test.go +++ b/internal/server/handlers_test.go @@ -496,6 +496,7 @@ type mockProvider struct { responseCancelCalls []responseCall responseDeleteCalls []responseCall capturedResponseUtilityReqs []*core.ResponsesRequest + capturedUtilityHeaders []http.Header capturedResponseUtility []responseUtilityCall } @@ -899,8 +900,9 @@ func (m *mockProvider) DeleteResponse(_ context.Context, providerType, id string return &core.ResponseDeleteResponse{ID: id, Object: "response", Deleted: true}, nil } -func (m *mockProvider) CountResponseInputTokens(_ context.Context, providerType string, req *core.ResponsesRequest) (*core.ResponseInputTokensResponse, error) { +func (m *mockProvider) CountResponseInputTokens(ctx context.Context, providerType string, req *core.ResponsesRequest) (*core.ResponseInputTokensResponse, error) { m.capturedResponseUtilityReqs = append(m.capturedResponseUtilityReqs, req) + m.capturedUtilityHeaders = append(m.capturedUtilityHeaders, core.PluginUpstreamHeaders(ctx)) m.capturedResponseUtility = append(m.capturedResponseUtility, responseUtilityCall{ provider: providerType, operation: "CountResponseInputTokens", @@ -914,8 +916,9 @@ func (m *mockProvider) CountResponseInputTokens(_ context.Context, providerType return &core.ResponseInputTokensResponse{Object: "response.input_tokens", InputTokens: 7}, nil } -func (m *mockProvider) CompactResponse(_ context.Context, providerType string, req *core.ResponsesRequest) (*core.ResponseCompactResponse, error) { +func (m *mockProvider) CompactResponse(ctx context.Context, providerType string, req *core.ResponsesRequest) (*core.ResponseCompactResponse, error) { m.capturedResponseUtilityReqs = append(m.capturedResponseUtilityReqs, req) + m.capturedUtilityHeaders = append(m.capturedUtilityHeaders, core.PluginUpstreamHeaders(ctx)) m.capturedResponseUtility = append(m.capturedResponseUtility, responseUtilityCall{ provider: providerType, operation: "CompactResponse", diff --git a/internal/server/native_response_service.go b/internal/server/native_response_service.go index 158b9d300..5738746d4 100644 --- a/internal/server/native_response_service.go +++ b/internal/server/native_response_service.go @@ -209,6 +209,9 @@ func (s *nativeResponseService) CountResponseInputTokens(c *echo.Context) error if err != nil { return handleError(c, err) } + // utilityRequest may have replaced the request with one carrying the + // plugin headers, so the provider call reads the context from it. + ctx = c.Request().Context() auditResponseEntry(c, providerType) router, ok := s.provider.(core.NativeResponseUtilityRoutableProvider) @@ -239,6 +242,9 @@ func (s *nativeResponseService) CompactResponse(c *echo.Context) error { if err != nil { return handleError(c, err) } + // utilityRequest may have replaced the request with one carrying the + // plugin headers, so the provider call reads the context from it. + ctx = c.Request().Context() auditResponseEntry(c, providerType) router, ok := s.provider.(core.NativeResponseUtilityRoutableProvider) @@ -285,6 +291,12 @@ func (s *nativeResponseService) utilityRequest(c *echo.Context) (*core.Responses } } + // The prompt chain above may have added headers to the provider call + // (Exchange.Headers.Upstream). Attach them to the live request like the + // inference paths do, so token counting and compaction reach a provider + // behind an authenticating proxy as well. + applyPluginRequestHeaders(c) + providerType := "" if workflow != nil { providerType = strings.TrimSpace(workflow.ProviderType) diff --git a/internal/server/native_response_utility_headers_test.go b/internal/server/native_response_utility_headers_test.go new file mode 100644 index 000000000..58993251c --- /dev/null +++ b/internal/server/native_response_utility_headers_test.go @@ -0,0 +1,63 @@ +package server + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/enterpilot/gomodel/internal/core" + "github.com/enterpilot/gomodel/internal/plugins" + "github.com/enterpilot/gomodel/pluginapi" +) + +// upstreamHeaderPatcher stands in for the prompt chain: it writes a header to +// the request state the way a prompt-phase plugin does, and leaves the request +// untouched. +type upstreamHeaderPatcher struct{} + +func (upstreamHeaderPatcher) PatchChatRequest(_ context.Context, req *core.ChatRequest) (*core.ChatRequest, error) { + return req, nil +} + +func (upstreamHeaderPatcher) PatchResponsesRequest(ctx context.Context, req *core.ResponsesRequest) (*core.ResponsesRequest, error) { + state := plugins.RequestStateFor(ctx) + exchange := state.NewExchange(ctx, pluginapi.Meta{}) + exchange.Headers.Upstream = http.Header{"X-Edge-Proxy-Token": {"edge-token"}} + state.Finish(exchange) + return req, nil +} + +// TestResponsesUtilityRoutesCarryPluginUpstreamHeaders proves that token +// counting and compaction reach a provider behind an authenticating proxy with +// the headers a prompt-phase plugin asked for, not only the inference routes. +func TestResponsesUtilityRoutesCarryPluginUpstreamHeaders(t *testing.T) { + provider := &mockProvider{ + supportedModels: []string{"gpt-5-mini"}, + providerTypes: map[string]string{"gpt-5-mini": "mock"}, + } + srv := New(provider, nil) + srv.handler.translatedRequestPatcher = upstreamHeaderPatcher{} + + for _, path := range []string{"/v1/responses/input_tokens", "/v1/responses/compact"} { + t.Run(path, func(t *testing.T) { + ctx, _ := plugins.WithRequestState(context.Background()) + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"model":"gpt-5-mini","input":"hello"}`)) + req.Header.Set("Content-Type", "application/json") + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + + srv.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + }) + } + + require.Len(t, provider.capturedUtilityHeaders, 2) + for _, headers := range provider.capturedUtilityHeaders { + require.Equal(t, "edge-token", headers.Get("X-Edge-Proxy-Token")) + } +} From f91c75eea3f611e56dddd4c032274b62195f3cfc Mon Sep 17 00:00:00 2001 From: Fede Ciliberti Date: Fri, 23 Oct 2026 11:05:26 -0300 Subject: [PATCH 05/17] docs(plugins): document where upstream headers are forwarded `Headers.Upstream` and `upstream_set` claimed the headers were recorded but not forwarded. They now reach every provider call of the request: failover attempts, token counting and compaction included. Credential and transport headers are refused, and the provider sets its own credentials last. --- docs/advanced/guardrails.mdx | 2 +- docs/advanced/plugins.mdx | 2 +- internal/plugins/builtin/headeredit/plugin.go | 2 +- internal/server/handlers_test.go | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/advanced/guardrails.mdx b/docs/advanced/guardrails.mdx index e0357e607..7d3c83dc8 100644 --- a/docs/advanced/guardrails.mdx +++ b/docs/advanced/guardrails.mdx @@ -454,7 +454,7 @@ remove lines are a bare `Name`. Blank lines and `#` comments are ignored. | `response_set` | Replace a header on the response sent to the client. | | `response_add` | Append a value to a client response header, keeping existing values. Applied once per request even when the instance runs in both phases. | | `response_remove` | Drop a header from the client response, including headers GoModel adds itself (for example `X-Request-Id`). | -| `upstream_set` | Static headers for the provider call. Recorded but not forwarded in this release. | +| `upstream_set` | Static headers merged into every provider call of the request: failover attempts, token counting and compaction included. Credential and transport headers (`Host`, `Content-Length`, `Transfer-Encoding`) are refused. | Credential headers (`Authorization`, `X-Api-Key`, `Cookie`, ...) can never be edited, and names containing `secret` or `token` cannot be set. diff --git a/docs/advanced/plugins.mdx b/docs/advanced/plugins.mdx index dc2d66848..72ae3b0ec 100644 --- a/docs/advanced/plugins.mdx +++ b/docs/advanced/plugins.mdx @@ -284,7 +284,7 @@ of a request: | `Prompt` | `*Prompt` | the conversation (`Messages` with stable IDs and typed `Parts`: text, image, audio, file, tool call, tool result, reasoning, refusal, opaque), `Tools`, `Params`, raw body. Edit through `SetText`, `SetMedia`, `SetToolArguments`, `SetToolResult`, `Insert`, `Append`, `Remove`, `SetParam`; `TextTargets(roles...)` lists every text part, tool-result text included, and `SetTargetText` writes one back | | `Response` | `*Completion` | the response, `nil` until the response phase: `Choices` with the same `Part` model, `Usage`, raw body. Edit through `SetText`, `ReplaceText`, `SetFinishReason`, `SetToolArguments`; `TextTargets` and `SetTargetText` work as on `Prompt` | | `Stream` | `*StreamState` | text accumulated so far per choice and the event count; `nil` for non-streaming requests | -| `Headers` | `*Headers` | `Request` (inbound, credentials redacted; prompt-phase edits apply to the live request for later plugins and request logging, not to the provider call), `Response` (applied to the client response; a header set to the empty string is removed, including ones the gateway adds such as `X-Request-Id`), `Upstream` (recorded, not forwarded in this release) | +| `Headers` | `*Headers` | `Request` (inbound, credentials redacted; prompt-phase edits apply to the live request for later plugins and request logging, not to the provider call), `Response` (applied to the client response; a header set to the empty string is removed, including ones the gateway adds such as `X-Request-Id`), `Upstream` (merged into every outgoing provider call of the request, failover attempts included, on token-counting and compaction calls too; credential headers and transport headers such as `Host`, `Content-Length` and `Transfer-Encoding` are refused, and the provider always sets its own credentials last) | | `Values` | `Values` | per-request bag shared by every hook of the request; prefix keys with the plugin name | Edits go through methods, not field assignment, so GoModel re-encodes only diff --git a/internal/plugins/builtin/headeredit/plugin.go b/internal/plugins/builtin/headeredit/plugin.go index fac32214d..f60578a8d 100644 --- a/internal/plugins/builtin/headeredit/plugin.go +++ b/internal/plugins/builtin/headeredit/plugin.go @@ -68,7 +68,7 @@ func (p *Plugin) Manifest() pluginapi.Manifest { }, { Key: "upstream_set", Label: "Upstream headers to set", Input: pluginapi.InputTextarea, Default: "", - Help: "One \"Name: value\" per line. Adds static headers to the provider call. Host support is limited in this version: providers that do not accept extra headers ignore the field.", + Help: "One \"Name: value\" per line. Adds static headers to every provider call of the request, failover attempts included. Credential headers are refused.", Placeholder: "X-Tenant: acme", }, }, diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go index 942ae920c..58e50c8d9 100644 --- a/internal/server/handlers_test.go +++ b/internal/server/handlers_test.go @@ -496,7 +496,7 @@ type mockProvider struct { responseCancelCalls []responseCall responseDeleteCalls []responseCall capturedResponseUtilityReqs []*core.ResponsesRequest - capturedUtilityHeaders []http.Header + capturedUtilityHeaders []http.Header capturedResponseUtility []responseUtilityCall } From e34f88f20bbdfaebd1c2f28073cad7e844717574 Mon Sep 17 00:00:00 2001 From: Fede Ciliberti Date: Fri, 23 Oct 2026 11:33:25 -0300 Subject: [PATCH 06/17] test(llmclient): hand the captured headers over a channel The httptest handler runs in another goroutine and wrote `received` for the assertions to read, with no synchronization: a data race under `-race`. Send the cloned headers through a buffered channel and read them after `Do` returns. `go test -race ./internal/llmclient/` is green. --- internal/llmclient/plugin_headers_test.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/internal/llmclient/plugin_headers_test.go b/internal/llmclient/plugin_headers_test.go index 2c9bc7203..f96354cb7 100644 --- a/internal/llmclient/plugin_headers_test.go +++ b/internal/llmclient/plugin_headers_test.go @@ -92,9 +92,11 @@ func TestBuildRequestWithoutPluginHeadersIsUnchanged(t *testing.T) { } func TestDoAppliesPluginUpstreamHeaders(t *testing.T) { - var received http.Header + received := make(chan http.Header, 1) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - received = r.Header.Clone() + // Hand the headers over through a channel: the handler runs in + // another goroutine, and a shared variable would be a data race. + received <- r.Header.Clone() w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"ok":true}`)) })) @@ -111,6 +113,7 @@ func TestDoAppliesPluginUpstreamHeaders(t *testing.T) { var result map[string]any err := client.Do(ctx, Request{Method: http.MethodGet, Endpoint: "/v1/models"}, &result) require.NoError(t, err) - assert.Equal(t, "edge-token", received.Get("X-Edge-Proxy-Token")) - assert.Equal(t, "Bearer provider", received.Get("Authorization")) + got := <-received + assert.Equal(t, "edge-token", got.Get("X-Edge-Proxy-Token")) + assert.Equal(t, "Bearer provider", got.Get("Authorization")) } From 4021d166d3483a05e94272de7065e0e8dbdbb9b4 Mon Sep 17 00:00:00 2001 From: Fede Ciliberti Date: Sun, 25 Oct 2026 15:11:00 -0300 Subject: [PATCH 07/17] test(llmclient): assert a plugin Host header is denied The existing assertion read req.Host, which net/http sets from the URL and never from req.Header, so it passed whether or not Host was denied. Assert the header map as well: without the deny entry the plugin value lands there and the test now fails. --- internal/llmclient/plugin_headers_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/llmclient/plugin_headers_test.go b/internal/llmclient/plugin_headers_test.go index f96354cb7..f7b0d8d5b 100644 --- a/internal/llmclient/plugin_headers_test.go +++ b/internal/llmclient/plugin_headers_test.go @@ -60,6 +60,7 @@ func TestBuildRequestPluginHeadersCannotOverrideCredentials(t *testing.T) { assert.Empty(t, req.Header.Get("Cookie")) assert.Empty(t, req.Header.Get("Set-Cookie")) assert.Equal(t, "upstream.test", req.Host) + assert.Empty(t, req.Header.Get("Host"), "a plugin Host is denied, not merely shadowed by the URL host") assert.Empty(t, req.Header.Get("Content-Length")) assert.Empty(t, req.Header.Get("Transfer-Encoding")) assert.Equal(t, "allowed", req.Header.Get("X-Allowed-Header")) From 5446e629629bc61b5085524e8e5f507f2bf0fa06 Mon Sep 17 00:00:00 2001 From: Fede Ciliberti Date: Fri, 30 Oct 2026 08:21:46 -0300 Subject: [PATCH 08/17] fix(llmclient): stop applying plugin headers to internal calls Gateway-internal inference issued by a plugin (a guardrail's LLM judge) runs on a context derived from the request that triggered it, so it inherited the headers the prompt phase recorded for the client's provider call and sent them to the model the gateway picked for itself. The merge now returns the request headers untouched unless the origin is external, the same signal idempotency-key forwarding uses. --- internal/llmclient/client_request.go | 10 +++++ internal/llmclient/plugin_headers_test.go | 51 +++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/internal/llmclient/client_request.go b/internal/llmclient/client_request.go index aa0cc0360..1159f36e1 100644 --- a/internal/llmclient/client_request.go +++ b/internal/llmclient/client_request.go @@ -320,7 +320,17 @@ var pluginTransportHeaders = map[string]struct{}{ // requestHeadersWithPluginOverrides merges the headers plugins wrote to // Exchange.Headers.Upstream into the request headers. It returns reqHeaders // unchanged when no plugin set any header. +// +// Only external traffic carries them. Gateway-internal inference issued by a +// plugin — its Host on behalf of an LLM-based guardrail, for example — is built +// on a context derived from the request that triggered it, so it would +// otherwise inherit headers chosen for the client's provider and send them to +// the model the gateway picked for itself. Origin is the same signal the +// idempotency-key forwarding above uses to tell the two apart. func requestHeadersWithPluginOverrides(ctx context.Context, reqHeaders http.Header) http.Header { + if core.GetRequestOrigin(ctx) != core.RequestOriginExternal { + return reqHeaders + } extra := core.PluginUpstreamHeaders(ctx) if len(extra) == 0 { return reqHeaders diff --git a/internal/llmclient/plugin_headers_test.go b/internal/llmclient/plugin_headers_test.go index f7b0d8d5b..d4571edd3 100644 --- a/internal/llmclient/plugin_headers_test.go +++ b/internal/llmclient/plugin_headers_test.go @@ -27,6 +27,57 @@ func TestBuildRequestAppliesPluginUpstreamHeaders(t *testing.T) { } } +// TestBuildRequestPluginOriginDropsPluginUpstreamHeaders proves that inference a +// plugin issues through its own Host — a guardrail's LLM judge, for example — +// does not inherit the headers the prompt phase recorded. Those headers +// describe the client's provider call, not the model the gateway picks for +// itself, and the internal call shares the context that carries them. +func TestBuildRequestPluginOriginDropsPluginUpstreamHeaders(t *testing.T) { + client := New(DefaultConfig("test", "http://upstream.test"), func(req *http.Request) { + req.Header.Set("Authorization", "Bearer provider") + }) + + tests := []struct { + name string + origin core.RequestOrigin + wantPlugin bool + }{ + {name: "external", origin: core.RequestOriginExternal, wantPlugin: true}, + {name: "plugin", origin: core.RequestOriginPlugin}, + {name: "guardrail", origin: core.RequestOriginGuardrail}, + {name: "unset", wantPlugin: true}, // external traffic is the default + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + if tt.origin != "" { + ctx = core.WithRequestOrigin(ctx, tt.origin) + } + ctx = core.WithPluginUpstreamHeaders(ctx, http.Header{ + "X-Edge-Proxy-Token": {"edge-token"}, + "X-Judge-Trace": {"plugin"}, + }) + + req, err := client.buildRequest(ctx, Request{ + Method: http.MethodGet, + Endpoint: "/v1/models", + Headers: http.Header{"X-Request-Scoped": {"scoped"}}, + }) + require.NoError(t, err) + + if tt.wantPlugin { + assert.Equal(t, "edge-token", req.Header.Get("X-Edge-Proxy-Token")) + assert.Equal(t, "plugin", req.Header.Get("X-Judge-Trace")) + } else { + assert.Empty(t, req.Header.Get("X-Edge-Proxy-Token")) + assert.Empty(t, req.Header.Get("X-Judge-Trace"), "a plugin's own inference carries no plugin upstream header") + } + assert.Equal(t, "Bearer provider", req.Header.Get("Authorization")) + assert.Equal(t, "scoped", req.Header.Get("X-Request-Scoped"), "request headers are left alone") + }) + } +} + func TestBuildRequestPluginHeadersCannotOverrideCredentials(t *testing.T) { client := New(DefaultConfig("test", "http://upstream.test"), func(req *http.Request) { req.Header.Set("Authorization", "Bearer provider") From 25810950ca349543b33538843fdb3550a1479fc9 Mon Sep 17 00:00:00 2001 From: Fede Ciliberti Date: Fri, 30 Oct 2026 08:22:50 -0300 Subject: [PATCH 09/17] fix(llmclient): complete the plugin header denial lists xi-api-key joins the shared credential list, so ElevenLabs keys are no longer forwarded from a plugin and no longer reach the audit log unredacted. Transport gains the connection-framing headers (Connection, Te, Trailer, Upgrade, Expect) and the body-encoding ones the gateway and the provider decide (Accept-Encoding, Content-Type). anthropic-beta gets its own list: the Anthropic provider adds oauth-2025-04-20 in a header hook this merge runs after, so a plugin value would drop the flag subscription credentials need. --- internal/core/credential_headers.go | 1 + internal/core/credential_headers_test.go | 34 +++++++++ internal/llmclient/client_request.go | 42 +++++++++-- internal/llmclient/plugin_headers_test.go | 85 +++++++++++++++++++++++ internal/providers/anthropic/anthropic.go | 9 +++ 5 files changed, 167 insertions(+), 4 deletions(-) diff --git a/internal/core/credential_headers.go b/internal/core/credential_headers.go index bfc03034f..edc2dd4ec 100644 --- a/internal/core/credential_headers.go +++ b/internal/core/credential_headers.go @@ -17,6 +17,7 @@ var credentialHeaders = map[string]struct{}{ "x-auth-token": {}, "x-access-token": {}, "x-gomodel-key": {}, + "xi-api-key": {}, // ElevenLabs credential header } // maxCredentialHeaderLen bounds the lowercase scratch buffer below; every diff --git a/internal/core/credential_headers_test.go b/internal/core/credential_headers_test.go index 5d8d693d9..55c921a43 100644 --- a/internal/core/credential_headers_test.go +++ b/internal/core/credential_headers_test.go @@ -1,11 +1,45 @@ package core import ( + "net/http" + "strings" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +// TestCredentialHeaderList is the coverage the list owes: one case per header the +// gateway treats as a secret. The length check ties the table to the map, so a +// header added to the list without a case — or a case for a header that is not in +// the list — fails here. +func TestCredentialHeaderList(t *testing.T) { + names := []string{ + "authorization", + "proxy-authorization", + "cookie", + "set-cookie", + "x-api-key", + "api-key", // Azure OpenAI + "x-goog-api-key", // Google Gemini / Vertex + "x-anthropic-api-key", // + "x-auth-token", + "x-access-token", + "x-gomodel-key", + "xi-api-key", // ElevenLabs + } + require.Len(t, credentialHeaders, len(names), "credential list and cases drifted apart") + + for _, name := range names { + // Clients and providers spell the header however they like; the scratch + // buffer in IsCredentialHeader must fold all of them, and must be long + // enough for every name in the list. + for _, variant := range []string{name, strings.ToUpper(name), http.CanonicalHeaderKey(name), " " + name + " "} { + assert.True(t, IsCredentialHeader(variant), "IsCredentialHeader(%q)", variant) + } + } +} + func TestIsCredentialHeader(t *testing.T) { tests := []struct { name string diff --git a/internal/llmclient/client_request.go b/internal/llmclient/client_request.go index 1159f36e1..d8134fd35 100644 --- a/internal/llmclient/client_request.go +++ b/internal/llmclient/client_request.go @@ -307,14 +307,48 @@ func (c *Client) buildRequest(ctx context.Context, req Request) (*http.Request, return httpReq, nil } -// pluginTransportHeaders control the transport rather than the provider call, -// so a plugin must not set them. Credential headers are refused separately, by -// the shared list in core, so the host, the request state and this merge cannot +// pluginTransportHeaders control the transport or the framing of the body +// rather than the provider call, so a plugin must not set them: the HTTP client +// frames the connection, and the gateway and the provider decide how the body is +// encoded and compressed. Credential headers are refused separately, by the +// shared list in core, so the host, the request state and this merge cannot // drift apart. var pluginTransportHeaders = map[string]struct{}{ "Host": {}, "Content-Length": {}, "Transfer-Encoding": {}, + "Connection": {}, + "Te": {}, + "Trailer": {}, + "Upgrade": {}, + "Expect": {}, + "Accept-Encoding": {}, + "Content-Type": {}, +} + +// pluginReservedProviderHeaders lists headers a provider negotiates with its +// own hook and that a plugin would silently break by replacing. +// +// anthropic-beta selects Anthropic features, and subscription credentials must +// carry oauth-2025-04-20 in it: the Anthropic provider adds that flag in its +// header hook, which this merge runs after, so a plugin value would drop the +// flag and the request would be rejected. It is denied here rather than inside +// the provider because no provider-side code runs after this merge. +var pluginReservedProviderHeaders = map[string]struct{}{ + "Anthropic-Beta": {}, +} + +// pluginHeaderDenied reports whether the merge must refuse the header: it +// carries a credential, it controls the transport or the body encoding, or it +// negotiates provider features the gateway owns. +func pluginHeaderDenied(name string) bool { + if _, denied := pluginTransportHeaders[name]; denied { + return true + } + if _, denied := pluginReservedProviderHeaders[name]; denied { + return true + } + return core.IsCredentialHeader(name) } // requestHeadersWithPluginOverrides merges the headers plugins wrote to @@ -341,7 +375,7 @@ func requestHeadersWithPluginOverrides(ctx context.Context, reqHeaders http.Head } for key, values := range extra { canonical := http.CanonicalHeaderKey(key) - if _, transport := pluginTransportHeaders[canonical]; transport || core.IsCredentialHeader(canonical) { + if pluginHeaderDenied(canonical) { continue } merged[canonical] = append([]string(nil), values...) diff --git a/internal/llmclient/plugin_headers_test.go b/internal/llmclient/plugin_headers_test.go index d4571edd3..ae5fef9bc 100644 --- a/internal/llmclient/plugin_headers_test.go +++ b/internal/llmclient/plugin_headers_test.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "net/http/httptest" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -78,6 +79,90 @@ func TestBuildRequestPluginOriginDropsPluginUpstreamHeaders(t *testing.T) { } } +// TestBuildRequestDeniesPluginHeaders pins one case per denied header: transport +// and body-framing headers, provider-negotiated headers, and the credential list. +// The length checks tie the table to the two lists that live in this package, so +// a header added to one of them without a case here fails. +func TestBuildRequestDeniesPluginHeaders(t *testing.T) { + transport := []string{ + "Host", + "Content-Length", + "Transfer-Encoding", + "Connection", + "Te", + "Trailer", + "Upgrade", + "Expect", + "Accept-Encoding", + "Content-Type", + } + reserved := []string{ + "Anthropic-Beta", + } + credentials := []string{ + "Authorization", + "Proxy-Authorization", + "Cookie", + "Set-Cookie", + "X-Api-Key", + "Api-Key", + "X-Goog-Api-Key", + "X-Anthropic-Api-Key", + "X-Auth-Token", + "X-Access-Token", + "X-GoModel-Key", + "Xi-Api-Key", + } + require.Len(t, pluginTransportHeaders, len(transport), "transport list and cases drifted apart") + require.Len(t, pluginReservedProviderHeaders, len(reserved), "reserved list and cases drifted apart") + + groups := []struct { + name string + headers []string + }{ + {name: "transport", headers: transport}, + {name: "reserved", headers: reserved}, + {name: "credential", headers: credentials}, + } + for _, group := range groups { + for _, name := range group.headers { + t.Run(group.name+"/"+name, func(t *testing.T) { + // A setter that claims the header itself proves the plugin value + // loses to the provider's, not merely that it was never written. + client := New(DefaultConfig("test", "http://upstream.test"), func(req *http.Request) { + req.Header.Set(name, "provider-value") + }) + // The plugin spells the name in lower case on purpose: the denial + // must survive the canonicalization the merge applies. + ctx := core.WithPluginUpstreamHeaders(context.Background(), http.Header{strings.ToLower(name): {"plugin-value"}}) + + req, err := client.buildRequest(ctx, Request{Method: http.MethodPost, Endpoint: "/v1/chat/completions", Body: []byte(`{}`)}) + require.NoError(t, err) + assert.NotContains(t, req.Header.Values(name), "plugin-value", "a plugin may not set %s", name) + assert.Equal(t, []string{"provider-value"}, req.Header.Values(name), "the provider's %s survives", name) + }) + } + } +} + +// TestBuildRequestKeepsProviderOAuthBetaFromPlugins pins the finding the review +// raised: a plugin that replaced anthropic-beta would drop the oauth-2025-04-20 +// flag the Anthropic provider adds in its own header hook, and subscription +// credentials need that flag. The merge refuses the header, so the provider's +// value is what reaches the wire. +func TestBuildRequestKeepsProviderOAuthBetaFromPlugins(t *testing.T) { + client := New(DefaultConfig("test", "http://upstream.test"), func(req *http.Request) { + req.Header.Set("Anthropic-Beta", "oauth-2025-04-20") + }) + ctx := core.WithPluginUpstreamHeaders(context.Background(), http.Header{ + "anthropic-beta": {"prompt-caching-2024-07-31"}, + }) + + req, err := client.buildRequest(ctx, Request{Method: http.MethodPost, Endpoint: "/v1/messages", Body: []byte(`{}`)}) + require.NoError(t, err) + assert.Equal(t, []string{"oauth-2025-04-20"}, req.Header.Values("Anthropic-Beta")) +} + func TestBuildRequestPluginHeadersCannotOverrideCredentials(t *testing.T) { client := New(DefaultConfig("test", "http://upstream.test"), func(req *http.Request) { req.Header.Set("Authorization", "Bearer provider") diff --git a/internal/providers/anthropic/anthropic.go b/internal/providers/anthropic/anthropic.go index 6561e7a4b..96693e9aa 100644 --- a/internal/providers/anthropic/anthropic.go +++ b/internal/providers/anthropic/anthropic.go @@ -208,6 +208,10 @@ func (p *Provider) setHeaders(req *http.Request) { req.Header.Set("Authorization", "Bearer "+key) case isOAuthToken(key): req.Header.Set("Authorization", "Bearer "+key) + // Nothing merges this flag back on the typed path: the plugin header + // merge in llmclient runs after this hook, so it denies anthropic-beta + // outright (llmclient.pluginReservedProviderHeaders). Keep the two in + // step; a plugin that replaced the header would drop the flag. req.Header.Set(anthropicBetaHeader, oauthBetaFlag) default: req.Header.Set("x-api-key", key) @@ -234,6 +238,11 @@ func (p *Provider) setHeaders(req *http.Request) { // by setHeaders, so a client that sends its own beta list would otherwise drop // the oauth flag subscription tokens require. Headers without an anthropic-beta // entry are returned unchanged: setHeaders' value survives in that case. +// +// Only client headers reach this merge: plugins cannot set anthropic-beta (see +// llmclient.pluginReservedProviderHeaders), and the typed path has no equivalent +// of this function, so the denial in the shared merge is what protects the flag +// there. func ensureOAuthBeta(headers http.Header) http.Header { for name, values := range headers { if !strings.EqualFold(strings.TrimSpace(name), anthropicBetaHeader) { From 5369d57371c7909452e243867bbf9b20f2ce0e3e Mon Sep 17 00:00:00 2001 From: Fede Ciliberti Date: Fri, 30 Oct 2026 09:04:56 -0300 Subject: [PATCH 10/17] docs(plugins): spell out the upstream header contract and its upgrade The Upstream row claimed the provider always wins. The merge runs after the provider's header hook, so a plugin value replaces the provider's too; only credential, transport/body and provider-negotiated names are refused. The new Upstream headers section lists those names, says the headers ride every provider attempt and the token-counting and compaction calls but not the inference a guardrail issues through its own Host, and carries a per-request Helicone-Property example. It also notes that installations already setting these headers start sending them after the upgrade. The de, pl and zh rows said the field was recorded and not forwarded; they now describe what ships. --- docs/advanced/plugins.mdx | 57 +++++++++++++++++++++++++++- docs/de/advanced/plugins.mdx | 2 +- docs/pl/advanced/plugins.mdx | 2 +- docs/zh/advanced/plugins.mdx | 2 +- internal/core/context.go | 5 ++- internal/llmclient/client_request.go | 7 +++- pluginapi/headers.go | 8 +++- 7 files changed, 74 insertions(+), 9 deletions(-) diff --git a/docs/advanced/plugins.mdx b/docs/advanced/plugins.mdx index 72ae3b0ec..9df29fd69 100644 --- a/docs/advanced/plugins.mdx +++ b/docs/advanced/plugins.mdx @@ -284,7 +284,7 @@ of a request: | `Prompt` | `*Prompt` | the conversation (`Messages` with stable IDs and typed `Parts`: text, image, audio, file, tool call, tool result, reasoning, refusal, opaque), `Tools`, `Params`, raw body. Edit through `SetText`, `SetMedia`, `SetToolArguments`, `SetToolResult`, `Insert`, `Append`, `Remove`, `SetParam`; `TextTargets(roles...)` lists every text part, tool-result text included, and `SetTargetText` writes one back | | `Response` | `*Completion` | the response, `nil` until the response phase: `Choices` with the same `Part` model, `Usage`, raw body. Edit through `SetText`, `ReplaceText`, `SetFinishReason`, `SetToolArguments`; `TextTargets` and `SetTargetText` work as on `Prompt` | | `Stream` | `*StreamState` | text accumulated so far per choice and the event count; `nil` for non-streaming requests | -| `Headers` | `*Headers` | `Request` (inbound, credentials redacted; prompt-phase edits apply to the live request for later plugins and request logging, not to the provider call), `Response` (applied to the client response; a header set to the empty string is removed, including ones the gateway adds such as `X-Request-Id`), `Upstream` (merged into every outgoing provider call of the request, failover attempts included, on token-counting and compaction calls too; credential headers and transport headers such as `Host`, `Content-Length` and `Transfer-Encoding` are refused, and the provider always sets its own credentials last) | +| `Headers` | `*Headers` | `Request` (inbound, credentials redacted; prompt-phase edits apply to the live request for later plugins and request logging, not to the provider call), `Response` (applied to the client response; a header set to the empty string is removed, including ones the gateway adds such as `X-Request-Id`), `Upstream` (sent with every provider call of the request; see [Upstream headers](#upstream-headers)) | | `Values` | `Values` | per-request bag shared by every hook of the request; prefix keys with the plugin name | Edits go through methods, not field assignment, so GoModel re-encodes only @@ -315,6 +315,61 @@ result is replaced with `SetToolResult`. messages the provider cache planner will mark as the cached prefix. Appending keeps the cache; editing inside the prefix invalidates it for the session. +### Upstream headers + +`Headers.Upstream` puts a header on the provider call itself: a per-request +routing or billing property the upstream expects, such as Helicone's, or an +organizational tag a gateway in front of the provider routes on. A provider +credential does not belong here — the names the gateway treats as credentials +are refused, and any other value is forwarded and logged as ordinary metadata. +Assign the map in a prompt-phase hook, before the provider call runs: + +```go +import "net/http" + +func (p *tenantHint) OnPrompt(_ context.Context, x *pluginapi.Exchange) (pluginapi.Decision, error) { + x.Headers.Upstream = http.Header{ + "Helicone-Property-Tenant": {x.Meta.UserPath}, + "Helicone-Property-Request": {x.Meta.RequestID}, + } + return pluginapi.Allow(), nil +} +``` + +What the gateway does with them: + +- Every provider attempt of the request carries them, failover to another + provider included, and so do the token-counting and compaction calls the + request makes: they reach the same upstream, behind the same gateway. +- Inference the gateway issues for its own work does not. A guardrail's LLM + judge calls the provider through the plugin `Host` on the request context, and + that call drops these headers: they describe the client's provider, not the + model the gateway picked for the guardrail. +- These names are refused rather than merged: credential headers + (`Authorization`, `Proxy-Authorization`, `x-api-key`, `api-key`, + `x-goog-api-key`, `x-anthropic-api-key`, `xi-api-key`, `x-auth-token`, + `x-access-token`, `x-gomodel-key`, `Cookie`, `Set-Cookie`), the ones that frame + the connection (`Host`, `Content-Length`, `Transfer-Encoding`, `Connection`, + `Te`, `Trailer`, `Upgrade`, `Expect`), the ones that describe the body the + gateway encoded (`Content-Type`, `Accept-Encoding`), and `anthropic-beta`, + which selects provider features and has to keep the `oauth-2025-04-20` flag + subscription credentials need. +- Any other name reaches the wire with the plugin's value, replacing a header + the request carried and one the provider set for itself. Only the names above + are protected; the rest of the provider's headers are not. +- The host folds a hook's headers into the request state when that phase + finishes, so a value written after the provider call has run — from a + response-phase hook — no longer affects it. Treat the map you get back from + the context as read-only. + + + **Upgrading from a release that recorded these headers without sending them.** + A plugin that already set `Headers.Upstream` on such a release had its values + stored and dropped. After the upgrade they reach the provider, or the proxy in + front of it, for the first time: check the upstream accepts them before + rolling the upgrade out. + + ## Configuration ### Enabling the plugin system diff --git a/docs/de/advanced/plugins.mdx b/docs/de/advanced/plugins.mdx index bc3d1b763..280889101 100644 --- a/docs/de/advanced/plugins.mdx +++ b/docs/de/advanced/plugins.mdx @@ -274,7 +274,7 @@ Jeder Hook erhält einen `*pluginapi.Exchange`, in jeder Phase eines Requests da | `Prompt` | `*Prompt` | die Konversation (`Messages` mit stabilen IDs und typisierten `Parts`: Text, Bild, Audio, Datei, Tool Call, Tool-Ergebnis, Reasoning, Refusal, opak), `Tools`, `Params`, Roh-Body. Bearbeitung über `SetText`, `SetMedia`, `SetToolArguments`, `SetToolResult`, `Insert`, `Append`, `Remove`, `SetParam`; `TextTargets(roles...)` listet jeden Textteil auf, einschließlich Text in Tool-Ergebnissen, und `SetTargetText` schreibt einen davon zurück | | `Response` | `*Completion` | die Response, `nil` bis zur Phase `response`: `Choices` mit demselben `Part`-Modell, `Usage`, Roh-Body. Bearbeitung über `SetText`, `ReplaceText`, `SetFinishReason`, `SetToolArguments`; `TextTargets` und `SetTargetText` funktionieren wie bei `Prompt` | | `Stream` | `*StreamState` | bisher angesammelter Text pro Choice und die Anzahl der Events; `nil` bei Requests ohne Streaming | -| `Headers` | `*Headers` | `Request` (eingehend, Zugangsdaten geschwärzt; Änderungen in der Phase `prompt` gelten für den laufenden Request bei späteren Plugins und beim Request-Logging, nicht für den Provider-Aufruf), `Response` (wird auf die Client-Response angewendet; ein auf den leeren String gesetzter Header wird entfernt, einschließlich solcher, die das Gateway hinzufügt, wie `X-Request-Id`), `Upstream` (wird festgehalten, in diesem Release aber nicht weitergeleitet) | +| `Headers` | `*Headers` | `Request` (eingehend, Zugangsdaten geschwärzt; Änderungen in der Phase `prompt` gelten für den laufenden Request bei späteren Plugins und beim Request-Logging, nicht für den Provider-Aufruf), `Response` (wird auf die Client-Response angewendet; ein auf den leeren String gesetzter Header wird entfernt, einschließlich solcher, die das Gateway hinzufügt, wie `X-Request-Id`), `Upstream` (wird mit jeder Provider-Anfrage des Requests gesendet, einschließlich Failover-Versuchen sowie Aufrufen zur Token-Zählung und Kompaktierung; Header mit Zugangsdaten, Header, die die Verbindung oder den Body bestimmen, und vom Provider ausgehandelte Header werden abgelehnt; Anfragen, die das Gateway für eigene Zwecke stellt, tragen keine dieser Header) | | `Values` | `Values` | Request-bezogener Speicher, den alle Hooks des Requests gemeinsam nutzen; stellen Sie Keys den Plugin-Namen voran | Änderungen erfolgen über Methoden, nicht über Feldzuweisungen, sodass GoModel nur das neu kodiert, diff --git a/docs/pl/advanced/plugins.mdx b/docs/pl/advanced/plugins.mdx index 7885835d1..7d8fe7d84 100644 --- a/docs/pl/advanced/plugins.mdx +++ b/docs/pl/advanced/plugins.mdx @@ -285,7 +285,7 @@ requestu: | `Prompt` | `*Prompt` | konwersacja (`Messages` ze stabilnymi ID i typowanymi `Parts`: tekst, obraz, audio, plik, wywołanie narzędzia, wynik narzędzia, reasoning, odmowa, nieprzezroczysta część), `Tools`, `Params`, surowa treść. Edytuj przez `SetText`, `SetMedia`, `SetToolArguments`, `SetToolResult`, `Insert`, `Append`, `Remove`, `SetParam`; `TextTargets(roles...)` wyświetla każdą część tekstową, łącznie z tekstem wyników narzędzi, a `SetTargetText` zapisuje ją z powrotem | | `Response` | `*Completion` | odpowiedź, `nil` do fazy response: `Choices` z tym samym modelem `Part`, `Usage`, surowa treść. Edytuj przez `SetText`, `ReplaceText`, `SetFinishReason`, `SetToolArguments`; `TextTargets` i `SetTargetText` działają jak w `Prompt` | | `Stream` | `*StreamState` | dotychczas zebrany tekst dla każdego choice i liczba zdarzeń; `nil` dla requestów bez streamingu | -| `Headers` | `*Headers` | `Request` (przychodzące, z zamaskowanymi danymi uwierzytelniającymi; edycje w fazie prompt dotyczą bieżącego requestu dla kolejnych pluginów i logowania requestu, a nie wywołania providera), `Response` (stosowane do odpowiedzi dla klienta; nagłówek ustawiony na pusty string jest usuwany, łącznie z tymi dodawanymi przez gateway, jak `X-Request-Id`), `Upstream` (zapisywane, w tym release nieprzekazywane dalej) | +| `Headers` | `*Headers` | `Request` (przychodzące, z zamaskowanymi danymi uwierzytelniającymi; edycje w fazie prompt dotyczą bieżącego requestu dla kolejnych pluginów i logowania requestu, a nie wywołania providera), `Response` (stosowane do odpowiedzi dla klienta; nagłówek ustawiony na pusty string jest usuwany, łącznie z tymi dodawanymi przez gateway, jak `X-Request-Id`), `Upstream` (wysyłane z każdym wywołaniem providera w ramach requestu, łącznie z próbami failover oraz wywołaniami zliczania tokenów i kompakcji; nagłówki z danymi uwierzytelniającymi, nagłówki określające połączenie lub ciało żądania oraz nagłówki negocjowane przez providera są odrzucane; requesty, które gateway wykonuje na własne potrzeby, ich nie przenoszą) | | `Values` | `Values` | zbiór danych na request współdzielony przez wszystkie hooki requestu; poprzedzaj klucze nazwą pluginu | Edycje przechodzą przez metody, a nie przypisania pól, więc GoModel ponownie koduje tylko diff --git a/docs/zh/advanced/plugins.mdx b/docs/zh/advanced/plugins.mdx index 032bc840e..7fed34409 100644 --- a/docs/zh/advanced/plugins.mdx +++ b/docs/zh/advanced/plugins.mdx @@ -204,7 +204,7 @@ keep-alive 注释(15 s)之前完成,针对缓冲响应做出的 `warn` 就 | `Prompt` | `*Prompt` | 对话(带稳定 ID 和类型化 `Parts` 的 `Messages`:文本、图像、音频、文件、工具调用、工具结果、推理、拒答、不透明内容)、`Tools`、`Params`、原始请求体。通过 `SetText`、`SetMedia`、`SetToolArguments`、`SetToolResult`、`Insert`、`Append`、`Remove`、`SetParam` 进行修改;`TextTargets(roles...)` 列出所有文本部分(包括工具结果中的文本),`SetTargetText` 将修改写回其中之一 | | `Response` | `*Completion` | 响应,在 response 阶段之前为 `nil`:使用相同 `Part` 模型的 `Choices`、`Usage`、原始响应体。通过 `SetText`、`ReplaceText`、`SetFinishReason`、`SetToolArguments` 进行修改;`TextTargets` 和 `SetTargetText` 的用法与 `Prompt` 上相同 | | `Stream` | `*StreamState` | 每个选项目前为止累积的文本和事件计数;非流式请求为 `nil` | -| `Headers` | `*Headers` | `Request`(入站请求头,凭据已脱敏;prompt 阶段的修改会作用于实时请求,供后续插件和请求日志使用,但不会作用于提供商调用)、`Response`(应用于客户端响应;设置为空字符串的 Header 会被删除,包括网关添加的 Header,例如 `X-Request-Id`)、`Upstream`(会被记录,但当前版本不会转发) | +| `Headers` | `*Headers` | `Request`(入站请求头,凭据已脱敏;prompt 阶段的修改会作用于实时请求,供后续插件和请求日志使用,但不会作用于提供商调用)、`Response`(应用于客户端响应;设置为空字符串的 Header 会被删除,包括网关添加的 Header,例如 `X-Request-Id`)、`Upstream`(随该请求的每次提供商调用发送,包括故障转移尝试以及 token 计数和压缩调用;携带凭据的 Header、决定连接或请求体的 Header 以及由提供商协商的 Header 会被拒绝;网关为自身发起的调用不携带这些 Header) | | `Values` | `Values` | 请求内所有 Hook 共享的键值集合;键名请以插件名称作为前缀 | 修改需要通过方法进行,而不是直接给字段赋值,这样 GoModel 只会重新编码发生变化的部分,未被修改的消息会保留所有提供商特定的字段 diff --git a/internal/core/context.go b/internal/core/context.go index 4907c1ccb..cc5d75004 100644 --- a/internal/core/context.go +++ b/internal/core/context.go @@ -416,7 +416,10 @@ func WithPluginUpstreamHeaders(ctx context.Context, headers http.Header) context } // PluginUpstreamHeaders returns the headers plugins asked to add to provider -// calls, or nil when no plugin set any. +// calls, or nil when no plugin set any. The map belongs to the request state and +// is shared with every reader of the context, so a caller must treat it as +// read-only: clone it before editing, as WithPluginUpstreamHeaders does on the +// way in. func PluginUpstreamHeaders(ctx context.Context) http.Header { if ctx == nil { return nil diff --git a/internal/llmclient/client_request.go b/internal/llmclient/client_request.go index d8134fd35..adf6a006d 100644 --- a/internal/llmclient/client_request.go +++ b/internal/llmclient/client_request.go @@ -285,8 +285,11 @@ func (c *Client) buildRequest(ctx context.Context, req Request) (*http.Request, } // Apply request-specific headers, including the ones prompt-phase plugins - // asked to add. Plugin headers may not override provider credentials or - // transport-controlled headers: the provider always wins. + // asked to add. A plugin may not set a credential header, one that frames the + // connection or describes the body, or one the provider negotiates — see + // pluginHeaderDenied. For every other name its value replaces both the one the + // request carried and the one the provider's header hook set above: the + // provider wins only on the denied names. for key, values := range requestHeadersWithPluginOverrides(ctx, req.Headers) { httpReq.Header.Del(key) for _, value := range values { diff --git a/pluginapi/headers.go b/pluginapi/headers.go index 0a65a66e3..d5b8af435 100644 --- a/pluginapi/headers.go +++ b/pluginapi/headers.go @@ -13,7 +13,11 @@ type Headers struct { // removed from the response instead, including headers the gateway adds // on its own. Response http.Header - // Upstream adds headers to the provider call. A host that does not - // support it ignores the field. + // Upstream adds headers to the provider call: one per request, applied to + // every attempt the request makes. Assign a map before the call runs; the + // host folds what a hook wrote into the request state when the phase + // finishes. A host may refuse headers it owns, such as credentials, the ones + // that frame the connection or describe the body, and provider feature + // negotiation. Upstream http.Header } From 4dbe8f0a33dd9fe40ce317a345174cfb79e63f10 Mon Sep 17 00:00:00 2001 From: Fede Ciliberti Date: Fri, 30 Oct 2026 10:51:16 -0300 Subject: [PATCH 11/17] docs(plugins): spell out the full upstream header deny list `header_edit`'s `upstream_set` and ADR-0012 still described only the three transport headers the first version refused. The merge also refuses the rest of the hop-by-hop set, `Content-Type`, `Accept-Encoding`, and `anthropic-beta`, so list them where an operator looks for them. ADR-0012 keeps section 10 as the record of the release it was written for, but `Headers.Upstream` is no longer deferred: say what the host does with it now, and that gateway-internal inference carries none of them. --- docs/adr/0012-plugin-system.md | 12 +++++++++--- docs/advanced/guardrails.mdx | 10 +++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/adr/0012-plugin-system.md b/docs/adr/0012-plugin-system.md index 99c82457c..dd7c4be2a 100644 --- a/docs/adr/0012-plugin-system.md +++ b/docs/adr/0012-plugin-system.md @@ -79,7 +79,8 @@ every phase: refusal, opaque), plus `Tools`, `Params`, and the raw body. - `Response`: a `Completion` with `Choices` using the same `Part` model. - `Stream`: text accumulated so far per choice. -- `Headers`: inbound (credentials redacted), outbound, and upstream headers. +- `Headers`: inbound (credentials redacted), outbound, and upstream headers, + which the host merges into every provider call of the request. - `Values`: a per-request bag shared by every hook of the request. Edits go through methods (`SetText`, `SetMedia`, `SetToolArguments`, @@ -359,9 +360,14 @@ Declared in the contract or the spec but not run by this release: - `request` and `complete` hook kinds: the interfaces, `Kind` values, and load-time validation exist; no runtime calls them. `ext.RequestRewriter` remains the request-phase mechanism. -- `Headers.Upstream` is recorded and logged, not forwarded to providers. - Prompt-phase edits to `Headers.Request` apply to the live request (later +- Prompt-phase edits to `Headers.Request` apply to the live request (later plugins, request logging) but are not forwarded to the provider. + `Headers.Upstream` was deferred when this ADR was written and no longer is: + the host merges it into every provider call of the request — failover + attempts, token counting and compaction included — minus the names that + carry credentials, frame the connection, describe the body, or select + provider features (`#1203`, which updated this section in place). Inference + the gateway issues for its own work carries none of them. - `RouteRequest.Prompt` is nil; `RouteChoice.Reason` reaches debug logs only. Instance-scoped `secret` fields reach a route plugin redacted. - `Host.HTTPClient` hands plugins one shared client built from the gateway's diff --git a/docs/advanced/guardrails.mdx b/docs/advanced/guardrails.mdx index 7d3c83dc8..74a4db5dd 100644 --- a/docs/advanced/guardrails.mdx +++ b/docs/advanced/guardrails.mdx @@ -454,11 +454,19 @@ remove lines are a bare `Name`. Blank lines and `#` comments are ignored. | `response_set` | Replace a header on the response sent to the client. | | `response_add` | Append a value to a client response header, keeping existing values. Applied once per request even when the instance runs in both phases. | | `response_remove` | Drop a header from the client response, including headers GoModel adds itself (for example `X-Request-Id`). | -| `upstream_set` | Static headers merged into every provider call of the request: failover attempts, token counting and compaction included. Credential and transport headers (`Host`, `Content-Length`, `Transfer-Encoding`) are refused. | +| `upstream_set` | Static headers merged into every provider call of the request: failover attempts, token counting and compaction included. Credential headers, the ones that frame the connection or describe the body, and `anthropic-beta` are refused; see [Upstream headers](/advanced/plugins#upstream-headers). | Credential headers (`Authorization`, `X-Api-Key`, `Cookie`, ...) can never be edited, and names containing `secret` or `token` cannot be set. +`upstream_set` additionally refuses the names that frame the connection +(`Host`, `Content-Length`, `Transfer-Encoding`, `Connection`, `Te`, `Trailer`, +`Upgrade`, `Expect`), the ones that describe the body the gateway encoded +(`Content-Type`, `Accept-Encoding`), and `anthropic-beta`, which selects +provider features and has to keep the `oauth-2025-04-20` flag subscription +credentials need. The rest of the provider's own headers are not protected: a +plugin value replaces them. + #### Example ```yaml From 33eee8eea16017cb25dea48fcf3a73350833ae51 Mon Sep 17 00:00:00 2001 From: Fede Ciliberti Date: Fri, 30 Oct 2026 12:03:07 -0300 Subject: [PATCH 12/17] fix(llmclient): deny the remaining hop-by-hop and encoding headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Content-Encoding` was missing, and so were `Keep-Alive`, `Proxy-Connection` and `Proxy-Authenticate`. A plugin setting `Content-Encoding: gzip` labels the uncompressed JSON the gateway sends, and the provider fails the request; a hop-by-hop header it sets is meaningless to the provider and can confuse the connection. Add them to the transport list and group the map by what each entry protects — connection framing, the body the gateway encoded, response compression — so the next addition lands in the right place. Tests: `internal/llmclient`, one case per name, tied to the map length. --- internal/llmclient/client_request.go | 42 ++++++++++++++--------- internal/llmclient/plugin_headers_test.go | 15 +++++--- 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/internal/llmclient/client_request.go b/internal/llmclient/client_request.go index adf6a006d..52cc6062e 100644 --- a/internal/llmclient/client_request.go +++ b/internal/llmclient/client_request.go @@ -310,23 +310,33 @@ func (c *Client) buildRequest(ctx context.Context, req Request) (*http.Request, return httpReq, nil } -// pluginTransportHeaders control the transport or the framing of the body -// rather than the provider call, so a plugin must not set them: the HTTP client -// frames the connection, and the gateway and the provider decide how the body is -// encoded and compressed. Credential headers are refused separately, by the -// shared list in core, so the host, the request state and this merge cannot -// drift apart. +// pluginTransportHeaders control the transport, the framing of the body, or the +// compression of the response rather than the provider call, so a plugin must +// not set them: the HTTP client owns the connection and the gateway owns the +// body it built and the encoding it negotiates. Credential headers are refused +// separately, by the shared list in core, so the host, the request state and +// this merge cannot drift apart. var pluginTransportHeaders = map[string]struct{}{ - "Host": {}, - "Content-Length": {}, - "Transfer-Encoding": {}, - "Connection": {}, - "Te": {}, - "Trailer": {}, - "Upgrade": {}, - "Expect": {}, - "Accept-Encoding": {}, - "Content-Type": {}, + // Connection framing. The hop-by-hop set of RFC 9110, minus + // Proxy-Authorization, which the credential list already refuses. + "Host": {}, + "Content-Length": {}, + "Transfer-Encoding": {}, + "Connection": {}, + "Keep-Alive": {}, + "Proxy-Connection": {}, + "Proxy-Authenticate": {}, + "Te": {}, + "Trailer": {}, + "Upgrade": {}, + "Expect": {}, + // The body itself: the gateway sends the JSON it encoded, uncompressed. + "Content-Type": {}, + "Content-Encoding": {}, + // Response compression. The transport decompresses gzip automatically only + // while the request carries no Accept-Encoding of its own, so a plugin value + // hands the gateway compressed bytes it cannot parse. + "Accept-Encoding": {}, } // pluginReservedProviderHeaders lists headers a provider negotiates with its diff --git a/internal/llmclient/plugin_headers_test.go b/internal/llmclient/plugin_headers_test.go index ae5fef9bc..6fd9df733 100644 --- a/internal/llmclient/plugin_headers_test.go +++ b/internal/llmclient/plugin_headers_test.go @@ -79,22 +79,27 @@ func TestBuildRequestPluginOriginDropsPluginUpstreamHeaders(t *testing.T) { } } -// TestBuildRequestDeniesPluginHeaders pins one case per denied header: transport -// and body-framing headers, provider-negotiated headers, and the credential list. -// The length checks tie the table to the two lists that live in this package, so -// a header added to one of them without a case here fails. +// TestBuildRequestDeniesPluginHeaders pins one case per denied header: the ones +// that frame the connection, the ones that describe the body and its encoding, +// the one that negotiates response compression, the provider-negotiated one, and +// the credential list. The length checks tie the table to the two lists that live +// in this package, so a header added to one of them without a case here fails. func TestBuildRequestDeniesPluginHeaders(t *testing.T) { transport := []string{ "Host", "Content-Length", "Transfer-Encoding", "Connection", + "Keep-Alive", + "Proxy-Connection", + "Proxy-Authenticate", "Te", "Trailer", "Upgrade", "Expect", - "Accept-Encoding", "Content-Type", + "Content-Encoding", + "Accept-Encoding", } reserved := []string{ "Anthropic-Beta", From b8a23253e1fe6815664e44c77aa6de11421d73df Mon Sep 17 00:00:00 2001 From: Fede Ciliberti Date: Fri, 30 Oct 2026 12:03:10 -0300 Subject: [PATCH 13/17] fix(server): carry plugin upstream headers on messages count_tokens `/v1/messages/count_tokens` reached the provider without the headers a prompt-phase plugin wrote to `Exchange.Headers.Upstream`. The inference paths and the Responses utility routes attach them, this one did not, so a provider behind an authenticating proxy answered generation calls and rejected the count. Call `applyPluginRequestHeaders` before the count, which also hands the refreshed context to the call below, the same pattern the other routes use. Tests: `internal/server`, a counter that records the headers its context carries; it fails without the call. --- .../messages_count_tokens_headers_test.go | 60 +++++++++++++++++++ internal/server/messages_handler.go | 5 ++ 2 files changed, 65 insertions(+) create mode 100644 internal/server/messages_count_tokens_headers_test.go diff --git a/internal/server/messages_count_tokens_headers_test.go b/internal/server/messages_count_tokens_headers_test.go new file mode 100644 index 000000000..533c53041 --- /dev/null +++ b/internal/server/messages_count_tokens_headers_test.go @@ -0,0 +1,60 @@ +package server + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/enterpilot/gomodel/internal/core" + "github.com/enterpilot/gomodel/internal/plugins" + "github.com/enterpilot/gomodel/pluginapi" +) + +// messagesCountTokensRecorder answers a native Messages token count and keeps +// the plugin upstream headers the call carried. +type messagesCountTokensRecorder struct { + *mockProvider + headers []http.Header +} + +func (m *messagesCountTokensRecorder) CountMessagesTokens(ctx context.Context, _ string, _ []byte) (int, error) { + m.headers = append(m.headers, core.PluginUpstreamHeaders(ctx)) + return 4242, nil +} + +// TestMessagesCountTokensCarriesPluginUpstreamHeaders proves that +// /v1/messages/count_tokens reaches a provider behind an authenticating proxy +// with the headers a prompt-phase plugin asked for, not only the inference and +// Responses utility routes. +func TestMessagesCountTokensCarriesPluginUpstreamHeaders(t *testing.T) { + provider := &messagesCountTokensRecorder{mockProvider: &mockProvider{ + supportedModels: []string{"claude-test"}, + providerTypes: map[string]string{"claude-test": "mock"}, + }} + srv := New(provider, nil) + + // Stand in for the prompt chain: the state holds what a prompt-phase plugin + // wrote to Exchange.Headers.Upstream and nothing else of the request. + ctx, state := plugins.WithRequestState(context.Background()) + exchange := state.NewExchange(ctx, pluginapi.Meta{}) + exchange.Headers.Upstream = http.Header{"Helicone-Property-Tenant": {"acme"}} + state.Finish(exchange) + + body := `{"model":"claude-test","max_tokens":64,"messages":[{"role":"user","content":"count these"}]}` + req := httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + + srv.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + require.Len(t, provider.headers, 1, "the count never reached the provider") + assert.Equal(t, "acme", provider.headers[0].Get("Helicone-Property-Tenant"), + "count_tokens dropped the plugin upstream header") +} diff --git a/internal/server/messages_handler.go b/internal/server/messages_handler.go index 075e9639f..15e75575c 100644 --- a/internal/server/messages_handler.go +++ b/internal/server/messages_handler.go @@ -223,6 +223,11 @@ func (s *translatedInferenceService) CountMessageTokens(c *echo.Context) error { if strings.TrimSpace(req.Model) == "" { return handleError(c, core.NewInvalidRequestError("model is required", nil).WithParam("model")) } + // A prompt-phase plugin may have written headers for the provider call + // (Exchange.Headers.Upstream). Attach them to the live request before the + // count, so the call below reads the context that carries them, as the + // inference and the Responses utility paths do. + applyPluginRequestHeaders(c) count, ok, err := s.countMessageTokensUpstream(c.Request().Context(), req, body) if err != nil { return handleError(c, err) From 6f434454b9e224613c61f427be45313838301b3f Mon Sep 17 00:00:00 2001 From: Fede Ciliberti Date: Fri, 30 Oct 2026 12:03:12 -0300 Subject: [PATCH 14/17] docs(plugins): state where upstream headers stop and what is logged Three claims were wrong or missing. The section said the host logs the header values; it logs how many a plugin set and nothing else, so say that. Every provider call was claimed, but Bedrock reaches AWS through its own SDK and never goes through `buildRequest`, so scope the promise to the gateway's HTTP client and name the exception where the field, the section and `upstream_set` describe it. `Accept-Encoding` sat with the headers that describe the request body; it negotiates response compression, and setting it turns off the transport's automatic gzip decompression. Give it its own group, add `Content-Encoding` and the remaining hop-by-hop names, and bring the de, pl and zh pages to the same contract: the translated pages still described the field as recorded and not forwarded, with no Upstream headers section at all. --- docs/advanced/guardrails.mdx | 19 ++++++----- docs/advanced/plugins.mdx | 11 +++--- docs/de/advanced/guardrails.mdx | 2 +- docs/de/advanced/plugins.mdx | 60 ++++++++++++++++++++++++++++++++- docs/pl/advanced/guardrails.mdx | 2 +- docs/pl/advanced/plugins.mdx | 57 ++++++++++++++++++++++++++++++- docs/zh/advanced/guardrails.mdx | 2 +- docs/zh/advanced/plugins.mdx | 40 +++++++++++++++++++++- 8 files changed, 175 insertions(+), 18 deletions(-) diff --git a/docs/advanced/guardrails.mdx b/docs/advanced/guardrails.mdx index 74a4db5dd..8cfa286c3 100644 --- a/docs/advanced/guardrails.mdx +++ b/docs/advanced/guardrails.mdx @@ -454,18 +454,21 @@ remove lines are a bare `Name`. Blank lines and `#` comments are ignored. | `response_set` | Replace a header on the response sent to the client. | | `response_add` | Append a value to a client response header, keeping existing values. Applied once per request even when the instance runs in both phases. | | `response_remove` | Drop a header from the client response, including headers GoModel adds itself (for example `X-Request-Id`). | -| `upstream_set` | Static headers merged into every provider call of the request: failover attempts, token counting and compaction included. Credential headers, the ones that frame the connection or describe the body, and `anthropic-beta` are refused; see [Upstream headers](/advanced/plugins#upstream-headers). | +| `upstream_set` | Static headers merged into every provider call of the request that goes through the gateway's HTTP client: failover attempts, token counting and compaction included, Bedrock excluded. Credential headers, the ones that frame the connection or describe the body, `Accept-Encoding`, and `anthropic-beta` are refused; see [Upstream headers](/advanced/plugins#upstream-headers). | Credential headers (`Authorization`, `X-Api-Key`, `Cookie`, ...) can never be edited, and names containing `secret` or `token` cannot be set. -`upstream_set` additionally refuses the names that frame the connection -(`Host`, `Content-Length`, `Transfer-Encoding`, `Connection`, `Te`, `Trailer`, -`Upgrade`, `Expect`), the ones that describe the body the gateway encoded -(`Content-Type`, `Accept-Encoding`), and `anthropic-beta`, which selects -provider features and has to keep the `oauth-2025-04-20` flag subscription -credentials need. The rest of the provider's own headers are not protected: a -plugin value replaces them. +`upstream_set` additionally refuses three groups of names. The ones that frame +the connection: `Host`, `Content-Length`, `Transfer-Encoding`, `Connection`, +`Keep-Alive`, `Proxy-Connection`, `Proxy-Authenticate`, `Te`, `Trailer`, +`Upgrade`, `Expect`. The ones that describe the body the gateway encoded: +`Content-Type`, `Content-Encoding`. And `Accept-Encoding`, which negotiates +response compression — setting it turns off the transport's automatic gzip +decompression, so the gateway would parse compressed bytes. `anthropic-beta` is +refused on its own: it selects provider features and has to keep the +`oauth-2025-04-20` flag subscription credentials need. The rest of the +provider's own headers are not protected: a plugin value replaces them. #### Example diff --git a/docs/advanced/plugins.mdx b/docs/advanced/plugins.mdx index 9df29fd69..dd8b6840a 100644 --- a/docs/advanced/plugins.mdx +++ b/docs/advanced/plugins.mdx @@ -284,7 +284,7 @@ of a request: | `Prompt` | `*Prompt` | the conversation (`Messages` with stable IDs and typed `Parts`: text, image, audio, file, tool call, tool result, reasoning, refusal, opaque), `Tools`, `Params`, raw body. Edit through `SetText`, `SetMedia`, `SetToolArguments`, `SetToolResult`, `Insert`, `Append`, `Remove`, `SetParam`; `TextTargets(roles...)` lists every text part, tool-result text included, and `SetTargetText` writes one back | | `Response` | `*Completion` | the response, `nil` until the response phase: `Choices` with the same `Part` model, `Usage`, raw body. Edit through `SetText`, `ReplaceText`, `SetFinishReason`, `SetToolArguments`; `TextTargets` and `SetTargetText` work as on `Prompt` | | `Stream` | `*StreamState` | text accumulated so far per choice and the event count; `nil` for non-streaming requests | -| `Headers` | `*Headers` | `Request` (inbound, credentials redacted; prompt-phase edits apply to the live request for later plugins and request logging, not to the provider call), `Response` (applied to the client response; a header set to the empty string is removed, including ones the gateway adds such as `X-Request-Id`), `Upstream` (sent with every provider call of the request; see [Upstream headers](#upstream-headers)) | +| `Headers` | `*Headers` | `Request` (inbound, credentials redacted; prompt-phase edits apply to the live request for later plugins and request logging, not to the provider call), `Response` (applied to the client response; a header set to the empty string is removed, including ones the gateway adds such as `X-Request-Id`), `Upstream` (sent with every provider call of the request that goes through the gateway's HTTP client; see [Upstream headers](#upstream-headers)) | | `Values` | `Values` | per-request bag shared by every hook of the request; prefix keys with the plugin name | Edits go through methods, not field assignment, so GoModel re-encodes only @@ -321,8 +321,9 @@ keeps the cache; editing inside the prefix invalidates it for the session. routing or billing property the upstream expects, such as Helicone's, or an organizational tag a gateway in front of the provider routes on. A provider credential does not belong here — the names the gateway treats as credentials -are refused, and any other value is forwarded and logged as ordinary metadata. -Assign the map in a prompt-phase hook, before the provider call runs: +are refused, and any other value is forwarded to the provider. The host logs +how many headers a plugin set, never their values. Assign the map in a +prompt-phase hook, before the provider call runs: ```go import "net/http" @@ -340,7 +341,9 @@ What the gateway does with them: - Every provider attempt of the request carries them, failover to another provider included, and so do the token-counting and compaction calls the - request makes: they reach the same upstream, behind the same gateway. + request makes: they reach the same upstream, behind the same gateway. Every + call that goes through the gateway's HTTP client, that is; Bedrock, which + calls the AWS SDK directly, does not carry them. - Inference the gateway issues for its own work does not. A guardrail's LLM judge calls the provider through the plugin `Host` on the request context, and that call drops these headers: they describe the client's provider, not the diff --git a/docs/de/advanced/guardrails.mdx b/docs/de/advanced/guardrails.mdx index 8704dc60e..e70316877 100644 --- a/docs/de/advanced/guardrails.mdx +++ b/docs/de/advanced/guardrails.mdx @@ -482,7 +482,7 @@ Leerzeilen und `#`-Kommentare werden ignoriert. | `response_set` | Ersetzt einen Header in der an den Client gesendeten Response. | | `response_add` | Hängt einen Wert an einen Header der Client-Response an und behält vorhandene Werte bei. Wird einmal pro Request angewendet, auch wenn die Instanz in beiden Phasen läuft. | | `response_remove` | Entfernt einen Header aus der Client-Response, einschließlich Headern, die GoModel selbst hinzufügt (zum Beispiel `X-Request-Id`). | -| `upstream_set` | Statische Header für den Provider-Aufruf. In diesem Release erfasst, aber nicht weitergeleitet. | +| `upstream_set` | Statische Header, die in jede Provider-Anfrage des Requests einfließen, die über den HTTP-Client des Gateways läuft: Failover-Versuche, Token-Zählung und Kompaktierung eingeschlossen, Bedrock ausgenommen. Header mit Zugangsdaten, Header, die die Verbindung oder den Body bestimmen, `Accept-Encoding` und `anthropic-beta` werden abgelehnt; siehe [Upstream-Header](/de/advanced/plugins). | Header mit Zugangsdaten (`Authorization`, `X-Api-Key`, `Cookie`, ...) können nie bearbeitet werden, und Namen, die `secret` oder `token` enthalten, können nicht diff --git a/docs/de/advanced/plugins.mdx b/docs/de/advanced/plugins.mdx index 280889101..072d5b6bc 100644 --- a/docs/de/advanced/plugins.mdx +++ b/docs/de/advanced/plugins.mdx @@ -274,7 +274,7 @@ Jeder Hook erhält einen `*pluginapi.Exchange`, in jeder Phase eines Requests da | `Prompt` | `*Prompt` | die Konversation (`Messages` mit stabilen IDs und typisierten `Parts`: Text, Bild, Audio, Datei, Tool Call, Tool-Ergebnis, Reasoning, Refusal, opak), `Tools`, `Params`, Roh-Body. Bearbeitung über `SetText`, `SetMedia`, `SetToolArguments`, `SetToolResult`, `Insert`, `Append`, `Remove`, `SetParam`; `TextTargets(roles...)` listet jeden Textteil auf, einschließlich Text in Tool-Ergebnissen, und `SetTargetText` schreibt einen davon zurück | | `Response` | `*Completion` | die Response, `nil` bis zur Phase `response`: `Choices` mit demselben `Part`-Modell, `Usage`, Roh-Body. Bearbeitung über `SetText`, `ReplaceText`, `SetFinishReason`, `SetToolArguments`; `TextTargets` und `SetTargetText` funktionieren wie bei `Prompt` | | `Stream` | `*StreamState` | bisher angesammelter Text pro Choice und die Anzahl der Events; `nil` bei Requests ohne Streaming | -| `Headers` | `*Headers` | `Request` (eingehend, Zugangsdaten geschwärzt; Änderungen in der Phase `prompt` gelten für den laufenden Request bei späteren Plugins und beim Request-Logging, nicht für den Provider-Aufruf), `Response` (wird auf die Client-Response angewendet; ein auf den leeren String gesetzter Header wird entfernt, einschließlich solcher, die das Gateway hinzufügt, wie `X-Request-Id`), `Upstream` (wird mit jeder Provider-Anfrage des Requests gesendet, einschließlich Failover-Versuchen sowie Aufrufen zur Token-Zählung und Kompaktierung; Header mit Zugangsdaten, Header, die die Verbindung oder den Body bestimmen, und vom Provider ausgehandelte Header werden abgelehnt; Anfragen, die das Gateway für eigene Zwecke stellt, tragen keine dieser Header) | +| `Headers` | `*Headers` | `Request` (eingehend, Zugangsdaten geschwärzt; Änderungen in der Phase `prompt` gelten für den laufenden Request bei späteren Plugins und beim Request-Logging, nicht für den Provider-Aufruf), `Response` (wird auf die Client-Response angewendet; ein auf den leeren String gesetzter Header wird entfernt, einschließlich solcher, die das Gateway hinzufügt, wie `X-Request-Id`), `Upstream` (wird mit jeder Provider-Anfrage des Requests gesendet, die über den HTTP-Client des Gateways läuft — Bedrock, das das AWS SDK direkt nutzt, ausgenommen —, einschließlich Failover-Versuchen sowie Aufrufen zur Token-Zählung und Kompaktierung; Header mit Zugangsdaten, Header, die die Verbindung oder den Body bestimmen, `Accept-Encoding` und vom Provider ausgehandelte Header werden abgelehnt; Anfragen, die das Gateway für eigene Zwecke stellt, tragen keine dieser Header) | | `Values` | `Values` | Request-bezogener Speicher, den alle Hooks des Requests gemeinsam nutzen; stellen Sie Keys den Plugin-Namen voran | Änderungen erfolgen über Methoden, nicht über Feldzuweisungen, sodass GoModel nur das neu kodiert, @@ -303,6 +303,64 @@ ruft es selbst ab. Medien innerhalb eines Tool-Ergebnisses werden mit `SetToolRe der Cache-Planer des Providers als gecachtes Präfix markieren wird. Anhängen erhält den Cache; Bearbeitungen innerhalb des Präfixes machen ihn für die Session ungültig. +### Upstream-Header + +`Headers.Upstream` setzt einen Header auf den Provider-Aufruf selbst: eine +request-bezogene Routing- oder Abrechnungs-Eigenschaft, die das Upstream erwartet, +etwa die von Helicone, oder ein organisatorisches Tag, nach dem ein Gateway vor dem +Provider routet. Zugangsdaten für einen Provider gehören nicht hierher — die Namen, +die das Gateway als Zugangsdaten behandelt, werden abgelehnt, und jeder andere Wert +wird an den Provider weitergegeben. Das Host loggt nur, wie viele Header ein Plugin +gesetzt hat, nie deren Werte. Weisen Sie die Map in einem Hook der Phase `prompt` zu, +bevor der Provider-Aufruf läuft: + +```go +import "net/http" + +func (p *tenantHint) OnPrompt(_ context.Context, x *pluginapi.Exchange) (pluginapi.Decision, error) { + x.Headers.Upstream = http.Header{ + "Helicone-Property-Tenant": {x.Meta.UserPath}, + "Helicone-Property-Request": {x.Meta.RequestID}, + } + return pluginapi.Allow(), nil +} +``` + +Was das Gateway damit macht: + +- Jeder Provider-Versuch des Requests trägt sie, einschließlich Failover auf einen + anderen Provider, ebenso die Aufrufe zur Token-Zählung und Kompaktierung, die der + Request auslöst: Sie erreichen dasselbe Upstream hinter demselben Gateway. Jeder + Aufruf, der über den HTTP-Client des Gateways läuft, wohlgemerkt; Bedrock, das das + AWS SDK direkt aufruft, trägt sie nicht. +- Inferenz, die das Gateway für eigene Zwecke stellt, trägt sie nicht. Der LLM-Judge + einer Guardrail ruft den Provider über das Plugin-`Host` im Request-Kontext auf, + und dieser Aufruf verwirft die Header: Sie beschreiben den Provider des Clients, + nicht das Modell, das das Gateway für die Guardrail gewählt hat. +- Diese Namen werden abgelehnt statt zusammengeführt: Header mit Zugangsdaten + (`Authorization`, `Proxy-Authorization`, `x-api-key`, `api-key`, `x-goog-api-key`, + `x-anthropic-api-key`, `xi-api-key`, `x-auth-token`, `x-access-token`, + `x-gomodel-key`, `Cookie`, `Set-Cookie`), die die Verbindung rahmen (`Host`, + `Content-Length`, `Transfer-Encoding`, `Connection`, `Keep-Alive`, + `Proxy-Connection`, `Proxy-Authenticate`, `Te`, `Trailer`, `Upgrade`, `Expect`), + die den Body beschreiben, den das Gateway kodiert hat (`Content-Type`, + `Content-Encoding`), `Accept-Encoding`, das die Kompression der Antwort aushandelt, + und `anthropic-beta`, das Provider-Features auswählt und das Flag + `oauth-2025-04-20` behalten muss, das Abo-Zugangsdaten brauchen. +- Jeder andere Name erreicht die Leitung mit dem Wert des Plugins und ersetzt einen + Header des Requests sowie einen, den der Provider selbst gesetzt hat. Nur die oben + genannten Namen sind geschützt; die übrigen Header des Providers nicht. +- Das Host faltet die Header eines Hooks in den Request-State, wenn diese Phase + endet; ein Wert, der erst nach dem Provider-Aufruf geschrieben wird — aus einem Hook + der Phase `response` —, wirkt nicht mehr. Behandeln Sie die Map, die Sie aus dem + Kontext erhalten, als schreibgeschützt. + +**Upgrade von einem Release, das diese Header festhielt, ohne sie zu senden.** Ein +Plugin, das `Headers.Upstream` auf einem solchen Release bereits gesetzt hat, bekam +seine Werte gespeichert und verworfen. Nach dem Upgrade erreichen sie zum ersten Mal +den Provider oder den Proxy davor: Prüfen Sie, ob das Upstream sie akzeptiert, bevor +Sie das Upgrade ausrollen. + ## Konfiguration ### Das Plugin-System aktivieren diff --git a/docs/pl/advanced/guardrails.mdx b/docs/pl/advanced/guardrails.mdx index 068d67c7c..0e87022d3 100644 --- a/docs/pl/advanced/guardrails.mdx +++ b/docs/pl/advanced/guardrails.mdx @@ -466,7 +466,7 @@ remove to sama `Name`. Puste linie i komentarze `#` są ignorowane. | `response_set` | Zastępuje nagłówek w odpowiedzi wysyłanej do klienta. | | `response_add` | Dopisuje wartość do nagłówka odpowiedzi do klienta, zachowując istniejące wartości. Stosowane raz na request, nawet gdy instancja działa w obu fazach. | | `response_remove` | Usuwa nagłówek z odpowiedzi do klienta, także nagłówki dodawane przez sam GoModel (na przykład `X-Request-Id`). | -| `upstream_set` | Statyczne nagłówki dla wywołania providera. W tym wydaniu zapisywane, ale nieprzekazywane. | +| `upstream_set` | Statyczne nagłówki wchodzące w każde wywołanie providera w ramach requestu, które przechodzi przez klienta HTTP gatewaya: z próbami failover, zliczaniem tokenów i kompakcją, z wyjątkiem Bedrocka. Nagłówki z danymi uwierzytelniającymi, nagłówki określające połączenie lub ciało żądania, `Accept-Encoding` oraz `anthropic-beta` są odrzucane; zobacz [Nagłówki upstream](/pl/advanced/plugins). | Nagłówków z danymi uwierzytelniającymi (`Authorization`, `X-Api-Key`, `Cookie`, ...) nigdy nie można edytować, a nazw zawierających `secret` lub diff --git a/docs/pl/advanced/plugins.mdx b/docs/pl/advanced/plugins.mdx index 7d8fe7d84..50ce1d141 100644 --- a/docs/pl/advanced/plugins.mdx +++ b/docs/pl/advanced/plugins.mdx @@ -285,7 +285,7 @@ requestu: | `Prompt` | `*Prompt` | konwersacja (`Messages` ze stabilnymi ID i typowanymi `Parts`: tekst, obraz, audio, plik, wywołanie narzędzia, wynik narzędzia, reasoning, odmowa, nieprzezroczysta część), `Tools`, `Params`, surowa treść. Edytuj przez `SetText`, `SetMedia`, `SetToolArguments`, `SetToolResult`, `Insert`, `Append`, `Remove`, `SetParam`; `TextTargets(roles...)` wyświetla każdą część tekstową, łącznie z tekstem wyników narzędzi, a `SetTargetText` zapisuje ją z powrotem | | `Response` | `*Completion` | odpowiedź, `nil` do fazy response: `Choices` z tym samym modelem `Part`, `Usage`, surowa treść. Edytuj przez `SetText`, `ReplaceText`, `SetFinishReason`, `SetToolArguments`; `TextTargets` i `SetTargetText` działają jak w `Prompt` | | `Stream` | `*StreamState` | dotychczas zebrany tekst dla każdego choice i liczba zdarzeń; `nil` dla requestów bez streamingu | -| `Headers` | `*Headers` | `Request` (przychodzące, z zamaskowanymi danymi uwierzytelniającymi; edycje w fazie prompt dotyczą bieżącego requestu dla kolejnych pluginów i logowania requestu, a nie wywołania providera), `Response` (stosowane do odpowiedzi dla klienta; nagłówek ustawiony na pusty string jest usuwany, łącznie z tymi dodawanymi przez gateway, jak `X-Request-Id`), `Upstream` (wysyłane z każdym wywołaniem providera w ramach requestu, łącznie z próbami failover oraz wywołaniami zliczania tokenów i kompakcji; nagłówki z danymi uwierzytelniającymi, nagłówki określające połączenie lub ciało żądania oraz nagłówki negocjowane przez providera są odrzucane; requesty, które gateway wykonuje na własne potrzeby, ich nie przenoszą) | +| `Headers` | `*Headers` | `Request` (przychodzące, z zamaskowanymi danymi uwierzytelniającymi; edycje w fazie prompt dotyczą bieżącego requestu dla kolejnych pluginów i logowania requestu, a nie wywołania providera), `Response` (stosowane do odpowiedzi dla klienta; nagłówek ustawiony na pusty string jest usuwany, łącznie z tymi dodawanymi przez gateway, jak `X-Request-Id`), `Upstream` (wysyłane z każdym wywołaniem providera w ramach requestu, które przechodzi przez klienta HTTP gatewaya — z wyjątkiem Bedrocka, który wywołuje SDK AWS bezpośrednio —, łącznie z próbami failover oraz wywołaniami zliczania tokenów i kompakcji; nagłówki z danymi uwierzytelniającymi, nagłówki określające połączenie lub ciało żądania, `Accept-Encoding` oraz nagłówki negocjowane przez providera są odrzucane; requesty, które gateway wykonuje na własne potrzeby, ich nie przenoszą) | | `Values` | `Values` | zbiór danych na request współdzielony przez wszystkie hooki requestu; poprzedzaj klucze nazwą pluginu | Edycje przechodzą przez metody, a nie przypisania pól, więc GoModel ponownie koduje tylko @@ -316,6 +316,61 @@ zastępuje się przez `SetToolResult`. wiadomości planer cache providera oznaczy jako prefiks w cache. Dopisywanie zachowuje cache; edycja wewnątrz prefiksu unieważnia go dla sesji. +### Nagłówki upstream + +`Headers.Upstream` umieszcza nagłówek w samym wywołaniu providera: właściwość +routingu lub rozliczeń na poziomie requestu, której oczekuje upstream, taka jak +w Helicone, albo tag organizacyjny, po którym routuje gateway przed providerem. +Dane uwierzytelniające providera nie należą tutaj — nazwy, które gateway traktuje +jako dane uwierzytelniające, są odrzucane, a każda inna wartość jest przekazywana +do providera. Host loguje tylko, ile nagłówków ustawił plugin, nigdy ich wartości. +Przypisz mapę w hooku fazy prompt, zanim wykona się wywołanie providera: + +```go +import "net/http" + +func (p *tenantHint) OnPrompt(_ context.Context, x *pluginapi.Exchange) (pluginapi.Decision, error) { + x.Headers.Upstream = http.Header{ + "Helicone-Property-Tenant": {x.Meta.UserPath}, + "Helicone-Property-Request": {x.Meta.RequestID}, + } + return pluginapi.Allow(), nil +} +``` + +Co gateway z nimi robi: + +- Każda próba providera w ramach requestu je przenosi, łącznie z failoverem do + innego providera, a także wywołania zliczania tokenów i kompakcji, które request + wykonuje: trafiają do tego samego upstreamu, za tym samym gatewayem. Każde + wywołanie, które przechodzi przez klienta HTTP gatewaya — Bedrock, który wywołuje + SDK AWS bezpośrednio, ich nie przenosi. +- Inferencja, którą gateway wykonuje na własne potrzeby, ich nie przenosi. Sędzia + LLM guardraila wywołuje providera przez pluginowe `Host` w kontekście requestu, + a to wywołanie odrzuca te nagłówki: opisują providera klienta, a nie model, który + gateway wybrał dla guardraila. +- Te nazwy są odrzucane, a nie scalane: nagłówki z danymi uwierzytelniającymi + (`Authorization`, `Proxy-Authorization`, `x-api-key`, `api-key`, `x-goog-api-key`, + `x-anthropic-api-key`, `xi-api-key`, `x-auth-token`, `x-access-token`, + `x-gomodel-key`, `Cookie`, `Set-Cookie`), te, które określają połączenie (`Host`, + `Content-Length`, `Transfer-Encoding`, `Connection`, `Keep-Alive`, + `Proxy-Connection`, `Proxy-Authenticate`, `Te`, `Trailer`, `Upgrade`, `Expect`), + te, które opisują ciało zakodowane przez gateway (`Content-Type`, + `Content-Encoding`), `Accept-Encoding`, które negocjuje kompresję odpowiedzi, + oraz `anthropic-beta`, które wybiera funkcje providera i musi zachować flagę + `oauth-2025-04-20` wymaganą przez dane uwierzytelniające subskrypcji. +- Każda inna nazwa trafia na wire z wartością pluginu, zastępując nagłówek + przenoszony przez request oraz ten, który provider ustawił sam. Chronione są tylko + nazwy wymienione powyżej; pozostałe nagłówki providera nie. +- Host scala nagłówki hooka ze stanem requestu, gdy faza się kończy, więc wartość + zapisana po wykonaniu wywołania providera — z hooka fazy response — już na nie nie + wpływa. Mapę otrzymaną z kontekstu traktuj jako tylko do odczytu. + +**Aktualizacja z wydania, które te nagłówki rejestrowało, ale nie wysyłało.** Plugin, +który ustawił `Headers.Upstream` już na takim wydaniu, miał wartości zapisane +i odrzucone. Po aktualizacji po raz pierwszy docierają do providera lub proxy przed +nim: sprawdź, czy upstream je akceptuje, zanim wdrożysz aktualizację. + ## Konfiguracja ### Włączanie systemu pluginów diff --git a/docs/zh/advanced/guardrails.mdx b/docs/zh/advanced/guardrails.mdx index 3ddc8ed0b..8ca52b66d 100644 --- a/docs/zh/advanced/guardrails.mdx +++ b/docs/zh/advanced/guardrails.mdx @@ -399,7 +399,7 @@ prompt 阶段只运行一次,因此故障转移尝试会收到相同的文本 | `response_set` | 替换发送给客户端的响应上的某个 Header。 | | `response_add` | 向客户端响应 Header 追加一个值,保留已有值。即使实例在两个阶段都运行,每个请求也只应用一次。 | | `response_remove` | 从客户端响应中删除某个 Header,包括 GoModel 自身添加的 Header(例如 `X-Request-Id`)。 | -| `upstream_set` | 用于提供商调用的静态 Header。在此版本中只记录、不转发。 | +| `upstream_set` | 合并到该请求的每一次提供商调用中的静态 Header(前提是经由网关的 HTTP 客户端发出:包括故障转移尝试、token 计数和压缩调用,Bedrock 除外)。携带凭据的 Header、决定连接或请求体的 Header、`Accept-Encoding` 以及 `anthropic-beta` 会被拒绝;参见[上游请求头](/zh/advanced/plugins)。 | 凭据类 Header(`Authorization`、`X-Api-Key`、`Cookie` 等)永远无法编辑,名称中包含 `secret` 或 `token` 的 Header 也无法设置。 diff --git a/docs/zh/advanced/plugins.mdx b/docs/zh/advanced/plugins.mdx index 7fed34409..2b6b9cde8 100644 --- a/docs/zh/advanced/plugins.mdx +++ b/docs/zh/advanced/plugins.mdx @@ -204,7 +204,7 @@ keep-alive 注释(15 s)之前完成,针对缓冲响应做出的 `warn` 就 | `Prompt` | `*Prompt` | 对话(带稳定 ID 和类型化 `Parts` 的 `Messages`:文本、图像、音频、文件、工具调用、工具结果、推理、拒答、不透明内容)、`Tools`、`Params`、原始请求体。通过 `SetText`、`SetMedia`、`SetToolArguments`、`SetToolResult`、`Insert`、`Append`、`Remove`、`SetParam` 进行修改;`TextTargets(roles...)` 列出所有文本部分(包括工具结果中的文本),`SetTargetText` 将修改写回其中之一 | | `Response` | `*Completion` | 响应,在 response 阶段之前为 `nil`:使用相同 `Part` 模型的 `Choices`、`Usage`、原始响应体。通过 `SetText`、`ReplaceText`、`SetFinishReason`、`SetToolArguments` 进行修改;`TextTargets` 和 `SetTargetText` 的用法与 `Prompt` 上相同 | | `Stream` | `*StreamState` | 每个选项目前为止累积的文本和事件计数;非流式请求为 `nil` | -| `Headers` | `*Headers` | `Request`(入站请求头,凭据已脱敏;prompt 阶段的修改会作用于实时请求,供后续插件和请求日志使用,但不会作用于提供商调用)、`Response`(应用于客户端响应;设置为空字符串的 Header 会被删除,包括网关添加的 Header,例如 `X-Request-Id`)、`Upstream`(随该请求的每次提供商调用发送,包括故障转移尝试以及 token 计数和压缩调用;携带凭据的 Header、决定连接或请求体的 Header 以及由提供商协商的 Header 会被拒绝;网关为自身发起的调用不携带这些 Header) | +| `Headers` | `*Headers` | `Request`(入站请求头,凭据已脱敏;prompt 阶段的修改会作用于实时请求,供后续插件和请求日志使用,但不会作用于提供商调用)、`Response`(应用于客户端响应;设置为空字符串的 Header 会被删除,包括网关添加的 Header,例如 `X-Request-Id`)、`Upstream`(随该请求的每次提供商调用发送,前提是经由网关的 HTTP 客户端发出(Bedrock 直接调用 AWS SDK,不在此列),包括故障转移尝试以及 token 计数和压缩调用;携带凭据的 Header、决定连接或请求体的 Header、`Accept-Encoding` 以及由提供商协商的 Header 会被拒绝;网关为自身发起的调用不携带这些 Header) | | `Values` | `Values` | 请求内所有 Hook 共享的键值集合;键名请以插件名称作为前缀 | 修改需要通过方法进行,而不是直接给字段赋值,这样 GoModel 只会重新编码发生变化的部分,未被修改的消息会保留所有提供商特定的字段 @@ -225,6 +225,44 @@ keep-alive 注释(15 s)之前完成,针对缓冲响应做出的 `warn` 就 `Meta.Cache.PlannedPrefixMessages` 告诉 prompt 插件,提供商缓存规划器会将开头的多少条消息标记为缓存前缀。 在末尾追加内容会保留缓存;修改前缀内的内容会使该会话的缓存失效。 +### 上游请求头 + +`Headers.Upstream` 把 Header 放到提供商调用本身:上游期望的、按请求生效的路由或计费属性(例如 Helicone 的), +或者提供商之前的网关用于路由的组织标签。提供商凭据不属于这里——网关视为凭据的名称会被拒绝, +其他任何值都会转发给提供商。host 只记录插件设置了多少个 Header,从不记录它们的值。 +在 prompt 阶段的 Hook 中、提供商调用执行之前赋值: + +```go +import "net/http" + +func (p *tenantHint) OnPrompt(_ context.Context, x *pluginapi.Exchange) (pluginapi.Decision, error) { + x.Headers.Upstream = http.Header{ + "Helicone-Property-Tenant": {x.Meta.UserPath}, + "Helicone-Property-Request": {x.Meta.RequestID}, + } + return pluginapi.Allow(), nil +} +``` + +网关如何处理这些 Header: + +- 该请求的每一次提供商尝试都会携带它们,包括故障转移到另一个提供商,以及该请求发起的 token 计数和压缩调用: + 它们到达同一个网关后面的同一个上游。前提是这些调用经由网关的 HTTP 客户端发出;Bedrock 直接调用 AWS SDK,不携带它们。 +- 网关为自身工作发起的推理不会携带。guardrail 的 LLM 判定通过插件 `Host` 在请求上下文中调用提供商, + 该调用会丢弃这些 Header:它们描述的是客户端的提供商,而不是网关为 guardrail 选择的模型。 +- 以下名称会被拒绝,而不是被合并:携带凭据的 Header(`Authorization`、`Proxy-Authorization`、`x-api-key`、`api-key`、 + `x-goog-api-key`、`x-anthropic-api-key`、`xi-api-key`、`x-auth-token`、`x-access-token`、`x-gomodel-key`、`Cookie`、`Set-Cookie`)、 + 决定连接的 Header(`Host`、`Content-Length`、`Transfer-Encoding`、`Connection`、`Keep-Alive`、`Proxy-Connection`、 + `Proxy-Authenticate`、`Te`、`Trailer`、`Upgrade`、`Expect`)、描述网关已编码请求体的 Header(`Content-Type`、`Content-Encoding`)、 + 协商响应压缩的 `Accept-Encoding`,以及选择提供商特性、且必须保留订阅凭据所需的 `oauth-2025-04-20` 标志的 `anthropic-beta`。 +- 其他任何名称都会以插件的值发到线上,替换请求携带的 Header 以及提供商自己设置的 Header。 + 只有上面列出的名称受保护;提供商其余 Header 不受保护。 +- host 在该阶段结束时把 Hook 的 Header 合并进请求状态,因此在提供商调用执行之后写入的值——来自 response 阶段的 Hook——不再影响它。 + 请把从上下文取回的 map 视为只读。 + +**从记录这些 Header 但不发送的版本升级。** 在此类版本上已经设置过 `Headers.Upstream` 的插件, +其值被保存后被丢弃。升级之后,它们首次到达提供商或提供商之前的代理:在推广升级之前,请确认上游能够接受它们。 + ## 配置 ### 启用插件系统 From 544d1808a7fe6ff2b085b71b2fd995df05216182 Mon Sep 17 00:00:00 2001 From: Fede Ciliberti Date: Fri, 30 Oct 2026 12:03:13 -0300 Subject: [PATCH 15/17] docs(headeredit): say where upstream_set headers do apply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The help text promised "every provider call of the request". Bedrock calls the AWS SDK directly, and inference the gateway issues for its own work — a guardrail's judge — deliberately drops the headers. Both are visible to an operator filling the field in, so name them there. --- internal/plugins/builtin/headeredit/plugin.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/plugins/builtin/headeredit/plugin.go b/internal/plugins/builtin/headeredit/plugin.go index f60578a8d..84044f62d 100644 --- a/internal/plugins/builtin/headeredit/plugin.go +++ b/internal/plugins/builtin/headeredit/plugin.go @@ -68,7 +68,7 @@ func (p *Plugin) Manifest() pluginapi.Manifest { }, { Key: "upstream_set", Label: "Upstream headers to set", Input: pluginapi.InputTextarea, Default: "", - Help: "One \"Name: value\" per line. Adds static headers to every provider call of the request, failover attempts included. Credential headers are refused.", + Help: "One \"Name: value\" per line. Adds static headers to every provider call of the request that goes through the gateway's HTTP client, failover attempts, token counting and compaction included; Bedrock, which calls the AWS SDK directly, does not carry them. Applies to requests from clients: inference the gateway issues for its own work, such as a guardrail's judge, does not inherit them. Credential and transport headers are refused.", Placeholder: "X-Tenant: acme", }, }, From dfd9f3fb7aa9ced1fb5f5930c1993a0bbfc03957 Mon Sep 17 00:00:00 2001 From: Fede Ciliberti Date: Fri, 30 Oct 2026 12:04:58 -0300 Subject: [PATCH 16/17] docs(plugins): list the connection and encoding names the merge refuses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The English section still grouped `Accept-Encoding` with the headers that describe the request body and stopped at the three transport names the first version denied. It now matches the list the merge runs — the hop-by-hop set, the body headers including `Content-Encoding`, and `Accept-Encoding` named for what it does, which is negotiate response compression — and the translated sections that landed with it. Tests: `npx mint validate`. --- docs/advanced/plugins.mdx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/advanced/plugins.mdx b/docs/advanced/plugins.mdx index dd8b6840a..76d8ce1ff 100644 --- a/docs/advanced/plugins.mdx +++ b/docs/advanced/plugins.mdx @@ -353,10 +353,11 @@ What the gateway does with them: `x-goog-api-key`, `x-anthropic-api-key`, `xi-api-key`, `x-auth-token`, `x-access-token`, `x-gomodel-key`, `Cookie`, `Set-Cookie`), the ones that frame the connection (`Host`, `Content-Length`, `Transfer-Encoding`, `Connection`, - `Te`, `Trailer`, `Upgrade`, `Expect`), the ones that describe the body the - gateway encoded (`Content-Type`, `Accept-Encoding`), and `anthropic-beta`, - which selects provider features and has to keep the `oauth-2025-04-20` flag - subscription credentials need. + `Keep-Alive`, `Proxy-Connection`, `Proxy-Authenticate`, `Te`, `Trailer`, + `Upgrade`, `Expect`), the ones that describe the body the gateway encoded + (`Content-Type`, `Content-Encoding`), `Accept-Encoding`, which negotiates + response compression, and `anthropic-beta`, which selects provider features and + has to keep the `oauth-2025-04-20` flag subscription credentials need. - Any other name reaches the wire with the plugin's value, replacing a header the request carried and one the provider set for itself. Only the names above are protected; the rest of the provider's headers are not. From 120f476b938e70a1fd533c34fd57274a3e5cfcc7 Mon Sep 17 00:00:00 2001 From: Fede Ciliberti Date: Fri, 30 Oct 2026 12:19:32 -0300 Subject: [PATCH 17/17] docs(plugins): send upstream headers to the attempt's own upstream The bullet said every provider attempt carries the headers "to the provider" and named the same upstream for the token-counting and compaction calls. A failover can resolve to a different provider and a different base URL, so the headers follow whichever upstream that attempt selected, not the client's. Same fix in de, pl and zh. The `upstream_set` help also said only credential and transport headers are refused; `Anthropic-Beta` is refused too, from its own list, so name it. --- docs/advanced/plugins.mdx | 8 +++++--- docs/de/advanced/plugins.mdx | 8 +++++--- docs/pl/advanced/plugins.mdx | 3 ++- docs/zh/advanced/plugins.mdx | 5 +++-- internal/plugins/builtin/headeredit/plugin.go | 2 +- 5 files changed, 16 insertions(+), 10 deletions(-) diff --git a/docs/advanced/plugins.mdx b/docs/advanced/plugins.mdx index 76d8ce1ff..7b59d205f 100644 --- a/docs/advanced/plugins.mdx +++ b/docs/advanced/plugins.mdx @@ -341,9 +341,11 @@ What the gateway does with them: - Every provider attempt of the request carries them, failover to another provider included, and so do the token-counting and compaction calls the - request makes: they reach the same upstream, behind the same gateway. Every - call that goes through the gateway's HTTP client, that is; Bedrock, which - calls the AWS SDK directly, does not carry them. + request makes. Each attempt sends them to the upstream selected for that + attempt: a failover can resolve to a different provider and a different base + URL, and the headers follow it. Every call that goes through the gateway's + HTTP client, that is; Bedrock, which calls the AWS SDK directly, does not carry + them. - Inference the gateway issues for its own work does not. A guardrail's LLM judge calls the provider through the plugin `Host` on the request context, and that call drops these headers: they describe the client's provider, not the diff --git a/docs/de/advanced/plugins.mdx b/docs/de/advanced/plugins.mdx index 072d5b6bc..dd18bf155 100644 --- a/docs/de/advanced/plugins.mdx +++ b/docs/de/advanced/plugins.mdx @@ -330,9 +330,11 @@ Was das Gateway damit macht: - Jeder Provider-Versuch des Requests trägt sie, einschließlich Failover auf einen anderen Provider, ebenso die Aufrufe zur Token-Zählung und Kompaktierung, die der - Request auslöst: Sie erreichen dasselbe Upstream hinter demselben Gateway. Jeder - Aufruf, der über den HTTP-Client des Gateways läuft, wohlgemerkt; Bedrock, das das - AWS SDK direkt aufruft, trägt sie nicht. + Request auslöst. Jeder Versuch sendet sie an das für diesen Versuch gewählte + Upstream: Ein Failover kann auf einen anderen Provider und eine andere Base-URL + auflösen, und die Header folgen ihm. Jeder Aufruf, der über den HTTP-Client des + Gateways läuft, wohlgemerkt; Bedrock, das das AWS SDK direkt aufruft, trägt sie + nicht. - Inferenz, die das Gateway für eigene Zwecke stellt, trägt sie nicht. Der LLM-Judge einer Guardrail ruft den Provider über das Plugin-`Host` im Request-Kontext auf, und dieser Aufruf verwirft die Header: Sie beschreiben den Provider des Clients, diff --git a/docs/pl/advanced/plugins.mdx b/docs/pl/advanced/plugins.mdx index 50ce1d141..ca2a5fa63 100644 --- a/docs/pl/advanced/plugins.mdx +++ b/docs/pl/advanced/plugins.mdx @@ -342,7 +342,8 @@ Co gateway z nimi robi: - Każda próba providera w ramach requestu je przenosi, łącznie z failoverem do innego providera, a także wywołania zliczania tokenów i kompakcji, które request - wykonuje: trafiają do tego samego upstreamu, za tym samym gatewayem. Każde + wykonuje. Każda próba wysyła je do upstreamu wybranego dla tej próby: failover może + rozwiązać się na innego providera i inny base URL, a nagłówki idą za nim. Każde wywołanie, które przechodzi przez klienta HTTP gatewaya — Bedrock, który wywołuje SDK AWS bezpośrednio, ich nie przenosi. - Inferencja, którą gateway wykonuje na własne potrzeby, ich nie przenosi. Sędzia diff --git a/docs/zh/advanced/plugins.mdx b/docs/zh/advanced/plugins.mdx index 2b6b9cde8..4dc22c15e 100644 --- a/docs/zh/advanced/plugins.mdx +++ b/docs/zh/advanced/plugins.mdx @@ -246,8 +246,9 @@ func (p *tenantHint) OnPrompt(_ context.Context, x *pluginapi.Exchange) (plugina 网关如何处理这些 Header: -- 该请求的每一次提供商尝试都会携带它们,包括故障转移到另一个提供商,以及该请求发起的 token 计数和压缩调用: - 它们到达同一个网关后面的同一个上游。前提是这些调用经由网关的 HTTP 客户端发出;Bedrock 直接调用 AWS SDK,不携带它们。 +- 该请求的每一次提供商尝试都会携带它们,包括故障转移到另一个提供商,以及该请求发起的 token 计数和压缩调用。 + 每次尝试都会把它们发到为该次尝试选定的上游:故障转移可能解析到另一个提供商和另一个 base URL,这些 Header 会随之而去。 + 前提是这些调用经由网关的 HTTP 客户端发出;Bedrock 直接调用 AWS SDK,不携带它们。 - 网关为自身工作发起的推理不会携带。guardrail 的 LLM 判定通过插件 `Host` 在请求上下文中调用提供商, 该调用会丢弃这些 Header:它们描述的是客户端的提供商,而不是网关为 guardrail 选择的模型。 - 以下名称会被拒绝,而不是被合并:携带凭据的 Header(`Authorization`、`Proxy-Authorization`、`x-api-key`、`api-key`、 diff --git a/internal/plugins/builtin/headeredit/plugin.go b/internal/plugins/builtin/headeredit/plugin.go index 84044f62d..961aa0c55 100644 --- a/internal/plugins/builtin/headeredit/plugin.go +++ b/internal/plugins/builtin/headeredit/plugin.go @@ -68,7 +68,7 @@ func (p *Plugin) Manifest() pluginapi.Manifest { }, { Key: "upstream_set", Label: "Upstream headers to set", Input: pluginapi.InputTextarea, Default: "", - Help: "One \"Name: value\" per line. Adds static headers to every provider call of the request that goes through the gateway's HTTP client, failover attempts, token counting and compaction included; Bedrock, which calls the AWS SDK directly, does not carry them. Applies to requests from clients: inference the gateway issues for its own work, such as a guardrail's judge, does not inherit them. Credential and transport headers are refused.", + Help: "One \"Name: value\" per line. Adds static headers to every provider call of the request that goes through the gateway's HTTP client, failover attempts, token counting and compaction included; Bedrock, which calls the AWS SDK directly, does not carry them. Applies to requests from clients: inference the gateway issues for its own work, such as a guardrail's judge, does not inherit them. Credential, transport and Anthropic-Beta headers are refused.", Placeholder: "X-Tenant: acme", }, },