@openhoo/hoopilot 0.3.1 → 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/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
- import { execFileSync } from "child_process";
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
- #authMode;
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.#authMode = options.authMode ?? "auto";
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 directCopilotToken = this.#resolveDirectCopilotToken();
43
- if (directCopilotToken) {
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() + 10 * 6e4,
79
- source: "direct-github-token",
80
- token: githubToken
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: asRecord2(request.reasoning).effort,
325
- response_format: asRecord2(request.text).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 = asRecord2(choice.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 = asRecord2(choice.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 = asRecord2(upstream);
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) => asRecord2(model)).filter((model) => typeof model.id === "string").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 = asRecord2(item);
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 = asRecord2(part);
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) => asRecord2(tool)).filter((tool) => tool.type === "function").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 = asRecord2(toolChoice);
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 = asRecord2(toolCall);
673
- const fn = asRecord2(record.function);
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(asRecord2(part).text)).filter(Boolean).join("");
782
+ }).map((part) => contentToText(asRecord(part).text)).filter(Boolean).join("");
715
783
  }
716
784
  function responseUsage(usage) {
717
- const record = asRecord2(usage);
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 asRecord2(choices[0]);
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 = asRecord2(choice.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 = asRecord2(toolCall);
762
- const fn = asRecord2(record.function);
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 asRecord2(JSON.parse(data));
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 asRecord2(value) {
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
- return jsonError(401, "invalid_api_key", "Invalid or missing Hoopilot API key.");
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 jsonResponse({
845
- name: "hoopilot",
846
- object: "health",
847
- status: "ok"
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 jsonError(404, "not_found", `No route for ${request.method} ${url.pathname}.`);
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
- return jsonError(401, "copilot_auth_error", error.message);
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
- return jsonError(500, "internal_error", errorMessage2(error));
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("Request body must be valid JSON.");
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 errorMessage2(error) {
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
- splitCommand,
1024
- startHoopilotServer
1295
+ startHoopilotServer,
1296
+ writeStoredCopilotAuth
1025
1297
  };
1026
1298
  //# sourceMappingURL=index.js.map