Skip to content

Commit 3ef0e0c

Browse files
committed
feat: add header mode configuration and session management for API requests
1 parent d391aad commit 3ef0e0c

5 files changed

Lines changed: 176 additions & 18 deletions

File tree

README.md

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -151,17 +151,18 @@ Copilot API now uses a subcommand structure with these main commands:
151151

152152
The following command line options are available for the `start` command:
153153

154-
| Option | Description | Default | Alias |
155-
| -------------- | ----------------------------------------------------------------------------- | ---------- | ----- |
156-
| --port | Port to listen on | 4141 | -p |
157-
| --verbose | Enable verbose logging | false | -v |
158-
| --account-type | Account type to use (individual, business, enterprise) | individual | -a |
159-
| --manual | Enable manual request approval | false | none |
160-
| --rate-limit | Rate limit in seconds between requests | none | -r |
161-
| --wait | Wait instead of error when rate limit is hit | false | -w |
162-
| --github-token | Provide GitHub token directly (must be generated using the `auth` subcommand) | none | -g |
163-
| --claude-code | Generate a command to launch Claude Code with Copilot API config | false | -c |
164-
| --show-token | Show GitHub and Copilot tokens on fetch and refresh | false | none |
154+
| Option | Description | Default | Alias |
155+
| -------------- | -------------------------------------------------------------------------------- | ---------- | ----- |
156+
| --port | Port to listen on | 4141 | -p |
157+
| --verbose | Enable verbose logging | false | -v |
158+
| --account-type | Account type to use (individual, business, enterprise) | individual | -a |
159+
| --manual | Enable manual request approval | false | none |
160+
| --rate-limit | Rate limit in seconds between requests | none | -r |
161+
| --wait | Wait instead of error when rate limit is hit | false | -w |
162+
| --github-token | Provide GitHub token directly (must be generated using the `auth` subcommand) | none | -g |
163+
| --claude-code | Generate a command to launch Claude Code with Copilot API config | false | -c |
164+
| --show-token | Show GitHub and Copilot tokens on fetch and refresh | false | none |
165+
| --header-mode | Header mode: savings (cost-optimized) or compatible (VS Code extension behavior) | savings | none |
165166

166167
### Auth Command Options
167168

@@ -336,3 +337,34 @@ bun run start
336337
- `--rate-limit <seconds>`: Enforces a minimum time interval between requests. For example, `copilot-api start --rate-limit 30` will ensure there's at least a 30-second gap between requests.
337338
- `--wait`: Use this with `--rate-limit`. It makes the server wait for the cooldown period to end instead of rejecting the request with an error. This is useful for clients that don't automatically retry on rate limit errors.
338339
- If you have a GitHub business or enterprise plan account with Copilot, use the `--account-type` flag (e.g., `--account-type business`). See the [official documentation](https://docs.github.com/en/enterprise-cloud@latest/copilot/managing-copilot/managing-github-copilot-in-your-organization/managing-access-to-github-copilot-in-your-organization/managing-github-copilot-access-to-your-organizations-network#configuring-copilot-subscription-based-network-routing-for-your-enterprise-or-organization) for more details.
340+
341+
## Request Header Modes
342+
343+
Copilot API supports two header modes to balance cost optimization with compatibility:
344+
345+
### `savings` Mode (Default)
346+
347+
Maximum cost optimization using current behavior:
348+
349+
- **Headers**: Only `X-Initiator`
350+
- **Logic**: Set to `agent` when assistant/tool messages present, otherwise `user`
351+
- **Use case**: Cost-sensitive applications, development, testing
352+
353+
```bash
354+
copilot-api start
355+
# or explicitly
356+
copilot-api start --header-mode savings
357+
```
358+
359+
### `compatible` Mode
360+
361+
VS Code Copilot extension compatibility:
362+
363+
- **Headers**: Both `X-Initiator` and `X-Interaction-Id`
364+
- **Logic**: Replicates VS Code extension's behavior(sort of)
365+
- **Session Management**: UUID-based session tracking
366+
- **Use case**: Use this when you want to mimic the behavior of the VS Code extension to avoid potential abuse detection. (I am not sure if they do that, but it's better to be cautious.)
367+
368+
```bash
369+
copilot-api start --header-mode compatible
370+
```

src/lib/headers.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { randomUUID } from "node:crypto"
2+
3+
import type { ChatCompletionsPayload } from "~/services/copilot/create-chat-completions"
4+
5+
export type HeaderMode = "savings" | "compatible"
6+
7+
export interface RequestHeaders {
8+
"X-Initiator": "user" | "agent"
9+
"X-Interaction-Id"?: string
10+
}
11+
12+
/**
13+
* Simple session manager for compatible mode
14+
*/
15+
class SessionManager {
16+
private sessionId: string = randomUUID()
17+
18+
newSession(): string {
19+
this.sessionId = randomUUID()
20+
return this.sessionId
21+
}
22+
23+
getCurrentSession(): string {
24+
return this.sessionId
25+
}
26+
}
27+
28+
const sessionManager = new SessionManager()
29+
30+
/**
31+
* Generate headers based on mode
32+
*/
33+
export function generateSessionHeaders(
34+
payload: ChatCompletionsPayload,
35+
mode: HeaderMode,
36+
): RequestHeaders {
37+
return mode === "savings" ?
38+
{
39+
"X-Initiator": getSavingsInitiator(payload),
40+
}
41+
: {
42+
"X-Initiator": getCompatibleInitiator(payload),
43+
"X-Interaction-Id": getSessionId(payload),
44+
}
45+
}
46+
47+
/**
48+
* Savings mode: default behavior
49+
*/
50+
function getSavingsInitiator(
51+
payload: ChatCompletionsPayload,
52+
): "user" | "agent" {
53+
return (
54+
payload.messages.some((msg) => ["assistant", "tool"].includes(msg.role))
55+
) ?
56+
"agent"
57+
: "user"
58+
}
59+
60+
/**
61+
* Compatible mode: replicate VS Code extension logic
62+
*/
63+
function getCompatibleInitiator(
64+
payload: ChatCompletionsPayload,
65+
): "user" | "agent" {
66+
// VS Code: userInitiatedRequest = iterationNumber === 0 && !isContinuation
67+
const hasAssistantMessage = payload.messages.some(
68+
(msg) => msg.role === "assistant",
69+
)
70+
const hasToolCalls = payload.messages.some(
71+
(msg) => msg.tool_calls && msg.tool_calls.length > 0,
72+
)
73+
const hasToolMessages = payload.messages.some((msg) => msg.role === "tool")
74+
75+
const isFirstIteration = !hasAssistantMessage
76+
const isContinuation = hasToolCalls || hasToolMessages
77+
78+
return isFirstIteration && !isContinuation ? "user" : "agent"
79+
}
80+
81+
/**
82+
* Detect if this is the start of a new conversation
83+
*/
84+
function isStartOfConversation(payload: ChatCompletionsPayload): boolean {
85+
const hasAssistantMessage = payload.messages.some(
86+
(msg) => msg.role === "assistant",
87+
)
88+
return !hasAssistantMessage
89+
}
90+
91+
/**
92+
* Get session ID for compatible mode
93+
*/
94+
function getSessionId(payload: ChatCompletionsPayload): string {
95+
if (isStartOfConversation(payload)) {
96+
return sessionManager.newSession()
97+
}
98+
return sessionManager.getCurrentSession()
99+
}

src/lib/state.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,15 @@ export interface State {
1515
// Rate limiting configuration
1616
rateLimitSeconds?: number
1717
lastRequestTimestamp?: number
18+
19+
// Header mode configuration
20+
headerMode: "savings" | "compatible"
1821
}
1922

2023
export const state: State = {
2124
accountType: "individual",
2225
manualApprove: false,
2326
rateLimitWait: false,
2427
showToken: false,
28+
headerMode: "savings",
2529
}

src/services/copilot/create-chat-completions.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { events } from "fetch-event-stream"
33

44
import { copilotHeaders, copilotBaseUrl } from "~/lib/api-config"
55
import { HTTPError } from "~/lib/error"
6+
import { generateSessionHeaders } from "~/lib/headers"
67
import { state } from "~/lib/state"
78

89
export const createChatCompletions = async (
@@ -16,16 +17,20 @@ export const createChatCompletions = async (
1617
&& x.content?.some((x) => x.type === "image_url"),
1718
)
1819

19-
// Agent/user check for X-Initiator header
20-
// Determine if any message is from an agent ("assistant" or "tool")
21-
const isAgentCall = payload.messages.some((msg) =>
22-
["assistant", "tool"].includes(msg.role),
23-
)
20+
// Generate headers based on current mode
21+
const sessionHeaders = generateSessionHeaders(payload, state.headerMode)
2422

25-
// Build headers and add X-Initiator
23+
// Build headers
2624
const headers: Record<string, string> = {
2725
...copilotHeaders(state, enableVision),
28-
"X-Initiator": isAgentCall ? "agent" : "user",
26+
...sessionHeaders, // This includes X-Initiator and optionally X-Interaction-Id
27+
}
28+
29+
// Optional: Add debug logging
30+
if (state.headerMode === "compatible") {
31+
consola.debug(
32+
`Compatible mode headers: X-Initiator=${sessionHeaders["X-Initiator"]}, X-Interaction-Id=${sessionHeaders["X-Interaction-Id"]?.slice(0, 8)}...`,
33+
)
2934
}
3035

3136
const response = await fetch(`${copilotBaseUrl(state)}/chat/completions`, {

src/start.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ interface RunServerOptions {
2323
githubToken?: string
2424
claudeCode: boolean
2525
showToken: boolean
26+
headerMode: string
2627
}
2728

2829
export async function runServer(options: RunServerOptions): Promise<void> {
@@ -41,10 +42,20 @@ export async function runServer(options: RunServerOptions): Promise<void> {
4142
state.rateLimitWait = options.rateLimitWait
4243
state.showToken = options.showToken
4344

45+
// Set header mode
46+
state.headerMode = options.headerMode as "savings" | "compatible"
47+
48+
if (state.headerMode === "compatible") {
49+
consola.info("Using compatible mode - full VS Code extension compatibility")
50+
} else {
51+
consola.info("Using savings mode - optimized for premium requests savings")
52+
}
53+
4454
await ensurePaths()
4555
await cacheVSCodeVersion()
4656

4757
if (options.githubToken) {
58+
// eslint-disable-next-line require-atomic-updates
4859
state.githubToken = options.githubToken
4960
consola.info("Using provided GitHub token")
5061
} else {
@@ -169,6 +180,12 @@ export const start = defineCommand({
169180
default: false,
170181
description: "Show GitHub and Copilot tokens on fetch and refresh",
171182
},
183+
"header-mode": {
184+
type: "string",
185+
default: "savings",
186+
description:
187+
"Header mode: savings (default, cost-optimized) or compatible (VS Code Copilot extension behavior)",
188+
},
172189
},
173190
run({ args }) {
174191
const rateLimitRaw = args["rate-limit"]
@@ -186,6 +203,7 @@ export const start = defineCommand({
186203
githubToken: args["github-token"],
187204
claudeCode: args["claude-code"],
188205
showToken: args["show-token"],
206+
headerMode: args["header-mode"],
189207
})
190208
},
191209
})

0 commit comments

Comments
 (0)