-
-
Notifications
You must be signed in to change notification settings - Fork 8.5k
Expand file tree
/
Copy pathghe-copilot.ts
More file actions
311 lines (284 loc) · 12.2 KB
/
Copy pathghe-copilot.ts
File metadata and controls
311 lines (284 loc) · 12.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
import { GithubExecutor } from "./github.ts";
import type { ProviderCredentials, ExecuteInput, ExecutorLog } from "./base.ts";
import { getModelTargetFormat } from "../config/providerModels.ts";
/** Result of a successful GHE Copilot internal token exchange. */
type CopilotTokenResult = {
token: string;
expiresAt: string | number;
endpoints?: { proxy?: string; api?: string };
};
export class GheCopilotExecutor extends GithubExecutor {
constructor(config?: Record<string, unknown>) {
super("ghe-copilot", {
format: "openai",
baseUrl: "https://api.githubcopilot.com/chat/completions",
responsesBaseUrl: "https://api.githubcopilot.com/responses",
// Static default only; the executor's getMessagesBase() derives the real
// per-connection host from copilotApiUrl/gheUrl at request time. Its
// presence enables Claude -> /v1/messages routing in the buildUrl override.
messagesUrl: "https://api.githubcopilot.com/v1/messages",
authType: "oauth",
authHeader: "bearer",
...config,
});
}
/**
* Derive the base URL for chat/completions from gheUrl in providerSpecificData.
* Appends /chat/completions if not already present.
*/
private getChatCompletionsBase(credentials: ProviderCredentials | null): string {
// The GHE token endpoint returns TWO hosts:
// endpoints.api → copilotApiUrl (chat/completions + the /models catalog)
// endpoints.proxy → copilotProxyUrl (NES / autocomplete / instant-apply only)
// Chat MUST go to the api host. The proxy host only serves the completion
// models (copilot-nes-*, instant-apply, suggestions) and 404s/errors for
// real chat models. Prefer copilotApiUrl, fall back to copilotProxyUrl for
// legacy connections, then the static gheUrl/chat/completions path.
const psd = credentials?.providerSpecificData;
const apiOrProxy =
(typeof psd?.copilotApiUrl === "string" ? psd.copilotApiUrl : undefined) ||
(typeof psd?.copilotProxyUrl === "string" ? psd.copilotProxyUrl : undefined);
if (apiOrProxy) {
const base = apiOrProxy.replace(/\/chat\/completions\/?$/, "").replace(/\/responses\/?$/, "").replace(/\/+$/, "");
return base.endsWith("/chat/completions") ? base : `${base}/chat/completions`;
}
const gheUrl = psd?.gheUrl as string | undefined;
if (!gheUrl) {
throw new Error("GHE Copilot executor requires gheUrl in providerSpecificData");
}
const base = gheUrl.replace(/\/chat\/completions\/?$/, "").replace(/\/responses\/?$/, "").replace(/\/+$/, "");
return base.endsWith("/chat/completions") ? base : `${base}/chat/completions`;
}
/**
* Derive the base URL for /responses from gheUrl in providerSpecificData.
* Appends /responses if not already present.
*/
private getResponsesBase(credentials: ProviderCredentials | null): string {
const psd = credentials?.providerSpecificData;
const apiOrProxy =
(typeof psd?.copilotApiUrl === "string" ? psd.copilotApiUrl : undefined) ||
(typeof psd?.copilotProxyUrl === "string" ? psd.copilotProxyUrl : undefined);
if (apiOrProxy) {
const base = apiOrProxy.replace(/\/chat\/completions\/?$/, "").replace(/\/responses\/?$/, "").replace(/\/+$/, "");
return `${base}/responses`;
}
const gheUrl = psd?.gheUrl as string | undefined;
if (!gheUrl) {
throw new Error("GHE Copilot executor requires gheUrl in providerSpecificData");
}
const base = gheUrl.replace(/\/chat\/completions\/?$/, "").replace(/\/responses\/?$/, "").replace(/\/+$/, "");
return `${base}/responses`;
}
/**
* Derive the base URL for the Anthropic-native /v1/messages shim from the GHE
* host in providerSpecificData. Claude models use this endpoint (prompt-cache
* token counts + lossless tool_use/tool_result/thinking blocks) rather than
* the OpenAI-shaped /chat/completions. Appends /v1/messages if not present.
*/
private getMessagesBase(credentials: ProviderCredentials | null): string {
const psd = credentials?.providerSpecificData;
const apiOrProxy =
(typeof psd?.copilotApiUrl === "string" ? psd.copilotApiUrl : undefined) ||
(typeof psd?.copilotProxyUrl === "string" ? psd.copilotProxyUrl : undefined);
const host = apiOrProxy || (psd?.gheUrl as string | undefined);
if (!host) {
throw new Error("GHE Copilot executor requires copilotApiUrl or gheUrl in providerSpecificData");
}
const base = host
.replace(/\/v1\/messages\/?$/, "")
.replace(/\/chat\/completions\/?$/, "")
.replace(/\/responses\/?$/, "")
.replace(/\/+$/, "");
return `${base}/v1/messages`;
}
/**
* Strip the `ghe-copilot/` provider prefix from a model id so the upstream
* GHE Copilot proxy receives the bare id (e.g. `gpt-5-mini`).
*/
private stripPrefix(model: string): string {
return typeof model === "string" && model.startsWith("ghe-copilot/")
? model.slice("ghe-copilot/".length)
: model;
}
override buildUrl(model: string, stream: boolean, urlIndex = 0, credentials: ProviderCredentials | null = null): string {
const bareModel = this.stripPrefix(model);
const targetFormat = getModelTargetFormat("ghe-copilot", bareModel);
// Claude models: ALWAYS route to the Anthropic-native /v1/messages shim
// (same as github.com Copilot), matched on the model NAME so a Claude id
// that is missing its registry targetFormat tag still gets the native shim
// instead of the lossy /chat/completions path.
if ((targetFormat === "claude" || /claude/i.test(bareModel)) && this.config.messagesUrl) {
return this.getMessagesBase(credentials);
}
if (
(targetFormat === "openai-responses" || /codex/i.test(bareModel)) &&
this.supportsResponsesEndpoint(bareModel)
) {
return this.getResponsesBase(credentials);
}
return this.getChatCompletionsBase(credentials);
}
/**
* Strip the `ghe-copilot/` provider prefix from the model before sending to
* the upstream GHE Copilot proxy, which expects bare model ids.
*/
override transformRequest(
model: string,
body: unknown,
stream: boolean,
credentials: ProviderCredentials
): unknown {
const bareModel = this.stripPrefix(model);
const transformed = super.transformRequest(bareModel, body, stream, credentials);
if (transformed && typeof transformed === "object") {
const record = transformed as Record<string, unknown>;
if (typeof record.model === "string") {
record.model = this.stripPrefix(record.model);
}
// GHE Copilot proxy is streaming-only: force stream:true upstream
// (chatCore drains the SSE back to JSON for non-stream clients).
record.stream = true;
}
return transformed;
}
override async refreshCopilotToken(
githubAccessToken: string,
log?: { info?: (cat: string, msg: string) => void; error?: (cat: string, msg: string) => void },
credentials?: ProviderCredentials | null
): Promise<CopilotTokenResult | null> {
const gheUrl = credentials?.providerSpecificData?.gheUrl as string | undefined;
if (!gheUrl) return null;
try {
const baseUrl = gheUrl.replace(/\/chat\/completions\/?$/, "").replace(/\/responses\/?$/, "");
const tokenUrl = `${baseUrl}/api/v3/copilot_internal/v2/token`;
const response = await fetch(tokenUrl, {
headers: {
Authorization: `Bearer ${githubAccessToken}`,
Accept: "application/json",
},
});
if (!response.ok) return null;
const data = await response.json();
log?.info?.("TOKEN", "GHE Copilot token refreshed");
// GHE returns a dynamic `endpoints` object; the chat/responses proxy host
// lives at endpoints.proxy (NOT a static path on the GHE web host).
const endpoints = data.endpoints
? { proxy: data.endpoints.proxy, api: data.endpoints.api }
: undefined;
return {
token: data.token,
expiresAt: data.expires_at,
...(endpoints ? { endpoints } : {}),
};
} catch (error) {
log?.error?.("TOKEN", `GHE Copilot refresh error: ${error.message}`);
return null;
}
}
override async refreshGitHubToken(
refreshToken: string,
log?: { info?: (cat: string, msg: string) => void; error?: (cat: string, msg: string) => void },
credentials?: ProviderCredentials | null
): Promise<{
accessToken: string;
refreshToken: string;
expiresIn: number;
} | null> {
const gheUrl = credentials?.providerSpecificData?.gheUrl as string | undefined;
if (!gheUrl) return null;
try {
// GHE OAuth token endpoint
const baseUrl = gheUrl.replace(/\/chat\/completions\/?$/, "").replace(/\/responses\/?$/, "");
const tokenUrl = `${baseUrl}/login/oauth/access_token`;
const params = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: this.config.clientId,
});
if (this.config.clientSecret) {
params.set("client_secret", this.config.clientSecret);
}
const response = await fetch(tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: params,
});
if (!response.ok) return null;
const tokens = await response.json();
log?.info?.("TOKEN", "GHE GitHub token refreshed");
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
};
} catch (error) {
log?.error?.("TOKEN", `GHE GitHub refresh error: ${error.message}`);
return null;
}
}
/**
* Merge a fresh Copilot token result into providerSpecificData, preserving
* existing fields and updating the token/expiry/endpoint bookkeeping GHE
* Copilot needs (copilotApiUrl for chat/models, copilotProxyUrl legacy
* fallback, gheUrl for the next refresh round-trip).
*/
private buildRefreshedProviderSpecificData(
credentials: ProviderCredentials,
copilotResult: CopilotTokenResult
): Record<string, unknown> {
return {
...credentials?.providerSpecificData,
copilotToken: copilotResult.token,
copilotTokenExpiresAt: copilotResult.expiresAt,
copilotApiUrl: copilotResult.endpoints?.api,
copilotProxyUrl: copilotResult.endpoints?.proxy,
gheUrl: credentials?.providerSpecificData?.gheUrl,
};
}
/**
* Fallback path when the cached GitHub access token can no longer mint a
* Copilot token directly: refresh the GitHub OAuth token first, then retry
* the Copilot token exchange with the new access token.
*/
private async refreshViaGitHubToken(credentials: ProviderCredentials, log?: ExecutorLog | null) {
const githubTokens = await this.refreshGitHubToken(
credentials.refreshToken as string,
log,
credentials
);
if (!githubTokens?.accessToken) return null;
const copilotResult = await this.refreshCopilotToken(githubTokens.accessToken, log, credentials);
if (!copilotResult) return githubTokens;
return {
...githubTokens,
copilotToken: copilotResult.token,
copilotTokenExpiresAt: copilotResult.expiresAt,
providerSpecificData: this.buildRefreshedProviderSpecificData(credentials, copilotResult),
};
}
/**
* Refresh credentials and capture the GHE Copilot proxy URL (endpoints.proxy)
* returned by the token endpoint, storing it in providerSpecificData so
* buildUrl routes chat/responses traffic to the correct enterprise host.
*/
override async refreshCredentials(credentials: ProviderCredentials, log?: ExecutorLog | null) {
const copilotResult = await this.refreshCopilotToken(credentials?.accessToken, log, credentials);
if (!copilotResult && credentials?.refreshToken) {
return this.refreshViaGitHubToken(credentials, log);
}
if (copilotResult) {
return {
accessToken: credentials?.accessToken,
refreshToken: credentials?.refreshToken,
copilotToken: copilotResult.token,
copilotTokenExpiresAt: copilotResult.expiresAt,
providerSpecificData: this.buildRefreshedProviderSpecificData(credentials, copilotResult),
};
}
return null;
}
}
export default GheCopilotExecutor;