opencodekit 0.18.13 → 0.18.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,25 +17,25 @@ const CLIENT_ID = "Ov23li8tweQw6odWQebz";
17
17
 
18
18
  // Logger function that will be set by the plugin
19
19
  let log: (
20
- level: "debug" | "info" | "warn" | "error",
21
- message: string,
22
- extra?: Record<string, any>,
20
+ level: "debug" | "info" | "warn" | "error",
21
+ message: string,
22
+ extra?: Record<string, any>,
23
23
  ) => void = () => {};
24
24
 
25
25
  /**
26
26
  * Set the logger function from the plugin context
27
27
  */
28
28
  function setLogger(client: any) {
29
- log = (level, message, extra) => {
30
- client.app
31
- .log({
32
- service: "copilot-auth",
33
- level,
34
- message,
35
- extra,
36
- })
37
- .catch(() => {}); // Fire and forget, don't block on logging
38
- };
29
+ log = (level, message, extra) => {
30
+ client.app
31
+ .log({
32
+ service: "copilot-auth",
33
+ level,
34
+ message,
35
+ extra,
36
+ })
37
+ .catch(() => {}); // Fire and forget, don't block on logging
38
+ };
39
39
  }
40
40
 
41
41
  // Add a small safety buffer when polling to avoid hitting the server
@@ -43,50 +43,132 @@ function setLogger(client: any) {
43
43
  const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000; // 3 seconds
44
44
 
45
45
  const HEADERS = {
46
- "User-Agent": "GitHubCopilotChat/0.35.0",
47
- "Editor-Version": "vscode/1.107.0",
48
- "Editor-Plugin-Version": "copilot-chat/0.35.0",
49
- "Copilot-Integration-Id": "vscode-chat",
46
+ "User-Agent": "GitHubCopilotChat/0.35.0",
47
+ "Editor-Version": "vscode/1.107.0",
48
+ "Editor-Plugin-Version": "copilot-chat/0.35.0",
49
+ "Copilot-Integration-Id": "vscode-chat",
50
50
  };
51
51
 
52
52
  const RESPONSES_API_ALTERNATE_INPUT_TYPES = [
53
- "file_search_call",
54
- "computer_call",
55
- "computer_call_output",
56
- "web_search_call",
57
- "function_call",
58
- "function_call_output",
59
- "image_generation_call",
60
- "code_interpreter_call",
61
- "local_shell_call",
62
- "local_shell_call_output",
63
- "mcp_list_tools",
64
- "mcp_approval_request",
65
- "mcp_approval_response",
66
- "mcp_call",
67
- "reasoning",
53
+ "file_search_call",
54
+ "computer_call",
55
+ "computer_call_output",
56
+ "web_search_call",
57
+ "function_call",
58
+ "function_call_output",
59
+ "image_generation_call",
60
+ "code_interpreter_call",
61
+ "local_shell_call",
62
+ "local_shell_call_output",
63
+ "mcp_list_tools",
64
+ "mcp_approval_request",
65
+ "mcp_approval_response",
66
+ "mcp_call",
67
+ "reasoning",
68
68
  ];
69
69
 
70
70
  function normalizeDomain(url: string): string {
71
- return url.replace(/^https?:\/\//, "").replace(/\/$/, "");
71
+ return url.replace(/^https?:\/\//, "").replace(/\/$/, "");
72
72
  }
73
73
 
74
74
  function getUrls(domain: string) {
75
- return {
76
- DEVICE_CODE_URL: `https://${domain}/login/device/code`,
77
- ACCESS_TOKEN_URL: `https://${domain}/login/oauth/access_token`,
78
- };
75
+ return {
76
+ DEVICE_CODE_URL: `https://${domain}/login/device/code`,
77
+ ACCESS_TOKEN_URL: `https://${domain}/login/oauth/access_token`,
78
+ };
79
79
  }
80
80
 
81
81
  const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
82
82
 
83
83
  // Rate limit handling configuration
84
84
  const RATE_LIMIT_CONFIG = {
85
- maxRetries: 3,
86
- baseDelayMs: 2000, // Start with 2 seconds
87
- maxDelayMs: 30000, // Cap at 30 seconds
85
+ maxRetries: 3,
86
+ baseDelayMs: 2000, // Start with 2 seconds
87
+ maxDelayMs: 60000, // Cap at 60 seconds
88
+ defaultCooldownMs: 60000, // Default cooldown when Retry-After header is missing
89
+ maxFallbacks: 4, // Max model fallback switches per request
88
90
  };
89
91
 
92
+ // Per-model rate limit state (in-memory, resets on restart)
93
+ interface RateLimitEntry {
94
+ rateLimitedUntil: number; // Unix timestamp (ms)
95
+ }
96
+ const rateLimitState = new Map<string, RateLimitEntry>();
97
+
98
+ // Model fallback chains: same-family alternatives when a model is rate-limited
99
+ const MODEL_FALLBACK_CHAINS: Record<string, string[]> = {
100
+ // Claude family
101
+ "claude-opus-4.6": ["claude-opus-4.5", "claude-sonnet-4.6", "gpt-5.3-codex"],
102
+ "claude-opus-4.5": ["claude-sonnet-4.6", "gpt-5.3-codex"],
103
+ "claude-sonnet-4.6": ["gpt-5.3-codex"],
104
+ };
105
+
106
+ /**
107
+ * Parse the Retry-After header from a 429 response.
108
+ * Returns cooldown in milliseconds, or null if header is missing/unparseable.
109
+ */
110
+ function parseRetryAfter(response: Response): number | null {
111
+ const header = response.headers.get("retry-after");
112
+ if (!header) return null;
113
+ // Try as seconds first (most common)
114
+ const seconds = parseInt(header, 10);
115
+ if (!isNaN(seconds) && seconds > 0) return seconds * 1000;
116
+ // Try as HTTP date
117
+ const date = Date.parse(header);
118
+ if (!isNaN(date)) return Math.max(0, date - Date.now());
119
+ return null;
120
+ }
121
+
122
+ function isModelRateLimited(model: string): boolean {
123
+ const entry = rateLimitState.get(model);
124
+ if (!entry) return false;
125
+ if (Date.now() >= entry.rateLimitedUntil) {
126
+ rateLimitState.delete(model);
127
+ return false;
128
+ }
129
+ return true;
130
+ }
131
+
132
+ function markModelRateLimited(model: string, cooldownMs: number): void {
133
+ rateLimitState.set(model, {
134
+ rateLimitedUntil: Date.now() + cooldownMs,
135
+ });
136
+ log(
137
+ "info",
138
+ `Marked ${model} as rate-limited for ${Math.round(cooldownMs / 1000)}s`,
139
+ );
140
+ }
141
+
142
+ /**
143
+ * Find the next available fallback model in the same family.
144
+ * Skips models that are themselves rate-limited.
145
+ */
146
+ function getNextFallbackModel(model: string): string | null {
147
+ const chain = MODEL_FALLBACK_CHAINS[model];
148
+ if (!chain) return null;
149
+ for (const fallback of chain) {
150
+ if (!isModelRateLimited(fallback)) return fallback;
151
+ }
152
+ return null;
153
+ }
154
+
155
+ /**
156
+ * Swap the model field in a fetch RequestInit body.
157
+ */
158
+ function swapModelInBody(
159
+ init: RequestInit | undefined,
160
+ newModel: string,
161
+ ): RequestInit | undefined {
162
+ if (!init?.body || typeof init.body !== "string") return init;
163
+ try {
164
+ const body = JSON.parse(init.body);
165
+ body.model = newModel;
166
+ return { ...init, body: JSON.stringify(body) };
167
+ } catch {
168
+ return init;
169
+ }
170
+ }
171
+
90
172
  // Maximum length for item IDs in the OpenAI Responses API
91
173
  const MAX_RESPONSE_API_ID_LENGTH = 64;
92
174
  /**
@@ -96,17 +178,17 @@ const MAX_RESPONSE_API_ID_LENGTH = 64;
96
178
  * See: https://github.com/vercel/ai/issues/5171
97
179
  */
98
180
  function sanitizeResponseId(id: string): string {
99
- if (!id || id.length <= MAX_RESPONSE_API_ID_LENGTH) return id;
100
- // Use a simple hash: take first 8 chars + hash of full string for uniqueness
101
- // Format: "h_" + first 8 chars + "_" + base36 hash (up to ~50 chars total)
102
- let hash = 0;
103
- for (let i = 0; i < id.length; i++) {
104
- hash = ((hash << 5) - hash + id.charCodeAt(i)) | 0;
105
- }
106
- const hashStr = Math.abs(hash).toString(36);
107
- const prefix = id.slice(0, 8);
108
- // Ensure total length <= 64: "h_" (2) + prefix (8) + "_" (1) + hash
109
- return `h_${prefix}_${hashStr}`.slice(0, MAX_RESPONSE_API_ID_LENGTH);
181
+ if (!id || id.length <= MAX_RESPONSE_API_ID_LENGTH) return id;
182
+ // Use a simple hash: take first 8 chars + hash of full string for uniqueness
183
+ // Format: "h_" + first 8 chars + "_" + base36 hash (up to ~50 chars total)
184
+ let hash = 0;
185
+ for (let i = 0; i < id.length; i++) {
186
+ hash = ((hash << 5) - hash + id.charCodeAt(i)) | 0;
187
+ }
188
+ const hashStr = Math.abs(hash).toString(36);
189
+ const prefix = id.slice(0, 8);
190
+ // Ensure total length <= 64: "h_" (2) + prefix (8) + "_" (1) + hash
191
+ return `h_${prefix}_${hashStr}`.slice(0, MAX_RESPONSE_API_ID_LENGTH);
110
192
  }
111
193
 
112
194
  /**
@@ -114,553 +196,632 @@ function sanitizeResponseId(id: string): string {
114
196
  * Recursively checks `id` and `call_id` fields on each input item.
115
197
  */
116
198
  function sanitizeResponseInputIds(input: any[]): any[] {
117
- return input.map((item: any) => {
118
- if (!item || typeof item !== "object") return item;
119
- const sanitized = { ...item };
120
- if (typeof sanitized.id === "string" && sanitized.id.length > MAX_RESPONSE_API_ID_LENGTH) {
121
- sanitized.id = sanitizeResponseId(sanitized.id);
122
- }
123
- if (typeof sanitized.call_id === "string" && sanitized.call_id.length > MAX_RESPONSE_API_ID_LENGTH) {
124
- sanitized.call_id = sanitizeResponseId(sanitized.call_id);
125
- }
126
- return sanitized;
127
- });
199
+ return input.map((item: any) => {
200
+ if (!item || typeof item !== "object") return item;
201
+ const sanitized = { ...item };
202
+ if (
203
+ typeof sanitized.id === "string" &&
204
+ sanitized.id.length > MAX_RESPONSE_API_ID_LENGTH
205
+ ) {
206
+ sanitized.id = sanitizeResponseId(sanitized.id);
207
+ }
208
+ if (
209
+ typeof sanitized.call_id === "string" &&
210
+ sanitized.call_id.length > MAX_RESPONSE_API_ID_LENGTH
211
+ ) {
212
+ sanitized.call_id = sanitizeResponseId(sanitized.call_id);
213
+ }
214
+ return sanitized;
215
+ });
128
216
  }
129
217
 
130
218
  /**
131
219
  * Retries: 2s, 4s, 8s (with jitter)
132
220
  */
133
221
  function calculateRetryDelay(attempt: number): number {
134
- const exponentialDelay = RATE_LIMIT_CONFIG.baseDelayMs * Math.pow(2, attempt);
135
- const jitter = Math.random() * 1000; // Add 0-1s random jitter
136
- const delay = Math.min(
137
- exponentialDelay + jitter,
138
- RATE_LIMIT_CONFIG.maxDelayMs,
139
- );
140
- return Math.round(delay);
222
+ const exponentialDelay = RATE_LIMIT_CONFIG.baseDelayMs * Math.pow(2, attempt);
223
+ const jitter = Math.random() * 1000; // Add 0-1s random jitter
224
+ const delay = Math.min(
225
+ exponentialDelay + jitter,
226
+ RATE_LIMIT_CONFIG.maxDelayMs,
227
+ );
228
+ return Math.round(delay);
141
229
  }
142
230
 
143
231
  export const CopilotAuthPlugin: Plugin = async ({ client: sdk }) => {
144
- // Initialize logger with the SDK client
145
- setLogger(sdk);
146
-
147
- return {
148
- auth: {
149
- provider: "github-copilot",
150
- loader: async (getAuth, provider) => {
151
- const info = await getAuth();
152
- if (!info || info.type !== "oauth") return {};
153
-
154
- // Enterprise URL support for baseURL
155
- const enterpriseUrl = (info as any).enterpriseUrl;
156
- const baseURL = enterpriseUrl
157
- ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}`
158
- : undefined;
159
-
160
- if (provider && provider.models) {
161
- for (const [_modelId, model] of Object.entries(provider.models)) {
162
- model.cost = {
163
- input: 0,
164
- output: 0,
165
- cache: {
166
- read: 0,
167
- write: 0,
168
- },
169
- };
170
-
171
- // All models use the standard github-copilot SDK
172
- // Reasoning support for Claude models is handled via:
173
- // 1. The fetch wrapper adds thinking_budget to request body
174
- // 2. The fetch wrapper strips invalid thinking blocks from messages
175
- model.api.npm = "@ai-sdk/github-copilot";
176
- }
177
- }
178
-
179
- return {
180
- baseURL,
181
- apiKey: "",
182
- async fetch(input, init) {
183
- const info = await getAuth();
184
- if (info.type !== "oauth") return fetch(input, init);
185
-
186
- let isAgentCall = false;
187
- let isVisionRequest = false;
188
- let modifiedBody: any = undefined;
189
- let isClaudeModel = false;
190
-
191
- try {
192
- const body =
193
- typeof init?.body === "string"
194
- ? JSON.parse(init.body)
195
- : init?.body;
196
-
197
- const url = input.toString();
198
-
199
- // Check if this is a Claude model request
200
- const modelId = body?.model || "";
201
- isClaudeModel = modelId.toLowerCase().includes("claude");
202
-
203
- // Completions API
204
- if (body?.messages && url.includes("completions")) {
205
- // Keep local logic: detect if any message is assistant/tool
206
- isAgentCall = body.messages.some((msg: any) =>
207
- ["tool", "assistant"].includes(msg.role),
208
- );
209
- isVisionRequest = body.messages.some(
210
- (msg: any) =>
211
- Array.isArray(msg.content) &&
212
- msg.content.some((part: any) => part.type === "image_url"),
213
- );
214
-
215
- // For Claude models, add thinking_budget to enable reasoning
216
- // The Copilot API accepts this parameter and returns reasoning_text/reasoning_opaque
217
- if (isClaudeModel) {
218
- // Use configured thinking_budget from model options, or default to 10000
219
- const thinkingBudget = body.thinking_budget || 10000;
220
-
221
- // Fix for "Invalid signature in thinking block" error:
222
- // The Copilot API uses reasoning_text/reasoning_opaque format for thinking
223
- // When these are passed back without proper signature, it causes errors
224
- // Solution: Ensure reasoning_opaque is present when reasoning_text exists,
225
- // or remove reasoning content entirely if signature is invalid/missing
226
- const cleanedMessages = body.messages.map(
227
- (msg: any, idx: number) => {
228
- if (msg.role !== "assistant") return msg;
229
-
230
- // Log message structure for debugging
231
- log("debug", `Processing assistant message ${idx}`, {
232
- has_reasoning_text: !!msg.reasoning_text,
233
- has_reasoning_opaque: !!msg.reasoning_opaque,
234
- content_type: typeof msg.content,
235
- content_is_array: Array.isArray(msg.content),
236
- });
237
-
238
- // If message has reasoning_text but no/invalid reasoning_opaque, remove reasoning
239
- if (msg.reasoning_text && !msg.reasoning_opaque) {
240
- log(
241
- "warn",
242
- `Removing reasoning_text without reasoning_opaque from message ${idx}`,
243
- );
244
- const { reasoning_text: _unused, ...cleanedMsg } = msg;
245
- return cleanedMsg;
246
- }
247
-
248
- // If content is an array, check for thinking blocks
249
- if (Array.isArray(msg.content)) {
250
- const hasThinkingBlock = msg.content.some(
251
- (part: any) => part.type === "thinking",
252
- );
253
- if (hasThinkingBlock) {
254
- log(
255
- "debug",
256
- `Message ${idx} has thinking blocks in content array`,
257
- );
258
- // Filter out thinking blocks without signatures
259
- const cleanedContent = msg.content.filter(
260
- (part: any) => {
261
- if (part.type === "thinking") {
262
- if (!part.signature) {
263
- log(
264
- "warn",
265
- `Removing thinking block without signature`,
266
- );
267
- return false;
268
- }
269
- }
270
- return true;
271
- },
272
- );
273
- return {
274
- ...msg,
275
- content:
276
- cleanedContent.length > 0 ? cleanedContent : null,
277
- };
278
- }
279
- }
280
-
281
- return msg;
282
- },
283
- );
284
-
285
- modifiedBody = {
286
- ...body,
287
- messages: cleanedMessages,
288
- thinking_budget: thinkingBudget,
289
- };
290
- log("info", `Adding thinking_budget for Claude model`, {
291
- model: modelId,
292
- thinking_budget: thinkingBudget,
293
- });
294
- }
295
-
296
- // For GPT models (o1, gpt-5, etc.), add reasoning parameter
297
- const isGptModel =
298
- modelId.toLowerCase().includes("gpt") ||
299
- modelId.toLowerCase().includes("o1") ||
300
- modelId.toLowerCase().includes("o3") ||
301
- modelId.toLowerCase().includes("o4");
302
-
303
- if (isGptModel && !isClaudeModel) {
304
- // Get reasoning effort from body options or default to "medium"
305
- const reasoningEffort =
306
- body.reasoning?.effort ||
307
- body.reasoningEffort ||
308
- body.reasoning_effort ||
309
- "medium";
310
-
311
- modifiedBody = {
312
- ...(modifiedBody || body),
313
- reasoning: {
314
- effort: reasoningEffort,
315
- },
316
- };
317
-
318
- // Also pass through other reasoning options if present
319
- if (body.reasoningSummary || body.reasoning?.summary) {
320
- modifiedBody.reasoning.summary =
321
- body.reasoningSummary || body.reasoning?.summary;
322
- }
323
-
324
- log("info", `Adding reasoning for GPT model`, {
325
- model: modelId,
326
- reasoning_effort: reasoningEffort,
327
- });
328
- }
329
- }
330
-
331
- // Responses API
332
- if (body?.input) {
333
- // Sanitize long IDs from Copilot backend (can be 400+ chars)
334
- // OpenAI Responses API enforces a 64-char max on item IDs
335
- const sanitizedInput = sanitizeResponseInputIds(body.input);
336
- const inputWasSanitized = sanitizedInput !== body.input &&
337
- JSON.stringify(sanitizedInput) !== JSON.stringify(body.input);
338
-
339
- if (inputWasSanitized) {
340
- log("info", "Sanitized long IDs in Responses API input", {
341
- original_count: body.input.filter(
342
- (item: any) =>
343
- (typeof item?.id === "string" && item.id.length > MAX_RESPONSE_API_ID_LENGTH) ||
344
- (typeof item?.call_id === "string" && item.call_id.length > MAX_RESPONSE_API_ID_LENGTH),
345
- ).length,
346
- });
347
- modifiedBody = {
348
- ...(modifiedBody || body),
349
- input: sanitizedInput,
350
- };
351
- }
352
-
353
- isAgentCall = (sanitizedInput || body.input).some(
354
- (item: any) =>
355
- item?.role === "assistant" ||
356
- (item?.type &&
357
- RESPONSES_API_ALTERNATE_INPUT_TYPES.includes(item.type)),
358
- );
359
-
360
- isVisionRequest = body.input.some(
361
- (item: any) =>
362
- Array.isArray(item?.content) &&
363
- item.content.some(
364
- (part: any) => part.type === "input_image",
365
- ),
366
- );
367
- }
368
-
369
- // Messages API (Anthropic style)
370
- if (body?.messages && !url.includes("completions")) {
371
- isAgentCall = body.messages.some((msg: any) =>
372
- ["tool", "assistant"].includes(msg.role),
373
- );
374
- isVisionRequest = body.messages.some(
375
- (item: any) =>
376
- Array.isArray(item?.content) &&
377
- item.content.some(
378
- (part: any) =>
379
- part?.type === "image" ||
380
- (part?.type === "tool_result" &&
381
- Array.isArray(part?.content) &&
382
- part.content.some(
383
- (nested: any) => nested?.type === "image",
384
- )),
385
- ),
386
- );
387
- }
388
- } catch {}
389
-
390
- const headers: Record<string, string> = {
391
- "x-initiator": isAgentCall ? "agent" : "user",
392
- ...(init?.headers as Record<string, string>),
393
- ...HEADERS,
394
- Authorization: `Bearer ${info.refresh}`,
395
- "Openai-Intent": "conversation-edits",
396
- };
397
-
398
- if (isVisionRequest) {
399
- headers["Copilot-Vision-Request"] = "true";
400
- }
401
-
402
- // Official only deletes lowercase "authorization"
403
- delete headers["x-api-key"];
404
- delete headers["authorization"];
405
-
406
- // Prepare the final init object with potentially modified body
407
- const finalInit = {
408
- ...init,
409
- headers,
410
- ...(modifiedBody ? { body: JSON.stringify(modifiedBody) } : {}),
411
- };
412
-
413
- // Retry logic with exponential backoff for rate limiting
414
- let lastError: Error | undefined;
415
- for (
416
- let attempt = 0;
417
- attempt <= RATE_LIMIT_CONFIG.maxRetries;
418
- attempt++
419
- ) {
420
- try {
421
- const response = await fetch(input, finalInit);
422
-
423
- // If we get a 429, retry with backoff
424
- if (
425
- response.status === 429 &&
426
- attempt < RATE_LIMIT_CONFIG.maxRetries
427
- ) {
428
- const delay = calculateRetryDelay(attempt);
429
- log("warn", `Rate limited (429), retrying`, {
430
- delay_ms: delay,
431
- attempt: attempt + 1,
432
- max_retries: RATE_LIMIT_CONFIG.maxRetries,
433
- });
434
- await sleep(delay);
435
- continue;
436
- }
437
-
438
- // Response transformation is now handled by the custom SDK at
439
- // .opencode/plugin/sdk/copilot/ which properly parses reasoning_text/reasoning_opaque
440
- // and converts them to AI SDK's reasoning content parts
441
- return response;
442
- } catch (error) {
443
- lastError = error as Error;
444
-
445
- // Network errors might be transient, retry
446
- if (attempt < RATE_LIMIT_CONFIG.maxRetries) {
447
- const delay = calculateRetryDelay(attempt);
448
- log("warn", `Request failed, retrying`, {
449
- delay_ms: delay,
450
- attempt: attempt + 1,
451
- max_retries: RATE_LIMIT_CONFIG.maxRetries,
452
- error: lastError.message,
453
- });
454
- await sleep(delay);
455
- continue;
456
- }
457
- throw error;
458
- }
459
- }
460
-
461
- // If we've exhausted all retries, throw the last error
462
- if (lastError) {
463
- throw new Error(
464
- `[Copilot] Max retries (${RATE_LIMIT_CONFIG.maxRetries}) exceeded. Last error: ${lastError.message}`,
465
- );
466
- }
467
-
468
- // This should not be reached, but just in case
469
- throw new Error(
470
- `[Copilot] Max retries (${RATE_LIMIT_CONFIG.maxRetries}) exceeded`,
471
- );
472
- },
473
- };
474
- },
475
- methods: [
476
- {
477
- type: "oauth",
478
- label: "Login with GitHub Copilot",
479
- prompts: [
480
- {
481
- type: "select",
482
- key: "deploymentType",
483
- message: "Select GitHub deployment type",
484
- options: [
485
- {
486
- label: "GitHub.com",
487
- value: "github.com",
488
- hint: "Public",
489
- },
490
- {
491
- label: "GitHub Enterprise",
492
- value: "enterprise",
493
- hint: "Data residency or self-hosted",
494
- },
495
- ],
496
- },
497
- {
498
- type: "text",
499
- key: "enterpriseUrl",
500
- message: "Enter your GitHub Enterprise URL or domain",
501
- placeholder: "company.ghe.com or https://company.ghe.com",
502
- condition: (inputs: any) =>
503
- inputs.deploymentType === "enterprise",
504
- validate: (value: string) => {
505
- if (!value) return "URL or domain is required";
506
- try {
507
- const url = value.includes("://")
508
- ? new URL(value)
509
- : new URL(`https://${value}`);
510
- if (!url.hostname)
511
- return "Please enter a valid URL or domain";
512
- return undefined;
513
- } catch {
514
- return "Please enter a valid URL (e.g., company.ghe.com or https://company.ghe.com)";
515
- }
516
- },
517
- },
518
- ],
519
- async authorize(inputs: any = {}) {
520
- const deploymentType = inputs.deploymentType || "github.com";
521
-
522
- let domain = "github.com";
523
- let actualProvider = "github-copilot";
524
-
525
- if (deploymentType === "enterprise") {
526
- const enterpriseUrl = inputs.enterpriseUrl;
527
- domain = normalizeDomain(enterpriseUrl);
528
- actualProvider = "github-copilot-enterprise";
529
- }
530
-
531
- const urls = getUrls(domain);
532
-
533
- const deviceResponse = await fetch(urls.DEVICE_CODE_URL, {
534
- method: "POST",
535
- headers: {
536
- Accept: "application/json",
537
- "Content-Type": "application/json",
538
- "User-Agent": "GitHubCopilotChat/0.35.0",
539
- },
540
- body: JSON.stringify({
541
- client_id: CLIENT_ID,
542
- scope: "read:user",
543
- }),
544
- });
545
-
546
- if (!deviceResponse.ok) {
547
- throw new Error("Failed to initiate device authorization");
548
- }
549
-
550
- const deviceData = await deviceResponse.json();
551
-
552
- return {
553
- url: deviceData.verification_uri,
554
- instructions: `Enter code: ${deviceData.user_code}`,
555
- method: "auto",
556
- callback: async () => {
557
- while (true) {
558
- const response = await fetch(urls.ACCESS_TOKEN_URL, {
559
- method: "POST",
560
- headers: {
561
- Accept: "application/json",
562
- "Content-Type": "application/json",
563
- "User-Agent": "GitHubCopilotChat/0.35.0",
564
- },
565
- body: JSON.stringify({
566
- client_id: CLIENT_ID,
567
- device_code: deviceData.device_code,
568
- grant_type:
569
- "urn:ietf:params:oauth:grant-type:device_code",
570
- }),
571
- });
572
-
573
- if (!response.ok) return { type: "failed" };
574
-
575
- const data = await response.json();
576
-
577
- if (data.access_token) {
578
- const result: {
579
- type: "success";
580
- refresh: string;
581
- access: string;
582
- expires: number;
583
- provider?: string;
584
- enterpriseUrl?: string;
585
- } = {
586
- type: "success",
587
- refresh: data.access_token,
588
- access: data.access_token,
589
- expires: 0,
590
- };
591
-
592
- if (actualProvider === "github-copilot-enterprise") {
593
- result.provider = "github-copilot-enterprise";
594
- result.enterpriseUrl = domain;
595
- }
596
-
597
- return result;
598
- }
599
-
600
- if (data.error === "authorization_pending") {
601
- await sleep(
602
- deviceData.interval * 1000 +
603
- OAUTH_POLLING_SAFETY_MARGIN_MS,
604
- );
605
- continue;
606
- }
607
-
608
- if (data.error === "slow_down") {
609
- // Based on the RFC spec, we must add 5 seconds to our current polling interval.
610
- let newInterval = (deviceData.interval + 5) * 1000;
611
-
612
- if (
613
- data.interval &&
614
- typeof data.interval === "number" &&
615
- data.interval > 0
616
- ) {
617
- newInterval = data.interval * 1000;
618
- }
619
-
620
- await sleep(newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS);
621
- continue;
622
- }
623
-
624
- if (data.error) return { type: "failed" };
625
-
626
- await sleep(
627
- deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS,
628
- );
629
- continue;
630
- }
631
- },
632
- };
633
- },
634
- },
635
- ],
636
- },
637
- // Hook to add custom headers for Claude reasoning support
638
- "chat.headers": async (input: any, output: any) => {
639
- // Only apply to GitHub Copilot provider
640
- if (!input.model?.providerID?.includes("github-copilot")) return;
641
-
642
- // Add Anthropic beta header for interleaved thinking (extended reasoning)
643
- // This is required for Claude models to return thinking blocks
644
- if (input.model?.api?.npm === "@ai-sdk/anthropic") {
645
- output.headers["anthropic-beta"] = "interleaved-thinking-2025-05-14";
646
- }
647
-
648
- // Mark subagent sessions as agent-initiated (matching standard Copilot tools)
649
- try {
650
- const session = await sdk.session
651
- .get({
652
- path: {
653
- id: input.sessionID,
654
- },
655
- throwOnError: true,
656
- })
657
- .catch(() => undefined);
658
- if (session?.data?.parentID) {
659
- output.headers["x-initiator"] = "agent";
660
- }
661
- } catch {
662
- // Ignore errors from session lookup
663
- }
664
- },
665
- };
232
+ // Initialize logger with the SDK client
233
+ setLogger(sdk);
234
+
235
+ return {
236
+ auth: {
237
+ provider: "github-copilot",
238
+ loader: async (getAuth, provider) => {
239
+ const info = await getAuth();
240
+ if (!info || info.type !== "oauth") return {};
241
+
242
+ // Enterprise URL support for baseURL
243
+ const enterpriseUrl = (info as any).enterpriseUrl;
244
+ const baseURL = enterpriseUrl
245
+ ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}`
246
+ : undefined;
247
+
248
+ if (provider && provider.models) {
249
+ for (const [_modelId, model] of Object.entries(provider.models)) {
250
+ model.cost = {
251
+ input: 0,
252
+ output: 0,
253
+ cache: {
254
+ read: 0,
255
+ write: 0,
256
+ },
257
+ };
258
+
259
+ // All models use the standard github-copilot SDK
260
+ // Reasoning support for Claude models is handled via:
261
+ // 1. The fetch wrapper adds thinking_budget to request body
262
+ // 2. The fetch wrapper strips invalid thinking blocks from messages
263
+ model.api.npm = "@ai-sdk/github-copilot";
264
+ }
265
+ }
266
+
267
+ return {
268
+ baseURL,
269
+ apiKey: "",
270
+ async fetch(input, init) {
271
+ const info = await getAuth();
272
+ if (info.type !== "oauth") return fetch(input, init);
273
+
274
+ let isAgentCall = false;
275
+ let isVisionRequest = false;
276
+ let modifiedBody: any = undefined;
277
+ let isClaudeModel = false;
278
+
279
+ try {
280
+ const body =
281
+ typeof init?.body === "string"
282
+ ? JSON.parse(init.body)
283
+ : init?.body;
284
+
285
+ const url = input.toString();
286
+
287
+ // Check if this is a Claude model request
288
+ const modelId = body?.model || "";
289
+ isClaudeModel = modelId.toLowerCase().includes("claude");
290
+
291
+ // Completions API
292
+ if (body?.messages && url.includes("completions")) {
293
+ // Keep local logic: detect if any message is assistant/tool
294
+ isAgentCall = body.messages.some((msg: any) =>
295
+ ["tool", "assistant"].includes(msg.role),
296
+ );
297
+ isVisionRequest = body.messages.some(
298
+ (msg: any) =>
299
+ Array.isArray(msg.content) &&
300
+ msg.content.some((part: any) => part.type === "image_url"),
301
+ );
302
+
303
+ // For Claude models, add thinking_budget to enable reasoning
304
+ // The Copilot API accepts this parameter and returns reasoning_text/reasoning_opaque
305
+ if (isClaudeModel) {
306
+ // Use configured thinking_budget from model options, or default to 10000
307
+ const thinkingBudget = body.thinking_budget || 10000;
308
+
309
+ // Fix for "Invalid signature in thinking block" error:
310
+ // The Copilot API uses reasoning_text/reasoning_opaque format for thinking
311
+ // When these are passed back without proper signature, it causes errors
312
+ // Solution: Ensure reasoning_opaque is present when reasoning_text exists,
313
+ // or remove reasoning content entirely if signature is invalid/missing
314
+ const cleanedMessages = body.messages.map(
315
+ (msg: any, idx: number) => {
316
+ if (msg.role !== "assistant") return msg;
317
+
318
+ // Log message structure for debugging
319
+ log("debug", `Processing assistant message ${idx}`, {
320
+ has_reasoning_text: !!msg.reasoning_text,
321
+ has_reasoning_opaque: !!msg.reasoning_opaque,
322
+ content_type: typeof msg.content,
323
+ content_is_array: Array.isArray(msg.content),
324
+ });
325
+
326
+ // If message has reasoning_text but no/invalid reasoning_opaque, remove reasoning
327
+ if (msg.reasoning_text && !msg.reasoning_opaque) {
328
+ log(
329
+ "warn",
330
+ `Removing reasoning_text without reasoning_opaque from message ${idx}`,
331
+ );
332
+ const { reasoning_text: _unused, ...cleanedMsg } = msg;
333
+ return cleanedMsg;
334
+ }
335
+
336
+ // If content is an array, check for thinking blocks
337
+ if (Array.isArray(msg.content)) {
338
+ const hasThinkingBlock = msg.content.some(
339
+ (part: any) => part.type === "thinking",
340
+ );
341
+ if (hasThinkingBlock) {
342
+ log(
343
+ "debug",
344
+ `Message ${idx} has thinking blocks in content array`,
345
+ );
346
+ // Filter out thinking blocks without signatures
347
+ const cleanedContent = msg.content.filter(
348
+ (part: any) => {
349
+ if (part.type === "thinking") {
350
+ if (!part.signature) {
351
+ log(
352
+ "warn",
353
+ `Removing thinking block without signature`,
354
+ );
355
+ return false;
356
+ }
357
+ }
358
+ return true;
359
+ },
360
+ );
361
+ return {
362
+ ...msg,
363
+ content:
364
+ cleanedContent.length > 0 ? cleanedContent : null,
365
+ };
366
+ }
367
+ }
368
+
369
+ return msg;
370
+ },
371
+ );
372
+
373
+ modifiedBody = {
374
+ ...body,
375
+ messages: cleanedMessages,
376
+ thinking_budget: thinkingBudget,
377
+ };
378
+ log("info", `Adding thinking_budget for Claude model`, {
379
+ model: modelId,
380
+ thinking_budget: thinkingBudget,
381
+ });
382
+ }
383
+
384
+ // For GPT models (o1, gpt-5, etc.), add reasoning parameter
385
+ const isGptModel =
386
+ modelId.toLowerCase().includes("gpt") ||
387
+ modelId.toLowerCase().includes("o1") ||
388
+ modelId.toLowerCase().includes("o3") ||
389
+ modelId.toLowerCase().includes("o4");
390
+
391
+ if (isGptModel && !isClaudeModel) {
392
+ // Get reasoning effort from body options or default to "medium"
393
+ const reasoningEffort =
394
+ body.reasoning?.effort ||
395
+ body.reasoningEffort ||
396
+ body.reasoning_effort ||
397
+ "medium";
398
+
399
+ modifiedBody = {
400
+ ...(modifiedBody || body),
401
+ reasoning: {
402
+ effort: reasoningEffort,
403
+ },
404
+ };
405
+
406
+ // Also pass through other reasoning options if present
407
+ if (body.reasoningSummary || body.reasoning?.summary) {
408
+ modifiedBody.reasoning.summary =
409
+ body.reasoningSummary || body.reasoning?.summary;
410
+ }
411
+
412
+ log("info", `Adding reasoning for GPT model`, {
413
+ model: modelId,
414
+ reasoning_effort: reasoningEffort,
415
+ });
416
+ }
417
+ }
418
+
419
+ // Responses API
420
+ if (body?.input) {
421
+ // Sanitize long IDs from Copilot backend (can be 400+ chars)
422
+ // OpenAI Responses API enforces a 64-char max on item IDs
423
+ const sanitizedInput = sanitizeResponseInputIds(body.input);
424
+ const inputWasSanitized =
425
+ sanitizedInput !== body.input &&
426
+ JSON.stringify(sanitizedInput) !== JSON.stringify(body.input);
427
+
428
+ if (inputWasSanitized) {
429
+ log("info", "Sanitized long IDs in Responses API input", {
430
+ original_count: body.input.filter(
431
+ (item: any) =>
432
+ (typeof item?.id === "string" &&
433
+ item.id.length > MAX_RESPONSE_API_ID_LENGTH) ||
434
+ (typeof item?.call_id === "string" &&
435
+ item.call_id.length > MAX_RESPONSE_API_ID_LENGTH),
436
+ ).length,
437
+ });
438
+ modifiedBody = {
439
+ ...(modifiedBody || body),
440
+ input: sanitizedInput,
441
+ };
442
+ }
443
+
444
+ isAgentCall = (sanitizedInput || body.input).some(
445
+ (item: any) =>
446
+ item?.role === "assistant" ||
447
+ (item?.type &&
448
+ RESPONSES_API_ALTERNATE_INPUT_TYPES.includes(item.type)),
449
+ );
450
+
451
+ isVisionRequest = body.input.some(
452
+ (item: any) =>
453
+ Array.isArray(item?.content) &&
454
+ item.content.some(
455
+ (part: any) => part.type === "input_image",
456
+ ),
457
+ );
458
+ }
459
+
460
+ // Messages API (Anthropic style)
461
+ if (body?.messages && !url.includes("completions")) {
462
+ isAgentCall = body.messages.some((msg: any) =>
463
+ ["tool", "assistant"].includes(msg.role),
464
+ );
465
+ isVisionRequest = body.messages.some(
466
+ (item: any) =>
467
+ Array.isArray(item?.content) &&
468
+ item.content.some(
469
+ (part: any) =>
470
+ part?.type === "image" ||
471
+ (part?.type === "tool_result" &&
472
+ Array.isArray(part?.content) &&
473
+ part.content.some(
474
+ (nested: any) => nested?.type === "image",
475
+ )),
476
+ ),
477
+ );
478
+ }
479
+ } catch {}
480
+
481
+ const headers: Record<string, string> = {
482
+ "x-initiator": isAgentCall ? "agent" : "user",
483
+ ...(init?.headers as Record<string, string>),
484
+ ...HEADERS,
485
+ Authorization: `Bearer ${info.refresh}`,
486
+ "Openai-Intent": "conversation-edits",
487
+ };
488
+
489
+ if (isVisionRequest) {
490
+ headers["Copilot-Vision-Request"] = "true";
491
+ }
492
+
493
+ // Official only deletes lowercase "authorization"
494
+ delete headers["x-api-key"];
495
+ delete headers["authorization"];
496
+
497
+ // Prepare the final init object with potentially modified body
498
+ const finalInit = {
499
+ ...init,
500
+ headers,
501
+ ...(modifiedBody ? { body: JSON.stringify(modifiedBody) } : {}),
502
+ };
503
+
504
+ // Extract model from request body for rate limit tracking
505
+ let currentModel = "";
506
+ try {
507
+ const bodyObj =
508
+ typeof finalInit.body === "string"
509
+ ? JSON.parse(finalInit.body)
510
+ : finalInit.body;
511
+ currentModel = bodyObj?.model || "";
512
+ } catch {}
513
+
514
+ // Pre-flight: if current model is already known rate-limited, switch to fallback
515
+ let activeFinalInit: RequestInit = finalInit;
516
+ if (currentModel && isModelRateLimited(currentModel)) {
517
+ const fallback = getNextFallbackModel(currentModel);
518
+ if (fallback) {
519
+ log(
520
+ "info",
521
+ `Model ${currentModel} is rate-limited, pre-switching to ${fallback}`,
522
+ );
523
+ activeFinalInit =
524
+ swapModelInBody(finalInit, fallback) || finalInit;
525
+ currentModel = fallback;
526
+ }
527
+ }
528
+
529
+ // Retry logic with model fallback and exponential backoff for rate limiting
530
+ let lastError: Error | undefined;
531
+ let fallbacksUsed = 0;
532
+ let attempt = 0;
533
+
534
+ while (attempt <= RATE_LIMIT_CONFIG.maxRetries) {
535
+ try {
536
+ const response = await fetch(input, activeFinalInit);
537
+
538
+ if (response.status === 429) {
539
+ // Parse Retry-After header for server-suggested cooldown
540
+ const retryAfterMs = parseRetryAfter(response);
541
+ const cooldownMs =
542
+ retryAfterMs ?? RATE_LIMIT_CONFIG.defaultCooldownMs;
543
+
544
+ // Mark this model as rate-limited
545
+ if (currentModel) {
546
+ markModelRateLimited(currentModel, cooldownMs);
547
+ }
548
+
549
+ // Try fallback model (doesn't count against retry budget)
550
+ if (
551
+ currentModel &&
552
+ fallbacksUsed < RATE_LIMIT_CONFIG.maxFallbacks
553
+ ) {
554
+ const fallback = getNextFallbackModel(currentModel);
555
+ if (fallback) {
556
+ log(
557
+ "warn",
558
+ `Rate limited on ${currentModel}, switching to ${fallback}`,
559
+ {
560
+ retry_after_ms: retryAfterMs,
561
+ cooldown_ms: cooldownMs,
562
+ fallbacks_used: fallbacksUsed + 1,
563
+ },
564
+ );
565
+ activeFinalInit =
566
+ swapModelInBody(activeFinalInit, fallback) ||
567
+ activeFinalInit;
568
+ currentModel = fallback;
569
+ fallbacksUsed++;
570
+ continue; // Retry immediately with new model, no delay
571
+ }
572
+ }
573
+
574
+ // No fallback available — use exponential backoff on same model
575
+ if (attempt < RATE_LIMIT_CONFIG.maxRetries) {
576
+ const delay =
577
+ retryAfterMs != null
578
+ ? Math.min(retryAfterMs, RATE_LIMIT_CONFIG.maxDelayMs)
579
+ : calculateRetryDelay(attempt);
580
+ log(
581
+ "warn",
582
+ `Rate limited (429), no fallback available, waiting ${delay}ms`,
583
+ {
584
+ delay_ms: delay,
585
+ attempt: attempt + 1,
586
+ max_retries: RATE_LIMIT_CONFIG.maxRetries,
587
+ fallbacks_exhausted: true,
588
+ },
589
+ );
590
+ await sleep(delay);
591
+ attempt++;
592
+ continue;
593
+ }
594
+
595
+ // Exhausted retries and fallbacks
596
+ throw new Error(
597
+ `[Copilot] Rate limited. Tried ${fallbacksUsed} fallback model(s) and ${attempt} retries. Model: ${currentModel}`,
598
+ );
599
+ }
600
+
601
+ // Response transformation is handled by the custom SDK at
602
+ // .opencode/plugin/sdk/copilot/
603
+ return response;
604
+ } catch (error) {
605
+ lastError = error as Error;
606
+
607
+ // Network errors might be transient, retry
608
+ if (attempt < RATE_LIMIT_CONFIG.maxRetries) {
609
+ const delay = calculateRetryDelay(attempt);
610
+ log("warn", `Request failed, retrying`, {
611
+ delay_ms: delay,
612
+ attempt: attempt + 1,
613
+ max_retries: RATE_LIMIT_CONFIG.maxRetries,
614
+ error: lastError.message,
615
+ });
616
+ await sleep(delay);
617
+ attempt++;
618
+ continue;
619
+ }
620
+ throw error;
621
+ }
622
+ }
623
+
624
+ // Exhausted all retries
625
+ if (lastError) {
626
+ throw new Error(
627
+ `[Copilot] Max retries (${RATE_LIMIT_CONFIG.maxRetries}) exceeded. Last error: ${lastError.message}`,
628
+ );
629
+ }
630
+ throw new Error(
631
+ `[Copilot] Max retries (${RATE_LIMIT_CONFIG.maxRetries}) exceeded`,
632
+ );
633
+ },
634
+ };
635
+ },
636
+ methods: [
637
+ {
638
+ type: "oauth",
639
+ label: "Login with GitHub Copilot",
640
+ prompts: [
641
+ {
642
+ type: "select",
643
+ key: "deploymentType",
644
+ message: "Select GitHub deployment type",
645
+ options: [
646
+ {
647
+ label: "GitHub.com",
648
+ value: "github.com",
649
+ hint: "Public",
650
+ },
651
+ {
652
+ label: "GitHub Enterprise",
653
+ value: "enterprise",
654
+ hint: "Data residency or self-hosted",
655
+ },
656
+ ],
657
+ },
658
+ {
659
+ type: "text",
660
+ key: "enterpriseUrl",
661
+ message: "Enter your GitHub Enterprise URL or domain",
662
+ placeholder: "company.ghe.com or https://company.ghe.com",
663
+ condition: (inputs: any) =>
664
+ inputs.deploymentType === "enterprise",
665
+ validate: (value: string) => {
666
+ if (!value) return "URL or domain is required";
667
+ try {
668
+ const url = value.includes("://")
669
+ ? new URL(value)
670
+ : new URL(`https://${value}`);
671
+ if (!url.hostname)
672
+ return "Please enter a valid URL or domain";
673
+ return undefined;
674
+ } catch {
675
+ return "Please enter a valid URL (e.g., company.ghe.com or https://company.ghe.com)";
676
+ }
677
+ },
678
+ },
679
+ ],
680
+ async authorize(inputs: any = {}) {
681
+ const deploymentType = inputs.deploymentType || "github.com";
682
+
683
+ let domain = "github.com";
684
+ let actualProvider = "github-copilot";
685
+
686
+ if (deploymentType === "enterprise") {
687
+ const enterpriseUrl = inputs.enterpriseUrl;
688
+ domain = normalizeDomain(enterpriseUrl);
689
+ actualProvider = "github-copilot-enterprise";
690
+ }
691
+
692
+ const urls = getUrls(domain);
693
+
694
+ const deviceResponse = await fetch(urls.DEVICE_CODE_URL, {
695
+ method: "POST",
696
+ headers: {
697
+ Accept: "application/json",
698
+ "Content-Type": "application/json",
699
+ "User-Agent": "GitHubCopilotChat/0.35.0",
700
+ },
701
+ body: JSON.stringify({
702
+ client_id: CLIENT_ID,
703
+ scope: "read:user",
704
+ }),
705
+ });
706
+
707
+ if (!deviceResponse.ok) {
708
+ throw new Error("Failed to initiate device authorization");
709
+ }
710
+
711
+ const deviceData = await deviceResponse.json();
712
+
713
+ return {
714
+ url: deviceData.verification_uri,
715
+ instructions: `Enter code: ${deviceData.user_code}`,
716
+ method: "auto",
717
+ callback: async () => {
718
+ while (true) {
719
+ const response = await fetch(urls.ACCESS_TOKEN_URL, {
720
+ method: "POST",
721
+ headers: {
722
+ Accept: "application/json",
723
+ "Content-Type": "application/json",
724
+ "User-Agent": "GitHubCopilotChat/0.35.0",
725
+ },
726
+ body: JSON.stringify({
727
+ client_id: CLIENT_ID,
728
+ device_code: deviceData.device_code,
729
+ grant_type:
730
+ "urn:ietf:params:oauth:grant-type:device_code",
731
+ }),
732
+ });
733
+
734
+ if (!response.ok) return { type: "failed" };
735
+
736
+ const data = await response.json();
737
+
738
+ if (data.access_token) {
739
+ const result: {
740
+ type: "success";
741
+ refresh: string;
742
+ access: string;
743
+ expires: number;
744
+ provider?: string;
745
+ enterpriseUrl?: string;
746
+ } = {
747
+ type: "success",
748
+ refresh: data.access_token,
749
+ access: data.access_token,
750
+ expires: 0,
751
+ };
752
+
753
+ if (actualProvider === "github-copilot-enterprise") {
754
+ result.provider = "github-copilot-enterprise";
755
+ result.enterpriseUrl = domain;
756
+ }
757
+
758
+ return result;
759
+ }
760
+
761
+ if (data.error === "authorization_pending") {
762
+ await sleep(
763
+ deviceData.interval * 1000 +
764
+ OAUTH_POLLING_SAFETY_MARGIN_MS,
765
+ );
766
+ continue;
767
+ }
768
+
769
+ if (data.error === "slow_down") {
770
+ // Based on the RFC spec, we must add 5 seconds to our current polling interval.
771
+ let newInterval = (deviceData.interval + 5) * 1000;
772
+
773
+ if (
774
+ data.interval &&
775
+ typeof data.interval === "number" &&
776
+ data.interval > 0
777
+ ) {
778
+ newInterval = data.interval * 1000;
779
+ }
780
+
781
+ await sleep(newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS);
782
+ continue;
783
+ }
784
+
785
+ if (data.error) return { type: "failed" };
786
+
787
+ await sleep(
788
+ deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS,
789
+ );
790
+ continue;
791
+ }
792
+ },
793
+ };
794
+ },
795
+ },
796
+ ],
797
+ },
798
+ // Hook to add custom headers for Claude reasoning support
799
+ "chat.headers": async (input: any, output: any) => {
800
+ // Only apply to GitHub Copilot provider
801
+ if (!input.model?.providerID?.includes("github-copilot")) return;
802
+
803
+ // Add Anthropic beta header for interleaved thinking (extended reasoning)
804
+ // This is required for Claude models to return thinking blocks
805
+ if (input.model?.api?.npm === "@ai-sdk/anthropic") {
806
+ output.headers["anthropic-beta"] = "interleaved-thinking-2025-05-14";
807
+ }
808
+
809
+ // Mark subagent sessions as agent-initiated (matching standard Copilot tools)
810
+ try {
811
+ const session = await sdk.session
812
+ .get({
813
+ path: {
814
+ id: input.sessionID,
815
+ },
816
+ throwOnError: true,
817
+ })
818
+ .catch(() => undefined);
819
+ if (session?.data?.parentID) {
820
+ output.headers["x-initiator"] = "agent";
821
+ }
822
+ } catch {
823
+ // Ignore errors from session lookup
824
+ }
825
+ },
826
+ };
666
827
  };