Conversation
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
|
|
||
| | 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. | |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
headersand per-turnrequestHeaders+headerMergeStrategywiring throughsession.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
requestHeadersto 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"` |
There was a problem hiding this comment.
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 {}).
| 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"` |
| Prompt string `json:"prompt"` | ||
| Attachments []Attachment `json:"attachments,omitempty"` | ||
| Mode string `json:"mode,omitempty"` | ||
| RequestHeaders map[string]string `json:"requestHeaders,omitempty"` |
There was a problem hiding this comment.
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.
| RequestHeaders map[string]string `json:"requestHeaders,omitempty"` | |
| RequestHeaders *map[string]string `json:"requestHeaders,omitempty"` |
| 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) | ||
| } | ||
| }) |
There was a problem hiding this comment.
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.
| ### 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}", | ||
| }, | ||
| }, | ||
| ) | ||
| ``` |
There was a problem hiding this comment.
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.
| @@ -1700,6 +1720,17 @@ protected MessageOptions(MessageOptions? other) | |||
| /// Interaction mode for the message (e.g., "plan", "edit"). | |||
There was a problem hiding this comment.
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.
| /// 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. |
| // 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 | ||
| } |
There was a problem hiding this comment.
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.
| // 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, |
There was a problem hiding this comment.
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.
| // 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}, |
| <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"}) | ||
| ``` |
There was a problem hiding this comment.
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.
Cross-SDK Consistency Review ✅This PR implements custom headers support consistently across all four SDKs with proper language-idiomatic naming:
Wire format consistency: All SDKs map to the same JSON keys ( One minor observation: The Go Otherwise, the cross-SDK implementation is thorough and well-aligned.
|
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
"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?
| | `${VAR}` | Replaced with the value of `VAR`. Fails if `VAR` is not set. | | ||
| | `$VAR` | Same as `${VAR}`. | |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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).
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>onProviderConfig— session-level headersHeaderMergeStrategytype ("override" | "merge")requestHeadersandheaderMergeStrategyonMessageOptions/send()— per-turn headersSession Methods (all 4 SDKs)
send()updated to passrequestHeadersandheaderMergeStrategyupdateProvider()/update_provider()/UpdateProviderAsync()method for mid-session provider config updatesDocumentation
docs/auth/byok.mdwith use cases, examples, merge strategies, env var expansion, security notesHeader Merge Strategies
"override"(default)"merge"Tests
Related