@openhoo/hoopilot 0.3.1 → 0.5.0
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.
- package/README.md +132 -67
- package/dist/cli.js +727 -295
- package/dist/cli.js.map +1 -1
- package/dist/codexx.js +98 -0
- package/dist/codexx.js.map +1 -0
- package/dist/index.cjs +556 -265
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +65 -10
- package/dist/index.d.ts +65 -10
- package/dist/index.js +535 -263
- package/dist/index.js.map +1 -1
- package/package.json +7 -2
package/dist/cli.js
CHANGED
|
@@ -1,254 +1,324 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { spawn } from "child_process";
|
|
5
|
+
|
|
6
|
+
// src/auth-store.ts
|
|
7
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
8
|
+
import { dirname, join } from "path";
|
|
9
|
+
function authStorePath(env = process.env) {
|
|
10
|
+
if (env.HOOPILOT_AUTH_FILE) {
|
|
11
|
+
return env.HOOPILOT_AUTH_FILE;
|
|
12
|
+
}
|
|
13
|
+
const base = env.XDG_CONFIG_HOME ?? env.APPDATA ?? (env.HOME ? join(env.HOME, ".config") : join(process.cwd(), ".config"));
|
|
14
|
+
return join(base, "hoopilot", "auth.json");
|
|
15
|
+
}
|
|
16
|
+
function readStoredCopilotAuth(path = authStorePath()) {
|
|
17
|
+
try {
|
|
18
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
19
|
+
if (!parsed || typeof parsed !== "object") {
|
|
20
|
+
return void 0;
|
|
21
|
+
}
|
|
22
|
+
const token = typeof parsed.token === "string" ? parsed.token.trim() : "";
|
|
23
|
+
if (!token) {
|
|
24
|
+
return void 0;
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
apiBaseUrl: typeof parsed.apiBaseUrl === "string" ? parsed.apiBaseUrl : void 0,
|
|
28
|
+
createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : void 0,
|
|
29
|
+
githubDomain: typeof parsed.githubDomain === "string" ? parsed.githubDomain : void 0,
|
|
30
|
+
source: typeof parsed.source === "string" ? parsed.source : void 0,
|
|
31
|
+
token
|
|
32
|
+
};
|
|
33
|
+
} catch {
|
|
34
|
+
return void 0;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function writeStoredCopilotAuth(auth, path = authStorePath()) {
|
|
38
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
39
|
+
writeFileSync(
|
|
40
|
+
path,
|
|
41
|
+
`${JSON.stringify(
|
|
42
|
+
{
|
|
43
|
+
...auth,
|
|
44
|
+
createdAt: auth.createdAt ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
45
|
+
},
|
|
46
|
+
null,
|
|
47
|
+
2
|
|
48
|
+
)}
|
|
49
|
+
`,
|
|
50
|
+
{ mode: 384 }
|
|
51
|
+
);
|
|
52
|
+
try {
|
|
53
|
+
chmodSync(path, 384);
|
|
54
|
+
} catch {
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
3
58
|
// src/auth.ts
|
|
4
|
-
|
|
5
|
-
var DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com";
|
|
6
|
-
var DEFAULT_TOKEN_EXCHANGE_URL = "https://api.github.com/copilot_internal/v2/token";
|
|
59
|
+
var DEFAULT_COPILOT_API_BASE_URL = "https://api.githubcopilot.com";
|
|
7
60
|
var REFRESH_SKEW_MS = 6e4;
|
|
61
|
+
var STORED_TOKEN_TTL_MS = 10 * 6e4;
|
|
8
62
|
var CopilotAuthError = class extends Error {
|
|
9
63
|
constructor(message) {
|
|
10
64
|
super(message);
|
|
11
65
|
this.name = "CopilotAuthError";
|
|
12
66
|
}
|
|
13
67
|
};
|
|
14
|
-
var CopilotTokenExchangeHttpError = class extends CopilotAuthError {
|
|
15
|
-
};
|
|
16
68
|
var CopilotAuth = class {
|
|
17
|
-
#
|
|
69
|
+
#authStorePath;
|
|
18
70
|
#copilotApiBaseUrl;
|
|
19
|
-
#copilotToken;
|
|
20
|
-
#env;
|
|
21
|
-
#fetch;
|
|
22
|
-
#githubToken;
|
|
23
|
-
#githubTokenCommand;
|
|
24
|
-
#logger;
|
|
25
|
-
#tokenExchangeUrl;
|
|
26
71
|
#cachedAccess;
|
|
27
72
|
constructor(options = {}) {
|
|
28
|
-
this.#
|
|
73
|
+
this.#authStorePath = options.authStorePath ?? options.env?.HOOPILOT_AUTH_FILE;
|
|
29
74
|
this.#copilotApiBaseUrl = trimTrailingSlash(
|
|
30
75
|
options.copilotApiBaseUrl ?? options.env?.COPILOT_API_BASE_URL ?? DEFAULT_COPILOT_API_BASE_URL
|
|
31
76
|
);
|
|
32
|
-
this.#copilotToken = options.copilotToken;
|
|
33
|
-
this.#env = options.env ?? process.env;
|
|
34
|
-
this.#fetch = options.fetch ?? fetch;
|
|
35
|
-
this.#githubToken = options.githubToken;
|
|
36
|
-
this.#githubTokenCommand = options.githubTokenCommand ?? "gh auth token";
|
|
37
|
-
this.#logger = options.logger;
|
|
38
|
-
this.#tokenExchangeUrl = options.tokenExchangeUrl ?? options.env?.COPILOT_TOKEN_EXCHANGE_URL ?? DEFAULT_TOKEN_EXCHANGE_URL;
|
|
39
77
|
}
|
|
40
78
|
async getAccess() {
|
|
41
79
|
if (this.#cachedAccess && this.#cachedAccess.expiresAtMs - REFRESH_SKEW_MS > Date.now()) {
|
|
42
80
|
return this.#cachedAccess;
|
|
43
81
|
}
|
|
44
|
-
const
|
|
45
|
-
if (
|
|
82
|
+
const stored = readStoredCopilotAuth(this.#authStorePath);
|
|
83
|
+
if (stored) {
|
|
46
84
|
return this.#cacheAccess({
|
|
47
|
-
apiBaseUrl: this.#copilotApiBaseUrl,
|
|
48
|
-
expiresAtMs: Date.now() +
|
|
49
|
-
source: "copilot-
|
|
50
|
-
token:
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
if (this.#authMode === "copilot-token") {
|
|
54
|
-
throw new CopilotAuthError("COPILOT_API_TOKEN or GITHUB_COPILOT_API_TOKEN is required.");
|
|
55
|
-
}
|
|
56
|
-
const githubToken = this.#resolveGithubToken();
|
|
57
|
-
if (!githubToken) {
|
|
58
|
-
throw new CopilotAuthError(
|
|
59
|
-
"No Copilot credential found. Set COPILOT_API_TOKEN, set COPILOT_GITHUB_TOKEN from gh auth token, or sign in with gh auth login."
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
if (isPersonalAccessToken(githubToken)) {
|
|
63
|
-
throw new CopilotAuthError(
|
|
64
|
-
"GitHub personal access tokens are not supported for Copilot authentication. Use gh auth login or COPILOT_API_TOKEN."
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
try {
|
|
68
|
-
return this.#cacheAccess(await this.#exchangeGithubToken(githubToken));
|
|
69
|
-
} catch (error) {
|
|
70
|
-
if (!(error instanceof CopilotTokenExchangeHttpError)) {
|
|
71
|
-
throw error;
|
|
72
|
-
}
|
|
73
|
-
this.#logger?.warn(
|
|
74
|
-
`Copilot token exchange failed; falling back to GitHub CLI token mode: ${errorMessage(
|
|
75
|
-
error
|
|
76
|
-
)}`
|
|
77
|
-
);
|
|
78
|
-
return this.#cacheAccess({
|
|
79
|
-
apiBaseUrl: this.#copilotApiBaseUrl,
|
|
80
|
-
expiresAtMs: Date.now() + 10 * 6e4,
|
|
81
|
-
source: "direct-github-token",
|
|
82
|
-
token: githubToken
|
|
85
|
+
apiBaseUrl: trimTrailingSlash(stored.apiBaseUrl ?? this.#copilotApiBaseUrl),
|
|
86
|
+
expiresAtMs: Date.now() + STORED_TOKEN_TTL_MS,
|
|
87
|
+
source: "github-copilot-oauth",
|
|
88
|
+
token: stored.token
|
|
83
89
|
});
|
|
84
90
|
}
|
|
91
|
+
throw new CopilotAuthError(
|
|
92
|
+
"No GitHub Copilot OAuth credential found. Run `hoopilot login` to sign in through your browser."
|
|
93
|
+
);
|
|
85
94
|
}
|
|
86
95
|
#cacheAccess(access) {
|
|
87
96
|
this.#cachedAccess = access;
|
|
88
97
|
return access;
|
|
89
98
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
99
|
+
};
|
|
100
|
+
function trimTrailingSlash(value) {
|
|
101
|
+
return value.replace(/\/+$/, "");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// src/github-device.ts
|
|
105
|
+
import { setTimeout as sleep } from "timers/promises";
|
|
106
|
+
var DEFAULT_GITHUB_COPILOT_CLIENT_ID = "Iv23lijnNxm2e9UX3CF8";
|
|
107
|
+
var DEFAULT_GITHUB_DOMAIN = "github.com";
|
|
108
|
+
var DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
|
109
|
+
var POLLING_SAFETY_MARGIN_MS = 3e3;
|
|
110
|
+
async function githubCopilotDeviceLogin(options = {}) {
|
|
111
|
+
const env = options.env ?? process.env;
|
|
112
|
+
const fetcher = options.fetch ?? fetch;
|
|
113
|
+
const sleeper = options.sleep ?? sleep;
|
|
114
|
+
const domain = normalizeDomain(
|
|
115
|
+
options.domain ?? env.HOOPILOT_GITHUB_DOMAIN ?? DEFAULT_GITHUB_DOMAIN
|
|
116
|
+
);
|
|
117
|
+
const clientId = options.clientId ?? env.HOOPILOT_GITHUB_CLIENT_ID ?? env.COPILOT_GITHUB_CLIENT_ID ?? DEFAULT_GITHUB_COPILOT_CLIENT_ID;
|
|
118
|
+
const device = await requestDeviceCode(fetcher, domain, clientId);
|
|
119
|
+
const verificationUrl = device.verification_uri;
|
|
120
|
+
const userCode = device.user_code;
|
|
121
|
+
const deviceCode = device.device_code;
|
|
122
|
+
if (!verificationUrl || !userCode || !deviceCode) {
|
|
123
|
+
throw new Error("GitHub device authorization response is missing required fields.");
|
|
124
|
+
}
|
|
125
|
+
options.logger?.info(`First copy your one-time code: ${userCode}`);
|
|
126
|
+
options.logger?.info(`Open ${verificationUrl} in your browser to authorize Hoopilot.`);
|
|
127
|
+
await options.openBrowser?.(verificationUrl);
|
|
128
|
+
return {
|
|
129
|
+
domain,
|
|
130
|
+
token: await pollForAccessToken(fetcher, sleeper, domain, clientId, {
|
|
131
|
+
deviceCode,
|
|
132
|
+
expiresIn: positiveSeconds(device.expires_in, 900),
|
|
133
|
+
interval: positiveSeconds(device.interval, 5)
|
|
134
|
+
})
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
async function requestDeviceCode(fetcher, domain, clientId) {
|
|
138
|
+
const response = await fetcher(`https://${domain}/login/device/code`, {
|
|
139
|
+
body: JSON.stringify({
|
|
140
|
+
client_id: clientId,
|
|
141
|
+
scope: "read:user"
|
|
142
|
+
}),
|
|
143
|
+
headers: oauthHeaders(),
|
|
144
|
+
method: "POST"
|
|
145
|
+
});
|
|
146
|
+
if (!response.ok) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`GitHub device authorization failed with ${response.status}: ${await safeResponseText(
|
|
149
|
+
response
|
|
150
|
+
)}`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
return await response.json();
|
|
154
|
+
}
|
|
155
|
+
async function pollForAccessToken(fetcher, sleeper, domain, clientId, device) {
|
|
156
|
+
let intervalMs = device.interval * 1e3 + POLLING_SAFETY_MARGIN_MS;
|
|
157
|
+
const deadline = Date.now() + device.expiresIn * 1e3;
|
|
158
|
+
while (Date.now() < deadline) {
|
|
159
|
+
await sleeper(intervalMs);
|
|
160
|
+
const response = await fetcher(`https://${domain}/login/oauth/access_token`, {
|
|
161
|
+
body: JSON.stringify({
|
|
162
|
+
client_id: clientId,
|
|
163
|
+
device_code: device.deviceCode,
|
|
164
|
+
grant_type: DEVICE_GRANT_TYPE
|
|
165
|
+
}),
|
|
166
|
+
headers: oauthHeaders(),
|
|
167
|
+
method: "POST"
|
|
100
168
|
});
|
|
101
169
|
if (!response.ok) {
|
|
102
|
-
throw new
|
|
103
|
-
`GitHub
|
|
170
|
+
throw new Error(
|
|
171
|
+
`GitHub device token exchange failed with ${response.status}: ${await safeResponseText(
|
|
104
172
|
response
|
|
105
173
|
)}`
|
|
106
174
|
);
|
|
107
175
|
}
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
throw new CopilotAuthError("GitHub Copilot token exchange response did not include a token.");
|
|
112
|
-
}
|
|
113
|
-
return {
|
|
114
|
-
apiBaseUrl: endpointFromResponse(body) ?? this.#copilotApiBaseUrl,
|
|
115
|
-
expiresAtMs: expiresAtFromResponse(body),
|
|
116
|
-
source: "github-token",
|
|
117
|
-
token
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
#resolveDirectCopilotToken() {
|
|
121
|
-
return firstNonEmpty(
|
|
122
|
-
this.#copilotToken,
|
|
123
|
-
this.#env.COPILOT_API_TOKEN,
|
|
124
|
-
this.#env.GITHUB_COPILOT_API_TOKEN,
|
|
125
|
-
this.#env.GITHUB_COPILOT_TOKEN
|
|
126
|
-
);
|
|
127
|
-
}
|
|
128
|
-
#resolveGithubToken() {
|
|
129
|
-
return firstNonEmpty(
|
|
130
|
-
this.#githubToken,
|
|
131
|
-
this.#env.COPILOT_GITHUB_TOKEN,
|
|
132
|
-
this.#env.GITHUB_COPILOT_GITHUB_TOKEN,
|
|
133
|
-
this.#readGithubTokenCommand()
|
|
134
|
-
);
|
|
135
|
-
}
|
|
136
|
-
#readGithubTokenCommand() {
|
|
137
|
-
if (this.#githubTokenCommand === false) {
|
|
138
|
-
return void 0;
|
|
139
|
-
}
|
|
140
|
-
const parts = splitCommand(this.#githubTokenCommand);
|
|
141
|
-
const [command, ...args] = parts;
|
|
142
|
-
if (!command) {
|
|
143
|
-
return void 0;
|
|
144
|
-
}
|
|
145
|
-
try {
|
|
146
|
-
const output = execFileSync(command, args, {
|
|
147
|
-
encoding: "utf8",
|
|
148
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
149
|
-
timeout: 5e3
|
|
150
|
-
});
|
|
151
|
-
return output.trim() || void 0;
|
|
152
|
-
} catch {
|
|
153
|
-
return void 0;
|
|
176
|
+
const data = await response.json();
|
|
177
|
+
if (data.access_token) {
|
|
178
|
+
return data.access_token;
|
|
154
179
|
}
|
|
155
|
-
|
|
156
|
-
};
|
|
157
|
-
function splitCommand(command) {
|
|
158
|
-
const parts = [];
|
|
159
|
-
let current = "";
|
|
160
|
-
let quote;
|
|
161
|
-
let escaping = false;
|
|
162
|
-
for (const character of command.trim()) {
|
|
163
|
-
if (escaping) {
|
|
164
|
-
current += character;
|
|
165
|
-
escaping = false;
|
|
180
|
+
if (data.error === "authorization_pending") {
|
|
166
181
|
continue;
|
|
167
182
|
}
|
|
168
|
-
if (
|
|
169
|
-
|
|
183
|
+
if (data.error === "slow_down") {
|
|
184
|
+
intervalMs = positiveSeconds(data.interval, device.interval + 5) * 1e3 + POLLING_SAFETY_MARGIN_MS;
|
|
170
185
|
continue;
|
|
171
186
|
}
|
|
172
|
-
if (
|
|
173
|
-
|
|
174
|
-
quote = void 0;
|
|
175
|
-
} else {
|
|
176
|
-
current += character;
|
|
177
|
-
}
|
|
178
|
-
continue;
|
|
187
|
+
if (data.error === "expired_token") {
|
|
188
|
+
throw new Error("GitHub device login expired. Run `hoopilot login` again.");
|
|
179
189
|
}
|
|
180
|
-
if (
|
|
181
|
-
|
|
182
|
-
continue;
|
|
190
|
+
if (data.error === "access_denied") {
|
|
191
|
+
throw new Error("GitHub device login was cancelled.");
|
|
183
192
|
}
|
|
184
|
-
if (
|
|
185
|
-
|
|
186
|
-
parts.push(current);
|
|
187
|
-
current = "";
|
|
188
|
-
}
|
|
189
|
-
continue;
|
|
193
|
+
if (data.error) {
|
|
194
|
+
throw new Error(data.error_description || `GitHub device login failed: ${data.error}`);
|
|
190
195
|
}
|
|
191
|
-
current += character;
|
|
192
196
|
}
|
|
193
|
-
|
|
194
|
-
parts.push(current);
|
|
195
|
-
}
|
|
196
|
-
return parts;
|
|
197
|
+
throw new Error("GitHub device login timed out. Run `hoopilot login` again.");
|
|
197
198
|
}
|
|
198
|
-
function
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
199
|
+
function oauthHeaders() {
|
|
200
|
+
const headers = new Headers();
|
|
201
|
+
headers.set("accept", "application/json");
|
|
202
|
+
headers.set("content-type", "application/json");
|
|
203
|
+
headers.set("user-agent", "hoopilot");
|
|
204
|
+
return headers;
|
|
202
205
|
}
|
|
203
|
-
function
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
206
|
+
function normalizeDomain(value) {
|
|
207
|
+
return value.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
|
208
|
+
}
|
|
209
|
+
function positiveSeconds(value, fallback) {
|
|
210
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
211
|
+
}
|
|
212
|
+
async function safeResponseText(response) {
|
|
213
|
+
const text = await response.text();
|
|
214
|
+
return text.slice(0, 500);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// src/logger.ts
|
|
218
|
+
import pino from "pino";
|
|
219
|
+
import pretty from "pino-pretty";
|
|
220
|
+
var DEFAULT_LOG_FORMAT = "pretty";
|
|
221
|
+
var DEFAULT_LOG_LEVEL = "info";
|
|
222
|
+
var LOG_FORMATS = ["json", "pretty"];
|
|
223
|
+
var LOG_LEVELS = ["trace", "debug", "info", "warn", "error", "fatal", "silent"];
|
|
224
|
+
var REDACT_PATHS = [
|
|
225
|
+
"apiKey",
|
|
226
|
+
"authorization",
|
|
227
|
+
"cookie",
|
|
228
|
+
"headers.authorization",
|
|
229
|
+
"headers.Authorization",
|
|
230
|
+
"headers.cookie",
|
|
231
|
+
"headers.Cookie",
|
|
232
|
+
"headers.x-api-key",
|
|
233
|
+
"headers.X-Api-Key",
|
|
234
|
+
"token",
|
|
235
|
+
"*.apiKey",
|
|
236
|
+
"*.authorization",
|
|
237
|
+
"*.cookie",
|
|
238
|
+
"*.token",
|
|
239
|
+
"*.headers.authorization",
|
|
240
|
+
"*.headers.Authorization",
|
|
241
|
+
"*.headers.cookie",
|
|
242
|
+
"*.headers.Cookie",
|
|
243
|
+
"*.headers.x-api-key",
|
|
244
|
+
"*.headers.X-Api-Key"
|
|
245
|
+
];
|
|
246
|
+
var noopLogger = {
|
|
247
|
+
child: () => noopLogger,
|
|
248
|
+
debug: () => {
|
|
249
|
+
},
|
|
250
|
+
error: () => {
|
|
251
|
+
},
|
|
252
|
+
fatal: () => {
|
|
253
|
+
},
|
|
254
|
+
info: () => {
|
|
255
|
+
},
|
|
256
|
+
trace: () => {
|
|
257
|
+
},
|
|
258
|
+
warn: () => {
|
|
207
259
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
260
|
+
};
|
|
261
|
+
function createHoopilotLogger(options = {}) {
|
|
262
|
+
const env = options.env ?? process.env;
|
|
263
|
+
const level = parseLogLevel(options.level ?? env.HOOPILOT_LOG_LEVEL);
|
|
264
|
+
const format = parseLogFormat(options.format ?? env.HOOPILOT_LOG_FORMAT);
|
|
265
|
+
const pinoOptions = {
|
|
266
|
+
base: {
|
|
267
|
+
service: "hoopilot",
|
|
268
|
+
...options.base
|
|
269
|
+
},
|
|
270
|
+
level,
|
|
271
|
+
redact: {
|
|
272
|
+
censor: "[Redacted]",
|
|
273
|
+
paths: REDACT_PATHS
|
|
274
|
+
},
|
|
275
|
+
timestamp: pino.stdTimeFunctions.isoTime
|
|
276
|
+
};
|
|
277
|
+
if (format === "pretty") {
|
|
278
|
+
return pino(
|
|
279
|
+
pinoOptions,
|
|
280
|
+
pretty({
|
|
281
|
+
colorize: options.colorize ?? process.stderr.isTTY,
|
|
282
|
+
destination: options.stream ?? 1,
|
|
283
|
+
ignore: "pid,hostname",
|
|
284
|
+
singleLine: true,
|
|
285
|
+
translateTime: "SYS:standard"
|
|
286
|
+
})
|
|
287
|
+
);
|
|
217
288
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
return Date.now() + refreshIn * 1e3;
|
|
289
|
+
if (options.stream) {
|
|
290
|
+
return pino(pinoOptions, options.stream);
|
|
221
291
|
}
|
|
222
|
-
return
|
|
292
|
+
return pino(pinoOptions);
|
|
223
293
|
}
|
|
224
|
-
function
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if (trimmed) {
|
|
228
|
-
return trimmed;
|
|
229
|
-
}
|
|
294
|
+
function parseLogFormat(value) {
|
|
295
|
+
if (!value) {
|
|
296
|
+
return DEFAULT_LOG_FORMAT;
|
|
230
297
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
}
|
|
236
|
-
function getString(record, key) {
|
|
237
|
-
const value = record[key];
|
|
238
|
-
return typeof value === "string" && value ? value : void 0;
|
|
298
|
+
if (isLogFormat(value)) {
|
|
299
|
+
return value;
|
|
300
|
+
}
|
|
301
|
+
throw new Error(`Invalid log format: ${value}. Expected one of: ${LOG_FORMATS.join(", ")}.`);
|
|
239
302
|
}
|
|
240
|
-
function
|
|
241
|
-
|
|
303
|
+
function parseLogLevel(value) {
|
|
304
|
+
if (!value) {
|
|
305
|
+
return DEFAULT_LOG_LEVEL;
|
|
306
|
+
}
|
|
307
|
+
if (isLogLevel(value)) {
|
|
308
|
+
return value;
|
|
309
|
+
}
|
|
310
|
+
throw new Error(`Invalid log level: ${value}. Expected one of: ${LOG_LEVELS.join(", ")}.`);
|
|
242
311
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
312
|
+
function shouldCreateLogger(options) {
|
|
313
|
+
return Boolean(
|
|
314
|
+
options.logger || options.logFormat || options.logLevel || options.env?.HOOPILOT_LOG_FORMAT || options.env?.HOOPILOT_LOG_LEVEL
|
|
315
|
+
);
|
|
246
316
|
}
|
|
247
|
-
function
|
|
248
|
-
return
|
|
317
|
+
function isLogFormat(value) {
|
|
318
|
+
return LOG_FORMATS.includes(value);
|
|
249
319
|
}
|
|
250
|
-
function
|
|
251
|
-
return
|
|
320
|
+
function isLogLevel(value) {
|
|
321
|
+
return LOG_LEVELS.includes(value);
|
|
252
322
|
}
|
|
253
323
|
|
|
254
324
|
// src/copilot.ts
|
|
@@ -298,6 +368,7 @@ var CopilotClient = class {
|
|
|
298
368
|
headers.set("editor-version", "Hoopilot/0.1.0");
|
|
299
369
|
headers.set("openai-intent", "conversation-panel");
|
|
300
370
|
headers.set("user-agent", "hoopilot/0.1.0");
|
|
371
|
+
headers.set("x-github-api-version", "2026-06-01");
|
|
301
372
|
return this.#fetch(`${access.apiBaseUrl}${path}`, {
|
|
302
373
|
...init,
|
|
303
374
|
headers
|
|
@@ -323,8 +394,8 @@ function responsesRequestToChatCompletion(request) {
|
|
|
323
394
|
metadata: request.metadata,
|
|
324
395
|
model: contentToText(request.model) || DEFAULT_MODEL,
|
|
325
396
|
presence_penalty: request.presence_penalty,
|
|
326
|
-
reasoning_effort:
|
|
327
|
-
response_format:
|
|
397
|
+
reasoning_effort: asRecord(request.reasoning).effort,
|
|
398
|
+
response_format: asRecord(request.text).format,
|
|
328
399
|
seed: request.seed,
|
|
329
400
|
stream: request.stream === true,
|
|
330
401
|
temperature: request.temperature,
|
|
@@ -346,7 +417,7 @@ function completionsRequestToChatCompletion(request) {
|
|
|
346
417
|
function chatCompletionToResponse(completion, responseId) {
|
|
347
418
|
const id = responseId ?? `resp_${randomId()}`;
|
|
348
419
|
const choice = firstChoice(completion);
|
|
349
|
-
const message =
|
|
420
|
+
const message = asRecord(choice.message);
|
|
350
421
|
const model = contentToText(completion.model) || DEFAULT_MODEL;
|
|
351
422
|
const output = outputItemsFromMessage(message);
|
|
352
423
|
const usage = responseUsage(completion.usage);
|
|
@@ -373,7 +444,7 @@ function chatCompletionToResponse(completion, responseId) {
|
|
|
373
444
|
}
|
|
374
445
|
function chatCompletionToCompletion(completion) {
|
|
375
446
|
const choice = firstChoice(completion);
|
|
376
|
-
const message =
|
|
447
|
+
const message = asRecord(choice.message);
|
|
377
448
|
return removeUndefined({
|
|
378
449
|
choices: [
|
|
379
450
|
{
|
|
@@ -391,9 +462,9 @@ function chatCompletionToCompletion(completion) {
|
|
|
391
462
|
});
|
|
392
463
|
}
|
|
393
464
|
function normalizeModelsResponse(upstream) {
|
|
394
|
-
const record =
|
|
465
|
+
const record = asRecord(upstream);
|
|
395
466
|
const data = Array.isArray(record.data) ? record.data : Array.isArray(upstream) ? upstream : [];
|
|
396
|
-
const models = data.map((model) =>
|
|
467
|
+
const models = data.map((model) => asRecord(model)).filter((model) => typeof model.id === "string").map((model) => ({
|
|
397
468
|
created: model.created ?? 0,
|
|
398
469
|
id: model.id,
|
|
399
470
|
object: "model",
|
|
@@ -542,7 +613,7 @@ function inputToMessages(input) {
|
|
|
542
613
|
}
|
|
543
614
|
const messages = [];
|
|
544
615
|
for (const item of input) {
|
|
545
|
-
const record =
|
|
616
|
+
const record = asRecord(item);
|
|
546
617
|
if (record.type === "function_call_output") {
|
|
547
618
|
messages.push({
|
|
548
619
|
content: contentToText(record.output),
|
|
@@ -584,7 +655,7 @@ function chatMessageContent(content) {
|
|
|
584
655
|
}
|
|
585
656
|
const parts = [];
|
|
586
657
|
for (const part of content) {
|
|
587
|
-
const record =
|
|
658
|
+
const record = asRecord(part);
|
|
588
659
|
const type = contentToText(record.type);
|
|
589
660
|
if (type === "input_text" || type === "output_text" || type === "text") {
|
|
590
661
|
parts.push({ text: contentToText(record.text), type: "text" });
|
|
@@ -642,7 +713,7 @@ function chatTools(tools) {
|
|
|
642
713
|
if (!Array.isArray(tools)) {
|
|
643
714
|
return void 0;
|
|
644
715
|
}
|
|
645
|
-
const converted = tools.map((tool) =>
|
|
716
|
+
const converted = tools.map((tool) => asRecord(tool)).filter((tool) => tool.type === "function").map((tool) => ({
|
|
646
717
|
function: removeUndefined({
|
|
647
718
|
description: tool.description,
|
|
648
719
|
name: tool.name,
|
|
@@ -657,7 +728,7 @@ function chatToolChoice(toolChoice) {
|
|
|
657
728
|
if (typeof toolChoice === "string" || toolChoice === void 0) {
|
|
658
729
|
return toolChoice;
|
|
659
730
|
}
|
|
660
|
-
const record =
|
|
731
|
+
const record = asRecord(toolChoice);
|
|
661
732
|
if (record.type === "function" && typeof record.name === "string") {
|
|
662
733
|
return { function: { name: record.name }, type: "function" };
|
|
663
734
|
}
|
|
@@ -671,8 +742,8 @@ function outputItemsFromMessage(message) {
|
|
|
671
742
|
}
|
|
672
743
|
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
|
673
744
|
for (const toolCall of toolCalls) {
|
|
674
|
-
const record =
|
|
675
|
-
const fn =
|
|
745
|
+
const record = asRecord(toolCall);
|
|
746
|
+
const fn = asRecord(record.function);
|
|
676
747
|
output.push(
|
|
677
748
|
functionCallItem({
|
|
678
749
|
arguments: contentToText(fn.arguments),
|
|
@@ -713,10 +784,10 @@ function outputText(output) {
|
|
|
713
784
|
return output.flatMap((item) => {
|
|
714
785
|
const content = item.content;
|
|
715
786
|
return Array.isArray(content) ? content : [];
|
|
716
|
-
}).map((part) => contentToText(
|
|
787
|
+
}).map((part) => contentToText(asRecord(part).text)).filter(Boolean).join("");
|
|
717
788
|
}
|
|
718
789
|
function responseUsage(usage) {
|
|
719
|
-
const record =
|
|
790
|
+
const record = asRecord(usage);
|
|
720
791
|
if (Object.keys(record).length === 0) {
|
|
721
792
|
return null;
|
|
722
793
|
}
|
|
@@ -730,7 +801,7 @@ function responseUsage(usage) {
|
|
|
730
801
|
}
|
|
731
802
|
function firstChoice(completion) {
|
|
732
803
|
const choices = Array.isArray(completion.choices) ? completion.choices : [];
|
|
733
|
-
return
|
|
804
|
+
return asRecord(choices[0]);
|
|
734
805
|
}
|
|
735
806
|
function processChatSseLine(line, enqueue, tools, appendText) {
|
|
736
807
|
const trimmed = line.trim();
|
|
@@ -746,7 +817,7 @@ function processChatSseLine(line, enqueue, tools, appendText) {
|
|
|
746
817
|
return;
|
|
747
818
|
}
|
|
748
819
|
const choice = firstChoice(parsed);
|
|
749
|
-
const delta =
|
|
820
|
+
const delta = asRecord(choice.delta);
|
|
750
821
|
const content = contentToText(delta.content);
|
|
751
822
|
if (content) {
|
|
752
823
|
appendText(content);
|
|
@@ -760,8 +831,8 @@ function processChatSseLine(line, enqueue, tools, appendText) {
|
|
|
760
831
|
}
|
|
761
832
|
const toolCalls = Array.isArray(delta.tool_calls) ? delta.tool_calls : [];
|
|
762
833
|
for (const toolCall of toolCalls) {
|
|
763
|
-
const record =
|
|
764
|
-
const fn =
|
|
834
|
+
const record = asRecord(toolCall);
|
|
835
|
+
const fn = asRecord(record.function);
|
|
765
836
|
const index = typeof record.index === "number" ? record.index : tools.size;
|
|
766
837
|
const existing = tools.get(index) ?? {
|
|
767
838
|
arguments: "",
|
|
@@ -809,7 +880,7 @@ data: ${JSON.stringify(data)}
|
|
|
809
880
|
}
|
|
810
881
|
function parseJson(data) {
|
|
811
882
|
try {
|
|
812
|
-
return
|
|
883
|
+
return asRecord(JSON.parse(data));
|
|
813
884
|
} catch {
|
|
814
885
|
return void 0;
|
|
815
886
|
}
|
|
@@ -817,7 +888,7 @@ function parseJson(data) {
|
|
|
817
888
|
function removeUndefined(record) {
|
|
818
889
|
return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== void 0));
|
|
819
890
|
}
|
|
820
|
-
function
|
|
891
|
+
function asRecord(value) {
|
|
821
892
|
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
822
893
|
}
|
|
823
894
|
function randomId() {
|
|
@@ -830,43 +901,111 @@ function epochSeconds() {
|
|
|
830
901
|
// src/server.ts
|
|
831
902
|
var DEFAULT_HOST = "127.0.0.1";
|
|
832
903
|
var DEFAULT_PORT = 4141;
|
|
904
|
+
var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
|
|
833
905
|
function createHoopilotHandler(options = {}) {
|
|
834
906
|
const client = new CopilotClient(options);
|
|
835
907
|
const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
|
|
908
|
+
const logger = serverLogger(options);
|
|
836
909
|
return async (request) => {
|
|
910
|
+
const startedAt = performance.now();
|
|
837
911
|
const url = new URL(request.url);
|
|
912
|
+
const requestId = requestIdFor(request);
|
|
913
|
+
const requestLogger = logger.child({
|
|
914
|
+
method: request.method,
|
|
915
|
+
path: url.pathname,
|
|
916
|
+
requestId,
|
|
917
|
+
route: routeFor(request.method, url.pathname)
|
|
918
|
+
});
|
|
838
919
|
if (request.method === "OPTIONS") {
|
|
839
|
-
return new Response(null, { headers: corsHeaders() })
|
|
920
|
+
return finishResponse(new Response(null, { headers: corsHeaders() }), {
|
|
921
|
+
logger: requestLogger,
|
|
922
|
+
requestId,
|
|
923
|
+
startedAt
|
|
924
|
+
});
|
|
840
925
|
}
|
|
841
926
|
if (!isAuthorized(request, apiKey)) {
|
|
842
|
-
|
|
927
|
+
requestLogger.warn({ event: "http.request.unauthorized" }, "invalid hoopilot api key");
|
|
928
|
+
return finishResponse(
|
|
929
|
+
jsonError(401, "invalid_api_key", "Invalid or missing Hoopilot API key."),
|
|
930
|
+
{
|
|
931
|
+
logger: requestLogger,
|
|
932
|
+
requestId,
|
|
933
|
+
startedAt
|
|
934
|
+
}
|
|
935
|
+
);
|
|
843
936
|
}
|
|
844
937
|
try {
|
|
845
938
|
if (request.method === "GET" && (url.pathname === "/" || url.pathname === "/healthz")) {
|
|
846
|
-
return
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
939
|
+
return finishResponse(
|
|
940
|
+
jsonResponse({
|
|
941
|
+
name: "hoopilot",
|
|
942
|
+
object: "health",
|
|
943
|
+
status: "ok"
|
|
944
|
+
}),
|
|
945
|
+
{ logger: requestLogger, requestId, startedAt }
|
|
946
|
+
);
|
|
851
947
|
}
|
|
852
948
|
if (request.method === "GET" && url.pathname === "/v1/models") {
|
|
853
|
-
return await handleModels(client, request.signal)
|
|
949
|
+
return finishResponse(await handleModels(client, request.signal, requestLogger), {
|
|
950
|
+
logger: requestLogger,
|
|
951
|
+
requestId,
|
|
952
|
+
startedAt
|
|
953
|
+
});
|
|
854
954
|
}
|
|
855
955
|
if (request.method === "POST" && url.pathname === "/v1/chat/completions") {
|
|
856
|
-
return await handleChatCompletions(client, request)
|
|
956
|
+
return finishResponse(await handleChatCompletions(client, request, requestLogger), {
|
|
957
|
+
logger: requestLogger,
|
|
958
|
+
requestId,
|
|
959
|
+
startedAt
|
|
960
|
+
});
|
|
857
961
|
}
|
|
858
962
|
if (request.method === "POST" && url.pathname === "/v1/completions") {
|
|
859
|
-
return await handleCompletions(client, request)
|
|
963
|
+
return finishResponse(await handleCompletions(client, request, requestLogger), {
|
|
964
|
+
logger: requestLogger,
|
|
965
|
+
requestId,
|
|
966
|
+
startedAt
|
|
967
|
+
});
|
|
860
968
|
}
|
|
861
969
|
if (request.method === "POST" && url.pathname === "/v1/responses") {
|
|
862
|
-
return await handleResponses(client, request)
|
|
970
|
+
return finishResponse(await handleResponses(client, request, requestLogger), {
|
|
971
|
+
logger: requestLogger,
|
|
972
|
+
requestId,
|
|
973
|
+
startedAt
|
|
974
|
+
});
|
|
863
975
|
}
|
|
864
|
-
return
|
|
976
|
+
return finishResponse(
|
|
977
|
+
jsonError(404, "not_found", `No route for ${request.method} ${url.pathname}.`),
|
|
978
|
+
{ logger: requestLogger, requestId, startedAt }
|
|
979
|
+
);
|
|
865
980
|
} catch (error) {
|
|
866
981
|
if (error instanceof CopilotAuthError) {
|
|
867
|
-
|
|
982
|
+
requestLogger.warn(
|
|
983
|
+
{ err: errorDetails(error), event: "copilot.auth.missing" },
|
|
984
|
+
"copilot auth failed"
|
|
985
|
+
);
|
|
986
|
+
return finishResponse(jsonError(401, "copilot_auth_error", error.message), {
|
|
987
|
+
logger: requestLogger,
|
|
988
|
+
requestId,
|
|
989
|
+
startedAt
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
const message = errorMessage(error);
|
|
993
|
+
if (message === INVALID_JSON_MESSAGE) {
|
|
994
|
+
requestLogger.warn(
|
|
995
|
+
{ err: errorDetails(error), event: "http.request.failed" },
|
|
996
|
+
"request body was invalid json"
|
|
997
|
+
);
|
|
998
|
+
} else {
|
|
999
|
+
requestLogger.error(
|
|
1000
|
+
{ err: errorDetails(error), event: "http.request.failed" },
|
|
1001
|
+
"request failed"
|
|
1002
|
+
);
|
|
868
1003
|
}
|
|
869
|
-
return jsonError(500, "internal_error",
|
|
1004
|
+
return finishResponse(jsonError(500, "internal_error", message), {
|
|
1005
|
+
logger: requestLogger,
|
|
1006
|
+
requestId,
|
|
1007
|
+
startedAt
|
|
1008
|
+
});
|
|
870
1009
|
}
|
|
871
1010
|
};
|
|
872
1011
|
}
|
|
@@ -895,35 +1034,53 @@ function startHoopilotServer(options = {}) {
|
|
|
895
1034
|
url: `http://${host}:${server.port}`
|
|
896
1035
|
};
|
|
897
1036
|
}
|
|
898
|
-
async function handleModels(client, signal) {
|
|
1037
|
+
async function handleModels(client, signal, logger) {
|
|
899
1038
|
const upstream = await client.models(signal);
|
|
900
1039
|
if (!upstream.ok) {
|
|
1040
|
+
if (isUpstreamAuthStatus(upstream.status)) {
|
|
1041
|
+
return proxyError(upstream, logger);
|
|
1042
|
+
}
|
|
1043
|
+
logger.warn(
|
|
1044
|
+
{
|
|
1045
|
+
event: "copilot.models.fallback",
|
|
1046
|
+
upstreamPath: "/models",
|
|
1047
|
+
upstreamStatus: upstream.status
|
|
1048
|
+
},
|
|
1049
|
+
"falling back to built-in model list"
|
|
1050
|
+
);
|
|
901
1051
|
return jsonResponse({ data: fallbackModels(), object: "list" });
|
|
902
1052
|
}
|
|
1053
|
+
logUpstreamSuccess(logger, "/models", upstream.status);
|
|
903
1054
|
return jsonResponse(normalizeModelsResponse(await upstream.json()));
|
|
904
1055
|
}
|
|
905
|
-
async function handleChatCompletions(client, request) {
|
|
1056
|
+
async function handleChatCompletions(client, request, logger) {
|
|
906
1057
|
const upstream = await client.forwardChatCompletions(await request.text(), request.signal);
|
|
1058
|
+
if (!upstream.ok) {
|
|
1059
|
+
return proxyError(upstream, logger);
|
|
1060
|
+
}
|
|
1061
|
+
logUpstreamSuccess(logger, "/chat/completions", upstream.status);
|
|
907
1062
|
return proxyResponse(upstream);
|
|
908
1063
|
}
|
|
909
|
-
async function handleCompletions(client, request) {
|
|
1064
|
+
async function handleCompletions(client, request, logger) {
|
|
910
1065
|
const body = await readJson(request);
|
|
911
1066
|
const upstream = await client.chatCompletions(
|
|
912
1067
|
completionsRequestToChatCompletion(body),
|
|
913
1068
|
request.signal
|
|
914
1069
|
);
|
|
915
1070
|
if (!upstream.ok) {
|
|
916
|
-
return proxyError(upstream);
|
|
1071
|
+
return proxyError(upstream, logger);
|
|
917
1072
|
}
|
|
1073
|
+
logUpstreamSuccess(logger, "/chat/completions", upstream.status);
|
|
918
1074
|
return jsonResponse(chatCompletionToCompletion(await upstream.json()));
|
|
919
1075
|
}
|
|
920
|
-
async function handleResponses(client, request) {
|
|
1076
|
+
async function handleResponses(client, request, logger) {
|
|
921
1077
|
const body = await readJson(request);
|
|
922
1078
|
const chatRequest = responsesRequestToChatCompletion(body);
|
|
923
1079
|
const upstream = await client.chatCompletions(chatRequest, request.signal);
|
|
924
1080
|
if (!upstream.ok) {
|
|
925
|
-
return proxyError(upstream);
|
|
1081
|
+
return proxyError(upstream, logger);
|
|
926
1082
|
}
|
|
1083
|
+
logUpstreamSuccess(logger, "/chat/completions", upstream.status);
|
|
927
1084
|
if (body.stream === true && upstream.body) {
|
|
928
1085
|
return new Response(
|
|
929
1086
|
responsesStreamFromChatStream(upstream.body, {
|
|
@@ -941,8 +1098,19 @@ async function handleResponses(client, request) {
|
|
|
941
1098
|
}
|
|
942
1099
|
return jsonResponse(chatCompletionToResponse(await upstream.json()));
|
|
943
1100
|
}
|
|
944
|
-
async function proxyError(upstream) {
|
|
1101
|
+
async function proxyError(upstream, logger) {
|
|
945
1102
|
const text = await upstream.text();
|
|
1103
|
+
if (isUpstreamAuthStatus(upstream.status)) {
|
|
1104
|
+
logger.warn(
|
|
1105
|
+
{ event: "copilot.auth.rejected", upstreamStatus: upstream.status },
|
|
1106
|
+
"copilot rejected credential or account access"
|
|
1107
|
+
);
|
|
1108
|
+
return jsonError(401, "copilot_auth_error", upstreamAuthMessage(text || upstream.statusText));
|
|
1109
|
+
}
|
|
1110
|
+
logger.warn(
|
|
1111
|
+
{ event: "copilot.request.failed", upstreamStatus: upstream.status },
|
|
1112
|
+
"copilot upstream request failed"
|
|
1113
|
+
);
|
|
946
1114
|
return jsonError(upstream.status, "copilot_error", text || upstream.statusText);
|
|
947
1115
|
}
|
|
948
1116
|
function proxyResponse(upstream) {
|
|
@@ -964,7 +1132,7 @@ async function readJson(request) {
|
|
|
964
1132
|
const value = await request.json();
|
|
965
1133
|
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
966
1134
|
} catch {
|
|
967
|
-
throw new Error(
|
|
1135
|
+
throw new Error(INVALID_JSON_MESSAGE);
|
|
968
1136
|
}
|
|
969
1137
|
}
|
|
970
1138
|
function jsonResponse(body, status = 200) {
|
|
@@ -1003,27 +1171,126 @@ function isAuthorized(request, apiKey) {
|
|
|
1003
1171
|
const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1];
|
|
1004
1172
|
return bearer === apiKey || request.headers.get("x-api-key") === apiKey;
|
|
1005
1173
|
}
|
|
1174
|
+
function isUpstreamAuthStatus(status) {
|
|
1175
|
+
return status === 401 || status === 403;
|
|
1176
|
+
}
|
|
1177
|
+
function upstreamAuthMessage(message) {
|
|
1178
|
+
return `GitHub Copilot rejected the credential or account access: ${message}`;
|
|
1179
|
+
}
|
|
1006
1180
|
function isLoopbackHost(host) {
|
|
1007
1181
|
return host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
1008
1182
|
}
|
|
1009
|
-
function
|
|
1183
|
+
function errorMessage(error) {
|
|
1010
1184
|
return error instanceof Error ? error.message : String(error);
|
|
1011
1185
|
}
|
|
1186
|
+
function serverLogger(options) {
|
|
1187
|
+
if (options.logger) {
|
|
1188
|
+
return options.logger.child({ component: "server" });
|
|
1189
|
+
}
|
|
1190
|
+
if (shouldCreateLogger(options)) {
|
|
1191
|
+
return createHoopilotLogger({
|
|
1192
|
+
env: options.env,
|
|
1193
|
+
format: options.logFormat,
|
|
1194
|
+
level: options.logLevel
|
|
1195
|
+
}).child({ component: "server" });
|
|
1196
|
+
}
|
|
1197
|
+
return noopLogger;
|
|
1198
|
+
}
|
|
1199
|
+
function finishResponse(response, options) {
|
|
1200
|
+
const withRequestId = responseWithRequestId(response, options.requestId);
|
|
1201
|
+
logRequestCompleted(options.logger, withRequestId, options.startedAt);
|
|
1202
|
+
return withRequestId;
|
|
1203
|
+
}
|
|
1204
|
+
function responseWithRequestId(response, requestId) {
|
|
1205
|
+
const headers = new Headers(response.headers);
|
|
1206
|
+
headers.set("x-request-id", requestId);
|
|
1207
|
+
return new Response(response.body, {
|
|
1208
|
+
headers,
|
|
1209
|
+
status: response.status,
|
|
1210
|
+
statusText: response.statusText
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
function logRequestCompleted(logger, response, startedAt) {
|
|
1214
|
+
const fields = {
|
|
1215
|
+
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
|
|
1216
|
+
event: "http.request.completed",
|
|
1217
|
+
status: response.status,
|
|
1218
|
+
stream: isStreamingResponse(response)
|
|
1219
|
+
};
|
|
1220
|
+
if (response.status >= 500) {
|
|
1221
|
+
logger.error(fields, "request completed with server error");
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
if (response.status >= 400) {
|
|
1225
|
+
logger.warn(fields, "request completed with client error");
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
logger.info(fields, "request completed");
|
|
1229
|
+
}
|
|
1230
|
+
function requestIdFor(request) {
|
|
1231
|
+
const existing = request.headers.get("x-request-id")?.trim();
|
|
1232
|
+
return existing || crypto.randomUUID();
|
|
1233
|
+
}
|
|
1234
|
+
function routeFor(method, path) {
|
|
1235
|
+
if (method === "OPTIONS") {
|
|
1236
|
+
return "cors.preflight";
|
|
1237
|
+
}
|
|
1238
|
+
if (method === "GET" && (path === "/" || path === "/healthz")) {
|
|
1239
|
+
return "health";
|
|
1240
|
+
}
|
|
1241
|
+
if (method === "GET" && path === "/v1/models") {
|
|
1242
|
+
return "models";
|
|
1243
|
+
}
|
|
1244
|
+
if (method === "POST" && path === "/v1/chat/completions") {
|
|
1245
|
+
return "chat_completions";
|
|
1246
|
+
}
|
|
1247
|
+
if (method === "POST" && path === "/v1/completions") {
|
|
1248
|
+
return "completions";
|
|
1249
|
+
}
|
|
1250
|
+
if (method === "POST" && path === "/v1/responses") {
|
|
1251
|
+
return "responses";
|
|
1252
|
+
}
|
|
1253
|
+
return "not_found";
|
|
1254
|
+
}
|
|
1255
|
+
function isStreamingResponse(response) {
|
|
1256
|
+
return response.headers.get("content-type")?.includes("text/event-stream") ?? false;
|
|
1257
|
+
}
|
|
1258
|
+
function logUpstreamSuccess(logger, upstreamPath, status) {
|
|
1259
|
+
logger.debug(
|
|
1260
|
+
{
|
|
1261
|
+
event: "copilot.request.completed",
|
|
1262
|
+
upstreamPath,
|
|
1263
|
+
upstreamStatus: status
|
|
1264
|
+
},
|
|
1265
|
+
"copilot upstream request completed"
|
|
1266
|
+
);
|
|
1267
|
+
}
|
|
1268
|
+
function errorDetails(error) {
|
|
1269
|
+
if (error instanceof Error) {
|
|
1270
|
+
return {
|
|
1271
|
+
message: error.message,
|
|
1272
|
+
name: error.name,
|
|
1273
|
+
stack: error.stack
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1276
|
+
return { message: String(error) };
|
|
1277
|
+
}
|
|
1012
1278
|
|
|
1013
1279
|
// src/update.ts
|
|
1014
|
-
import { execFileSync
|
|
1280
|
+
import { execFileSync } from "child_process";
|
|
1281
|
+
import { createHash } from "crypto";
|
|
1015
1282
|
import {
|
|
1016
|
-
chmodSync,
|
|
1283
|
+
chmodSync as chmodSync2,
|
|
1017
1284
|
copyFileSync,
|
|
1018
1285
|
existsSync,
|
|
1019
|
-
mkdirSync,
|
|
1286
|
+
mkdirSync as mkdirSync2,
|
|
1020
1287
|
realpathSync,
|
|
1021
1288
|
renameSync,
|
|
1022
1289
|
rmSync
|
|
1023
1290
|
} from "fs";
|
|
1024
1291
|
import { readFile, writeFile } from "fs/promises";
|
|
1025
1292
|
import { homedir } from "os";
|
|
1026
|
-
import { dirname, join } from "path";
|
|
1293
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
1027
1294
|
|
|
1028
1295
|
// src/update-core.ts
|
|
1029
1296
|
var REPO_OWNER = "openhoo";
|
|
@@ -1192,16 +1459,16 @@ function checksumFor(sumsText, fileName) {
|
|
|
1192
1459
|
}
|
|
1193
1460
|
return void 0;
|
|
1194
1461
|
}
|
|
1195
|
-
function resolveCacheDir(env, platform, homedir2,
|
|
1462
|
+
function resolveCacheDir(env, platform, homedir2, join3) {
|
|
1196
1463
|
if (platform === "win32") {
|
|
1197
|
-
const base2 = env.LOCALAPPDATA ||
|
|
1198
|
-
return
|
|
1464
|
+
const base2 = env.LOCALAPPDATA || join3(homedir2, "AppData", "Local");
|
|
1465
|
+
return join3(base2, "hoopilot");
|
|
1199
1466
|
}
|
|
1200
1467
|
if (platform === "darwin") {
|
|
1201
|
-
return
|
|
1468
|
+
return join3(homedir2, "Library", "Caches", "hoopilot");
|
|
1202
1469
|
}
|
|
1203
|
-
const base = env.XDG_CACHE_HOME ||
|
|
1204
|
-
return
|
|
1470
|
+
const base = env.XDG_CACHE_HOME || join3(homedir2, ".cache");
|
|
1471
|
+
return join3(base, "hoopilot");
|
|
1205
1472
|
}
|
|
1206
1473
|
function latestReleaseApiUrl() {
|
|
1207
1474
|
return `https://api.github.com/repos/${REPO}/releases/latest`;
|
|
@@ -1238,10 +1505,10 @@ function userAgent(version) {
|
|
|
1238
1505
|
return `hoopilot/${version}`;
|
|
1239
1506
|
}
|
|
1240
1507
|
function cacheDir() {
|
|
1241
|
-
return resolveCacheDir(process.env, process.platform, homedir(),
|
|
1508
|
+
return resolveCacheDir(process.env, process.platform, homedir(), join2);
|
|
1242
1509
|
}
|
|
1243
1510
|
function stateFilePath() {
|
|
1244
|
-
return
|
|
1511
|
+
return join2(cacheDir(), "update-check.json");
|
|
1245
1512
|
}
|
|
1246
1513
|
async function readStateSafe() {
|
|
1247
1514
|
try {
|
|
@@ -1252,7 +1519,7 @@ async function readStateSafe() {
|
|
|
1252
1519
|
}
|
|
1253
1520
|
async function writeStateSafe(state) {
|
|
1254
1521
|
try {
|
|
1255
|
-
|
|
1522
|
+
mkdirSync2(cacheDir(), { recursive: true });
|
|
1256
1523
|
await writeFile(stateFilePath(), JSON.stringify(state), "utf8");
|
|
1257
1524
|
} catch {
|
|
1258
1525
|
}
|
|
@@ -1286,27 +1553,44 @@ async function fetchLatest(version, etag) {
|
|
|
1286
1553
|
return null;
|
|
1287
1554
|
}
|
|
1288
1555
|
}
|
|
1289
|
-
async function maybeNotifyUpdate(currentVersion, kind) {
|
|
1556
|
+
async function maybeNotifyUpdate(currentVersion, kind, logger) {
|
|
1290
1557
|
if (isUpdateCheckDisabled(process.env, Boolean(process.stderr.isTTY))) {
|
|
1558
|
+
logger?.debug({ event: "update.check.skipped" }, "update check skipped");
|
|
1291
1559
|
return;
|
|
1292
1560
|
}
|
|
1293
1561
|
const state = await readStateSafe();
|
|
1294
1562
|
if (state.latestVersion && isOutdated(currentVersion, state.latestVersion)) {
|
|
1563
|
+
logger?.debug(
|
|
1564
|
+
{
|
|
1565
|
+
currentVersion,
|
|
1566
|
+
event: "update.notice.cached",
|
|
1567
|
+
installKind: kind,
|
|
1568
|
+
latestVersion: state.latestVersion
|
|
1569
|
+
},
|
|
1570
|
+
"showing cached update notice"
|
|
1571
|
+
);
|
|
1295
1572
|
process.stderr.write(formatUpdateNotice(currentVersion, state.latestVersion, kind));
|
|
1296
1573
|
}
|
|
1297
1574
|
if (shouldRefresh(state.lastCheck, Date.now())) {
|
|
1298
|
-
|
|
1575
|
+
logger?.debug({ event: "update.check.refresh_queued" }, "queued update check refresh");
|
|
1576
|
+
void refreshState(currentVersion, state.etag ?? null, logger).catch((error) => {
|
|
1577
|
+
logger?.debug(
|
|
1578
|
+
{ err: errorDetails2(error), event: "update.check.refresh_failed" },
|
|
1579
|
+
"update check refresh failed"
|
|
1580
|
+
);
|
|
1299
1581
|
});
|
|
1300
1582
|
}
|
|
1301
1583
|
}
|
|
1302
|
-
async function refreshState(currentVersion, etag) {
|
|
1584
|
+
async function refreshState(currentVersion, etag, logger) {
|
|
1303
1585
|
const result = await fetchLatest(currentVersion, etag);
|
|
1304
1586
|
if (!result) {
|
|
1587
|
+
logger?.debug({ event: "update.check.unavailable" }, "update check unavailable");
|
|
1305
1588
|
return;
|
|
1306
1589
|
}
|
|
1307
1590
|
if (result.status === 304) {
|
|
1308
1591
|
const prev = await readStateSafe();
|
|
1309
1592
|
await writeStateSafe({ ...prev, lastCheck: Date.now() });
|
|
1593
|
+
logger?.debug({ event: "update.check.not_modified" }, "latest release unchanged");
|
|
1310
1594
|
return;
|
|
1311
1595
|
}
|
|
1312
1596
|
if (result.release) {
|
|
@@ -1315,6 +1599,10 @@ async function refreshState(currentVersion, etag) {
|
|
|
1315
1599
|
latestVersion: result.release.version,
|
|
1316
1600
|
etag: result.etag
|
|
1317
1601
|
});
|
|
1602
|
+
logger?.debug(
|
|
1603
|
+
{ event: "update.check.updated", latestVersion: result.release.version },
|
|
1604
|
+
"updated cached latest release state"
|
|
1605
|
+
);
|
|
1318
1606
|
}
|
|
1319
1607
|
}
|
|
1320
1608
|
function detectInstallKind() {
|
|
@@ -1335,7 +1623,7 @@ function detectMusl() {
|
|
|
1335
1623
|
if (existsSync("/etc/alpine-release")) {
|
|
1336
1624
|
return true;
|
|
1337
1625
|
}
|
|
1338
|
-
const ldd =
|
|
1626
|
+
const ldd = execFileSync("ldd", ["--version"], {
|
|
1339
1627
|
encoding: "utf8",
|
|
1340
1628
|
stdio: ["ignore", "pipe", "pipe"],
|
|
1341
1629
|
timeout: 2e3
|
|
@@ -1354,12 +1642,10 @@ async function downloadToFile(url, dest, version) {
|
|
|
1354
1642
|
if (!response.ok || !response.body) {
|
|
1355
1643
|
throw new Error(`Download failed (${response.status}) for ${url}`);
|
|
1356
1644
|
}
|
|
1357
|
-
await
|
|
1645
|
+
await writeFile(dest, new Uint8Array(await response.arrayBuffer()));
|
|
1358
1646
|
}
|
|
1359
1647
|
async function sha256File(path) {
|
|
1360
|
-
|
|
1361
|
-
hasher.update(await Bun.file(path).arrayBuffer());
|
|
1362
|
-
return hasher.digest("hex");
|
|
1648
|
+
return createHash("sha256").update(await readFile(path)).digest("hex");
|
|
1363
1649
|
}
|
|
1364
1650
|
async function verifyChecksum(release, assetName, file, version) {
|
|
1365
1651
|
const sums = release.assets.find((asset) => asset.name === SHA256SUMS);
|
|
@@ -1422,7 +1708,7 @@ function swapBinary(tmpFile, exePath) {
|
|
|
1422
1708
|
const code = error.code;
|
|
1423
1709
|
if (code === "EXDEV") {
|
|
1424
1710
|
copyFileSync(tmpFile, exePath);
|
|
1425
|
-
|
|
1711
|
+
chmodSync2(exePath, 493);
|
|
1426
1712
|
} else if (code === "EACCES" || code === "EPERM") {
|
|
1427
1713
|
throw new Error(
|
|
1428
1714
|
`No permission to update ${exePath}. Re-run with sudo, or reinstall to a writable directory.`
|
|
@@ -1441,9 +1727,10 @@ function cleanupOldBinary() {
|
|
|
1441
1727
|
} catch {
|
|
1442
1728
|
}
|
|
1443
1729
|
}
|
|
1444
|
-
async function runUpdate(currentVersion) {
|
|
1730
|
+
async function runUpdate(currentVersion, logger) {
|
|
1445
1731
|
cleanupOldBinary();
|
|
1446
1732
|
const kind = detectInstallKind();
|
|
1733
|
+
logger?.debug({ currentVersion, event: "update.started", installKind: kind }, "update started");
|
|
1447
1734
|
if (kind !== "binary") {
|
|
1448
1735
|
console.log(`hoopilot ${currentVersion} was installed via npm.`);
|
|
1449
1736
|
console.log(`Update with: ${upgradeCommandFor("npm")}`);
|
|
@@ -1456,6 +1743,10 @@ async function runUpdate(currentVersion) {
|
|
|
1456
1743
|
throw new Error("Could not reach GitHub to check for the latest release.");
|
|
1457
1744
|
}
|
|
1458
1745
|
if (!isOutdated(currentVersion, release.version)) {
|
|
1746
|
+
logger?.debug(
|
|
1747
|
+
{ currentVersion, event: "update.already_current", latestVersion: release.version },
|
|
1748
|
+
"hoopilot is already up to date"
|
|
1749
|
+
);
|
|
1459
1750
|
console.log(`Already up to date (latest: ${release.version}).`);
|
|
1460
1751
|
return;
|
|
1461
1752
|
}
|
|
@@ -1467,13 +1758,22 @@ async function runUpdate(currentVersion) {
|
|
|
1467
1758
|
throw new Error(`Release ${release.tag} has no asset "${assetName}". Available: ${available}.`);
|
|
1468
1759
|
}
|
|
1469
1760
|
console.log(`Updating ${currentVersion} \u2192 ${release.version} (${assetName})...`);
|
|
1761
|
+
logger?.debug(
|
|
1762
|
+
{
|
|
1763
|
+
assetName,
|
|
1764
|
+
currentVersion,
|
|
1765
|
+
event: "update.installing",
|
|
1766
|
+
latestVersion: release.version
|
|
1767
|
+
},
|
|
1768
|
+
"installing update"
|
|
1769
|
+
);
|
|
1470
1770
|
const exePath = realpathSync(process.execPath);
|
|
1471
|
-
const tmpFile =
|
|
1771
|
+
const tmpFile = join2(dirname2(exePath), `.hoopilot-update-${process.pid}.tmp`);
|
|
1472
1772
|
try {
|
|
1473
1773
|
await downloadToFile(asset.url, tmpFile, currentVersion);
|
|
1474
1774
|
await verifyChecksum(release, assetName, tmpFile, currentVersion);
|
|
1475
1775
|
if (process.platform !== "win32") {
|
|
1476
|
-
|
|
1776
|
+
chmodSync2(tmpFile, 493);
|
|
1477
1777
|
}
|
|
1478
1778
|
swapBinary(tmpFile, exePath);
|
|
1479
1779
|
} catch (error) {
|
|
@@ -1491,20 +1791,59 @@ async function runUpdate(currentVersion) {
|
|
|
1491
1791
|
}
|
|
1492
1792
|
}
|
|
1493
1793
|
console.log(`Updated hoopilot to ${release.version}.`);
|
|
1794
|
+
logger?.debug(
|
|
1795
|
+
{ currentVersion, event: "update.completed", latestVersion: release.version },
|
|
1796
|
+
"update completed"
|
|
1797
|
+
);
|
|
1494
1798
|
if (process.platform === "win32") {
|
|
1495
1799
|
console.log("Restart hoopilot to run the new version.");
|
|
1496
1800
|
}
|
|
1497
1801
|
}
|
|
1802
|
+
function errorDetails2(error) {
|
|
1803
|
+
if (error instanceof Error) {
|
|
1804
|
+
return {
|
|
1805
|
+
message: error.message,
|
|
1806
|
+
name: error.name,
|
|
1807
|
+
stack: error.stack
|
|
1808
|
+
};
|
|
1809
|
+
}
|
|
1810
|
+
return { message: String(error) };
|
|
1811
|
+
}
|
|
1498
1812
|
|
|
1499
1813
|
// src/cli.ts
|
|
1814
|
+
var DEFAULT_COPILOT_API_BASE_URL2 = "https://api.githubcopilot.com";
|
|
1500
1815
|
async function main(argv = Bun.argv.slice(2)) {
|
|
1501
1816
|
cleanupOldBinary();
|
|
1502
1817
|
const command = argv[0];
|
|
1503
1818
|
if (command === "update" || command === "upgrade") {
|
|
1504
|
-
|
|
1819
|
+
const args2 = withRuntimeEnv(parseArgs(argv.slice(1)));
|
|
1820
|
+
if (args2.help) {
|
|
1821
|
+
console.log(helpText(await getVersion()));
|
|
1822
|
+
return;
|
|
1823
|
+
}
|
|
1824
|
+
const logger2 = createHoopilotLogger({
|
|
1825
|
+
env: args2.env,
|
|
1826
|
+
format: args2.logFormat,
|
|
1827
|
+
level: args2.logLevel
|
|
1828
|
+
}).child({ component: "cli", command });
|
|
1829
|
+
await runUpdate(await getVersion(), logger2);
|
|
1830
|
+
return;
|
|
1831
|
+
}
|
|
1832
|
+
if (command === "login") {
|
|
1833
|
+
const args2 = withRuntimeEnv(parseArgs(argv.slice(1)));
|
|
1834
|
+
if (args2.help) {
|
|
1835
|
+
console.log(helpText(await getVersion()));
|
|
1836
|
+
return;
|
|
1837
|
+
}
|
|
1838
|
+
args2.logger = createHoopilotLogger({
|
|
1839
|
+
env: args2.env,
|
|
1840
|
+
format: args2.logFormat,
|
|
1841
|
+
level: args2.logLevel
|
|
1842
|
+
}).child({ component: "cli", command: "login" });
|
|
1843
|
+
await runLogin(args2);
|
|
1505
1844
|
return;
|
|
1506
1845
|
}
|
|
1507
|
-
const args = parseArgs(argv);
|
|
1846
|
+
const args = withRuntimeEnv(parseArgs(argv));
|
|
1508
1847
|
if (args.help) {
|
|
1509
1848
|
console.log(helpText(await getVersion()));
|
|
1510
1849
|
return;
|
|
@@ -1513,12 +1852,27 @@ async function main(argv = Bun.argv.slice(2)) {
|
|
|
1513
1852
|
console.log(await getVersion());
|
|
1514
1853
|
return;
|
|
1515
1854
|
}
|
|
1855
|
+
const logger = createHoopilotLogger({
|
|
1856
|
+
env: args.env,
|
|
1857
|
+
format: args.logFormat,
|
|
1858
|
+
level: args.logLevel
|
|
1859
|
+
}).child({ component: "cli", command: "serve" });
|
|
1860
|
+
args.logger = logger;
|
|
1516
1861
|
const started = startHoopilotServer(args);
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1862
|
+
logger.info(
|
|
1863
|
+
{
|
|
1864
|
+
baseUrl: `${started.url}/v1`,
|
|
1865
|
+
event: "server.started",
|
|
1866
|
+
url: started.url
|
|
1867
|
+
},
|
|
1868
|
+
"hoopilot server started"
|
|
1869
|
+
);
|
|
1870
|
+
if (!args.noUpdateCheck && process.env.HOOPILOT_NO_UPDATE_CHECK !== "1") {
|
|
1871
|
+
void maybeNotifyUpdate(
|
|
1872
|
+
await getVersion(),
|
|
1873
|
+
IS_STANDALONE_BINARY ? "binary" : "npm",
|
|
1874
|
+
logger.child({ component: "update" })
|
|
1875
|
+
);
|
|
1522
1876
|
}
|
|
1523
1877
|
}
|
|
1524
1878
|
function parseArgs(argv) {
|
|
@@ -1544,10 +1898,6 @@ function parseArgs(argv) {
|
|
|
1544
1898
|
args.allowUnauthenticated = true;
|
|
1545
1899
|
continue;
|
|
1546
1900
|
}
|
|
1547
|
-
if (arg === "--no-gh") {
|
|
1548
|
-
args.githubTokenCommand = false;
|
|
1549
|
-
continue;
|
|
1550
|
-
}
|
|
1551
1901
|
if (arg === "--no-update-check") {
|
|
1552
1902
|
args.noUpdateCheck = true;
|
|
1553
1903
|
continue;
|
|
@@ -1561,20 +1911,17 @@ function parseArgs(argv) {
|
|
|
1561
1911
|
case "--api-key":
|
|
1562
1912
|
args.apiKey = value;
|
|
1563
1913
|
break;
|
|
1564
|
-
case "--auth-
|
|
1565
|
-
args.
|
|
1914
|
+
case "--auth-file":
|
|
1915
|
+
args.authStorePath = value;
|
|
1566
1916
|
break;
|
|
1567
1917
|
case "--copilot-api-base-url":
|
|
1568
1918
|
args.copilotApiBaseUrl = value;
|
|
1569
1919
|
break;
|
|
1570
|
-
case "--
|
|
1571
|
-
args.
|
|
1572
|
-
break;
|
|
1573
|
-
case "--github-token":
|
|
1574
|
-
args.githubToken = value;
|
|
1920
|
+
case "--log-format":
|
|
1921
|
+
args.logFormat = parseLogFormat(value);
|
|
1575
1922
|
break;
|
|
1576
|
-
case "--
|
|
1577
|
-
args.
|
|
1923
|
+
case "--log-level":
|
|
1924
|
+
args.logLevel = parseLogLevel(value);
|
|
1578
1925
|
break;
|
|
1579
1926
|
case "--host":
|
|
1580
1927
|
args.host = value;
|
|
@@ -1592,11 +1939,92 @@ function parseArgs(argv) {
|
|
|
1592
1939
|
}
|
|
1593
1940
|
return args;
|
|
1594
1941
|
}
|
|
1595
|
-
function
|
|
1596
|
-
|
|
1597
|
-
|
|
1942
|
+
async function runLogin(options = {}) {
|
|
1943
|
+
const logger = options.logger?.child({ component: "auth" }) ?? noopLogger;
|
|
1944
|
+
logger.debug({ event: "auth.login.started" }, "starting github copilot browser login");
|
|
1945
|
+
console.log("Starting GitHub Copilot browser login...");
|
|
1946
|
+
const login = await githubCopilotDeviceLogin({
|
|
1947
|
+
env: options.env,
|
|
1948
|
+
logger: console,
|
|
1949
|
+
openBrowser: openBrowserBestEffort
|
|
1950
|
+
});
|
|
1951
|
+
console.log("Checking GitHub Copilot access...");
|
|
1952
|
+
const access = await verifyCopilotOAuthToken(login.token, options);
|
|
1953
|
+
logger.debug(
|
|
1954
|
+
{ apiBaseUrl: access.apiBaseUrl, event: "auth.login.verified" },
|
|
1955
|
+
"github copilot oauth token verified"
|
|
1956
|
+
);
|
|
1957
|
+
const path = options.authStorePath ?? authStorePath(options.env);
|
|
1958
|
+
writeStoredCopilotAuth(
|
|
1959
|
+
{
|
|
1960
|
+
apiBaseUrl: access.apiBaseUrl,
|
|
1961
|
+
githubDomain: login.domain,
|
|
1962
|
+
source: "github-device-oauth",
|
|
1963
|
+
token: login.token
|
|
1964
|
+
},
|
|
1965
|
+
path
|
|
1966
|
+
);
|
|
1967
|
+
logger.debug({ authStorePath: path, event: "auth.login.stored" }, "copilot credential stored");
|
|
1968
|
+
console.log(`Copilot OAuth credential stored at ${path}`);
|
|
1969
|
+
console.log("Copilot authentication ready.");
|
|
1970
|
+
}
|
|
1971
|
+
async function verifyCopilotOAuthToken(token, options = {}) {
|
|
1972
|
+
const apiBaseUrl = trimTrailingSlash2(
|
|
1973
|
+
options.copilotApiBaseUrl ?? options.env?.COPILOT_API_BASE_URL ?? DEFAULT_COPILOT_API_BASE_URL2
|
|
1974
|
+
);
|
|
1975
|
+
const fetcher = options.fetch ?? fetch;
|
|
1976
|
+
const response = await fetcher(`${apiBaseUrl}/models`, {
|
|
1977
|
+
headers: copilotHeaders(token),
|
|
1978
|
+
method: "GET"
|
|
1979
|
+
});
|
|
1980
|
+
if (!response.ok) {
|
|
1981
|
+
const message = `GitHub Copilot API verification failed with ${response.status}: ${await safeResponseText2(response)}`;
|
|
1982
|
+
if (response.status === 401 || response.status === 403) {
|
|
1983
|
+
throw new CopilotAuthError(message);
|
|
1984
|
+
}
|
|
1985
|
+
throw new Error(message);
|
|
1598
1986
|
}
|
|
1599
|
-
|
|
1987
|
+
return {
|
|
1988
|
+
apiBaseUrl,
|
|
1989
|
+
expiresAtMs: Date.now() + 10 * 6e4,
|
|
1990
|
+
source: "github-copilot-oauth",
|
|
1991
|
+
token
|
|
1992
|
+
};
|
|
1993
|
+
}
|
|
1994
|
+
function openBrowserBestEffort(url) {
|
|
1995
|
+
const platform = process.platform;
|
|
1996
|
+
const command = platform === "win32" ? "cmd" : platform === "darwin" ? "open" : "xdg-open";
|
|
1997
|
+
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
1998
|
+
try {
|
|
1999
|
+
const child = spawn(command, args, {
|
|
2000
|
+
detached: true,
|
|
2001
|
+
stdio: "ignore"
|
|
2002
|
+
});
|
|
2003
|
+
child.unref();
|
|
2004
|
+
} catch {
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
function copilotHeaders(token) {
|
|
2008
|
+
const headers = new Headers();
|
|
2009
|
+
headers.set("accept", "application/json");
|
|
2010
|
+
headers.set("authorization", `Bearer ${token}`);
|
|
2011
|
+
headers.set("copilot-integration-id", "vscode-chat");
|
|
2012
|
+
headers.set("editor-plugin-version", "hoopilot/0.1.0");
|
|
2013
|
+
headers.set("editor-version", "Hoopilot/0.1.0");
|
|
2014
|
+
headers.set("openai-intent", "conversation-panel");
|
|
2015
|
+
headers.set("user-agent", "hoopilot/0.1.0");
|
|
2016
|
+
headers.set("x-github-api-version", "2026-06-01");
|
|
2017
|
+
return headers;
|
|
2018
|
+
}
|
|
2019
|
+
async function safeResponseText2(response) {
|
|
2020
|
+
const text = await response.text();
|
|
2021
|
+
return text.slice(0, 500);
|
|
2022
|
+
}
|
|
2023
|
+
function trimTrailingSlash2(value) {
|
|
2024
|
+
return value.replace(/\/+$/, "");
|
|
2025
|
+
}
|
|
2026
|
+
function withRuntimeEnv(args) {
|
|
2027
|
+
return { ...args, env: process.env };
|
|
1600
2028
|
}
|
|
1601
2029
|
function helpText(version) {
|
|
1602
2030
|
return `hoopilot ${version}
|
|
@@ -1605,23 +2033,23 @@ OpenAI-compatible proxy for GitHub Copilot.
|
|
|
1605
2033
|
|
|
1606
2034
|
Usage:
|
|
1607
2035
|
hoopilot [serve] [options]
|
|
2036
|
+
hoopilot login [options]
|
|
1608
2037
|
hoopilot update
|
|
1609
2038
|
npx @openhoo/hoopilot [options]
|
|
1610
2039
|
|
|
1611
2040
|
Commands:
|
|
1612
2041
|
serve Start the proxy server (default)
|
|
2042
|
+
login Sign in through GitHub OAuth in a browser and verify Copilot access
|
|
1613
2043
|
update, upgrade Update hoopilot to the latest release
|
|
1614
2044
|
|
|
1615
2045
|
Options:
|
|
1616
2046
|
-p, --port <port> Port to listen on. Default: 4141
|
|
1617
2047
|
--host <host> Host to listen on. Default: 127.0.0.1
|
|
1618
2048
|
--api-key <key> Require clients to send Authorization: Bearer <key>
|
|
1619
|
-
--auth-
|
|
1620
|
-
--github-token <token> GitHub CLI OAuth token for a Copilot account. PATs are rejected.
|
|
1621
|
-
--github-token-command <cmd> Command used to read a GitHub token. Default: gh auth token
|
|
1622
|
-
--copilot-token <token> Short-lived Copilot API bearer token
|
|
2049
|
+
--auth-file <path> OAuth credential store path
|
|
1623
2050
|
--copilot-api-base-url <url> Copilot API base URL override
|
|
1624
|
-
--
|
|
2051
|
+
--log-level <level> trace, debug, info, warn, error, fatal, or silent
|
|
2052
|
+
--log-format <format> json or pretty. Default: pretty
|
|
1625
2053
|
--no-update-check Do not check GitHub for a newer release
|
|
1626
2054
|
--allow-unauthenticated Allow non-loopback bind without --api-key
|
|
1627
2055
|
-h, --help Show help
|
|
@@ -1629,8 +2057,11 @@ Options:
|
|
|
1629
2057
|
|
|
1630
2058
|
Environment:
|
|
1631
2059
|
HOOPILOT_API_KEY
|
|
1632
|
-
|
|
1633
|
-
|
|
2060
|
+
HOOPILOT_AUTH_FILE
|
|
2061
|
+
HOOPILOT_GITHUB_CLIENT_ID
|
|
2062
|
+
HOOPILOT_GITHUB_DOMAIN
|
|
2063
|
+
HOOPILOT_LOG_FORMAT json or pretty. Default: pretty
|
|
2064
|
+
HOOPILOT_LOG_LEVEL trace, debug, info, warn, error, fatal, or silent
|
|
1634
2065
|
COPILOT_API_BASE_URL
|
|
1635
2066
|
HOOPILOT_NO_UPDATE_CHECK Set to disable update checks (also NO_UPDATE_NOTIFIER)
|
|
1636
2067
|
`;
|
|
@@ -1643,6 +2074,7 @@ if (import.meta.main) {
|
|
|
1643
2074
|
}
|
|
1644
2075
|
export {
|
|
1645
2076
|
main,
|
|
1646
|
-
parseArgs
|
|
2077
|
+
parseArgs,
|
|
2078
|
+
verifyCopilotOAuthToken
|
|
1647
2079
|
};
|
|
1648
2080
|
//# sourceMappingURL=cli.js.map
|