Skip to content

Commit c04c8c0

Browse files
stephentoubCopilot
andauthored
Add runtime header options across SDKs (#1094)
Expose provider headers and per-message requestHeaders across Node, Python, Go, and .NET, and add focused tests covering create, resume, and send request forwarding. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 21c6d5e commit c04c8c0

File tree

12 files changed

+400
-14
lines changed

12 files changed

+400
-14
lines changed

dotnet/src/Session.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,8 @@ public async Task<string> SendAsync(MessageOptions options, CancellationToken ca
192192
Attachments = options.Attachments,
193193
Mode = options.Mode,
194194
Traceparent = traceparent,
195-
Tracestate = tracestate
195+
Tracestate = tracestate,
196+
RequestHeaders = options.RequestHeaders,
196197
};
197198

198199
var response = await InvokeRpcAsync<SendMessageResponse>(
@@ -1223,6 +1224,7 @@ internal record SendMessageRequest
12231224
public string? Mode { get; init; }
12241225
public string? Traceparent { get; init; }
12251226
public string? Tracestate { get; init; }
1227+
public IDictionary<string, string>? RequestHeaders { get; init; }
12261228
}
12271229

12281230
internal record SendMessageResponse

dotnet/src/Types.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1483,6 +1483,12 @@ public class ProviderConfig
14831483
/// </summary>
14841484
[JsonPropertyName("azure")]
14851485
public AzureOptions? Azure { get; set; }
1486+
1487+
/// <summary>
1488+
/// Custom HTTP headers to include in outbound provider requests.
1489+
/// </summary>
1490+
[JsonPropertyName("headers")]
1491+
public IDictionary<string, string>? Headers { get; set; }
14861492
}
14871493

14881494
/// <summary>
@@ -2157,6 +2163,9 @@ protected MessageOptions(MessageOptions? other)
21572163
Attachments = other.Attachments is not null ? [.. other.Attachments] : null;
21582164
Mode = other.Mode;
21592165
Prompt = other.Prompt;
2166+
RequestHeaders = other.RequestHeaders is not null
2167+
? new Dictionary<string, string>(other.RequestHeaders)
2168+
: null;
21602169
}
21612170

21622171
/// <summary>
@@ -2171,6 +2180,10 @@ protected MessageOptions(MessageOptions? other)
21712180
/// Interaction mode for the message (e.g., "plan", "edit").
21722181
/// </summary>
21732182
public string? Mode { get; set; }
2183+
/// <summary>
2184+
/// Custom per-turn HTTP headers for outbound model requests.
2185+
/// </summary>
2186+
public IDictionary<string, string>? RequestHeaders { get; set; }
21742187

21752188
/// <summary>
21762189
/// Creates a shallow clone of this <see cref="MessageOptions"/> instance.

dotnet/test/SerializationTests.cs

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,74 @@ public void SerializerOptions_CanResolveRequestIdTypeInfo()
6767
Assert.Equal(typeof(RequestId), typeInfo.Type);
6868
}
6969

70+
[Fact]
71+
public void ProviderConfig_CanSerializeHeaders_WithSdkOptions()
72+
{
73+
var options = GetSerializerOptions();
74+
var original = new ProviderConfig
75+
{
76+
BaseUrl = "https://example.com/provider",
77+
Headers = new Dictionary<string, string> { ["Authorization"] = "Bearer provider-token" }
78+
};
79+
80+
var json = JsonSerializer.Serialize(original, options);
81+
using var document = JsonDocument.Parse(json);
82+
var root = document.RootElement;
83+
Assert.Equal("https://example.com/provider", root.GetProperty("baseUrl").GetString());
84+
Assert.Equal("Bearer provider-token", root.GetProperty("headers").GetProperty("Authorization").GetString());
85+
86+
var deserialized = JsonSerializer.Deserialize<ProviderConfig>(json, options);
87+
Assert.NotNull(deserialized);
88+
Assert.Equal("https://example.com/provider", deserialized.BaseUrl);
89+
Assert.Equal("Bearer provider-token", deserialized.Headers!["Authorization"]);
90+
}
91+
92+
[Fact]
93+
public void MessageOptions_CanSerializeRequestHeaders_WithSdkOptions()
94+
{
95+
var options = GetSerializerOptions();
96+
var original = new MessageOptions
97+
{
98+
Prompt = "real prompt",
99+
Mode = "plan",
100+
RequestHeaders = new Dictionary<string, string> { ["X-Trace"] = "trace-value" }
101+
};
102+
103+
var json = JsonSerializer.Serialize(original, options);
104+
using var document = JsonDocument.Parse(json);
105+
var root = document.RootElement;
106+
Assert.Equal("real prompt", root.GetProperty("prompt").GetString());
107+
Assert.Equal("plan", root.GetProperty("mode").GetString());
108+
Assert.Equal("trace-value", root.GetProperty("requestHeaders").GetProperty("X-Trace").GetString());
109+
110+
var deserialized = JsonSerializer.Deserialize<MessageOptions>(json, options);
111+
Assert.NotNull(deserialized);
112+
Assert.Equal("real prompt", deserialized.Prompt);
113+
Assert.Equal("plan", deserialized.Mode);
114+
Assert.Equal("trace-value", deserialized.RequestHeaders!["X-Trace"]);
115+
}
116+
117+
[Fact]
118+
public void SendMessageRequest_CanSerializeRequestHeaders_WithSdkOptions()
119+
{
120+
var options = GetSerializerOptions();
121+
var requestType = GetNestedType(typeof(CopilotSession), "SendMessageRequest");
122+
var request = CreateInternalRequest(
123+
requestType,
124+
("SessionId", "session-id"),
125+
("Prompt", "real prompt"),
126+
("Mode", "plan"),
127+
("RequestHeaders", new Dictionary<string, string> { ["X-Trace"] = "trace-value" }));
128+
129+
var json = JsonSerializer.Serialize(request, requestType, options);
130+
using var document = JsonDocument.Parse(json);
131+
var root = document.RootElement;
132+
Assert.Equal("session-id", root.GetProperty("sessionId").GetString());
133+
Assert.Equal("real prompt", root.GetProperty("prompt").GetString());
134+
Assert.Equal("plan", root.GetProperty("mode").GetString());
135+
Assert.Equal("trace-value", root.GetProperty("requestHeaders").GetProperty("X-Trace").GetString());
136+
}
137+
70138
private static JsonSerializerOptions GetSerializerOptions()
71139
{
72140
var prop = typeof(CopilotClient)
@@ -77,4 +145,34 @@ private static JsonSerializerOptions GetSerializerOptions()
77145
Assert.NotNull(options);
78146
return options;
79147
}
148+
149+
private static Type GetNestedType(Type containingType, string name)
150+
{
151+
var type = containingType.GetNestedType(name, System.Reflection.BindingFlags.NonPublic);
152+
Assert.NotNull(type);
153+
return type!;
154+
}
155+
156+
private static object CreateInternalRequest(Type type, params (string Name, object? Value)[] properties)
157+
{
158+
var instance = System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject(type);
159+
160+
foreach (var (name, value) in properties)
161+
{
162+
var property = type.GetProperty(name, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic);
163+
Assert.NotNull(property);
164+
165+
if (property!.SetMethod is not null)
166+
{
167+
property.SetValue(instance, value);
168+
continue;
169+
}
170+
171+
var field = type.GetField($"<{name}>k__BackingField", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
172+
Assert.NotNull(field);
173+
field!.SetValue(instance, value);
174+
}
175+
176+
return instance;
177+
}
80178
}

go/session.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -132,12 +132,13 @@ func newSession(sessionID string, client *jsonrpc2.Client, workspacePath string)
132132
func (s *Session) Send(ctx context.Context, options MessageOptions) (string, error) {
133133
traceparent, tracestate := getTraceContext(ctx)
134134
req := sessionSendRequest{
135-
SessionID: s.SessionID,
136-
Prompt: options.Prompt,
137-
Attachments: options.Attachments,
138-
Mode: options.Mode,
139-
Traceparent: traceparent,
140-
Tracestate: tracestate,
135+
SessionID: s.SessionID,
136+
Prompt: options.Prompt,
137+
Attachments: options.Attachments,
138+
Mode: options.Mode,
139+
Traceparent: traceparent,
140+
Tracestate: tracestate,
141+
RequestHeaders: options.RequestHeaders,
141142
}
142143

143144
result, err := s.client.Request("session.send", req)

go/types.go

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -783,6 +783,8 @@ type ProviderConfig struct {
783783
BearerToken string `json:"bearerToken,omitempty"`
784784
// Azure contains Azure-specific options
785785
Azure *AzureProviderOptions `json:"azure,omitempty"`
786+
// Headers are custom HTTP headers included in outbound provider requests.
787+
Headers map[string]string `json:"headers,omitempty"`
786788
}
787789

788790
// AzureProviderOptions contains Azure-specific provider configuration
@@ -807,6 +809,8 @@ type MessageOptions struct {
807809
Attachments []Attachment
808810
// Mode is the message delivery mode (default: "enqueue")
809811
Mode string
812+
// RequestHeaders are custom per-turn HTTP headers for outbound model requests.
813+
RequestHeaders map[string]string
810814
}
811815

812816
// SessionEventHandler is a callback for session events
@@ -1142,12 +1146,13 @@ type sessionAbortRequest struct {
11421146
}
11431147

11441148
type sessionSendRequest struct {
1145-
SessionID string `json:"sessionId"`
1146-
Prompt string `json:"prompt"`
1147-
Attachments []Attachment `json:"attachments,omitempty"`
1148-
Mode string `json:"mode,omitempty"`
1149-
Traceparent string `json:"traceparent,omitempty"`
1150-
Tracestate string `json:"tracestate,omitempty"`
1149+
SessionID string `json:"sessionId"`
1150+
Prompt string `json:"prompt"`
1151+
Attachments []Attachment `json:"attachments,omitempty"`
1152+
Mode string `json:"mode,omitempty"`
1153+
Traceparent string `json:"traceparent,omitempty"`
1154+
Tracestate string `json:"tracestate,omitempty"`
1155+
RequestHeaders map[string]string `json:"requestHeaders,omitempty"`
11511156
}
11521157

11531158
// sessionSendResponse is the response from session.send

go/types_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,60 @@ func TestPermissionRequestResult_JSONSerialize(t *testing.T) {
9191
t.Errorf("expected %s, got %s", expected, string(data))
9292
}
9393
}
94+
95+
func TestProviderConfig_JSONIncludesHeaders(t *testing.T) {
96+
config := ProviderConfig{
97+
BaseURL: "https://example.com/provider",
98+
Headers: map[string]string{"Authorization": "Bearer provider-token"},
99+
}
100+
101+
data, err := json.Marshal(config)
102+
if err != nil {
103+
t.Fatalf("failed to marshal provider config: %v", err)
104+
}
105+
106+
var decoded map[string]any
107+
if err := json.Unmarshal(data, &decoded); err != nil {
108+
t.Fatalf("failed to unmarshal provider config: %v", err)
109+
}
110+
111+
if decoded["baseUrl"] != "https://example.com/provider" {
112+
t.Fatalf("expected baseUrl to round-trip, got %v", decoded["baseUrl"])
113+
}
114+
headers, ok := decoded["headers"].(map[string]any)
115+
if !ok {
116+
t.Fatalf("expected headers object, got %T", decoded["headers"])
117+
}
118+
if headers["Authorization"] != "Bearer provider-token" {
119+
t.Fatalf("expected Authorization header, got %v", headers["Authorization"])
120+
}
121+
}
122+
123+
func TestSessionSendRequest_JSONIncludesRequestHeaders(t *testing.T) {
124+
req := sessionSendRequest{
125+
SessionID: "session-1",
126+
Prompt: "hello",
127+
RequestHeaders: map[string]string{"Authorization": "Bearer turn-token"},
128+
}
129+
130+
data, err := json.Marshal(req)
131+
if err != nil {
132+
t.Fatalf("failed to marshal session send request: %v", err)
133+
}
134+
135+
var decoded map[string]any
136+
if err := json.Unmarshal(data, &decoded); err != nil {
137+
t.Fatalf("failed to unmarshal session send request: %v", err)
138+
}
139+
140+
if decoded["prompt"] != "hello" {
141+
t.Fatalf("expected prompt to round-trip, got %v", decoded["prompt"])
142+
}
143+
headers, ok := decoded["requestHeaders"].(map[string]any)
144+
if !ok {
145+
t.Fatalf("expected requestHeaders object, got %T", decoded["requestHeaders"])
146+
}
147+
if headers["Authorization"] != "Bearer turn-token" {
148+
t.Fatalf("expected Authorization header, got %v", headers["Authorization"])
149+
}
150+
}

nodejs/src/session.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ export class CopilotSession {
184184
prompt: options.prompt,
185185
attachments: options.attachments,
186186
mode: options.mode,
187+
requestHeaders: options.requestHeaders,
187188
});
188189

189190
return (response as { messageId: string }).messageId;

nodejs/src/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1403,6 +1403,11 @@ export interface ProviderConfig {
14031403
*/
14041404
apiVersion?: string;
14051405
};
1406+
1407+
/**
1408+
* Custom HTTP headers to include in outbound provider requests.
1409+
*/
1410+
headers?: Record<string, string>;
14061411
}
14071412

14081413
/**
@@ -1452,6 +1457,11 @@ export interface MessageOptions {
14521457
* - "immediate": Send immediately
14531458
*/
14541459
mode?: "enqueue" | "immediate";
1460+
1461+
/**
1462+
* Custom HTTP headers to include in outbound model requests for this turn.
1463+
*/
1464+
requestHeaders?: Record<string, string>;
14551465
}
14561466

14571467
/**

0 commit comments

Comments
 (0)