@openhoo/hoopilot 0.3.0 → 0.4.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 +110 -67
- package/dist/cli.js +724 -291
- package/dist/cli.js.map +1 -1
- 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 +5 -1
package/dist/index.js
CHANGED
|
@@ -1,253 +1,100 @@
|
|
|
1
|
+
// src/auth-store.ts
|
|
2
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
3
|
+
import { dirname, join } from "path";
|
|
4
|
+
function authStorePath(env = process.env) {
|
|
5
|
+
if (env.HOOPILOT_AUTH_FILE) {
|
|
6
|
+
return env.HOOPILOT_AUTH_FILE;
|
|
7
|
+
}
|
|
8
|
+
const base = env.XDG_CONFIG_HOME ?? env.APPDATA ?? (env.HOME ? join(env.HOME, ".config") : join(process.cwd(), ".config"));
|
|
9
|
+
return join(base, "hoopilot", "auth.json");
|
|
10
|
+
}
|
|
11
|
+
function readStoredCopilotAuth(path = authStorePath()) {
|
|
12
|
+
try {
|
|
13
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
14
|
+
if (!parsed || typeof parsed !== "object") {
|
|
15
|
+
return void 0;
|
|
16
|
+
}
|
|
17
|
+
const token = typeof parsed.token === "string" ? parsed.token.trim() : "";
|
|
18
|
+
if (!token) {
|
|
19
|
+
return void 0;
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
apiBaseUrl: typeof parsed.apiBaseUrl === "string" ? parsed.apiBaseUrl : void 0,
|
|
23
|
+
createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : void 0,
|
|
24
|
+
githubDomain: typeof parsed.githubDomain === "string" ? parsed.githubDomain : void 0,
|
|
25
|
+
source: typeof parsed.source === "string" ? parsed.source : void 0,
|
|
26
|
+
token
|
|
27
|
+
};
|
|
28
|
+
} catch {
|
|
29
|
+
return void 0;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function writeStoredCopilotAuth(auth, path = authStorePath()) {
|
|
33
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
34
|
+
writeFileSync(
|
|
35
|
+
path,
|
|
36
|
+
`${JSON.stringify(
|
|
37
|
+
{
|
|
38
|
+
...auth,
|
|
39
|
+
createdAt: auth.createdAt ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
40
|
+
},
|
|
41
|
+
null,
|
|
42
|
+
2
|
|
43
|
+
)}
|
|
44
|
+
`,
|
|
45
|
+
{ mode: 384 }
|
|
46
|
+
);
|
|
47
|
+
try {
|
|
48
|
+
chmodSync(path, 384);
|
|
49
|
+
} catch {
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
1
53
|
// src/auth.ts
|
|
2
|
-
|
|
3
|
-
var DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com";
|
|
4
|
-
var DEFAULT_TOKEN_EXCHANGE_URL = "https://api.github.com/copilot_internal/v2/token";
|
|
54
|
+
var DEFAULT_COPILOT_API_BASE_URL = "https://api.githubcopilot.com";
|
|
5
55
|
var REFRESH_SKEW_MS = 6e4;
|
|
56
|
+
var STORED_TOKEN_TTL_MS = 10 * 6e4;
|
|
6
57
|
var CopilotAuthError = class extends Error {
|
|
7
58
|
constructor(message) {
|
|
8
59
|
super(message);
|
|
9
60
|
this.name = "CopilotAuthError";
|
|
10
61
|
}
|
|
11
62
|
};
|
|
12
|
-
var CopilotTokenExchangeHttpError = class extends CopilotAuthError {
|
|
13
|
-
};
|
|
14
63
|
var CopilotAuth = class {
|
|
15
|
-
#
|
|
64
|
+
#authStorePath;
|
|
16
65
|
#copilotApiBaseUrl;
|
|
17
|
-
#copilotToken;
|
|
18
|
-
#env;
|
|
19
|
-
#fetch;
|
|
20
|
-
#githubToken;
|
|
21
|
-
#githubTokenCommand;
|
|
22
|
-
#logger;
|
|
23
|
-
#tokenExchangeUrl;
|
|
24
66
|
#cachedAccess;
|
|
25
67
|
constructor(options = {}) {
|
|
26
|
-
this.#
|
|
68
|
+
this.#authStorePath = options.authStorePath ?? options.env?.HOOPILOT_AUTH_FILE;
|
|
27
69
|
this.#copilotApiBaseUrl = trimTrailingSlash(
|
|
28
70
|
options.copilotApiBaseUrl ?? options.env?.COPILOT_API_BASE_URL ?? DEFAULT_COPILOT_API_BASE_URL
|
|
29
71
|
);
|
|
30
|
-
this.#copilotToken = options.copilotToken;
|
|
31
|
-
this.#env = options.env ?? process.env;
|
|
32
|
-
this.#fetch = options.fetch ?? fetch;
|
|
33
|
-
this.#githubToken = options.githubToken;
|
|
34
|
-
this.#githubTokenCommand = options.githubTokenCommand ?? "gh auth token";
|
|
35
|
-
this.#logger = options.logger;
|
|
36
|
-
this.#tokenExchangeUrl = options.tokenExchangeUrl ?? options.env?.COPILOT_TOKEN_EXCHANGE_URL ?? DEFAULT_TOKEN_EXCHANGE_URL;
|
|
37
72
|
}
|
|
38
73
|
async getAccess() {
|
|
39
74
|
if (this.#cachedAccess && this.#cachedAccess.expiresAtMs - REFRESH_SKEW_MS > Date.now()) {
|
|
40
75
|
return this.#cachedAccess;
|
|
41
76
|
}
|
|
42
|
-
const
|
|
43
|
-
if (
|
|
44
|
-
return this.#cacheAccess({
|
|
45
|
-
apiBaseUrl: this.#copilotApiBaseUrl,
|
|
46
|
-
expiresAtMs: Date.now() + 10 * 6e4,
|
|
47
|
-
source: "copilot-token",
|
|
48
|
-
token: directCopilotToken
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
if (this.#authMode === "copilot-token") {
|
|
52
|
-
throw new CopilotAuthError("COPILOT_API_TOKEN or GITHUB_COPILOT_API_TOKEN is required.");
|
|
53
|
-
}
|
|
54
|
-
const githubToken = this.#resolveGithubToken();
|
|
55
|
-
if (!githubToken) {
|
|
56
|
-
throw new CopilotAuthError(
|
|
57
|
-
"No Copilot credential found. Set COPILOT_API_TOKEN, set COPILOT_GITHUB_TOKEN from gh auth token, or sign in with gh auth login."
|
|
58
|
-
);
|
|
59
|
-
}
|
|
60
|
-
if (isPersonalAccessToken(githubToken)) {
|
|
61
|
-
throw new CopilotAuthError(
|
|
62
|
-
"GitHub personal access tokens are not supported for Copilot authentication. Use gh auth login or COPILOT_API_TOKEN."
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
try {
|
|
66
|
-
return this.#cacheAccess(await this.#exchangeGithubToken(githubToken));
|
|
67
|
-
} catch (error) {
|
|
68
|
-
if (!(error instanceof CopilotTokenExchangeHttpError)) {
|
|
69
|
-
throw error;
|
|
70
|
-
}
|
|
71
|
-
this.#logger?.warn(
|
|
72
|
-
`Copilot token exchange failed; falling back to GitHub CLI token mode: ${errorMessage(
|
|
73
|
-
error
|
|
74
|
-
)}`
|
|
75
|
-
);
|
|
77
|
+
const stored = readStoredCopilotAuth(this.#authStorePath);
|
|
78
|
+
if (stored) {
|
|
76
79
|
return this.#cacheAccess({
|
|
77
|
-
apiBaseUrl: this.#copilotApiBaseUrl,
|
|
78
|
-
expiresAtMs: Date.now() +
|
|
79
|
-
source: "
|
|
80
|
-
token:
|
|
80
|
+
apiBaseUrl: trimTrailingSlash(stored.apiBaseUrl ?? this.#copilotApiBaseUrl),
|
|
81
|
+
expiresAtMs: Date.now() + STORED_TOKEN_TTL_MS,
|
|
82
|
+
source: "github-copilot-oauth",
|
|
83
|
+
token: stored.token
|
|
81
84
|
});
|
|
82
85
|
}
|
|
86
|
+
throw new CopilotAuthError(
|
|
87
|
+
"No GitHub Copilot OAuth credential found. Run `hoopilot login` to sign in through your browser."
|
|
88
|
+
);
|
|
83
89
|
}
|
|
84
90
|
#cacheAccess(access) {
|
|
85
91
|
this.#cachedAccess = access;
|
|
86
92
|
return access;
|
|
87
93
|
}
|
|
88
|
-
async #exchangeGithubToken(githubToken) {
|
|
89
|
-
const response = await this.#fetch(this.#tokenExchangeUrl, {
|
|
90
|
-
headers: {
|
|
91
|
-
accept: "application/vnd.github+json",
|
|
92
|
-
authorization: `token ${githubToken}`,
|
|
93
|
-
"editor-plugin-version": "hoopilot/0.1.0",
|
|
94
|
-
"editor-version": "Hoopilot/0.1.0",
|
|
95
|
-
"user-agent": "hoopilot/0.1.0"
|
|
96
|
-
},
|
|
97
|
-
method: "GET"
|
|
98
|
-
});
|
|
99
|
-
if (!response.ok) {
|
|
100
|
-
throw new CopilotTokenExchangeHttpError(
|
|
101
|
-
`GitHub Copilot token exchange failed with ${response.status}: ${await safeResponseText(
|
|
102
|
-
response
|
|
103
|
-
)}`
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
const body = asRecord(await response.json());
|
|
107
|
-
const token = getString(body, "token");
|
|
108
|
-
if (!token) {
|
|
109
|
-
throw new CopilotAuthError("GitHub Copilot token exchange response did not include a token.");
|
|
110
|
-
}
|
|
111
|
-
return {
|
|
112
|
-
apiBaseUrl: endpointFromResponse(body) ?? this.#copilotApiBaseUrl,
|
|
113
|
-
expiresAtMs: expiresAtFromResponse(body),
|
|
114
|
-
source: "github-token",
|
|
115
|
-
token
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
#resolveDirectCopilotToken() {
|
|
119
|
-
return firstNonEmpty(
|
|
120
|
-
this.#copilotToken,
|
|
121
|
-
this.#env.COPILOT_API_TOKEN,
|
|
122
|
-
this.#env.GITHUB_COPILOT_API_TOKEN,
|
|
123
|
-
this.#env.GITHUB_COPILOT_TOKEN
|
|
124
|
-
);
|
|
125
|
-
}
|
|
126
|
-
#resolveGithubToken() {
|
|
127
|
-
return firstNonEmpty(
|
|
128
|
-
this.#githubToken,
|
|
129
|
-
this.#env.COPILOT_GITHUB_TOKEN,
|
|
130
|
-
this.#env.GITHUB_COPILOT_GITHUB_TOKEN,
|
|
131
|
-
this.#readGithubTokenCommand()
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
|
-
#readGithubTokenCommand() {
|
|
135
|
-
if (this.#githubTokenCommand === false) {
|
|
136
|
-
return void 0;
|
|
137
|
-
}
|
|
138
|
-
const parts = splitCommand(this.#githubTokenCommand);
|
|
139
|
-
const [command, ...args] = parts;
|
|
140
|
-
if (!command) {
|
|
141
|
-
return void 0;
|
|
142
|
-
}
|
|
143
|
-
try {
|
|
144
|
-
const output = execFileSync(command, args, {
|
|
145
|
-
encoding: "utf8",
|
|
146
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
147
|
-
timeout: 5e3
|
|
148
|
-
});
|
|
149
|
-
return output.trim() || void 0;
|
|
150
|
-
} catch {
|
|
151
|
-
return void 0;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
94
|
};
|
|
155
|
-
function splitCommand(command) {
|
|
156
|
-
const parts = [];
|
|
157
|
-
let current = "";
|
|
158
|
-
let quote;
|
|
159
|
-
let escaping = false;
|
|
160
|
-
for (const character of command.trim()) {
|
|
161
|
-
if (escaping) {
|
|
162
|
-
current += character;
|
|
163
|
-
escaping = false;
|
|
164
|
-
continue;
|
|
165
|
-
}
|
|
166
|
-
if (character === "\\") {
|
|
167
|
-
escaping = true;
|
|
168
|
-
continue;
|
|
169
|
-
}
|
|
170
|
-
if (quote) {
|
|
171
|
-
if (character === quote) {
|
|
172
|
-
quote = void 0;
|
|
173
|
-
} else {
|
|
174
|
-
current += character;
|
|
175
|
-
}
|
|
176
|
-
continue;
|
|
177
|
-
}
|
|
178
|
-
if (character === "'" || character === '"') {
|
|
179
|
-
quote = character;
|
|
180
|
-
continue;
|
|
181
|
-
}
|
|
182
|
-
if (/\s/.test(character)) {
|
|
183
|
-
if (current) {
|
|
184
|
-
parts.push(current);
|
|
185
|
-
current = "";
|
|
186
|
-
}
|
|
187
|
-
continue;
|
|
188
|
-
}
|
|
189
|
-
current += character;
|
|
190
|
-
}
|
|
191
|
-
if (current) {
|
|
192
|
-
parts.push(current);
|
|
193
|
-
}
|
|
194
|
-
return parts;
|
|
195
|
-
}
|
|
196
|
-
function endpointFromResponse(body) {
|
|
197
|
-
const endpoints = asRecord(body.endpoints);
|
|
198
|
-
const apiUrl = getString(endpoints, "api") ?? getString(endpoints, "proxy");
|
|
199
|
-
return apiUrl ? trimTrailingSlash(apiUrl) : void 0;
|
|
200
|
-
}
|
|
201
|
-
function expiresAtFromResponse(body) {
|
|
202
|
-
const expiresAt = body.expires_at;
|
|
203
|
-
if (typeof expiresAt === "number") {
|
|
204
|
-
return expiresAt < 1e10 ? expiresAt * 1e3 : expiresAt;
|
|
205
|
-
}
|
|
206
|
-
if (typeof expiresAt === "string") {
|
|
207
|
-
const asNumber = Number(expiresAt);
|
|
208
|
-
if (Number.isFinite(asNumber)) {
|
|
209
|
-
return asNumber < 1e10 ? asNumber * 1e3 : asNumber;
|
|
210
|
-
}
|
|
211
|
-
const parsed = Date.parse(expiresAt);
|
|
212
|
-
if (Number.isFinite(parsed)) {
|
|
213
|
-
return parsed;
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
const refreshIn = body.refresh_in;
|
|
217
|
-
if (typeof refreshIn === "number" && Number.isFinite(refreshIn)) {
|
|
218
|
-
return Date.now() + refreshIn * 1e3;
|
|
219
|
-
}
|
|
220
|
-
return Date.now() + 10 * 6e4;
|
|
221
|
-
}
|
|
222
|
-
function firstNonEmpty(...values) {
|
|
223
|
-
for (const value of values) {
|
|
224
|
-
const trimmed = value?.trim();
|
|
225
|
-
if (trimmed) {
|
|
226
|
-
return trimmed;
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
return void 0;
|
|
230
|
-
}
|
|
231
|
-
function asRecord(value) {
|
|
232
|
-
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
233
|
-
}
|
|
234
|
-
function getString(record, key) {
|
|
235
|
-
const value = record[key];
|
|
236
|
-
return typeof value === "string" && value ? value : void 0;
|
|
237
|
-
}
|
|
238
95
|
function trimTrailingSlash(value) {
|
|
239
96
|
return value.replace(/\/+$/, "");
|
|
240
97
|
}
|
|
241
|
-
async function safeResponseText(response) {
|
|
242
|
-
const text = await response.text();
|
|
243
|
-
return text.slice(0, 500);
|
|
244
|
-
}
|
|
245
|
-
function isPersonalAccessToken(token) {
|
|
246
|
-
return token.startsWith("github_pat_") || token.startsWith("ghp_");
|
|
247
|
-
}
|
|
248
|
-
function errorMessage(error) {
|
|
249
|
-
return error instanceof Error ? error.message : String(error);
|
|
250
|
-
}
|
|
251
98
|
|
|
252
99
|
// src/copilot.ts
|
|
253
100
|
var CopilotClient = class {
|
|
@@ -296,6 +143,7 @@ var CopilotClient = class {
|
|
|
296
143
|
headers.set("editor-version", "Hoopilot/0.1.0");
|
|
297
144
|
headers.set("openai-intent", "conversation-panel");
|
|
298
145
|
headers.set("user-agent", "hoopilot/0.1.0");
|
|
146
|
+
headers.set("x-github-api-version", "2026-06-01");
|
|
299
147
|
return this.#fetch(`${access.apiBaseUrl}${path}`, {
|
|
300
148
|
...init,
|
|
301
149
|
headers
|
|
@@ -303,6 +151,226 @@ var CopilotClient = class {
|
|
|
303
151
|
}
|
|
304
152
|
};
|
|
305
153
|
|
|
154
|
+
// src/github-device.ts
|
|
155
|
+
import { setTimeout as sleep } from "timers/promises";
|
|
156
|
+
var DEFAULT_GITHUB_COPILOT_CLIENT_ID = "Iv23lijnNxm2e9UX3CF8";
|
|
157
|
+
var DEFAULT_GITHUB_DOMAIN = "github.com";
|
|
158
|
+
var DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
|
159
|
+
var POLLING_SAFETY_MARGIN_MS = 3e3;
|
|
160
|
+
async function githubCopilotDeviceLogin(options = {}) {
|
|
161
|
+
const env = options.env ?? process.env;
|
|
162
|
+
const fetcher = options.fetch ?? fetch;
|
|
163
|
+
const sleeper = options.sleep ?? sleep;
|
|
164
|
+
const domain = normalizeDomain(
|
|
165
|
+
options.domain ?? env.HOOPILOT_GITHUB_DOMAIN ?? DEFAULT_GITHUB_DOMAIN
|
|
166
|
+
);
|
|
167
|
+
const clientId = options.clientId ?? env.HOOPILOT_GITHUB_CLIENT_ID ?? env.COPILOT_GITHUB_CLIENT_ID ?? DEFAULT_GITHUB_COPILOT_CLIENT_ID;
|
|
168
|
+
const device = await requestDeviceCode(fetcher, domain, clientId);
|
|
169
|
+
const verificationUrl = device.verification_uri;
|
|
170
|
+
const userCode = device.user_code;
|
|
171
|
+
const deviceCode = device.device_code;
|
|
172
|
+
if (!verificationUrl || !userCode || !deviceCode) {
|
|
173
|
+
throw new Error("GitHub device authorization response is missing required fields.");
|
|
174
|
+
}
|
|
175
|
+
options.logger?.info(`First copy your one-time code: ${userCode}`);
|
|
176
|
+
options.logger?.info(`Open ${verificationUrl} in your browser to authorize Hoopilot.`);
|
|
177
|
+
await options.openBrowser?.(verificationUrl);
|
|
178
|
+
return {
|
|
179
|
+
domain,
|
|
180
|
+
token: await pollForAccessToken(fetcher, sleeper, domain, clientId, {
|
|
181
|
+
deviceCode,
|
|
182
|
+
expiresIn: positiveSeconds(device.expires_in, 900),
|
|
183
|
+
interval: positiveSeconds(device.interval, 5)
|
|
184
|
+
})
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
async function requestDeviceCode(fetcher, domain, clientId) {
|
|
188
|
+
const response = await fetcher(`https://${domain}/login/device/code`, {
|
|
189
|
+
body: JSON.stringify({
|
|
190
|
+
client_id: clientId,
|
|
191
|
+
scope: "read:user"
|
|
192
|
+
}),
|
|
193
|
+
headers: oauthHeaders(),
|
|
194
|
+
method: "POST"
|
|
195
|
+
});
|
|
196
|
+
if (!response.ok) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`GitHub device authorization failed with ${response.status}: ${await safeResponseText(
|
|
199
|
+
response
|
|
200
|
+
)}`
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
return await response.json();
|
|
204
|
+
}
|
|
205
|
+
async function pollForAccessToken(fetcher, sleeper, domain, clientId, device) {
|
|
206
|
+
let intervalMs = device.interval * 1e3 + POLLING_SAFETY_MARGIN_MS;
|
|
207
|
+
const deadline = Date.now() + device.expiresIn * 1e3;
|
|
208
|
+
while (Date.now() < deadline) {
|
|
209
|
+
await sleeper(intervalMs);
|
|
210
|
+
const response = await fetcher(`https://${domain}/login/oauth/access_token`, {
|
|
211
|
+
body: JSON.stringify({
|
|
212
|
+
client_id: clientId,
|
|
213
|
+
device_code: device.deviceCode,
|
|
214
|
+
grant_type: DEVICE_GRANT_TYPE
|
|
215
|
+
}),
|
|
216
|
+
headers: oauthHeaders(),
|
|
217
|
+
method: "POST"
|
|
218
|
+
});
|
|
219
|
+
if (!response.ok) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
`GitHub device token exchange failed with ${response.status}: ${await safeResponseText(
|
|
222
|
+
response
|
|
223
|
+
)}`
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
const data = await response.json();
|
|
227
|
+
if (data.access_token) {
|
|
228
|
+
return data.access_token;
|
|
229
|
+
}
|
|
230
|
+
if (data.error === "authorization_pending") {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (data.error === "slow_down") {
|
|
234
|
+
intervalMs = positiveSeconds(data.interval, device.interval + 5) * 1e3 + POLLING_SAFETY_MARGIN_MS;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (data.error === "expired_token") {
|
|
238
|
+
throw new Error("GitHub device login expired. Run `hoopilot login` again.");
|
|
239
|
+
}
|
|
240
|
+
if (data.error === "access_denied") {
|
|
241
|
+
throw new Error("GitHub device login was cancelled.");
|
|
242
|
+
}
|
|
243
|
+
if (data.error) {
|
|
244
|
+
throw new Error(data.error_description || `GitHub device login failed: ${data.error}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
throw new Error("GitHub device login timed out. Run `hoopilot login` again.");
|
|
248
|
+
}
|
|
249
|
+
function oauthHeaders() {
|
|
250
|
+
const headers = new Headers();
|
|
251
|
+
headers.set("accept", "application/json");
|
|
252
|
+
headers.set("content-type", "application/json");
|
|
253
|
+
headers.set("user-agent", "hoopilot");
|
|
254
|
+
return headers;
|
|
255
|
+
}
|
|
256
|
+
function normalizeDomain(value) {
|
|
257
|
+
return value.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
|
258
|
+
}
|
|
259
|
+
function positiveSeconds(value, fallback) {
|
|
260
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
261
|
+
}
|
|
262
|
+
async function safeResponseText(response) {
|
|
263
|
+
const text = await response.text();
|
|
264
|
+
return text.slice(0, 500);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// src/logger.ts
|
|
268
|
+
import pino from "pino";
|
|
269
|
+
import pretty from "pino-pretty";
|
|
270
|
+
var DEFAULT_LOG_FORMAT = "pretty";
|
|
271
|
+
var DEFAULT_LOG_LEVEL = "info";
|
|
272
|
+
var LOG_FORMATS = ["json", "pretty"];
|
|
273
|
+
var LOG_LEVELS = ["trace", "debug", "info", "warn", "error", "fatal", "silent"];
|
|
274
|
+
var REDACT_PATHS = [
|
|
275
|
+
"apiKey",
|
|
276
|
+
"authorization",
|
|
277
|
+
"cookie",
|
|
278
|
+
"headers.authorization",
|
|
279
|
+
"headers.Authorization",
|
|
280
|
+
"headers.cookie",
|
|
281
|
+
"headers.Cookie",
|
|
282
|
+
"headers.x-api-key",
|
|
283
|
+
"headers.X-Api-Key",
|
|
284
|
+
"token",
|
|
285
|
+
"*.apiKey",
|
|
286
|
+
"*.authorization",
|
|
287
|
+
"*.cookie",
|
|
288
|
+
"*.token",
|
|
289
|
+
"*.headers.authorization",
|
|
290
|
+
"*.headers.Authorization",
|
|
291
|
+
"*.headers.cookie",
|
|
292
|
+
"*.headers.Cookie",
|
|
293
|
+
"*.headers.x-api-key",
|
|
294
|
+
"*.headers.X-Api-Key"
|
|
295
|
+
];
|
|
296
|
+
var noopLogger = {
|
|
297
|
+
child: () => noopLogger,
|
|
298
|
+
debug: () => {
|
|
299
|
+
},
|
|
300
|
+
error: () => {
|
|
301
|
+
},
|
|
302
|
+
fatal: () => {
|
|
303
|
+
},
|
|
304
|
+
info: () => {
|
|
305
|
+
},
|
|
306
|
+
trace: () => {
|
|
307
|
+
},
|
|
308
|
+
warn: () => {
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
function createHoopilotLogger(options = {}) {
|
|
312
|
+
const env = options.env ?? process.env;
|
|
313
|
+
const level = parseLogLevel(options.level ?? env.HOOPILOT_LOG_LEVEL);
|
|
314
|
+
const format = parseLogFormat(options.format ?? env.HOOPILOT_LOG_FORMAT);
|
|
315
|
+
const pinoOptions = {
|
|
316
|
+
base: {
|
|
317
|
+
service: "hoopilot",
|
|
318
|
+
...options.base
|
|
319
|
+
},
|
|
320
|
+
level,
|
|
321
|
+
redact: {
|
|
322
|
+
censor: "[Redacted]",
|
|
323
|
+
paths: REDACT_PATHS
|
|
324
|
+
},
|
|
325
|
+
timestamp: pino.stdTimeFunctions.isoTime
|
|
326
|
+
};
|
|
327
|
+
if (format === "pretty") {
|
|
328
|
+
return pino(
|
|
329
|
+
pinoOptions,
|
|
330
|
+
pretty({
|
|
331
|
+
colorize: options.colorize ?? process.stderr.isTTY,
|
|
332
|
+
destination: options.stream ?? 1,
|
|
333
|
+
ignore: "pid,hostname",
|
|
334
|
+
singleLine: true,
|
|
335
|
+
translateTime: "SYS:standard"
|
|
336
|
+
})
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
if (options.stream) {
|
|
340
|
+
return pino(pinoOptions, options.stream);
|
|
341
|
+
}
|
|
342
|
+
return pino(pinoOptions);
|
|
343
|
+
}
|
|
344
|
+
function parseLogFormat(value) {
|
|
345
|
+
if (!value) {
|
|
346
|
+
return DEFAULT_LOG_FORMAT;
|
|
347
|
+
}
|
|
348
|
+
if (isLogFormat(value)) {
|
|
349
|
+
return value;
|
|
350
|
+
}
|
|
351
|
+
throw new Error(`Invalid log format: ${value}. Expected one of: ${LOG_FORMATS.join(", ")}.`);
|
|
352
|
+
}
|
|
353
|
+
function parseLogLevel(value) {
|
|
354
|
+
if (!value) {
|
|
355
|
+
return DEFAULT_LOG_LEVEL;
|
|
356
|
+
}
|
|
357
|
+
if (isLogLevel(value)) {
|
|
358
|
+
return value;
|
|
359
|
+
}
|
|
360
|
+
throw new Error(`Invalid log level: ${value}. Expected one of: ${LOG_LEVELS.join(", ")}.`);
|
|
361
|
+
}
|
|
362
|
+
function shouldCreateLogger(options) {
|
|
363
|
+
return Boolean(
|
|
364
|
+
options.logger || options.logFormat || options.logLevel || options.env?.HOOPILOT_LOG_FORMAT || options.env?.HOOPILOT_LOG_LEVEL
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
function isLogFormat(value) {
|
|
368
|
+
return LOG_FORMATS.includes(value);
|
|
369
|
+
}
|
|
370
|
+
function isLogLevel(value) {
|
|
371
|
+
return LOG_LEVELS.includes(value);
|
|
372
|
+
}
|
|
373
|
+
|
|
306
374
|
// src/openai.ts
|
|
307
375
|
var DEFAULT_MODEL = "gpt-4.1";
|
|
308
376
|
function responsesRequestToChatCompletion(request) {
|
|
@@ -321,8 +389,8 @@ function responsesRequestToChatCompletion(request) {
|
|
|
321
389
|
metadata: request.metadata,
|
|
322
390
|
model: contentToText(request.model) || DEFAULT_MODEL,
|
|
323
391
|
presence_penalty: request.presence_penalty,
|
|
324
|
-
reasoning_effort:
|
|
325
|
-
response_format:
|
|
392
|
+
reasoning_effort: asRecord(request.reasoning).effort,
|
|
393
|
+
response_format: asRecord(request.text).format,
|
|
326
394
|
seed: request.seed,
|
|
327
395
|
stream: request.stream === true,
|
|
328
396
|
temperature: request.temperature,
|
|
@@ -344,7 +412,7 @@ function completionsRequestToChatCompletion(request) {
|
|
|
344
412
|
function chatCompletionToResponse(completion, responseId) {
|
|
345
413
|
const id = responseId ?? `resp_${randomId()}`;
|
|
346
414
|
const choice = firstChoice(completion);
|
|
347
|
-
const message =
|
|
415
|
+
const message = asRecord(choice.message);
|
|
348
416
|
const model = contentToText(completion.model) || DEFAULT_MODEL;
|
|
349
417
|
const output = outputItemsFromMessage(message);
|
|
350
418
|
const usage = responseUsage(completion.usage);
|
|
@@ -371,7 +439,7 @@ function chatCompletionToResponse(completion, responseId) {
|
|
|
371
439
|
}
|
|
372
440
|
function chatCompletionToCompletion(completion) {
|
|
373
441
|
const choice = firstChoice(completion);
|
|
374
|
-
const message =
|
|
442
|
+
const message = asRecord(choice.message);
|
|
375
443
|
return removeUndefined({
|
|
376
444
|
choices: [
|
|
377
445
|
{
|
|
@@ -389,9 +457,9 @@ function chatCompletionToCompletion(completion) {
|
|
|
389
457
|
});
|
|
390
458
|
}
|
|
391
459
|
function normalizeModelsResponse(upstream) {
|
|
392
|
-
const record =
|
|
460
|
+
const record = asRecord(upstream);
|
|
393
461
|
const data = Array.isArray(record.data) ? record.data : Array.isArray(upstream) ? upstream : [];
|
|
394
|
-
const models = data.map((model) =>
|
|
462
|
+
const models = data.map((model) => asRecord(model)).filter((model) => typeof model.id === "string").map((model) => ({
|
|
395
463
|
created: model.created ?? 0,
|
|
396
464
|
id: model.id,
|
|
397
465
|
object: "model",
|
|
@@ -540,7 +608,7 @@ function inputToMessages(input) {
|
|
|
540
608
|
}
|
|
541
609
|
const messages = [];
|
|
542
610
|
for (const item of input) {
|
|
543
|
-
const record =
|
|
611
|
+
const record = asRecord(item);
|
|
544
612
|
if (record.type === "function_call_output") {
|
|
545
613
|
messages.push({
|
|
546
614
|
content: contentToText(record.output),
|
|
@@ -582,7 +650,7 @@ function chatMessageContent(content) {
|
|
|
582
650
|
}
|
|
583
651
|
const parts = [];
|
|
584
652
|
for (const part of content) {
|
|
585
|
-
const record =
|
|
653
|
+
const record = asRecord(part);
|
|
586
654
|
const type = contentToText(record.type);
|
|
587
655
|
if (type === "input_text" || type === "output_text" || type === "text") {
|
|
588
656
|
parts.push({ text: contentToText(record.text), type: "text" });
|
|
@@ -640,7 +708,7 @@ function chatTools(tools) {
|
|
|
640
708
|
if (!Array.isArray(tools)) {
|
|
641
709
|
return void 0;
|
|
642
710
|
}
|
|
643
|
-
const converted = tools.map((tool) =>
|
|
711
|
+
const converted = tools.map((tool) => asRecord(tool)).filter((tool) => tool.type === "function").map((tool) => ({
|
|
644
712
|
function: removeUndefined({
|
|
645
713
|
description: tool.description,
|
|
646
714
|
name: tool.name,
|
|
@@ -655,7 +723,7 @@ function chatToolChoice(toolChoice) {
|
|
|
655
723
|
if (typeof toolChoice === "string" || toolChoice === void 0) {
|
|
656
724
|
return toolChoice;
|
|
657
725
|
}
|
|
658
|
-
const record =
|
|
726
|
+
const record = asRecord(toolChoice);
|
|
659
727
|
if (record.type === "function" && typeof record.name === "string") {
|
|
660
728
|
return { function: { name: record.name }, type: "function" };
|
|
661
729
|
}
|
|
@@ -669,8 +737,8 @@ function outputItemsFromMessage(message) {
|
|
|
669
737
|
}
|
|
670
738
|
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
|
671
739
|
for (const toolCall of toolCalls) {
|
|
672
|
-
const record =
|
|
673
|
-
const fn =
|
|
740
|
+
const record = asRecord(toolCall);
|
|
741
|
+
const fn = asRecord(record.function);
|
|
674
742
|
output.push(
|
|
675
743
|
functionCallItem({
|
|
676
744
|
arguments: contentToText(fn.arguments),
|
|
@@ -711,10 +779,10 @@ function outputText(output) {
|
|
|
711
779
|
return output.flatMap((item) => {
|
|
712
780
|
const content = item.content;
|
|
713
781
|
return Array.isArray(content) ? content : [];
|
|
714
|
-
}).map((part) => contentToText(
|
|
782
|
+
}).map((part) => contentToText(asRecord(part).text)).filter(Boolean).join("");
|
|
715
783
|
}
|
|
716
784
|
function responseUsage(usage) {
|
|
717
|
-
const record =
|
|
785
|
+
const record = asRecord(usage);
|
|
718
786
|
if (Object.keys(record).length === 0) {
|
|
719
787
|
return null;
|
|
720
788
|
}
|
|
@@ -728,7 +796,7 @@ function responseUsage(usage) {
|
|
|
728
796
|
}
|
|
729
797
|
function firstChoice(completion) {
|
|
730
798
|
const choices = Array.isArray(completion.choices) ? completion.choices : [];
|
|
731
|
-
return
|
|
799
|
+
return asRecord(choices[0]);
|
|
732
800
|
}
|
|
733
801
|
function processChatSseLine(line, enqueue, tools, appendText) {
|
|
734
802
|
const trimmed = line.trim();
|
|
@@ -744,7 +812,7 @@ function processChatSseLine(line, enqueue, tools, appendText) {
|
|
|
744
812
|
return;
|
|
745
813
|
}
|
|
746
814
|
const choice = firstChoice(parsed);
|
|
747
|
-
const delta =
|
|
815
|
+
const delta = asRecord(choice.delta);
|
|
748
816
|
const content = contentToText(delta.content);
|
|
749
817
|
if (content) {
|
|
750
818
|
appendText(content);
|
|
@@ -758,8 +826,8 @@ function processChatSseLine(line, enqueue, tools, appendText) {
|
|
|
758
826
|
}
|
|
759
827
|
const toolCalls = Array.isArray(delta.tool_calls) ? delta.tool_calls : [];
|
|
760
828
|
for (const toolCall of toolCalls) {
|
|
761
|
-
const record =
|
|
762
|
-
const fn =
|
|
829
|
+
const record = asRecord(toolCall);
|
|
830
|
+
const fn = asRecord(record.function);
|
|
763
831
|
const index = typeof record.index === "number" ? record.index : tools.size;
|
|
764
832
|
const existing = tools.get(index) ?? {
|
|
765
833
|
arguments: "",
|
|
@@ -807,7 +875,7 @@ data: ${JSON.stringify(data)}
|
|
|
807
875
|
}
|
|
808
876
|
function parseJson(data) {
|
|
809
877
|
try {
|
|
810
|
-
return
|
|
878
|
+
return asRecord(JSON.parse(data));
|
|
811
879
|
} catch {
|
|
812
880
|
return void 0;
|
|
813
881
|
}
|
|
@@ -815,7 +883,7 @@ function parseJson(data) {
|
|
|
815
883
|
function removeUndefined(record) {
|
|
816
884
|
return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== void 0));
|
|
817
885
|
}
|
|
818
|
-
function
|
|
886
|
+
function asRecord(value) {
|
|
819
887
|
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
820
888
|
}
|
|
821
889
|
function randomId() {
|
|
@@ -828,43 +896,111 @@ function epochSeconds() {
|
|
|
828
896
|
// src/server.ts
|
|
829
897
|
var DEFAULT_HOST = "127.0.0.1";
|
|
830
898
|
var DEFAULT_PORT = 4141;
|
|
899
|
+
var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
|
|
831
900
|
function createHoopilotHandler(options = {}) {
|
|
832
901
|
const client = new CopilotClient(options);
|
|
833
902
|
const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
|
|
903
|
+
const logger = serverLogger(options);
|
|
834
904
|
return async (request) => {
|
|
905
|
+
const startedAt = performance.now();
|
|
835
906
|
const url = new URL(request.url);
|
|
907
|
+
const requestId = requestIdFor(request);
|
|
908
|
+
const requestLogger = logger.child({
|
|
909
|
+
method: request.method,
|
|
910
|
+
path: url.pathname,
|
|
911
|
+
requestId,
|
|
912
|
+
route: routeFor(request.method, url.pathname)
|
|
913
|
+
});
|
|
836
914
|
if (request.method === "OPTIONS") {
|
|
837
|
-
return new Response(null, { headers: corsHeaders() })
|
|
915
|
+
return finishResponse(new Response(null, { headers: corsHeaders() }), {
|
|
916
|
+
logger: requestLogger,
|
|
917
|
+
requestId,
|
|
918
|
+
startedAt
|
|
919
|
+
});
|
|
838
920
|
}
|
|
839
921
|
if (!isAuthorized(request, apiKey)) {
|
|
840
|
-
|
|
922
|
+
requestLogger.warn({ event: "http.request.unauthorized" }, "invalid hoopilot api key");
|
|
923
|
+
return finishResponse(
|
|
924
|
+
jsonError(401, "invalid_api_key", "Invalid or missing Hoopilot API key."),
|
|
925
|
+
{
|
|
926
|
+
logger: requestLogger,
|
|
927
|
+
requestId,
|
|
928
|
+
startedAt
|
|
929
|
+
}
|
|
930
|
+
);
|
|
841
931
|
}
|
|
842
932
|
try {
|
|
843
933
|
if (request.method === "GET" && (url.pathname === "/" || url.pathname === "/healthz")) {
|
|
844
|
-
return
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
934
|
+
return finishResponse(
|
|
935
|
+
jsonResponse({
|
|
936
|
+
name: "hoopilot",
|
|
937
|
+
object: "health",
|
|
938
|
+
status: "ok"
|
|
939
|
+
}),
|
|
940
|
+
{ logger: requestLogger, requestId, startedAt }
|
|
941
|
+
);
|
|
849
942
|
}
|
|
850
943
|
if (request.method === "GET" && url.pathname === "/v1/models") {
|
|
851
|
-
return await handleModels(client, request.signal)
|
|
944
|
+
return finishResponse(await handleModels(client, request.signal, requestLogger), {
|
|
945
|
+
logger: requestLogger,
|
|
946
|
+
requestId,
|
|
947
|
+
startedAt
|
|
948
|
+
});
|
|
852
949
|
}
|
|
853
950
|
if (request.method === "POST" && url.pathname === "/v1/chat/completions") {
|
|
854
|
-
return await handleChatCompletions(client, request)
|
|
951
|
+
return finishResponse(await handleChatCompletions(client, request, requestLogger), {
|
|
952
|
+
logger: requestLogger,
|
|
953
|
+
requestId,
|
|
954
|
+
startedAt
|
|
955
|
+
});
|
|
855
956
|
}
|
|
856
957
|
if (request.method === "POST" && url.pathname === "/v1/completions") {
|
|
857
|
-
return await handleCompletions(client, request)
|
|
958
|
+
return finishResponse(await handleCompletions(client, request, requestLogger), {
|
|
959
|
+
logger: requestLogger,
|
|
960
|
+
requestId,
|
|
961
|
+
startedAt
|
|
962
|
+
});
|
|
858
963
|
}
|
|
859
964
|
if (request.method === "POST" && url.pathname === "/v1/responses") {
|
|
860
|
-
return await handleResponses(client, request)
|
|
965
|
+
return finishResponse(await handleResponses(client, request, requestLogger), {
|
|
966
|
+
logger: requestLogger,
|
|
967
|
+
requestId,
|
|
968
|
+
startedAt
|
|
969
|
+
});
|
|
861
970
|
}
|
|
862
|
-
return
|
|
971
|
+
return finishResponse(
|
|
972
|
+
jsonError(404, "not_found", `No route for ${request.method} ${url.pathname}.`),
|
|
973
|
+
{ logger: requestLogger, requestId, startedAt }
|
|
974
|
+
);
|
|
863
975
|
} catch (error) {
|
|
864
976
|
if (error instanceof CopilotAuthError) {
|
|
865
|
-
|
|
977
|
+
requestLogger.warn(
|
|
978
|
+
{ err: errorDetails(error), event: "copilot.auth.missing" },
|
|
979
|
+
"copilot auth failed"
|
|
980
|
+
);
|
|
981
|
+
return finishResponse(jsonError(401, "copilot_auth_error", error.message), {
|
|
982
|
+
logger: requestLogger,
|
|
983
|
+
requestId,
|
|
984
|
+
startedAt
|
|
985
|
+
});
|
|
866
986
|
}
|
|
867
|
-
|
|
987
|
+
const message = errorMessage(error);
|
|
988
|
+
if (message === INVALID_JSON_MESSAGE) {
|
|
989
|
+
requestLogger.warn(
|
|
990
|
+
{ err: errorDetails(error), event: "http.request.failed" },
|
|
991
|
+
"request body was invalid json"
|
|
992
|
+
);
|
|
993
|
+
} else {
|
|
994
|
+
requestLogger.error(
|
|
995
|
+
{ err: errorDetails(error), event: "http.request.failed" },
|
|
996
|
+
"request failed"
|
|
997
|
+
);
|
|
998
|
+
}
|
|
999
|
+
return finishResponse(jsonError(500, "internal_error", message), {
|
|
1000
|
+
logger: requestLogger,
|
|
1001
|
+
requestId,
|
|
1002
|
+
startedAt
|
|
1003
|
+
});
|
|
868
1004
|
}
|
|
869
1005
|
};
|
|
870
1006
|
}
|
|
@@ -893,35 +1029,53 @@ function startHoopilotServer(options = {}) {
|
|
|
893
1029
|
url: `http://${host}:${server.port}`
|
|
894
1030
|
};
|
|
895
1031
|
}
|
|
896
|
-
async function handleModels(client, signal) {
|
|
1032
|
+
async function handleModels(client, signal, logger) {
|
|
897
1033
|
const upstream = await client.models(signal);
|
|
898
1034
|
if (!upstream.ok) {
|
|
1035
|
+
if (isUpstreamAuthStatus(upstream.status)) {
|
|
1036
|
+
return proxyError(upstream, logger);
|
|
1037
|
+
}
|
|
1038
|
+
logger.warn(
|
|
1039
|
+
{
|
|
1040
|
+
event: "copilot.models.fallback",
|
|
1041
|
+
upstreamPath: "/models",
|
|
1042
|
+
upstreamStatus: upstream.status
|
|
1043
|
+
},
|
|
1044
|
+
"falling back to built-in model list"
|
|
1045
|
+
);
|
|
899
1046
|
return jsonResponse({ data: fallbackModels(), object: "list" });
|
|
900
1047
|
}
|
|
1048
|
+
logUpstreamSuccess(logger, "/models", upstream.status);
|
|
901
1049
|
return jsonResponse(normalizeModelsResponse(await upstream.json()));
|
|
902
1050
|
}
|
|
903
|
-
async function handleChatCompletions(client, request) {
|
|
1051
|
+
async function handleChatCompletions(client, request, logger) {
|
|
904
1052
|
const upstream = await client.forwardChatCompletions(await request.text(), request.signal);
|
|
1053
|
+
if (!upstream.ok) {
|
|
1054
|
+
return proxyError(upstream, logger);
|
|
1055
|
+
}
|
|
1056
|
+
logUpstreamSuccess(logger, "/chat/completions", upstream.status);
|
|
905
1057
|
return proxyResponse(upstream);
|
|
906
1058
|
}
|
|
907
|
-
async function handleCompletions(client, request) {
|
|
1059
|
+
async function handleCompletions(client, request, logger) {
|
|
908
1060
|
const body = await readJson(request);
|
|
909
1061
|
const upstream = await client.chatCompletions(
|
|
910
1062
|
completionsRequestToChatCompletion(body),
|
|
911
1063
|
request.signal
|
|
912
1064
|
);
|
|
913
1065
|
if (!upstream.ok) {
|
|
914
|
-
return proxyError(upstream);
|
|
1066
|
+
return proxyError(upstream, logger);
|
|
915
1067
|
}
|
|
1068
|
+
logUpstreamSuccess(logger, "/chat/completions", upstream.status);
|
|
916
1069
|
return jsonResponse(chatCompletionToCompletion(await upstream.json()));
|
|
917
1070
|
}
|
|
918
|
-
async function handleResponses(client, request) {
|
|
1071
|
+
async function handleResponses(client, request, logger) {
|
|
919
1072
|
const body = await readJson(request);
|
|
920
1073
|
const chatRequest = responsesRequestToChatCompletion(body);
|
|
921
1074
|
const upstream = await client.chatCompletions(chatRequest, request.signal);
|
|
922
1075
|
if (!upstream.ok) {
|
|
923
|
-
return proxyError(upstream);
|
|
1076
|
+
return proxyError(upstream, logger);
|
|
924
1077
|
}
|
|
1078
|
+
logUpstreamSuccess(logger, "/chat/completions", upstream.status);
|
|
925
1079
|
if (body.stream === true && upstream.body) {
|
|
926
1080
|
return new Response(
|
|
927
1081
|
responsesStreamFromChatStream(upstream.body, {
|
|
@@ -939,8 +1093,19 @@ async function handleResponses(client, request) {
|
|
|
939
1093
|
}
|
|
940
1094
|
return jsonResponse(chatCompletionToResponse(await upstream.json()));
|
|
941
1095
|
}
|
|
942
|
-
async function proxyError(upstream) {
|
|
1096
|
+
async function proxyError(upstream, logger) {
|
|
943
1097
|
const text = await upstream.text();
|
|
1098
|
+
if (isUpstreamAuthStatus(upstream.status)) {
|
|
1099
|
+
logger.warn(
|
|
1100
|
+
{ event: "copilot.auth.rejected", upstreamStatus: upstream.status },
|
|
1101
|
+
"copilot rejected credential or account access"
|
|
1102
|
+
);
|
|
1103
|
+
return jsonError(401, "copilot_auth_error", upstreamAuthMessage(text || upstream.statusText));
|
|
1104
|
+
}
|
|
1105
|
+
logger.warn(
|
|
1106
|
+
{ event: "copilot.request.failed", upstreamStatus: upstream.status },
|
|
1107
|
+
"copilot upstream request failed"
|
|
1108
|
+
);
|
|
944
1109
|
return jsonError(upstream.status, "copilot_error", text || upstream.statusText);
|
|
945
1110
|
}
|
|
946
1111
|
function proxyResponse(upstream) {
|
|
@@ -962,7 +1127,7 @@ async function readJson(request) {
|
|
|
962
1127
|
const value = await request.json();
|
|
963
1128
|
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
964
1129
|
} catch {
|
|
965
|
-
throw new Error(
|
|
1130
|
+
throw new Error(INVALID_JSON_MESSAGE);
|
|
966
1131
|
}
|
|
967
1132
|
}
|
|
968
1133
|
function jsonResponse(body, status = 200) {
|
|
@@ -1001,26 +1166,133 @@ function isAuthorized(request, apiKey) {
|
|
|
1001
1166
|
const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1];
|
|
1002
1167
|
return bearer === apiKey || request.headers.get("x-api-key") === apiKey;
|
|
1003
1168
|
}
|
|
1169
|
+
function isUpstreamAuthStatus(status) {
|
|
1170
|
+
return status === 401 || status === 403;
|
|
1171
|
+
}
|
|
1172
|
+
function upstreamAuthMessage(message) {
|
|
1173
|
+
return `GitHub Copilot rejected the credential or account access: ${message}`;
|
|
1174
|
+
}
|
|
1004
1175
|
function isLoopbackHost(host) {
|
|
1005
1176
|
return host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
1006
1177
|
}
|
|
1007
|
-
function
|
|
1178
|
+
function errorMessage(error) {
|
|
1008
1179
|
return error instanceof Error ? error.message : String(error);
|
|
1009
1180
|
}
|
|
1181
|
+
function serverLogger(options) {
|
|
1182
|
+
if (options.logger) {
|
|
1183
|
+
return options.logger.child({ component: "server" });
|
|
1184
|
+
}
|
|
1185
|
+
if (shouldCreateLogger(options)) {
|
|
1186
|
+
return createHoopilotLogger({
|
|
1187
|
+
env: options.env,
|
|
1188
|
+
format: options.logFormat,
|
|
1189
|
+
level: options.logLevel
|
|
1190
|
+
}).child({ component: "server" });
|
|
1191
|
+
}
|
|
1192
|
+
return noopLogger;
|
|
1193
|
+
}
|
|
1194
|
+
function finishResponse(response, options) {
|
|
1195
|
+
const withRequestId = responseWithRequestId(response, options.requestId);
|
|
1196
|
+
logRequestCompleted(options.logger, withRequestId, options.startedAt);
|
|
1197
|
+
return withRequestId;
|
|
1198
|
+
}
|
|
1199
|
+
function responseWithRequestId(response, requestId) {
|
|
1200
|
+
const headers = new Headers(response.headers);
|
|
1201
|
+
headers.set("x-request-id", requestId);
|
|
1202
|
+
return new Response(response.body, {
|
|
1203
|
+
headers,
|
|
1204
|
+
status: response.status,
|
|
1205
|
+
statusText: response.statusText
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
function logRequestCompleted(logger, response, startedAt) {
|
|
1209
|
+
const fields = {
|
|
1210
|
+
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
|
|
1211
|
+
event: "http.request.completed",
|
|
1212
|
+
status: response.status,
|
|
1213
|
+
stream: isStreamingResponse(response)
|
|
1214
|
+
};
|
|
1215
|
+
if (response.status >= 500) {
|
|
1216
|
+
logger.error(fields, "request completed with server error");
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
if (response.status >= 400) {
|
|
1220
|
+
logger.warn(fields, "request completed with client error");
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
logger.info(fields, "request completed");
|
|
1224
|
+
}
|
|
1225
|
+
function requestIdFor(request) {
|
|
1226
|
+
const existing = request.headers.get("x-request-id")?.trim();
|
|
1227
|
+
return existing || crypto.randomUUID();
|
|
1228
|
+
}
|
|
1229
|
+
function routeFor(method, path) {
|
|
1230
|
+
if (method === "OPTIONS") {
|
|
1231
|
+
return "cors.preflight";
|
|
1232
|
+
}
|
|
1233
|
+
if (method === "GET" && (path === "/" || path === "/healthz")) {
|
|
1234
|
+
return "health";
|
|
1235
|
+
}
|
|
1236
|
+
if (method === "GET" && path === "/v1/models") {
|
|
1237
|
+
return "models";
|
|
1238
|
+
}
|
|
1239
|
+
if (method === "POST" && path === "/v1/chat/completions") {
|
|
1240
|
+
return "chat_completions";
|
|
1241
|
+
}
|
|
1242
|
+
if (method === "POST" && path === "/v1/completions") {
|
|
1243
|
+
return "completions";
|
|
1244
|
+
}
|
|
1245
|
+
if (method === "POST" && path === "/v1/responses") {
|
|
1246
|
+
return "responses";
|
|
1247
|
+
}
|
|
1248
|
+
return "not_found";
|
|
1249
|
+
}
|
|
1250
|
+
function isStreamingResponse(response) {
|
|
1251
|
+
return response.headers.get("content-type")?.includes("text/event-stream") ?? false;
|
|
1252
|
+
}
|
|
1253
|
+
function logUpstreamSuccess(logger, upstreamPath, status) {
|
|
1254
|
+
logger.debug(
|
|
1255
|
+
{
|
|
1256
|
+
event: "copilot.request.completed",
|
|
1257
|
+
upstreamPath,
|
|
1258
|
+
upstreamStatus: status
|
|
1259
|
+
},
|
|
1260
|
+
"copilot upstream request completed"
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
function errorDetails(error) {
|
|
1264
|
+
if (error instanceof Error) {
|
|
1265
|
+
return {
|
|
1266
|
+
message: error.message,
|
|
1267
|
+
name: error.name,
|
|
1268
|
+
stack: error.stack
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
return { message: String(error) };
|
|
1272
|
+
}
|
|
1010
1273
|
export {
|
|
1011
1274
|
CopilotAuth,
|
|
1012
1275
|
CopilotAuthError,
|
|
1013
1276
|
CopilotClient,
|
|
1277
|
+
DEFAULT_LOG_FORMAT,
|
|
1278
|
+
DEFAULT_LOG_LEVEL,
|
|
1014
1279
|
DEFAULT_MODEL,
|
|
1280
|
+
authStorePath,
|
|
1015
1281
|
chatCompletionToCompletion,
|
|
1016
1282
|
chatCompletionToResponse,
|
|
1017
1283
|
completionsRequestToChatCompletion,
|
|
1018
1284
|
createHoopilotHandler,
|
|
1285
|
+
createHoopilotLogger,
|
|
1019
1286
|
fallbackModels,
|
|
1287
|
+
githubCopilotDeviceLogin,
|
|
1288
|
+
noopLogger,
|
|
1020
1289
|
normalizeModelsResponse,
|
|
1290
|
+
parseLogFormat,
|
|
1291
|
+
parseLogLevel,
|
|
1292
|
+
readStoredCopilotAuth,
|
|
1021
1293
|
responsesRequestToChatCompletion,
|
|
1022
1294
|
responsesStreamFromChatStream,
|
|
1023
|
-
|
|
1024
|
-
|
|
1295
|
+
startHoopilotServer,
|
|
1296
|
+
writeStoredCopilotAuth
|
|
1025
1297
|
};
|
|
1026
1298
|
//# sourceMappingURL=index.js.map
|