-
Notifications
You must be signed in to change notification settings - Fork 21
Expand file tree
/
Copy pathcopilot.js
More file actions
337 lines (307 loc) · 11.2 KB
/
copilot.js
File metadata and controls
337 lines (307 loc) · 11.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
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
'use strict';
/**
* GitHub Copilot provider adapter.
*
* Port: 10002
* Auth: Bearer token (COPILOT_GITHUB_TOKEN or COPILOT_API_KEY)
* Credentials: COPILOT_GITHUB_TOKEN (GitHub OAuth, higher trust) or COPILOT_API_KEY (BYOK)
* Target: COPILOT_API_TARGET (auto-derived from GITHUB_SERVER_URL if not set)
* Base path: optional `COPILOT_API_BASE_PATH` for prefixed BYOK routers
*
* Special routing: GET /models (and /models/*) always uses COPILOT_GITHUB_TOKEN
* regardless of which auth mode is active, because the /models endpoint only
* accepts OAuth tokens, not API keys.
*/
const { normalizeApiTarget, normalizeBasePath } = require('../proxy-utils');
const { URL } = require('url');
/**
* Strip any accidental "Bearer " prefix from a raw credential value and trim
* surrounding whitespace. Returns undefined when the result is empty so that
* callers can use `|| undefined` fall-through cleanly.
*
* A value like "Bearer " (prefix with nothing after it) reduces to undefined
* rather than "Bearer", which is why the prefix is removed before trimming.
*
* @param {string|undefined} value - Raw credential string
* @returns {string|undefined}
*/
function stripBearerPrefix(value) {
return ((value || '').replace(/^\s*Bearer\s+/i, '').trim()) || undefined;
}
/**
* Resolves the Copilot auth token from environment variables.
* COPILOT_GITHUB_TOKEN (GitHub OAuth) takes precedence over COPILOT_API_KEY (direct key).
*
* Any accidental "Bearer " prefix is stripped via stripBearerPrefix so that
* the injected Authorization header is exactly "Bearer <token>" rather than
* the double-prefixed "Bearer Bearer <token>" that would be rejected by
* external providers in BYOK mode.
*
* @param {Record<string, string|undefined>} env - Environment variables to inspect
* @returns {string|undefined} The resolved auth token, or undefined if neither is set
*/
function resolveCopilotAuthToken(env = process.env) {
return stripBearerPrefix(env.COPILOT_GITHUB_TOKEN) || stripBearerPrefix(env.COPILOT_API_KEY);
}
/**
* Derive the Copilot API target hostname from environment variables.
*
* Priority:
* 1. Explicit COPILOT_API_TARGET env var
* 2. Auto-derived from GITHUB_SERVER_URL:
* - *.ghe.com (GHEC tenant) → copilot-api.<subdomain>.ghe.com
* - Other non-github.com (GHES) → api.enterprise.githubcopilot.com
* 3. Default: api.githubcopilot.com
*
* @param {Record<string, string|undefined>} env - Environment variables
* @returns {string} Copilot API target hostname
*/
function deriveCopilotApiTarget(env = process.env) {
if (env.COPILOT_API_TARGET) {
const target = normalizeApiTarget(env.COPILOT_API_TARGET);
// Only use the explicit value if it parsed into a valid hostname;
// fall through to auto-derivation when the value is malformed.
if (target) return target;
}
const serverUrl = env.GITHUB_SERVER_URL;
if (serverUrl) {
try {
const hostname = new URL(serverUrl).hostname;
if (hostname !== 'github.com') {
if (hostname.endsWith('.ghe.com')) {
const subdomain = hostname.slice(0, -8); // Remove '.ghe.com'
return `copilot-api.${subdomain}.ghe.com`;
}
return 'api.enterprise.githubcopilot.com';
}
} catch {
// Invalid URL — fall through to default
}
}
return 'api.githubcopilot.com';
}
/**
* Derive the GitHub REST API target hostname (used for GHES/GHEC endpoints).
*
* Priority:
* 1. Explicit GITHUB_API_URL env var (hostname extracted)
* 2. Auto-derived from GITHUB_SERVER_URL for GHEC tenants (*.ghe.com)
* 3. Default: api.github.com
*
* @param {Record<string, string|undefined>} env - Environment variables
* @returns {string} GitHub REST API target hostname
*/
function deriveGitHubApiTarget(env = process.env) {
if (env.GITHUB_API_URL) {
const target = normalizeApiTarget(env.GITHUB_API_URL);
if (target) return target;
}
const serverUrl = env.GITHUB_SERVER_URL;
if (serverUrl) {
try {
const hostname = new URL(serverUrl).hostname;
if (hostname !== 'github.com' && hostname.endsWith('.ghe.com')) {
const subdomain = hostname.slice(0, -8);
return `api.${subdomain}.ghe.com`;
}
} catch {
// Invalid URL — fall through to default
}
}
return 'api.github.com';
}
/**
* Extract the base path from GITHUB_API_URL for GHES deployments
* (e.g. https://ghes.example.com/api/v3 → '/api/v3').
* Returns '' for github.com or when no path component is present.
*
* @param {Record<string, string|undefined>} env - Environment variables
* @returns {string} Base path or ''
*/
function deriveGitHubApiBasePath(env = process.env) {
const raw = env.GITHUB_API_URL;
if (!raw) return '';
try {
const parsed = new URL(raw.trim().startsWith('http') ? raw.trim() : `https://${raw.trim()}`);
const p = parsed.pathname.replace(/\/+$/, '');
return p === '/' ? '' : p;
} catch {
return '';
}
}
/**
* Create the GitHub Copilot provider adapter.
*
* @param {Record<string, string|undefined>} env - Environment variables
* @param {{ bodyTransform: ((body: Buffer) => Buffer|null)|null }} deps - Injected dependencies
* @returns {import('./index').ProviderAdapter}
*/
function createCopilotAdapter(env, deps = {}) {
const githubToken = stripBearerPrefix(env.COPILOT_GITHUB_TOKEN);
const apiKey = stripBearerPrefix(env.COPILOT_API_KEY);
const authToken = resolveCopilotAuthToken(env);
const integrationId = env.COPILOT_INTEGRATION_ID || 'copilot-developer-cli';
const rawTarget = deriveCopilotApiTarget(env);
const basePath = normalizeBasePath(env.COPILOT_API_BASE_PATH);
const bodyTransform = deps.bodyTransform || null;
// Pre-computed models path used by getModelsFetchConfig and getReflectionInfo.
// For BYOK/custom providers the base path prefix is included (e.g. /api/v1/models
// for COPILOT_PROVIDER_BASE_URL=https://openrouter.ai/api/v1).
// A basePath of '/' (normalizeBasePath returns '/') is treated as no prefix to
// avoid producing '//models'.
const modelsPath = (basePath && basePath !== '/') ? `${basePath}/models` : '/models';
return {
name: 'copilot',
port: 10002,
isManagementPort: false,
/**
* Port 10002 always starts so agents get a clear 503 "not configured"
* error rather than a silent connection-refused.
*/
alwaysBind: true,
/**
* The stub server does NOT count toward the startup validation latch —
* only the fully-configured server (when credentials are present) does.
*/
get participatesInValidation() { return this.isEnabled(); },
isEnabled() { return !!authToken; },
getTargetHost() { return rawTarget; },
getBasePath() { return basePath; },
/**
* Build Copilot auth headers for this request.
*
* The Copilot /models endpoint only accepts COPILOT_GITHUB_TOKEN (GitHub OAuth).
* All other requests use the resolved auth token (COPILOT_GITHUB_TOKEN or COPILOT_API_KEY).
*
* @param {import('http').IncomingMessage} req
* @returns {Record<string, string>}
*/
getAuthHeaders(req) {
let reqPathname;
try {
reqPathname = new URL(req.url, 'http://localhost').pathname;
} catch {
reqPathname = req.url || '';
}
const isModelsPath = reqPathname === '/models' || reqPathname.startsWith('/models/');
if (isModelsPath && req.method === 'GET' && githubToken) {
return {
'Authorization': `Bearer ${githubToken}`,
'Copilot-Integration-Id': integrationId,
};
}
return {
'Authorization': `Bearer ${authToken}`,
'Copilot-Integration-Id': integrationId,
};
},
getBodyTransform() { return bodyTransform; },
getValidationProbe() {
if (!authToken) return null;
// Only COPILOT_GITHUB_TOKEN has a probe endpoint (/models).
// COPILOT_API_KEY alone cannot be validated at startup.
if (!githubToken) {
return {
skip: true,
reason: 'COPILOT_API_KEY configured but startup validation is not supported for this auth mode',
};
}
if (rawTarget !== 'api.githubcopilot.com') {
return { skip: true, reason: `Custom target ${rawTarget}; validation skipped` };
}
return {
url: `https://${rawTarget}/models`,
opts: {
method: 'GET',
headers: {
'Authorization': `Bearer ${githubToken}`,
'Copilot-Integration-Id': integrationId,
},
},
};
},
getModelsFetchConfig() {
if (!authToken) return null;
// Standard Copilot API (api.githubcopilot.com):
// The /models endpoint only accepts GitHub OAuth tokens (COPILOT_GITHUB_TOKEN).
// Skip startup model fetch when only a BYOK API key is configured.
if (rawTarget === 'api.githubcopilot.com') {
if (!githubToken) return null;
return {
url: `https://${rawTarget}/models`,
opts: {
method: 'GET',
headers: {
'Authorization': `Bearer ${githubToken}`,
'Copilot-Integration-Id': integrationId,
},
},
cacheKey: 'copilot',
};
}
// BYOK / custom provider (e.g. OpenRouter):
// Use the explicit BYOK API key (COPILOT_API_KEY) rather than authToken
// to ensure we never send a GitHub OAuth token to third-party providers.
// Skip the fetch when no BYOK key is configured.
if (!apiKey) return null;
return {
url: `https://${rawTarget}${modelsPath}`,
opts: {
method: 'GET',
headers: {
'Authorization': `Bearer ${apiKey}`,
},
},
cacheKey: 'copilot',
};
},
getReflectionInfo() {
// For BYOK / custom providers, include the base path in the models URL so
// that clients (e.g. the gh-aw framework) use the correct endpoint to
// discover available models (e.g. /api/v1/models for OpenRouter).
return {
provider: 'copilot',
port: 10002,
base_url: 'http://api-proxy:10002',
configured: !!authToken,
models_cache_key: 'copilot',
models_url: `http://api-proxy:10002${modelsPath}`,
};
},
/** Response returned for all requests when no Copilot credentials are configured. */
getUnconfiguredResponse() {
return {
statusCode: 503,
body: {
error: {
message: 'Credentials for GitHub Copilot (port 10002) are not configured. Set COPILOT_GITHUB_TOKEN or COPILOT_API_KEY to enable this provider.',
type: 'provider_not_configured',
provider: 'copilot',
port: 10002,
},
},
};
},
/** /health response when not configured. */
getUnconfiguredHealthResponse() {
return {
statusCode: 503,
body: { status: 'not_configured', service: 'awf-api-proxy-copilot', error: 'COPILOT_GITHUB_TOKEN or COPILOT_API_KEY not configured in api-proxy sidecar' },
};
},
// Exposed for introspection / testing
_githubToken: githubToken,
_apiKey: apiKey,
_integrationId: integrationId,
_rawTarget: rawTarget,
_basePath: basePath,
};
}
module.exports = {
createCopilotAdapter,
resolveCopilotAuthToken,
stripBearerPrefix,
deriveCopilotApiTarget,
deriveGitHubApiTarget,
deriveGitHubApiBasePath,
};