Skip to content

Commit 7b6377d

Browse files
committed
feat: support custom headers for model backends
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
1 parent ec72d41 commit 7b6377d

18 files changed

Lines changed: 1495 additions & 12 deletions

docs/auth/byok.md

Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ Console.WriteLine(response?.Data.Content);
175175
| `apiKey` / `api_key` | string | API key (optional for local providers like Ollama) |
176176
| `bearerToken` / `bearer_token` | string | Bearer token auth (takes precedence over apiKey) |
177177
| `wireApi` / `wire_api` | `"completions"` \| `"responses"` | API format (default: `"completions"`) |
178+
| `headers` | `Record<string, string>` | Custom HTTP headers for all outbound requests ([details](#custom-headers)) |
178179
| `azure.apiVersion` / `azure.api_version` | string | Azure API version (default: `"2024-10-21"`) |
179180

180181
### Wire API Format
@@ -304,6 +305,327 @@ provider: {
304305

305306
> **Note:** The `bearerToken` option accepts a **static token string** only. The SDK does not refresh this token automatically. If your token expires, requests will fail and you'll need to create a new session with a fresh token.
306307
308+
## Custom Headers
309+
310+
Custom headers let you attach additional HTTP headers to every outbound model request. This is useful when your provider endpoint sits behind an API gateway or proxy that requires extra authentication or routing headers.
311+
312+
### Use Cases
313+
314+
| Scenario | Example Header |
315+
|----------|---------------|
316+
| Azure API Management / AI Gateway | `Ocp-Apim-Subscription-Key` |
317+
| Cloudflare Tunnel authentication | `CF-Access-Client-Id`, `CF-Access-Client-Secret` |
318+
| Custom API gateways with proprietary auth | `X-Gateway-Auth`, `X-Tenant-Id` |
319+
| BYOK routing through enterprise proxies | `X-Proxy-Authorization`, `X-Route-Target` |
320+
321+
### Session-Level Headers
322+
323+
Set `headers` on `ProviderConfig` when creating a session. These headers are included in **every** outbound request for the lifetime of the session.
324+
325+
<details open>
326+
<summary><strong>Node.js / TypeScript</strong></summary>
327+
328+
```typescript
329+
import { CopilotClient } from "@github/copilot-sdk";
330+
331+
const client = new CopilotClient();
332+
const session = await client.createSession({
333+
model: "gpt-4.1",
334+
provider: {
335+
type: "openai",
336+
baseUrl: "https://my-gateway.example.com/v1",
337+
apiKey: process.env.OPENAI_API_KEY,
338+
headers: {
339+
"Ocp-Apim-Subscription-Key": process.env.APIM_KEY!,
340+
"X-Tenant-Id": "my-team",
341+
},
342+
},
343+
});
344+
```
345+
346+
</details>
347+
348+
<details>
349+
<summary><strong>Python</strong></summary>
350+
351+
```python
352+
import os
353+
from copilot import CopilotClient
354+
355+
client = CopilotClient()
356+
await client.start()
357+
358+
session = await client.create_session(
359+
model="gpt-4.1",
360+
provider={
361+
"type": "openai",
362+
"base_url": "https://my-gateway.example.com/v1",
363+
"api_key": os.environ["OPENAI_API_KEY"],
364+
"headers": {
365+
"Ocp-Apim-Subscription-Key": os.environ["APIM_KEY"],
366+
"X-Tenant-Id": "my-team",
367+
},
368+
},
369+
)
370+
```
371+
372+
</details>
373+
374+
<details>
375+
<summary><strong>Go</strong></summary>
376+
377+
```go
378+
session, err := client.CreateSession(ctx, &copilot.SessionConfig{
379+
Model: "gpt-4.1",
380+
Provider: &copilot.ProviderConfig{
381+
Type: "openai",
382+
BaseURL: "https://my-gateway.example.com/v1",
383+
APIKey: os.Getenv("OPENAI_API_KEY"),
384+
Headers: map[string]string{
385+
"Ocp-Apim-Subscription-Key": os.Getenv("APIM_KEY"),
386+
"X-Tenant-Id": "my-team",
387+
},
388+
},
389+
})
390+
```
391+
392+
</details>
393+
394+
<details>
395+
<summary><strong>.NET</strong></summary>
396+
397+
```csharp
398+
var session = await client.CreateSessionAsync(new SessionConfig
399+
{
400+
Model = "gpt-4.1",
401+
Provider = new ProviderConfig
402+
{
403+
Type = "openai",
404+
BaseUrl = "https://my-gateway.example.com/v1",
405+
ApiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY"),
406+
Headers = new Dictionary<string, string>
407+
{
408+
["Ocp-Apim-Subscription-Key"] = Environment.GetEnvironmentVariable("APIM_KEY")!,
409+
["X-Tenant-Id"] = "my-team",
410+
},
411+
},
412+
});
413+
```
414+
415+
</details>
416+
417+
### Per-Turn Headers
418+
419+
Pass `requestHeaders` on `send()` to include headers for a **single turn** only. This is useful when headers change between requests (e.g., per-request trace IDs or rotating tokens).
420+
421+
<details open>
422+
<summary><strong>Node.js / TypeScript</strong></summary>
423+
424+
```typescript
425+
await session.send({
426+
prompt: "Summarize this document",
427+
requestHeaders: {
428+
"X-Request-Id": crypto.randomUUID(),
429+
},
430+
});
431+
```
432+
433+
</details>
434+
435+
<details>
436+
<summary><strong>Python</strong></summary>
437+
438+
```python
439+
import uuid
440+
441+
await session.send(
442+
"Summarize this document",
443+
request_headers={
444+
"X-Request-Id": str(uuid.uuid4()),
445+
},
446+
)
447+
```
448+
449+
</details>
450+
451+
<details>
452+
<summary><strong>Go</strong></summary>
453+
454+
```go
455+
_, err := session.Send(ctx, copilot.MessageOptions{
456+
Prompt: "Summarize this document",
457+
RequestHeaders: map[string]string{
458+
"X-Request-Id": uuid.NewString(),
459+
},
460+
})
461+
```
462+
463+
</details>
464+
465+
<details>
466+
<summary><strong>.NET</strong></summary>
467+
468+
```csharp
469+
await session.SendAsync(new MessageOptions
470+
{
471+
Prompt = "Summarize this document",
472+
RequestHeaders = new Dictionary<string, string>
473+
{
474+
["X-Request-Id"] = Guid.NewGuid().ToString(),
475+
},
476+
});
477+
```
478+
479+
</details>
480+
481+
### Header Merge Strategy
482+
483+
When you provide both session-level `headers` and per-turn `requestHeaders`, the `headerMergeStrategy` controls how they combine.
484+
485+
| Strategy | Behavior |
486+
|----------|----------|
487+
| `"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. |
488+
| `"merge"` | Per-turn headers are **merged** with session-level headers. Per-turn values win on key conflicts. |
489+
490+
#### Override (Default)
491+
492+
```typescript
493+
// Session created with headers: { "X-Team": "alpha", "X-Env": "prod" }
494+
495+
await session.send({
496+
prompt: "Hello",
497+
requestHeaders: { "X-Request-Id": "abc-123" },
498+
// headerMergeStrategy defaults to "override"
499+
});
500+
// Only "X-Request-Id" is sent — session headers are NOT included
501+
```
502+
503+
#### Merge
504+
505+
```typescript
506+
// Session created with headers: { "X-Team": "alpha", "X-Env": "prod" }
507+
508+
await session.send({
509+
prompt: "Hello",
510+
requestHeaders: { "X-Env": "staging", "X-Request-Id": "abc-123" },
511+
headerMergeStrategy: "merge",
512+
});
513+
// Sent headers: { "X-Team": "alpha", "X-Env": "staging", "X-Request-Id": "abc-123" }
514+
// "X-Env" from per-turn wins over session-level value
515+
```
516+
517+
The merge strategy setting is available in all languages:
518+
519+
| Language | Field |
520+
|----------|-------|
521+
| TypeScript | `headerMergeStrategy: "override" \| "merge"` |
522+
| Python | `header_merge_strategy: Literal["override", "merge"]` |
523+
| Go | `HeaderMergeStrategy: copilot.HeaderMergeStrategyOverride \| copilot.HeaderMergeStrategyMerge` |
524+
| C# | `HeaderMergeStrategy = HeaderMergeStrategy.Override \| HeaderMergeStrategy.Merge` |
525+
526+
### Updating Provider Configuration Mid-Session
527+
528+
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.
529+
530+
<details open>
531+
<summary><strong>Node.js / TypeScript</strong></summary>
532+
533+
```typescript
534+
// Rotate the subscription key between turns
535+
await session.updateProvider({
536+
headers: {
537+
"Ocp-Apim-Subscription-Key": newSubscriptionKey,
538+
"X-Tenant-Id": "new-team",
539+
},
540+
});
541+
542+
// Subsequent sends use the updated headers
543+
await session.send({ prompt: "Continue" });
544+
```
545+
546+
</details>
547+
548+
<details>
549+
<summary><strong>Python</strong></summary>
550+
551+
```python
552+
await session.update_provider({
553+
"headers": {
554+
"Ocp-Apim-Subscription-Key": new_subscription_key,
555+
"X-Tenant-Id": "new-team",
556+
},
557+
})
558+
559+
await session.send("Continue")
560+
```
561+
562+
</details>
563+
564+
<details>
565+
<summary><strong>Go</strong></summary>
566+
567+
```go
568+
err := session.UpdateProvider(ctx, copilot.ProviderConfig{
569+
Headers: map[string]string{
570+
"Ocp-Apim-Subscription-Key": newSubscriptionKey,
571+
"X-Tenant-Id": "new-team",
572+
},
573+
})
574+
575+
_, err = session.Send(ctx, copilot.MessageOptions{Prompt: "Continue"})
576+
```
577+
578+
</details>
579+
580+
<details>
581+
<summary><strong>.NET</strong></summary>
582+
583+
```csharp
584+
await session.UpdateProviderAsync(new ProviderConfig
585+
{
586+
Headers = new Dictionary<string, string>
587+
{
588+
["Ocp-Apim-Subscription-Key"] = newSubscriptionKey,
589+
["X-Tenant-Id"] = "new-team",
590+
},
591+
});
592+
593+
await session.SendAsync(new MessageOptions { Prompt = "Continue" });
594+
```
595+
596+
</details>
597+
598+
### Environment Variable Expansion
599+
600+
Header values support environment variable expansion at the runtime level. This lets you reference secrets without hardcoding them in your application code.
601+
602+
| Syntax | Behavior |
603+
|--------|----------|
604+
| `${VAR}` | Replaced with the value of `VAR`. Fails if `VAR` is not set. |
605+
| `$VAR` | Same as `${VAR}`. |
606+
| `${VAR:-default}` | Replaced with the value of `VAR`, or `default` if `VAR` is not set. |
607+
608+
```typescript
609+
provider: {
610+
type: "openai",
611+
baseUrl: "https://my-gateway.example.com/v1",
612+
headers: {
613+
// Expanded at runtime from the APIM_KEY environment variable
614+
"Ocp-Apim-Subscription-Key": "${APIM_KEY}",
615+
// Falls back to "default-tenant" if X_TENANT is not set
616+
"X-Tenant-Id": "${X_TENANT:-default-tenant}",
617+
},
618+
}
619+
```
620+
621+
> **Note:** Expansion is performed by the CLI server, not the SDK client. The SDK passes header values as-is to the server, which resolves environment variables before sending requests to your provider.
622+
623+
### Security Considerations
624+
625+
- **Scoped to your endpoint** — Custom headers are sent only to the configured `baseUrl`. They are never sent to GitHub Copilot servers or other endpoints.
626+
- **Prefer env var expansion** — Use `${VAR}` syntax for sensitive values like API keys and tokens rather than hardcoding them. This avoids secrets in source code and logs.
627+
- **Override is the safe default** — The default `headerMergeStrategy` of `"override"` ensures per-turn headers completely replace session-level headers, preventing accidental leakage of session headers into turns that specify their own.
628+
307629
## Custom Model Listing
308630

309631
When using BYOK, the CLI server may not know which models your provider supports. You can supply a custom `onListModels` handler at the client level so that `client.listModels()` returns your provider's models in the standard `ModelInfo` format. This lets downstream consumers discover available models without querying the CLI.

dotnet/README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,29 @@ var session = await client.CreateSessionAsync(new SessionConfig
596596
});
597597
```
598598

599+
### Custom Headers
600+
601+
You can attach custom HTTP headers to outbound model requests — useful for API gateways, proxy authentication, or tenant routing:
602+
603+
```csharp
604+
var session = await client.CreateSessionAsync(new SessionConfig
605+
{
606+
Model = "gpt-4.1",
607+
Provider = new ProviderConfig
608+
{
609+
Type = "openai",
610+
BaseUrl = "https://my-gateway.example.com/v1",
611+
ApiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY"),
612+
Headers = new Dictionary<string, string>
613+
{
614+
["Ocp-Apim-Subscription-Key"] = "${APIM_KEY}",
615+
},
616+
},
617+
});
618+
```
619+
620+
Per-turn headers and merge strategies are also supported. See the [Custom Headers](docs/auth/byok.md#custom-headers) section in the BYOK guide for full details.
621+
599622
## Telemetry
600623

601624
The SDK supports OpenTelemetry for distributed tracing. Provide a `Telemetry` config to enable trace export and automatic W3C Trace Context propagation.

0 commit comments

Comments
 (0)