@openhoo/hoopilot 0.2.2

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/cli.js ADDED
@@ -0,0 +1,1162 @@
1
+ #!/usr/bin/env bun
2
+
3
+ // src/auth.ts
4
+ import { execFileSync } from "child_process";
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";
7
+ var REFRESH_SKEW_MS = 6e4;
8
+ var defaultLogger = {
9
+ info: () => void 0,
10
+ warn: () => void 0,
11
+ error: () => void 0
12
+ };
13
+ var CopilotAuthError = class extends Error {
14
+ constructor(message) {
15
+ super(message);
16
+ this.name = "CopilotAuthError";
17
+ }
18
+ };
19
+ var CopilotAuth = class {
20
+ #authMode;
21
+ #copilotApiBaseUrl;
22
+ #copilotToken;
23
+ #env;
24
+ #fetch;
25
+ #githubToken;
26
+ #githubTokenCommand;
27
+ #logger;
28
+ #tokenExchangeUrl;
29
+ #cachedAccess;
30
+ constructor(options = {}) {
31
+ this.#authMode = options.authMode ?? "auto";
32
+ this.#copilotApiBaseUrl = trimTrailingSlash(
33
+ options.copilotApiBaseUrl ?? options.env?.COPILOT_API_BASE_URL ?? DEFAULT_COPILOT_API_BASE_URL
34
+ );
35
+ this.#copilotToken = options.copilotToken;
36
+ this.#env = options.env ?? process.env;
37
+ this.#fetch = options.fetch ?? fetch;
38
+ this.#githubToken = options.githubToken;
39
+ this.#githubTokenCommand = options.githubTokenCommand ?? "gh auth token";
40
+ this.#logger = options.logger ?? defaultLogger;
41
+ this.#tokenExchangeUrl = options.tokenExchangeUrl ?? options.env?.COPILOT_TOKEN_EXCHANGE_URL ?? DEFAULT_TOKEN_EXCHANGE_URL;
42
+ }
43
+ async getAccess() {
44
+ if (this.#cachedAccess && this.#cachedAccess.expiresAtMs - REFRESH_SKEW_MS > Date.now()) {
45
+ return this.#cachedAccess;
46
+ }
47
+ const directCopilotToken = this.#resolveDirectCopilotToken();
48
+ if (this.#authMode === "copilot-token") {
49
+ if (!directCopilotToken) {
50
+ throw new CopilotAuthError("COPILOT_API_TOKEN or GITHUB_COPILOT_API_TOKEN is required.");
51
+ }
52
+ return this.#cacheAccess({
53
+ apiBaseUrl: this.#copilotApiBaseUrl,
54
+ expiresAtMs: Date.now() + 10 * 6e4,
55
+ source: "copilot-token",
56
+ token: directCopilotToken
57
+ });
58
+ }
59
+ if (directCopilotToken) {
60
+ return this.#cacheAccess({
61
+ apiBaseUrl: this.#copilotApiBaseUrl,
62
+ expiresAtMs: Date.now() + 10 * 6e4,
63
+ source: "copilot-token",
64
+ token: directCopilotToken
65
+ });
66
+ }
67
+ const githubToken = this.#resolveGithubToken();
68
+ if (!githubToken) {
69
+ throw new CopilotAuthError(
70
+ "No Copilot credential found. Set COPILOT_GITHUB_TOKEN, GITHUB_TOKEN, or sign in with gh auth login."
71
+ );
72
+ }
73
+ if (this.#authMode === "direct-github-token") {
74
+ return this.#cacheAccess({
75
+ apiBaseUrl: this.#copilotApiBaseUrl,
76
+ expiresAtMs: Date.now() + 10 * 6e4,
77
+ source: "direct-github-token",
78
+ token: githubToken
79
+ });
80
+ }
81
+ try {
82
+ const exchanged = await this.#exchangeGithubToken(githubToken);
83
+ return this.#cacheAccess(exchanged);
84
+ } catch (error) {
85
+ if (this.#authMode === "github-token") {
86
+ throw error;
87
+ }
88
+ this.#logger.warn(
89
+ `Copilot token exchange failed; falling back to direct GitHub token mode: ${errorMessage(error)}`
90
+ );
91
+ return this.#cacheAccess({
92
+ apiBaseUrl: this.#copilotApiBaseUrl,
93
+ expiresAtMs: Date.now() + 10 * 6e4,
94
+ source: "direct-github-token",
95
+ token: githubToken
96
+ });
97
+ }
98
+ }
99
+ #cacheAccess(access) {
100
+ this.#cachedAccess = access;
101
+ return access;
102
+ }
103
+ async #exchangeGithubToken(githubToken) {
104
+ const response = await this.#fetch(this.#tokenExchangeUrl, {
105
+ headers: {
106
+ accept: "application/vnd.github+json",
107
+ authorization: `token ${githubToken}`,
108
+ "editor-plugin-version": "hoopilot/0.1.0",
109
+ "editor-version": "Hoopilot/0.1.0",
110
+ "user-agent": "hoopilot/0.1.0"
111
+ },
112
+ method: "GET"
113
+ });
114
+ if (!response.ok) {
115
+ throw new CopilotAuthError(
116
+ `GitHub Copilot token exchange failed with ${response.status}: ${await safeResponseText(
117
+ response
118
+ )}`
119
+ );
120
+ }
121
+ const body = asRecord(await response.json());
122
+ const token = getString(body, "token");
123
+ if (!token) {
124
+ throw new CopilotAuthError("GitHub Copilot token exchange response did not include a token.");
125
+ }
126
+ return {
127
+ apiBaseUrl: endpointFromResponse(body) ?? this.#copilotApiBaseUrl,
128
+ expiresAtMs: expiresAtFromResponse(body),
129
+ source: "github-token",
130
+ token
131
+ };
132
+ }
133
+ #resolveDirectCopilotToken() {
134
+ return firstNonEmpty(
135
+ this.#copilotToken,
136
+ this.#env.COPILOT_API_TOKEN,
137
+ this.#env.GITHUB_COPILOT_API_TOKEN,
138
+ this.#env.GITHUB_COPILOT_TOKEN
139
+ );
140
+ }
141
+ #resolveGithubToken() {
142
+ return firstNonEmpty(
143
+ this.#githubToken,
144
+ this.#env.COPILOT_GITHUB_TOKEN,
145
+ this.#env.GITHUB_COPILOT_GITHUB_TOKEN,
146
+ this.#env.GH_TOKEN,
147
+ this.#env.GITHUB_TOKEN,
148
+ this.#readGithubTokenCommand()
149
+ );
150
+ }
151
+ #readGithubTokenCommand() {
152
+ if (this.#githubTokenCommand === false) {
153
+ return void 0;
154
+ }
155
+ const parts = splitCommand(this.#githubTokenCommand);
156
+ const [command, ...args] = parts;
157
+ if (!command) {
158
+ return void 0;
159
+ }
160
+ try {
161
+ const output = execFileSync(command, args, {
162
+ encoding: "utf8",
163
+ stdio: ["ignore", "pipe", "ignore"],
164
+ timeout: 5e3
165
+ });
166
+ return output.trim() || void 0;
167
+ } catch {
168
+ return void 0;
169
+ }
170
+ }
171
+ };
172
+ function splitCommand(command) {
173
+ const parts = [];
174
+ let current = "";
175
+ let quote;
176
+ let escaping = false;
177
+ for (const character of command.trim()) {
178
+ if (escaping) {
179
+ current += character;
180
+ escaping = false;
181
+ continue;
182
+ }
183
+ if (character === "\\") {
184
+ escaping = true;
185
+ continue;
186
+ }
187
+ if (quote) {
188
+ if (character === quote) {
189
+ quote = void 0;
190
+ } else {
191
+ current += character;
192
+ }
193
+ continue;
194
+ }
195
+ if (character === "'" || character === '"') {
196
+ quote = character;
197
+ continue;
198
+ }
199
+ if (/\s/.test(character)) {
200
+ if (current) {
201
+ parts.push(current);
202
+ current = "";
203
+ }
204
+ continue;
205
+ }
206
+ current += character;
207
+ }
208
+ if (current) {
209
+ parts.push(current);
210
+ }
211
+ return parts;
212
+ }
213
+ function endpointFromResponse(body) {
214
+ const endpoints = asRecord(body.endpoints);
215
+ const apiUrl = getString(endpoints, "api") ?? getString(endpoints, "proxy");
216
+ return apiUrl ? trimTrailingSlash(apiUrl) : void 0;
217
+ }
218
+ function expiresAtFromResponse(body) {
219
+ const expiresAt = body.expires_at;
220
+ if (typeof expiresAt === "number") {
221
+ return expiresAt < 1e10 ? expiresAt * 1e3 : expiresAt;
222
+ }
223
+ if (typeof expiresAt === "string") {
224
+ const asNumber = Number(expiresAt);
225
+ if (Number.isFinite(asNumber)) {
226
+ return asNumber < 1e10 ? asNumber * 1e3 : asNumber;
227
+ }
228
+ const parsed = Date.parse(expiresAt);
229
+ if (Number.isFinite(parsed)) {
230
+ return parsed;
231
+ }
232
+ }
233
+ const refreshIn = body.refresh_in;
234
+ if (typeof refreshIn === "number" && Number.isFinite(refreshIn)) {
235
+ return Date.now() + refreshIn * 1e3;
236
+ }
237
+ return Date.now() + 10 * 6e4;
238
+ }
239
+ function firstNonEmpty(...values) {
240
+ for (const value of values) {
241
+ const trimmed = value?.trim();
242
+ if (trimmed) {
243
+ return trimmed;
244
+ }
245
+ }
246
+ return void 0;
247
+ }
248
+ function asRecord(value) {
249
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
250
+ }
251
+ function getString(record, key) {
252
+ const value = record[key];
253
+ return typeof value === "string" && value ? value : void 0;
254
+ }
255
+ function trimTrailingSlash(value) {
256
+ return value.replace(/\/+$/, "");
257
+ }
258
+ async function safeResponseText(response) {
259
+ const text = await response.text();
260
+ return text.slice(0, 500);
261
+ }
262
+ function errorMessage(error) {
263
+ return error instanceof Error ? error.message : String(error);
264
+ }
265
+
266
+ // src/copilot.ts
267
+ var CopilotClient = class {
268
+ #auth;
269
+ #fetch;
270
+ constructor(options = {}) {
271
+ this.#auth = new CopilotAuth(options);
272
+ this.#fetch = options.fetch ?? fetch;
273
+ }
274
+ async chatCompletions(body, signal) {
275
+ return this.fetchCopilot("/chat/completions", {
276
+ body: JSON.stringify(body),
277
+ headers: {
278
+ "content-type": "application/json"
279
+ },
280
+ method: "POST",
281
+ signal
282
+ });
283
+ }
284
+ async forwardChatCompletions(body, signal) {
285
+ return this.fetchCopilot("/chat/completions", {
286
+ body,
287
+ headers: {
288
+ "content-type": "application/json"
289
+ },
290
+ method: "POST",
291
+ signal
292
+ });
293
+ }
294
+ async models(signal) {
295
+ return this.fetchCopilot("/models", {
296
+ headers: {
297
+ accept: "application/json"
298
+ },
299
+ method: "GET",
300
+ signal
301
+ });
302
+ }
303
+ async fetchCopilot(path, init) {
304
+ const access = await this.#auth.getAccess();
305
+ const headers = new Headers(init.headers);
306
+ headers.set("accept", headers.get("accept") ?? "application/json");
307
+ headers.set("authorization", `Bearer ${access.token}`);
308
+ headers.set("copilot-integration-id", "vscode-chat");
309
+ headers.set("editor-plugin-version", "hoopilot/0.1.0");
310
+ headers.set("editor-version", "Hoopilot/0.1.0");
311
+ headers.set("openai-intent", "conversation-panel");
312
+ headers.set("user-agent", "hoopilot/0.1.0");
313
+ return this.#fetch(`${access.apiBaseUrl}${path}`, {
314
+ ...init,
315
+ headers
316
+ });
317
+ }
318
+ };
319
+
320
+ // src/openai.ts
321
+ var DEFAULT_MODEL = "gpt-4.1";
322
+ function responsesRequestToChatCompletion(request) {
323
+ const messages = [];
324
+ const instructions = contentToText(request.instructions);
325
+ if (instructions) {
326
+ messages.push({ content: instructions, role: "system" });
327
+ }
328
+ for (const message of inputToMessages(request.input)) {
329
+ messages.push(message);
330
+ }
331
+ return removeUndefined({
332
+ frequency_penalty: request.frequency_penalty,
333
+ max_tokens: request.max_output_tokens ?? request.max_tokens,
334
+ messages,
335
+ metadata: request.metadata,
336
+ model: contentToText(request.model) || DEFAULT_MODEL,
337
+ presence_penalty: request.presence_penalty,
338
+ reasoning_effort: asRecord2(request.reasoning).effort,
339
+ response_format: asRecord2(request.text).format,
340
+ seed: request.seed,
341
+ stream: request.stream === true,
342
+ temperature: request.temperature,
343
+ tool_choice: chatToolChoice(request.tool_choice),
344
+ tools: chatTools(request.tools),
345
+ top_p: request.top_p
346
+ });
347
+ }
348
+ function completionsRequestToChatCompletion(request) {
349
+ return removeUndefined({
350
+ max_tokens: request.max_tokens,
351
+ messages: [{ content: promptToText(request.prompt), role: "user" }],
352
+ model: contentToText(request.model) || DEFAULT_MODEL,
353
+ stream: request.stream === true,
354
+ temperature: request.temperature,
355
+ top_p: request.top_p
356
+ });
357
+ }
358
+ function chatCompletionToResponse(completion, responseId) {
359
+ const id = responseId ?? `resp_${randomId()}`;
360
+ const choice = firstChoice(completion);
361
+ const message = asRecord2(choice.message);
362
+ const model = contentToText(completion.model) || DEFAULT_MODEL;
363
+ const output = outputItemsFromMessage(message);
364
+ const usage = responseUsage(completion.usage);
365
+ return removeUndefined({
366
+ created_at: epochSeconds(),
367
+ error: null,
368
+ id,
369
+ incomplete_details: null,
370
+ instructions: null,
371
+ max_output_tokens: null,
372
+ metadata: {},
373
+ model,
374
+ object: "response",
375
+ output,
376
+ output_text: outputText(output),
377
+ parallel_tool_calls: true,
378
+ status: "completed",
379
+ temperature: null,
380
+ tool_choice: "auto",
381
+ tools: [],
382
+ top_p: null,
383
+ usage
384
+ });
385
+ }
386
+ function chatCompletionToCompletion(completion) {
387
+ const choice = firstChoice(completion);
388
+ const message = asRecord2(choice.message);
389
+ return removeUndefined({
390
+ choices: [
391
+ {
392
+ finish_reason: choice.finish_reason ?? "stop",
393
+ index: 0,
394
+ logprobs: null,
395
+ text: contentToText(message.content)
396
+ }
397
+ ],
398
+ created: completion.created ?? epochSeconds(),
399
+ id: completion.id ?? `cmpl_${randomId()}`,
400
+ model: completion.model ?? DEFAULT_MODEL,
401
+ object: "text_completion",
402
+ usage: completion.usage
403
+ });
404
+ }
405
+ function normalizeModelsResponse(upstream) {
406
+ const record = asRecord2(upstream);
407
+ const data = Array.isArray(record.data) ? record.data : Array.isArray(upstream) ? upstream : [];
408
+ const models = data.map((model) => asRecord2(model)).filter((model) => typeof model.id === "string").map((model) => ({
409
+ created: model.created ?? 0,
410
+ id: model.id,
411
+ object: "model",
412
+ owned_by: model.owned_by ?? "github-copilot"
413
+ }));
414
+ return {
415
+ data: models.length > 0 ? models : fallbackModels(),
416
+ object: "list"
417
+ };
418
+ }
419
+ function fallbackModels() {
420
+ return [
421
+ {
422
+ created: 0,
423
+ id: DEFAULT_MODEL,
424
+ object: "model",
425
+ owned_by: "github-copilot"
426
+ }
427
+ ];
428
+ }
429
+ function responsesStreamFromChatStream(chatStream, options) {
430
+ const encoder = new TextEncoder();
431
+ const decoder = new TextDecoder();
432
+ const responseId = options.responseId ?? `resp_${randomId()}`;
433
+ const messageId = `msg_${randomId()}`;
434
+ const createdAt = epochSeconds();
435
+ let buffer = "";
436
+ let text = "";
437
+ const tools = /* @__PURE__ */ new Map();
438
+ return new ReadableStream({
439
+ async start(controller) {
440
+ const enqueue = (event, data) => {
441
+ controller.enqueue(encoder.encode(encodeSse(event, data)));
442
+ };
443
+ enqueue("response.created", {
444
+ response: baseStreamResponse(responseId, options.model, createdAt, "in_progress", []),
445
+ type: "response.created"
446
+ });
447
+ enqueue("response.output_item.added", {
448
+ item: {
449
+ content: [],
450
+ id: messageId,
451
+ role: "assistant",
452
+ status: "in_progress",
453
+ type: "message"
454
+ },
455
+ output_index: 0,
456
+ type: "response.output_item.added"
457
+ });
458
+ enqueue("response.content_part.added", {
459
+ content_index: 0,
460
+ item_id: messageId,
461
+ output_index: 0,
462
+ part: {
463
+ annotations: [],
464
+ text: "",
465
+ type: "output_text"
466
+ },
467
+ type: "response.content_part.added"
468
+ });
469
+ const reader = chatStream.getReader();
470
+ try {
471
+ while (true) {
472
+ const result = await reader.read();
473
+ if (result.done) {
474
+ break;
475
+ }
476
+ buffer += decoder.decode(result.value, { stream: true });
477
+ const lines = buffer.split(/\r?\n/);
478
+ buffer = lines.pop() ?? "";
479
+ for (const line of lines) {
480
+ processChatSseLine(line, enqueue, tools, (delta) => {
481
+ text += delta;
482
+ });
483
+ }
484
+ }
485
+ if (buffer) {
486
+ processChatSseLine(buffer, enqueue, tools, (delta) => {
487
+ text += delta;
488
+ });
489
+ }
490
+ const output = streamOutputItems(messageId, text, [...tools.values()]);
491
+ enqueue("response.output_text.done", {
492
+ content_index: 0,
493
+ item_id: messageId,
494
+ output_index: 0,
495
+ text,
496
+ type: "response.output_text.done"
497
+ });
498
+ enqueue("response.content_part.done", {
499
+ content_index: 0,
500
+ item_id: messageId,
501
+ output_index: 0,
502
+ part: {
503
+ annotations: [],
504
+ text,
505
+ type: "output_text"
506
+ },
507
+ type: "response.content_part.done"
508
+ });
509
+ enqueue("response.output_item.done", {
510
+ item: output[0],
511
+ output_index: 0,
512
+ type: "response.output_item.done"
513
+ });
514
+ tools.forEach((tool, index) => {
515
+ const item = functionCallItem(tool);
516
+ const outputIndex = index + 1;
517
+ enqueue("response.output_item.added", {
518
+ item,
519
+ output_index: outputIndex,
520
+ type: "response.output_item.added"
521
+ });
522
+ enqueue("response.function_call_arguments.done", {
523
+ arguments: tool.arguments,
524
+ item_id: item.id,
525
+ output_index: outputIndex,
526
+ type: "response.function_call_arguments.done"
527
+ });
528
+ enqueue("response.output_item.done", {
529
+ item,
530
+ output_index: outputIndex,
531
+ type: "response.output_item.done"
532
+ });
533
+ });
534
+ enqueue("response.completed", {
535
+ response: baseStreamResponse(responseId, options.model, createdAt, "completed", output),
536
+ type: "response.completed"
537
+ });
538
+ enqueue("done", "[DONE]");
539
+ controller.close();
540
+ } catch (error) {
541
+ controller.error(error);
542
+ } finally {
543
+ reader.releaseLock();
544
+ }
545
+ }
546
+ });
547
+ }
548
+ function inputToMessages(input) {
549
+ if (typeof input === "string") {
550
+ return [{ content: input, role: "user" }];
551
+ }
552
+ if (!Array.isArray(input)) {
553
+ return [];
554
+ }
555
+ const messages = [];
556
+ for (const item of input) {
557
+ const record = asRecord2(item);
558
+ if (record.type === "function_call_output") {
559
+ messages.push({
560
+ content: contentToText(record.output),
561
+ role: "tool",
562
+ tool_call_id: contentToText(record.call_id)
563
+ });
564
+ continue;
565
+ }
566
+ if (record.type === "function_call") {
567
+ messages.push({
568
+ role: "assistant",
569
+ tool_calls: [
570
+ {
571
+ function: {
572
+ arguments: contentToText(record.arguments),
573
+ name: contentToText(record.name)
574
+ },
575
+ id: contentToText(record.call_id) || contentToText(record.id),
576
+ type: "function"
577
+ }
578
+ ]
579
+ });
580
+ continue;
581
+ }
582
+ const role = roleToChatRole(contentToText(record.role));
583
+ const content = chatMessageContent(record.content);
584
+ if (role && content !== void 0) {
585
+ messages.push({ content, role });
586
+ }
587
+ }
588
+ return messages;
589
+ }
590
+ function chatMessageContent(content) {
591
+ if (typeof content === "string") {
592
+ return content;
593
+ }
594
+ if (!Array.isArray(content)) {
595
+ return contentToText(content) || void 0;
596
+ }
597
+ const parts = [];
598
+ for (const part of content) {
599
+ const record = asRecord2(part);
600
+ const type = contentToText(record.type);
601
+ if (type === "input_text" || type === "output_text" || type === "text") {
602
+ parts.push({ text: contentToText(record.text), type: "text" });
603
+ }
604
+ if (type === "input_image") {
605
+ const imageUrl = contentToText(record.image_url);
606
+ if (imageUrl) {
607
+ parts.push({ image_url: { url: imageUrl }, type: "image_url" });
608
+ }
609
+ }
610
+ }
611
+ if (parts.length === 0) {
612
+ return void 0;
613
+ }
614
+ if (parts.every((part) => part.type === "text")) {
615
+ return parts.map((part) => contentToText(part.text)).join("\n");
616
+ }
617
+ return parts;
618
+ }
619
+ function promptToText(prompt) {
620
+ if (Array.isArray(prompt)) {
621
+ return prompt.map((item) => contentToText(item)).join("\n");
622
+ }
623
+ return contentToText(prompt);
624
+ }
625
+ function contentToText(content) {
626
+ if (typeof content === "string") {
627
+ return content;
628
+ }
629
+ if (typeof content === "number" || typeof content === "boolean") {
630
+ return String(content);
631
+ }
632
+ if (Array.isArray(content)) {
633
+ return content.map((item) => contentToText(item)).filter(Boolean).join("\n");
634
+ }
635
+ if (content && typeof content === "object") {
636
+ const record = content;
637
+ if (typeof record.text === "string") {
638
+ return record.text;
639
+ }
640
+ if (typeof record.output_text === "string") {
641
+ return record.output_text;
642
+ }
643
+ return JSON.stringify(content);
644
+ }
645
+ return "";
646
+ }
647
+ function roleToChatRole(role) {
648
+ if (role === "assistant" || role === "developer" || role === "system" || role === "tool") {
649
+ return role === "developer" ? "system" : role;
650
+ }
651
+ return "user";
652
+ }
653
+ function chatTools(tools) {
654
+ if (!Array.isArray(tools)) {
655
+ return void 0;
656
+ }
657
+ const converted = tools.map((tool) => asRecord2(tool)).filter((tool) => tool.type === "function").map((tool) => ({
658
+ function: removeUndefined({
659
+ description: tool.description,
660
+ name: tool.name,
661
+ parameters: tool.parameters,
662
+ strict: tool.strict
663
+ }),
664
+ type: "function"
665
+ }));
666
+ return converted.length > 0 ? converted : void 0;
667
+ }
668
+ function chatToolChoice(toolChoice) {
669
+ if (typeof toolChoice === "string" || toolChoice === void 0) {
670
+ return toolChoice;
671
+ }
672
+ const record = asRecord2(toolChoice);
673
+ if (record.type === "function" && typeof record.name === "string") {
674
+ return { function: { name: record.name }, type: "function" };
675
+ }
676
+ return toolChoice;
677
+ }
678
+ function outputItemsFromMessage(message) {
679
+ const output = [];
680
+ const text = contentToText(message.content);
681
+ if (text) {
682
+ output.push(messageOutputItem(text));
683
+ }
684
+ const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
685
+ for (const toolCall of toolCalls) {
686
+ const record = asRecord2(toolCall);
687
+ const fn = asRecord2(record.function);
688
+ output.push(
689
+ functionCallItem({
690
+ arguments: contentToText(fn.arguments),
691
+ id: contentToText(record.id) || `call_${randomId()}`,
692
+ index: output.length,
693
+ name: contentToText(fn.name)
694
+ })
695
+ );
696
+ }
697
+ return output;
698
+ }
699
+ function messageOutputItem(text, id = `msg_${randomId()}`) {
700
+ return {
701
+ content: [
702
+ {
703
+ annotations: [],
704
+ text,
705
+ type: "output_text"
706
+ }
707
+ ],
708
+ id,
709
+ role: "assistant",
710
+ status: "completed",
711
+ type: "message"
712
+ };
713
+ }
714
+ function functionCallItem(tool) {
715
+ return {
716
+ arguments: tool.arguments,
717
+ call_id: tool.id,
718
+ id: `fc_${randomId()}`,
719
+ name: tool.name,
720
+ status: "completed",
721
+ type: "function_call"
722
+ };
723
+ }
724
+ function outputText(output) {
725
+ return output.flatMap((item) => {
726
+ const content = item.content;
727
+ return Array.isArray(content) ? content : [];
728
+ }).map((part) => contentToText(asRecord2(part).text)).filter(Boolean).join("");
729
+ }
730
+ function responseUsage(usage) {
731
+ const record = asRecord2(usage);
732
+ if (Object.keys(record).length === 0) {
733
+ return null;
734
+ }
735
+ return removeUndefined({
736
+ input_tokens: record.prompt_tokens,
737
+ input_tokens_details: record.prompt_tokens_details,
738
+ output_tokens: record.completion_tokens,
739
+ output_tokens_details: record.completion_tokens_details,
740
+ total_tokens: record.total_tokens
741
+ });
742
+ }
743
+ function firstChoice(completion) {
744
+ const choices = Array.isArray(completion.choices) ? completion.choices : [];
745
+ return asRecord2(choices[0]);
746
+ }
747
+ function processChatSseLine(line, enqueue, tools, appendText) {
748
+ const trimmed = line.trim();
749
+ if (!trimmed.startsWith("data:")) {
750
+ return;
751
+ }
752
+ const data = trimmed.slice("data:".length).trim();
753
+ if (!data || data === "[DONE]") {
754
+ return;
755
+ }
756
+ const parsed = parseJson(data);
757
+ if (!parsed) {
758
+ return;
759
+ }
760
+ const choice = firstChoice(parsed);
761
+ const delta = asRecord2(choice.delta);
762
+ const content = contentToText(delta.content);
763
+ if (content) {
764
+ appendText(content);
765
+ enqueue("response.output_text.delta", {
766
+ content_index: 0,
767
+ delta: content,
768
+ item_id: "",
769
+ output_index: 0,
770
+ type: "response.output_text.delta"
771
+ });
772
+ }
773
+ const toolCalls = Array.isArray(delta.tool_calls) ? delta.tool_calls : [];
774
+ for (const toolCall of toolCalls) {
775
+ const record = asRecord2(toolCall);
776
+ const fn = asRecord2(record.function);
777
+ const index = typeof record.index === "number" ? record.index : tools.size;
778
+ const existing = tools.get(index) ?? {
779
+ arguments: "",
780
+ id: contentToText(record.id) || `call_${randomId()}`,
781
+ index,
782
+ name: ""
783
+ };
784
+ existing.id = contentToText(record.id) || existing.id;
785
+ existing.name += contentToText(fn.name);
786
+ existing.arguments += contentToText(fn.arguments);
787
+ tools.set(index, existing);
788
+ }
789
+ }
790
+ function streamOutputItems(messageId, text, tools) {
791
+ return [messageOutputItem(text, messageId), ...tools.map((tool) => functionCallItem(tool))];
792
+ }
793
+ function baseStreamResponse(id, model, createdAt, status, output) {
794
+ return {
795
+ created_at: createdAt,
796
+ error: null,
797
+ id,
798
+ incomplete_details: null,
799
+ instructions: null,
800
+ max_output_tokens: null,
801
+ metadata: {},
802
+ model,
803
+ object: "response",
804
+ output,
805
+ parallel_tool_calls: true,
806
+ status,
807
+ temperature: null,
808
+ tool_choice: "auto",
809
+ tools: [],
810
+ top_p: null
811
+ };
812
+ }
813
+ function encodeSse(event, data) {
814
+ if (data === "[DONE]") {
815
+ return "data: [DONE]\n\n";
816
+ }
817
+ return `event: ${event}
818
+ data: ${JSON.stringify(data)}
819
+
820
+ `;
821
+ }
822
+ function parseJson(data) {
823
+ try {
824
+ return asRecord2(JSON.parse(data));
825
+ } catch {
826
+ return void 0;
827
+ }
828
+ }
829
+ function removeUndefined(record) {
830
+ return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== void 0));
831
+ }
832
+ function asRecord2(value) {
833
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
834
+ }
835
+ function randomId() {
836
+ return crypto.randomUUID().replaceAll("-", "");
837
+ }
838
+ function epochSeconds() {
839
+ return Math.floor(Date.now() / 1e3);
840
+ }
841
+
842
+ // src/server.ts
843
+ var DEFAULT_HOST = "127.0.0.1";
844
+ var DEFAULT_PORT = 4141;
845
+ function createHoopilotHandler(options = {}) {
846
+ const client = new CopilotClient(options);
847
+ const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
848
+ return async (request) => {
849
+ const url = new URL(request.url);
850
+ if (request.method === "OPTIONS") {
851
+ return new Response(null, { headers: corsHeaders() });
852
+ }
853
+ if (!isAuthorized(request, apiKey)) {
854
+ return jsonError(401, "invalid_api_key", "Invalid or missing Hoopilot API key.");
855
+ }
856
+ try {
857
+ if (request.method === "GET" && (url.pathname === "/" || url.pathname === "/healthz")) {
858
+ return jsonResponse({
859
+ name: "hoopilot",
860
+ object: "health",
861
+ status: "ok"
862
+ });
863
+ }
864
+ if (request.method === "GET" && url.pathname === "/v1/models") {
865
+ return await handleModels(client, request.signal);
866
+ }
867
+ if (request.method === "POST" && url.pathname === "/v1/chat/completions") {
868
+ return await handleChatCompletions(client, request);
869
+ }
870
+ if (request.method === "POST" && url.pathname === "/v1/completions") {
871
+ return await handleCompletions(client, request);
872
+ }
873
+ if (request.method === "POST" && url.pathname === "/v1/responses") {
874
+ return await handleResponses(client, request);
875
+ }
876
+ return jsonError(404, "not_found", `No route for ${request.method} ${url.pathname}.`);
877
+ } catch (error) {
878
+ if (error instanceof CopilotAuthError) {
879
+ return jsonError(401, "copilot_auth_error", error.message);
880
+ }
881
+ return jsonError(500, "internal_error", errorMessage2(error));
882
+ }
883
+ };
884
+ }
885
+ function startHoopilotServer(options = {}) {
886
+ const host = options.host ?? options.env?.HOST ?? DEFAULT_HOST;
887
+ const port = Number(options.port ?? options.env?.PORT ?? DEFAULT_PORT);
888
+ const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
889
+ const allowUnauthenticated = options.allowUnauthenticated ?? options.env?.HOOPILOT_ALLOW_UNAUTHENTICATED === "1";
890
+ if (!isLoopbackHost(host) && !apiKey && !allowUnauthenticated) {
891
+ throw new Error(
892
+ "Refusing to listen on a non-loopback host without HOOPILOT_API_KEY. Set an API key or pass --allow-unauthenticated."
893
+ );
894
+ }
895
+ const server = Bun.serve({
896
+ fetch: createHoopilotHandler({
897
+ ...options,
898
+ apiKey,
899
+ host,
900
+ port
901
+ }),
902
+ hostname: host,
903
+ port
904
+ });
905
+ return {
906
+ server,
907
+ url: `http://${host}:${server.port}`
908
+ };
909
+ }
910
+ async function handleModels(client, signal) {
911
+ const upstream = await client.models(signal);
912
+ if (!upstream.ok) {
913
+ return jsonResponse({ data: fallbackModels(), object: "list" });
914
+ }
915
+ return jsonResponse(normalizeModelsResponse(await upstream.json()));
916
+ }
917
+ async function handleChatCompletions(client, request) {
918
+ const upstream = await client.forwardChatCompletions(await request.text(), request.signal);
919
+ return proxyResponse(upstream);
920
+ }
921
+ async function handleCompletions(client, request) {
922
+ const body = await readJson(request);
923
+ const upstream = await client.chatCompletions(
924
+ completionsRequestToChatCompletion(body),
925
+ request.signal
926
+ );
927
+ if (!upstream.ok) {
928
+ return proxyError(upstream);
929
+ }
930
+ return jsonResponse(chatCompletionToCompletion(await upstream.json()));
931
+ }
932
+ async function handleResponses(client, request) {
933
+ const body = await readJson(request);
934
+ const chatRequest = responsesRequestToChatCompletion(body);
935
+ const upstream = await client.chatCompletions(chatRequest, request.signal);
936
+ if (!upstream.ok) {
937
+ return proxyError(upstream);
938
+ }
939
+ if (body.stream === true && upstream.body) {
940
+ return new Response(
941
+ responsesStreamFromChatStream(upstream.body, {
942
+ model: typeof chatRequest.model === "string" ? chatRequest.model : "gpt-4.1"
943
+ }),
944
+ {
945
+ headers: {
946
+ ...corsHeaders(),
947
+ "cache-control": "no-cache",
948
+ connection: "keep-alive",
949
+ "content-type": "text/event-stream; charset=utf-8"
950
+ }
951
+ }
952
+ );
953
+ }
954
+ return jsonResponse(chatCompletionToResponse(await upstream.json()));
955
+ }
956
+ async function proxyError(upstream) {
957
+ const text = await upstream.text();
958
+ return jsonError(upstream.status, "copilot_error", text || upstream.statusText);
959
+ }
960
+ function proxyResponse(upstream) {
961
+ const headers = new Headers(upstream.headers);
962
+ headers.delete("content-encoding");
963
+ headers.delete("content-length");
964
+ headers.delete("transfer-encoding");
965
+ for (const [key, value] of Object.entries(corsHeaders())) {
966
+ headers.set(key, value);
967
+ }
968
+ return new Response(upstream.body, {
969
+ headers,
970
+ status: upstream.status,
971
+ statusText: upstream.statusText
972
+ });
973
+ }
974
+ async function readJson(request) {
975
+ try {
976
+ const value = await request.json();
977
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
978
+ } catch {
979
+ throw new Error("Request body must be valid JSON.");
980
+ }
981
+ }
982
+ function jsonResponse(body, status = 200) {
983
+ return new Response(JSON.stringify(body), {
984
+ headers: {
985
+ ...corsHeaders(),
986
+ "content-type": "application/json; charset=utf-8"
987
+ },
988
+ status
989
+ });
990
+ }
991
+ function jsonError(status, code, message) {
992
+ return jsonResponse(
993
+ {
994
+ error: {
995
+ code,
996
+ message,
997
+ type: code
998
+ }
999
+ },
1000
+ status
1001
+ );
1002
+ }
1003
+ function corsHeaders() {
1004
+ return {
1005
+ "access-control-allow-headers": "authorization, content-type, x-api-key",
1006
+ "access-control-allow-methods": "GET, POST, OPTIONS",
1007
+ "access-control-allow-origin": "*"
1008
+ };
1009
+ }
1010
+ function isAuthorized(request, apiKey) {
1011
+ if (!apiKey) {
1012
+ return true;
1013
+ }
1014
+ const authorization = request.headers.get("authorization") ?? "";
1015
+ const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1];
1016
+ return bearer === apiKey || request.headers.get("x-api-key") === apiKey;
1017
+ }
1018
+ function isLoopbackHost(host) {
1019
+ return host === "localhost" || host === "127.0.0.1" || host === "::1";
1020
+ }
1021
+ function errorMessage2(error) {
1022
+ return error instanceof Error ? error.message : String(error);
1023
+ }
1024
+
1025
+ // src/cli.ts
1026
+ async function main(argv = Bun.argv.slice(2)) {
1027
+ const args = parseArgs(argv);
1028
+ if (args.help) {
1029
+ console.log(helpText(await packageVersion()));
1030
+ return;
1031
+ }
1032
+ if (args.version) {
1033
+ console.log(await packageVersion());
1034
+ return;
1035
+ }
1036
+ const started = startHoopilotServer(args);
1037
+ console.log(`hoopilot listening on ${started.url}`);
1038
+ console.log(`OpenAI base URL: ${started.url}/v1`);
1039
+ console.log("Use Ctrl+C to stop.");
1040
+ }
1041
+ function parseArgs(argv) {
1042
+ const args = {};
1043
+ const rest = [...argv];
1044
+ if (rest[0] === "serve") {
1045
+ rest.shift();
1046
+ }
1047
+ while (rest.length > 0) {
1048
+ const arg = rest.shift();
1049
+ if (!arg) {
1050
+ continue;
1051
+ }
1052
+ if (arg === "--help" || arg === "-h") {
1053
+ args.help = true;
1054
+ continue;
1055
+ }
1056
+ if (arg === "--version" || arg === "-v") {
1057
+ args.version = true;
1058
+ continue;
1059
+ }
1060
+ if (arg === "--allow-unauthenticated") {
1061
+ args.allowUnauthenticated = true;
1062
+ continue;
1063
+ }
1064
+ if (arg === "--no-gh") {
1065
+ args.githubTokenCommand = false;
1066
+ continue;
1067
+ }
1068
+ const [name, inlineValue] = arg.split("=", 2);
1069
+ const value = inlineValue ?? rest.shift();
1070
+ if (!value) {
1071
+ throw new Error(`Missing value for ${arg}.`);
1072
+ }
1073
+ switch (name) {
1074
+ case "--api-key":
1075
+ args.apiKey = value;
1076
+ break;
1077
+ case "--auth-mode":
1078
+ args.authMode = parseAuthMode(value);
1079
+ break;
1080
+ case "--copilot-api-base-url":
1081
+ args.copilotApiBaseUrl = value;
1082
+ break;
1083
+ case "--copilot-token":
1084
+ args.copilotToken = value;
1085
+ break;
1086
+ case "--github-token":
1087
+ args.githubToken = value;
1088
+ break;
1089
+ case "--github-token-command":
1090
+ args.githubTokenCommand = value;
1091
+ break;
1092
+ case "--host":
1093
+ args.host = value;
1094
+ break;
1095
+ case "--port":
1096
+ case "-p":
1097
+ args.port = Number(value);
1098
+ if (!Number.isInteger(args.port) || args.port <= 0) {
1099
+ throw new Error(`Invalid port: ${value}.`);
1100
+ }
1101
+ break;
1102
+ default:
1103
+ throw new Error(`Unknown option: ${name}.`);
1104
+ }
1105
+ }
1106
+ return args;
1107
+ }
1108
+ function parseAuthMode(value) {
1109
+ if (value === "auto" || value === "copilot-token" || value === "github-token" || value === "direct-github-token") {
1110
+ return value;
1111
+ }
1112
+ throw new Error(`Invalid auth mode: ${value}.`);
1113
+ }
1114
+ async function packageVersion() {
1115
+ try {
1116
+ const manifest = await Bun.file(new URL("../package.json", import.meta.url)).json();
1117
+ return typeof manifest.version === "string" ? manifest.version : "0.0.0";
1118
+ } catch {
1119
+ return "0.0.0";
1120
+ }
1121
+ }
1122
+ function helpText(version) {
1123
+ return `hoopilot ${version}
1124
+
1125
+ OpenAI-compatible proxy for GitHub Copilot.
1126
+
1127
+ Usage:
1128
+ hoopilot [serve] [options]
1129
+ npx @openhoo/hoopilot [options]
1130
+
1131
+ Options:
1132
+ -p, --port <port> Port to listen on. Default: 4141
1133
+ --host <host> Host to listen on. Default: 127.0.0.1
1134
+ --api-key <key> Require clients to send Authorization: Bearer <key>
1135
+ --auth-mode <mode> auto, github-token, direct-github-token, copilot-token
1136
+ --github-token <token> GitHub OAuth token for a Copilot account
1137
+ --github-token-command <cmd> Command used to read a GitHub token. Default: gh auth token
1138
+ --copilot-token <token> Short-lived Copilot API bearer token
1139
+ --copilot-api-base-url <url> Copilot API base URL override
1140
+ --no-gh Do not try gh auth token
1141
+ --allow-unauthenticated Allow non-loopback bind without --api-key
1142
+ -h, --help Show help
1143
+ -v, --version Show version
1144
+
1145
+ Environment:
1146
+ HOOPILOT_API_KEY
1147
+ COPILOT_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN
1148
+ COPILOT_API_TOKEN, GITHUB_COPILOT_API_TOKEN
1149
+ COPILOT_API_BASE_URL
1150
+ `;
1151
+ }
1152
+ if (import.meta.main) {
1153
+ main().catch((error) => {
1154
+ console.error(error instanceof Error ? error.message : String(error));
1155
+ process.exit(1);
1156
+ });
1157
+ }
1158
+ export {
1159
+ main,
1160
+ parseArgs
1161
+ };
1162
+ //# sourceMappingURL=cli.js.map