Skip to content

feat: support custom headers for model backends#986

Open
patniko wants to merge 1 commit intomainfrom
custom-headers
Open

feat: support custom headers for model backends#986
patniko wants to merge 1 commit intomainfrom
custom-headers

Conversation

@patniko
Copy link
Copy Markdown
Contributor

@patniko patniko commented Apr 2, 2026

Summary

Adds custom headers support across all 4 SDKs (Node.js, Python, Go, .NET), enabling integrators to pass custom HTTP headers to model/tool backends at session creation and between turns.

Changes

Types (all 4 SDKs)

  • headers?: Record<string, string> on ProviderConfig — session-level headers
  • HeaderMergeStrategy type ("override" | "merge")
  • requestHeaders and headerMergeStrategy on MessageOptions/send() — per-turn headers

Session Methods (all 4 SDKs)

  • send() updated to pass requestHeaders and headerMergeStrategy
  • New updateProvider() / update_provider() / UpdateProviderAsync() method for mid-session provider config updates

Documentation

  • Comprehensive "Custom Headers" section in docs/auth/byok.md with use cases, examples, merge strategies, env var expansion, security notes
  • Custom headers examples in all 4 SDK READMEs

Header Merge Strategies

Strategy Behavior
"override" (default) Per-turn headers completely replace session-level headers
"merge" Per-turn headers merge with session-level; per-turn wins on conflicts

Tests

  • Node.js: 8 tests — send RPC params, updateProvider
  • Python: 14 tests — types, send, updateProvider, wire format
  • Go: 12 subtests — JSON marshaling, types, send request
  • .NET: 12 tests — properties, cloning, all header fields

Related

Add custom headers support across all 4 SDKs (Node.js, Python, Go, .NET):

- Add headers field to ProviderConfig for session-level custom headers
- Add HeaderMergeStrategy type (override, merge) for configurable merge behavior
- Add requestHeaders and headerMergeStrategy to MessageOptions/send()
- Add updateProvider/update_provider/UpdateProviderAsync method on Session
- Add comprehensive documentation in docs/auth/byok.md
- Update all SDK READMEs with custom headers examples

Closes #355
Copilot AI review requested due to automatic review settings April 2, 2026 15:08
@patniko patniko requested a review from a team as a code owner April 2, 2026 15:08

| Strategy | Behavior |
|----------|----------|
| `"override"` (default) | Per-turn headers **completely replace** session-level headers. No session headers are sent for that turn. This is the safest default — no unexpected header leakage. |
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an interesting default. I'm curious what drives this. What are the scenarios we have for each of override and merge that drove this choice?

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds cross-SDK support (Node.js, Python, Go, .NET) for passing custom HTTP headers to model/tool backends, both at session/provider level and per-turn, plus a mid-session provider update API and documentation updates.

Changes:

  • Adds provider-level headers and per-turn requestHeaders + headerMergeStrategy wiring through session.send.
  • Introduces mid-session provider mutation (updateProvider / update_provider / UpdateProviderAsync) across SDKs.
  • Adds/extends unit tests and documentation for custom headers, merge strategies, and usage patterns.

Reviewed changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
python/test_custom_headers.py New Python unit tests covering provider headers, per-turn headers, merge strategy, and update_provider wire shape.
python/README.md Adds Python “Custom Headers” documentation section and example.
python/copilot/session.py Adds ProviderConfig headers, per-turn send params, and update_provider method.
python/copilot/client.py Wires provider headers into provider wire-format conversion.
nodejs/test/client.test.ts Adds Node tests asserting send/updateProvider RPC params include header fields.
nodejs/src/types.ts Adds ProviderConfig.headers, HeaderMergeStrategy, and per-turn message header options types.
nodejs/src/session.ts Forwards per-turn header fields in send() and adds updateProvider().
nodejs/src/index.ts Re-exports HeaderMergeStrategy and ProviderConfig.
nodejs/README.md Adds Node “Custom Headers” documentation section and example.
go/types.go Adds provider/session request header fields + merge strategy types to Go SDK wire/request structs.
go/session.go Forwards header fields in Send() and adds UpdateProvider().
go/README.md Adds Go “Custom Headers” documentation section and example.
go/custom_headers_test.go New Go unit tests covering JSON marshaling and constants for the new fields.
dotnet/test/CustomHeadersTests.cs New .NET unit tests for headers fields, merge strategy constants, and cloning behavior.
dotnet/src/Types.cs Adds provider headers, merge strategy constants, and per-turn message header fields (plus cloning support).
dotnet/src/Session.cs Wires per-turn header fields into send request and adds UpdateProviderAsync() RPC.
dotnet/README.md Adds .NET “Custom Headers” documentation section and example.
docs/auth/byok.md Adds a comprehensive “Custom Headers” section, merge strategy semantics, env-var expansion, and mid-session update docs.
Comments suppressed due to low confidence (1)

go/custom_headers_test.go:211

  • This test expects empty requestHeaders to be omitted. For per-turn header override semantics, callers often need to explicitly send an empty object to suppress session-level provider headers for a single turn. If that’s the intended cross-SDK behavior, update the Go wire struct/tests so an explicitly provided empty map serializes as {} rather than being omitted.
	t.Run("omits empty requestHeaders with omitempty", func(t *testing.T) {
		req := sessionSendRequest{
			SessionID:      "sess-1",
			Prompt:         "hello",
			RequestHeaders: map[string]string{},
		}

		data, err := json.Marshal(req)
		if err != nil {
			t.Fatalf("failed to marshal: %v", err)
		}

		jsonStr := string(data)
		// Go's omitempty omits empty maps
		if contains(jsonStr, `"requestHeaders"`) {
			t.Errorf("expected empty requestHeaders to be omitted with omitempty, got %s", jsonStr)
		}
	})

BearerToken string `json:"bearerToken,omitempty"`
// Headers contains custom HTTP headers to include in all outbound requests to the provider.
// Supports env var expansion (e.g. ${VAR}, ${VAR:-default}).
Headers map[string]string `json:"headers,omitempty"`
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Go, omitempty on ProviderConfig.Headers prevents sending an explicit empty headers object. That makes it impossible to clear previously-set session headers via UpdateProvider, and differs from the other SDKs (which can send {} to clear). Consider representing headers as a pointer/optional wrapper so you can distinguish “unset” (omit) vs “set to empty” (send {}).

Suggested change
Headers map[string]string `json:"headers,omitempty"`
// Pointer type allows distinguishing between:
// - nil (field omitted; leave headers unchanged)
// - non-nil empty map (serialized as {}; clear existing headers)
// - non-nil non-empty map (set/override headers).
Headers *map[string]string `json:"headers,omitempty"`

Copilot uses AI. Check for mistakes.
Prompt string `json:"prompt"`
Attachments []Attachment `json:"attachments,omitempty"`
Mode string `json:"mode,omitempty"`
RequestHeaders map[string]string `json:"requestHeaders,omitempty"`
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RequestHeaders is tagged json:"requestHeaders,omitempty", so an explicitly provided empty map (map[string]string{}) will be omitted during JSON marshaling. That breaks the documented/expected “override” behavior where a caller should be able to override session-level headers with an empty per-turn set (send no headers for that turn). Use a pointer/optional wrapper (or custom marshaling) to preserve {} when intentionally provided.

Suggested change
RequestHeaders map[string]string `json:"requestHeaders,omitempty"`
RequestHeaders *map[string]string `json:"requestHeaders,omitempty"`

Copilot uses AI. Check for mistakes.
Comment on lines +53 to +69
t.Run("omits empty headers with omitempty", func(t *testing.T) {
config := ProviderConfig{
BaseURL: "https://api.example.com",
Headers: map[string]string{},
}

data, err := json.Marshal(config)
if err != nil {
t.Fatalf("failed to marshal: %v", err)
}

jsonStr := string(data)
// Go's omitempty omits empty maps
if contains(jsonStr, `"headers"`) {
t.Errorf("expected empty headers to be omitted with omitempty, got %s", jsonStr)
}
})
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests assert that empty headers are omitted due to omitempty. If empty headers are meant to be a supported/meaningful value (e.g., to clear provider headers or override session headers with none), the SDK should be able to send {} when explicitly provided, and the tests should be updated to cover that behavior instead of locking in omission.

This issue also appears on line 194 of the same file.

Copilot uses AI. Check for mistakes.
Comment on lines +496 to +512
### Custom Headers

You can attach custom HTTP headers to outbound model requests — useful for API gateways, proxy authentication, or tenant routing:

```python
session = await client.create_session(
model="gpt-4.1",
provider={
"type": "openai",
"base_url": "https://my-gateway.example.com/v1",
"api_key": os.environ["OPENAI_API_KEY"],
"headers": {
"Ocp-Apim-Subscription-Key": "${APIM_KEY}",
},
},
)
```
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new Custom Headers example uses os.environ[...] but the snippet doesn’t include import os, so it won’t run as-is. Add the import (or adjust the example) for consistency with the other provider examples above.

Copilot uses AI. Check for mistakes.
@@ -1700,6 +1720,17 @@ protected MessageOptions(MessageOptions? other)
/// Interaction mode for the message (e.g., "plan", "edit").
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MessageOptions.Mode is documented here as an “Interaction mode (plan/edit)”, but in this SDK it’s used as the message delivery mode (enqueue/immediate) and is serialized as mode in the session.send request. Updating this doc comment would prevent confusion, especially now that more per-send knobs are being added.

Suggested change
/// Interaction mode for the message (e.g., "plan", "edit").
/// Message delivery mode for this send (for example, "enqueue" or "immediate").
/// Serialized as <c>mode</c> in the underlying <c>session.send</c> request.

Copilot uses AI. Check for mistakes.
Comment on lines +244 to +256
// contains checks if substr is present in s.
func contains(s, substr string) bool {
return len(s) >= len(substr) && searchString(s, substr)
}

func searchString(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This helper reimplements substring search (contains/searchString), but the standard library already provides strings.Contains. Using the standard function would simplify the test and reduce maintenance surface.

Copilot uses AI. Check for mistakes.
Comment on lines +845 to +853
// UpdateProvider updates the provider configuration for this session.
// This allows changing headers, authentication, or other provider settings between turns.
func (s *Session) UpdateProvider(ctx context.Context, provider ProviderConfig) error {
req := struct {
SessionID string `json:"sessionId"`
Provider ProviderConfig `json:"provider"`
}{
SessionID: s.SessionID,
Provider: provider,
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UpdateProvider accepts a full ProviderConfig, but ProviderConfig.BaseURL is tagged json:"baseUrl" (no omitempty). If callers follow the docs/examples and pass only Headers, the wire payload will still include baseUrl:"", which may overwrite the session’s provider base URL with an empty string. Consider introducing a separate “partial update” type (pointer fields / omitempty) or making the request build omit unset fields so UpdateProvider can safely patch only the provided settings.

Suggested change
// UpdateProvider updates the provider configuration for this session.
// This allows changing headers, authentication, or other provider settings between turns.
func (s *Session) UpdateProvider(ctx context.Context, provider ProviderConfig) error {
req := struct {
SessionID string `json:"sessionId"`
Provider ProviderConfig `json:"provider"`
}{
SessionID: s.SessionID,
Provider: provider,
// providerUpdateConfig wraps ProviderConfig to customize JSON marshaling for
// session.provider.update. In particular, it omits the "baseUrl" field when
// BaseURL is the empty string so that callers can update headers or other
// fields without accidentally clearing the session's provider base URL.
type providerUpdateConfig struct {
ProviderConfig
}
func (p providerUpdateConfig) MarshalJSON() ([]byte, error) {
// Marshal the embedded ProviderConfig first.
raw, err := json.Marshal(p.ProviderConfig)
if err != nil {
return nil, err
}
// Work with a map so we can selectively remove keys.
var m map[string]interface{}
if err := json.Unmarshal(raw, &m); err != nil {
return nil, err
}
// ProviderConfig.BaseURL is tagged `json:"baseUrl"` without omitempty.
// If BaseURL is empty, omit "baseUrl" entirely so the server does not
// treat it as an explicit request to clear the base URL.
if p.BaseURL == "" {
delete(m, "baseUrl")
}
return json.Marshal(m)
}
type providerUpdateRequest struct {
SessionID string `json:"sessionId"`
Provider providerUpdateConfig `json:"provider"`
}
// UpdateProvider updates the provider configuration for this session.
// This allows changing headers, authentication, or other provider settings between turns.
func (s *Session) UpdateProvider(ctx context.Context, provider ProviderConfig) error {
req := providerUpdateRequest{
SessionID: s.SessionID,
Provider: providerUpdateConfig{ProviderConfig: provider},

Copilot uses AI. Check for mistakes.
Comment on lines +565 to +576
<summary><strong>Go</strong></summary>

```go
err := session.UpdateProvider(ctx, copilot.ProviderConfig{
Headers: map[string]string{
"Ocp-Apim-Subscription-Key": newSubscriptionKey,
"X-Tenant-Id": "new-team",
},
})

_, err = session.Send(ctx, copilot.MessageOptions{Prompt: "Continue"})
```
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Go UpdateProvider example passes only Headers. With the current Go SDK implementation (ProviderConfig.BaseURL is non-omitempty), that call would serialize baseUrl:"" as well. Either adjust the Go SDK to support true partial updates, or update this example to include the required base URL to avoid accidentally clearing it.

Copilot uses AI. Check for mistakes.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 2, 2026

Cross-SDK Consistency Review ✅

This PR implements custom headers support consistently across all four SDKs with proper language-idiomatic naming:

Feature Node.js Python Go .NET
ProviderConfig.headers Record<string, string>? dict[str, str] map[string]string (omitempty) Dictionary<string, string>?
Per-turn headers field requestHeaders request_headers RequestHeaders RequestHeaders
Merge strategy field headerMergeStrategy header_merge_strategy HeaderMergeStrategy HeaderMergeStrategy
Merge strategy type "override" | "merge" Literal["override", "merge"] typed const (HeaderMergeStrategyOverride/Merge) static class strings
Update provider method updateProvider(Partial<ProviderConfig>) update_provider(ProviderConfig) UpdateProvider(ctx, ProviderConfig) UpdateProviderAsync(ProviderConfig)

Wire format consistency: All SDKs map to the same JSON keys (requestHeaders, headerMergeStrategy, session.provider.update RPC). The Python SDK correctly runs _convert_provider_to_wire_format through update_provider for snake_case → camelCase conversion.

One minor observation: The Go UpdateProvider method takes ProviderConfig by value instead of *ProviderConfig (pointer), which is inconsistent with every other ProviderConfig usage in the Go SDK. Flagged inline. This is a minor ergonomics issue, not a correctness problem.

Otherwise, the cross-SDK implementation is thorough and well-aligned.

Generated by SDK Consistency Review Agent for issue #986 ·

Copy link
Copy Markdown
Contributor

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated by SDK Consistency Review Agent for issue #986


// UpdateProvider updates the provider configuration for this session.
// This allows changing headers, authentication, or other provider settings between turns.
func (s *Session) UpdateProvider(ctx context.Context, provider ProviderConfig) error {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Go API consistency: UpdateProvider takes ProviderConfig by value, but every other place in the Go SDK that accepts a provider uses *ProviderConfig (pointer) — e.g., SessionConfig.Provider *ProviderConfig, ResumeSessionConfig.Provider *ProviderConfig, and the wire-format request types. This creates an inconsistency: users who pattern-match against CreateSession will instinctively try to pass &copilot.ProviderConfig{…} and get a compile error.

Consider making the signature consistent with the rest of the SDK:

func (s *Session) UpdateProvider(ctx context.Context, provider *ProviderConfig) error {

This is a minor ergonomics issue and doesn't affect correctness (all fields use omitempty so partial updates work with either form), but it would align with the established Go SDK pattern.


### Updating Provider Configuration Mid-Session

Use `updateProvider()` to change provider configuration — including headers — between turns without recreating the session. This is useful for rotating API keys, switching tenants, or adjusting gateway headers on the fly.
Copy link
Copy Markdown
Collaborator

@stephentoub stephentoub Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"between turns"....

If I Send and then immediately UpdateProvider, do those updates affect the current turn (which might make many LLM requests well after that), or are they delayed taking effect until idle?

Comment on lines +604 to +605
| `${VAR}` | Replaced with the value of `VAR`. Fails if `VAR` is not set. |
| `$VAR` | Same as `${VAR}`. |
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why two different syntaxes for the same thing?


### Security Considerations

- **Scoped to your endpoint** — Custom headers are sent only to the configured `baseUrl`. They are never sent to GitHub Copilot servers or other endpoints.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any situation where you might be able to connect to a CLI server and use this to exfiltrate environment variable values you may not have otherwise had access to?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally the threat model is that anyone who can connect to the CLI server as the SDK has full access to the whole machine, since you can always create sessions that have auto-approve-everything, don't set any filesystem virtualization, and then instruct the agent to do anything.

This doesn't mean that users who have sessions can do anything - it comes down to who can control the session options.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having said that, we should clarify that these headers are only sent when the runtime issues LLM inference calls, and not in general for all HTTP traffic. If it were the latter, an end user who doesn't control session options might be able to tell the agent to do an HTTP request to a URL within baseUrl that has some vulnerability (e.g., trigger an error and get back a dump of all incoming headers).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Passing custom headers to model and tool backends.

4 participants