@openhoo/hoopilot 0.7.2 → 0.7.4
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 +3 -0
- package/dist/{chunk-TEDEVCKM.js → chunk-7GSQVYYT.js} +61 -7
- package/dist/chunk-7GSQVYYT.js.map +1 -0
- package/dist/cli.js +273 -114
- package/dist/cli.js.map +1 -1
- package/dist/codexx.js +1 -1
- package/dist/index.cjs +352 -117
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +352 -117
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/dist/chunk-TEDEVCKM.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
// src/auth-store.ts
|
|
2
2
|
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
|
3
3
|
import { dirname, join } from "path";
|
|
4
|
+
|
|
5
|
+
// src/util.ts
|
|
6
|
+
function trimTrailingSlash(value) {
|
|
7
|
+
return value.replace(/\/+$/, "");
|
|
8
|
+
}
|
|
9
|
+
function envValue(value) {
|
|
10
|
+
const trimmed = value?.trim();
|
|
11
|
+
return trimmed ? trimmed : void 0;
|
|
12
|
+
}
|
|
13
|
+
function isTrustedTokenBaseUrl(rawUrl, allowedHttpsHosts, allowUnsafeHttps = false) {
|
|
14
|
+
const url = parseUrl(rawUrl);
|
|
15
|
+
if (!url) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
if (url.pathname !== "" && url.pathname !== "/") {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
if (isLoopbackHttpUrl(url)) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
if (url.protocol !== "https:") {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
const host = url.hostname.toLowerCase();
|
|
31
|
+
return allowedHttpsHosts.includes(host) || allowUnsafeHttps;
|
|
32
|
+
}
|
|
33
|
+
function parseUrl(rawUrl) {
|
|
34
|
+
let url;
|
|
35
|
+
try {
|
|
36
|
+
url = new URL(rawUrl);
|
|
37
|
+
} catch {
|
|
38
|
+
return void 0;
|
|
39
|
+
}
|
|
40
|
+
return url;
|
|
41
|
+
}
|
|
42
|
+
function isLoopbackHttpUrl(url) {
|
|
43
|
+
return url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1" || url.hostname === "[::1]");
|
|
44
|
+
}
|
|
45
|
+
async function truncatedResponseText(response, max = 500) {
|
|
46
|
+
const text = await response.text();
|
|
47
|
+
return text.slice(0, max);
|
|
48
|
+
}
|
|
49
|
+
function asRecord(value) {
|
|
50
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// src/auth-store.ts
|
|
4
54
|
var StoredCopilotAuthError = class extends Error {
|
|
5
55
|
constructor(message) {
|
|
6
56
|
super(message);
|
|
@@ -8,10 +58,25 @@ var StoredCopilotAuthError = class extends Error {
|
|
|
8
58
|
}
|
|
9
59
|
};
|
|
10
60
|
function authStorePath(env = process.env) {
|
|
11
|
-
|
|
12
|
-
|
|
61
|
+
const explicit = envValue(env.HOOPILOT_AUTH_FILE);
|
|
62
|
+
if (explicit) {
|
|
63
|
+
return explicit;
|
|
64
|
+
}
|
|
65
|
+
const xdg = envValue(env.XDG_CONFIG_HOME);
|
|
66
|
+
if (xdg) {
|
|
67
|
+
return join(xdg, "hoopilot", "auth.json");
|
|
68
|
+
}
|
|
69
|
+
const appdata = envValue(env.APPDATA);
|
|
70
|
+
if (appdata) {
|
|
71
|
+
return join(appdata, "hoopilot", "auth.json");
|
|
13
72
|
}
|
|
14
|
-
const
|
|
73
|
+
const home = envValue(env.HOME);
|
|
74
|
+
if (!home) {
|
|
75
|
+
throw new StoredCopilotAuthError(
|
|
76
|
+
"Cannot resolve Hoopilot auth file path without HOOPILOT_AUTH_FILE, XDG_CONFIG_HOME, APPDATA, or HOME."
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
const base = join(home, ".config");
|
|
15
80
|
return join(base, "hoopilot", "auth.json");
|
|
16
81
|
}
|
|
17
82
|
function readStoredCopilotAuth(path = authStorePath()) {
|
|
@@ -68,30 +133,6 @@ function writeStoredCopilotAuth(auth, path = authStorePath()) {
|
|
|
68
133
|
}
|
|
69
134
|
}
|
|
70
135
|
|
|
71
|
-
// src/util.ts
|
|
72
|
-
function trimTrailingSlash(value) {
|
|
73
|
-
return value.replace(/\/+$/, "");
|
|
74
|
-
}
|
|
75
|
-
function isHttpsOrLoopbackUrl(rawUrl) {
|
|
76
|
-
let url;
|
|
77
|
-
try {
|
|
78
|
-
url = new URL(rawUrl);
|
|
79
|
-
} catch {
|
|
80
|
-
return false;
|
|
81
|
-
}
|
|
82
|
-
if (url.protocol === "https:") {
|
|
83
|
-
return true;
|
|
84
|
-
}
|
|
85
|
-
return url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1" || url.hostname === "[::1]");
|
|
86
|
-
}
|
|
87
|
-
async function truncatedResponseText(response, max = 500) {
|
|
88
|
-
const text = await response.text();
|
|
89
|
-
return text.slice(0, max);
|
|
90
|
-
}
|
|
91
|
-
function asRecord(value) {
|
|
92
|
-
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
93
|
-
}
|
|
94
|
-
|
|
95
136
|
// src/auth.ts
|
|
96
137
|
var DEFAULT_COPILOT_API_BASE_URL = "https://api.githubcopilot.com";
|
|
97
138
|
var REFRESH_SKEW_MS = 6e4;
|
|
@@ -105,11 +146,15 @@ var CopilotAuthError = class extends Error {
|
|
|
105
146
|
var CopilotAuth = class {
|
|
106
147
|
#authStorePath;
|
|
107
148
|
#copilotApiBaseUrl;
|
|
149
|
+
#hasCopilotApiBaseUrlOverride;
|
|
108
150
|
#cachedAccess;
|
|
109
151
|
constructor(options = {}) {
|
|
110
|
-
|
|
152
|
+
const envAuthStorePath = envValue(options.env?.HOOPILOT_AUTH_FILE);
|
|
153
|
+
const envCopilotApiBaseUrl = envValue(options.env?.COPILOT_API_BASE_URL);
|
|
154
|
+
this.#authStorePath = options.authStorePath ?? envAuthStorePath;
|
|
155
|
+
this.#hasCopilotApiBaseUrlOverride = Boolean(options.copilotApiBaseUrl ?? envCopilotApiBaseUrl);
|
|
111
156
|
this.#copilotApiBaseUrl = trimTrailingSlash(
|
|
112
|
-
options.copilotApiBaseUrl ??
|
|
157
|
+
options.copilotApiBaseUrl ?? envCopilotApiBaseUrl ?? DEFAULT_COPILOT_API_BASE_URL
|
|
113
158
|
);
|
|
114
159
|
}
|
|
115
160
|
async getAccess() {
|
|
@@ -127,7 +172,9 @@ var CopilotAuth = class {
|
|
|
127
172
|
}
|
|
128
173
|
if (stored) {
|
|
129
174
|
return this.#cacheAccess({
|
|
130
|
-
apiBaseUrl: trimTrailingSlash(
|
|
175
|
+
apiBaseUrl: trimTrailingSlash(
|
|
176
|
+
this.#hasCopilotApiBaseUrlOverride ? this.#copilotApiBaseUrl : stored.apiBaseUrl ?? this.#copilotApiBaseUrl
|
|
177
|
+
),
|
|
131
178
|
expiresAtMs: Date.now() + STORED_TOKEN_TTL_MS,
|
|
132
179
|
source: "github-copilot-oauth",
|
|
133
180
|
token: stored.token
|
|
@@ -145,6 +192,8 @@ var CopilotAuth = class {
|
|
|
145
192
|
|
|
146
193
|
// src/copilot.ts
|
|
147
194
|
var DEFAULT_GITHUB_API_BASE_URL = "https://api.github.com";
|
|
195
|
+
var ALLOWED_COPILOT_API_HOSTS = ["api.githubcopilot.com"];
|
|
196
|
+
var ALLOWED_GITHUB_API_HOSTS = ["api.github.com"];
|
|
148
197
|
var COPILOT_USAGE_API_VERSION = "2025-04-01";
|
|
149
198
|
function applyCopilotHeaders(headers, token) {
|
|
150
199
|
headers.set("accept", headers.get("accept") ?? "application/json");
|
|
@@ -168,13 +217,15 @@ function applyGithubApiHeaders(headers, token) {
|
|
|
168
217
|
}
|
|
169
218
|
var CopilotClient = class {
|
|
170
219
|
#auth;
|
|
220
|
+
#allowUnsafeUpstream;
|
|
171
221
|
#fetch;
|
|
172
222
|
#githubApiBaseUrl;
|
|
173
223
|
constructor(options = {}) {
|
|
174
224
|
this.#auth = new CopilotAuth(options);
|
|
225
|
+
this.#allowUnsafeUpstream = envValue(options.env?.HOOPILOT_ALLOW_UNSAFE_UPSTREAM) === "1";
|
|
175
226
|
this.#fetch = options.fetch ?? fetch;
|
|
176
227
|
this.#githubApiBaseUrl = trimTrailingSlash(
|
|
177
|
-
options.githubApiBaseUrl ?? options.env?.HOOPILOT_GITHUB_API_BASE_URL ?? DEFAULT_GITHUB_API_BASE_URL
|
|
228
|
+
options.githubApiBaseUrl ?? envValue(options.env?.HOOPILOT_GITHUB_API_BASE_URL) ?? DEFAULT_GITHUB_API_BASE_URL
|
|
178
229
|
);
|
|
179
230
|
}
|
|
180
231
|
/**
|
|
@@ -183,9 +234,13 @@ var CopilotClient = class {
|
|
|
183
234
|
* accepted directly here — no Copilot token exchange is required to read quota.
|
|
184
235
|
*/
|
|
185
236
|
async usage(signal) {
|
|
186
|
-
if (!
|
|
237
|
+
if (!isTrustedTokenBaseUrl(
|
|
238
|
+
this.#githubApiBaseUrl,
|
|
239
|
+
ALLOWED_GITHUB_API_HOSTS,
|
|
240
|
+
this.#allowUnsafeUpstream
|
|
241
|
+
)) {
|
|
187
242
|
throw new Error(
|
|
188
|
-
`Refusing to send the GitHub OAuth token to
|
|
243
|
+
`Refusing to send the GitHub OAuth token to an untrusted GitHub API host: ${this.#githubApiBaseUrl}`
|
|
189
244
|
);
|
|
190
245
|
}
|
|
191
246
|
const access = await this.#auth.getAccess();
|
|
@@ -227,9 +282,13 @@ var CopilotClient = class {
|
|
|
227
282
|
}
|
|
228
283
|
async fetchCopilot(path, init) {
|
|
229
284
|
const access = await this.#auth.getAccess();
|
|
230
|
-
if (!
|
|
285
|
+
if (!isTrustedTokenBaseUrl(
|
|
286
|
+
access.apiBaseUrl,
|
|
287
|
+
ALLOWED_COPILOT_API_HOSTS,
|
|
288
|
+
this.#allowUnsafeUpstream
|
|
289
|
+
)) {
|
|
231
290
|
throw new Error(
|
|
232
|
-
`Refusing to send the GitHub OAuth token to
|
|
291
|
+
`Refusing to send the GitHub OAuth token to an untrusted Copilot API host: ${access.apiBaseUrl}`
|
|
233
292
|
);
|
|
234
293
|
}
|
|
235
294
|
const headers = applyCopilotHeaders(new Headers(init.headers), access.token);
|
|
@@ -315,9 +374,9 @@ async function githubCopilotDeviceLogin(options = {}) {
|
|
|
315
374
|
const fetcher = options.fetch ?? fetch;
|
|
316
375
|
const sleeper = options.sleep ?? sleep;
|
|
317
376
|
const domain = normalizeDomain(
|
|
318
|
-
options.domain ?? env.HOOPILOT_GITHUB_DOMAIN ?? DEFAULT_GITHUB_DOMAIN
|
|
377
|
+
options.domain ?? envValue(env.HOOPILOT_GITHUB_DOMAIN) ?? DEFAULT_GITHUB_DOMAIN
|
|
319
378
|
);
|
|
320
|
-
const clientId = options.clientId ?? env.HOOPILOT_GITHUB_CLIENT_ID ?? env.COPILOT_GITHUB_CLIENT_ID ?? DEFAULT_GITHUB_COPILOT_CLIENT_ID;
|
|
379
|
+
const clientId = options.clientId ?? envValue(env.HOOPILOT_GITHUB_CLIENT_ID) ?? envValue(env.COPILOT_GITHUB_CLIENT_ID) ?? DEFAULT_GITHUB_COPILOT_CLIENT_ID;
|
|
321
380
|
const device = await requestDeviceCode(fetcher, domain, clientId);
|
|
322
381
|
const verificationUrl = device.verification_uri;
|
|
323
382
|
const userCode = device.user_code;
|
|
@@ -475,8 +534,8 @@ var noopLogger = {
|
|
|
475
534
|
};
|
|
476
535
|
function createHoopilotLogger(options = {}) {
|
|
477
536
|
const env = options.env ?? process.env;
|
|
478
|
-
const level = parseLogLevel(options.level ?? env.HOOPILOT_LOG_LEVEL);
|
|
479
|
-
const format = parseLogFormat(options.format ?? env.HOOPILOT_LOG_FORMAT);
|
|
537
|
+
const level = parseLogLevel(options.level ?? envValue(env.HOOPILOT_LOG_LEVEL));
|
|
538
|
+
const format = parseLogFormat(options.format ?? envValue(env.HOOPILOT_LOG_FORMAT));
|
|
480
539
|
const pinoOptions = {
|
|
481
540
|
base: {
|
|
482
541
|
service: "hoopilot",
|
|
@@ -526,7 +585,7 @@ function parseLogLevel(value) {
|
|
|
526
585
|
}
|
|
527
586
|
function shouldCreateLogger(options) {
|
|
528
587
|
return Boolean(
|
|
529
|
-
options.logger || options.logFormat || options.logLevel || options.env?.HOOPILOT_LOG_FORMAT || options.env?.HOOPILOT_LOG_LEVEL
|
|
588
|
+
options.logger || options.logFormat || options.logLevel || envValue(options.env?.HOOPILOT_LOG_FORMAT) || envValue(options.env?.HOOPILOT_LOG_LEVEL)
|
|
530
589
|
);
|
|
531
590
|
}
|
|
532
591
|
function errorDetails(error) {
|
|
@@ -548,6 +607,12 @@ function isLogLevel(value) {
|
|
|
548
607
|
|
|
549
608
|
// src/openai.ts
|
|
550
609
|
var DEFAULT_MODEL = "gpt-4.1";
|
|
610
|
+
var OpenAICompatibilityError = class extends Error {
|
|
611
|
+
constructor(message) {
|
|
612
|
+
super(message);
|
|
613
|
+
this.name = "OpenAICompatibilityError";
|
|
614
|
+
}
|
|
615
|
+
};
|
|
551
616
|
function responsesRequestToChatCompletion(request) {
|
|
552
617
|
const messages = [];
|
|
553
618
|
const instructions = contentToText(request.instructions);
|
|
@@ -581,13 +646,22 @@ function normalizeChatCompletionRequest(request) {
|
|
|
581
646
|
});
|
|
582
647
|
}
|
|
583
648
|
function completionsRequestToChatCompletion(request) {
|
|
649
|
+
assertSupportedLegacyCompletionRequest(request);
|
|
584
650
|
return removeUndefined({
|
|
651
|
+
frequency_penalty: request.frequency_penalty,
|
|
652
|
+
logit_bias: request.logit_bias,
|
|
585
653
|
max_tokens: request.max_tokens,
|
|
586
|
-
messages: [{ content:
|
|
654
|
+
messages: [{ content: legacyPromptToText(request.prompt), role: "user" }],
|
|
587
655
|
model: normalizeRequestedModel(request.model),
|
|
656
|
+
n: request.n,
|
|
657
|
+
presence_penalty: request.presence_penalty,
|
|
658
|
+
seed: request.seed,
|
|
659
|
+
stop: request.stop,
|
|
588
660
|
stream: request.stream === true,
|
|
661
|
+
stream_options: request.stream_options,
|
|
589
662
|
temperature: request.temperature,
|
|
590
|
-
top_p: request.top_p
|
|
663
|
+
top_p: request.top_p,
|
|
664
|
+
user: request.user
|
|
591
665
|
});
|
|
592
666
|
}
|
|
593
667
|
function normalizeRequestedModel(model) {
|
|
@@ -623,21 +697,21 @@ function chatCompletionToResponse(completion, responseId) {
|
|
|
623
697
|
});
|
|
624
698
|
}
|
|
625
699
|
function chatCompletionToCompletion(completion) {
|
|
626
|
-
const choice = firstChoice(completion);
|
|
627
|
-
const message = asRecord(choice.message);
|
|
628
700
|
return removeUndefined({
|
|
629
|
-
choices:
|
|
630
|
-
|
|
701
|
+
choices: completionChoices(completion).map((choice, index) => {
|
|
702
|
+
const message = asRecord(choice.message);
|
|
703
|
+
return {
|
|
631
704
|
finish_reason: choice.finish_reason ?? "stop",
|
|
632
|
-
index:
|
|
633
|
-
logprobs: null,
|
|
634
|
-
text: contentToText(message.content)
|
|
635
|
-
}
|
|
636
|
-
|
|
705
|
+
index: typeof choice.index === "number" ? choice.index : index,
|
|
706
|
+
logprobs: choice.logprobs ?? null,
|
|
707
|
+
text: contentToText(choice.text) || contentToText(message.content)
|
|
708
|
+
};
|
|
709
|
+
}),
|
|
637
710
|
created: completion.created ?? epochSeconds(),
|
|
638
711
|
id: completion.id ?? `cmpl_${randomId()}`,
|
|
639
712
|
model: completion.model ?? DEFAULT_MODEL,
|
|
640
713
|
object: "text_completion",
|
|
714
|
+
system_fingerprint: completion.system_fingerprint,
|
|
641
715
|
usage: completion.usage
|
|
642
716
|
});
|
|
643
717
|
}
|
|
@@ -645,12 +719,15 @@ function completionStreamFromChatStream(chatStream) {
|
|
|
645
719
|
const encoder = new TextEncoder();
|
|
646
720
|
const decoder = new TextDecoder();
|
|
647
721
|
let buffer = "";
|
|
648
|
-
let
|
|
722
|
+
let sawTerminalEvent = false;
|
|
649
723
|
return new ReadableStream({
|
|
650
724
|
async start(controller) {
|
|
651
725
|
const enqueue = (data) => {
|
|
652
726
|
controller.enqueue(encoder.encode(encodeDataSse(data)));
|
|
653
727
|
};
|
|
728
|
+
const markTerminal = () => {
|
|
729
|
+
sawTerminalEvent = true;
|
|
730
|
+
};
|
|
654
731
|
const reader = chatStream.getReader();
|
|
655
732
|
try {
|
|
656
733
|
while (true) {
|
|
@@ -659,20 +736,17 @@ function completionStreamFromChatStream(chatStream) {
|
|
|
659
736
|
break;
|
|
660
737
|
}
|
|
661
738
|
buffer += decoder.decode(result.value, { stream: true });
|
|
662
|
-
const
|
|
663
|
-
buffer =
|
|
664
|
-
for (const
|
|
665
|
-
|
|
666
|
-
sawDone = true;
|
|
667
|
-
});
|
|
739
|
+
const blocks = buffer.split(/\r?\n\r?\n/);
|
|
740
|
+
buffer = blocks.pop() ?? "";
|
|
741
|
+
for (const block of blocks) {
|
|
742
|
+
processCompletionSseBlock(block, enqueue, markTerminal);
|
|
668
743
|
}
|
|
669
744
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
});
|
|
745
|
+
const tail = `${buffer}${decoder.decode()}`;
|
|
746
|
+
if (tail.trim()) {
|
|
747
|
+
processCompletionSseBlock(tail, enqueue, markTerminal);
|
|
674
748
|
}
|
|
675
|
-
if (!
|
|
749
|
+
if (!sawTerminalEvent) {
|
|
676
750
|
enqueue("[DONE]");
|
|
677
751
|
}
|
|
678
752
|
controller.close();
|
|
@@ -901,7 +975,8 @@ function inputToMessages(input) {
|
|
|
901
975
|
const messages = [];
|
|
902
976
|
for (const item of input) {
|
|
903
977
|
const record = asRecord(item);
|
|
904
|
-
|
|
978
|
+
const type = contentToText(record.type);
|
|
979
|
+
if (type === "function_call_output") {
|
|
905
980
|
messages.push({
|
|
906
981
|
content: contentToText(record.output),
|
|
907
982
|
role: "tool",
|
|
@@ -909,7 +984,7 @@ function inputToMessages(input) {
|
|
|
909
984
|
});
|
|
910
985
|
continue;
|
|
911
986
|
}
|
|
912
|
-
if (
|
|
987
|
+
if (type === "function_call") {
|
|
913
988
|
messages.push({
|
|
914
989
|
role: "assistant",
|
|
915
990
|
tool_calls: [
|
|
@@ -925,7 +1000,10 @@ function inputToMessages(input) {
|
|
|
925
1000
|
});
|
|
926
1001
|
continue;
|
|
927
1002
|
}
|
|
928
|
-
|
|
1003
|
+
if (type && type !== "message") {
|
|
1004
|
+
unsupportedResponsesFeature(`input item type "${type}"`);
|
|
1005
|
+
}
|
|
1006
|
+
const role = responsesRoleToChatRole(contentToText(record.role));
|
|
929
1007
|
const content = chatMessageContent(record.content);
|
|
930
1008
|
if (role && content !== void 0) {
|
|
931
1009
|
messages.push({ content, role });
|
|
@@ -938,7 +1016,10 @@ function chatMessageContent(content) {
|
|
|
938
1016
|
return content;
|
|
939
1017
|
}
|
|
940
1018
|
if (!Array.isArray(content)) {
|
|
941
|
-
|
|
1019
|
+
if (content === void 0 || content === null) {
|
|
1020
|
+
return void 0;
|
|
1021
|
+
}
|
|
1022
|
+
unsupportedResponsesFeature("non-array message content objects");
|
|
942
1023
|
}
|
|
943
1024
|
const parts = [];
|
|
944
1025
|
for (const part of content) {
|
|
@@ -946,13 +1027,31 @@ function chatMessageContent(content) {
|
|
|
946
1027
|
const type = contentToText(record.type);
|
|
947
1028
|
if (type === "input_text" || type === "output_text" || type === "text") {
|
|
948
1029
|
parts.push({ text: contentToText(record.text), type: "text" });
|
|
1030
|
+
continue;
|
|
949
1031
|
}
|
|
950
1032
|
if (type === "input_image") {
|
|
1033
|
+
if (contentToText(record.file_id)) {
|
|
1034
|
+
unsupportedResponsesFeature("input_image file_id parts");
|
|
1035
|
+
}
|
|
951
1036
|
const imageUrl = contentToText(record.image_url);
|
|
952
|
-
if (imageUrl) {
|
|
953
|
-
|
|
1037
|
+
if (!imageUrl) {
|
|
1038
|
+
unsupportedResponsesFeature("input_image parts without image_url");
|
|
954
1039
|
}
|
|
1040
|
+
const image = { url: imageUrl };
|
|
1041
|
+
const detail = contentToText(record.detail);
|
|
1042
|
+
if (detail) {
|
|
1043
|
+
image.detail = detail;
|
|
1044
|
+
}
|
|
1045
|
+
parts.push({ image_url: image, type: "image_url" });
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
1048
|
+
if (type === "input_file") {
|
|
1049
|
+
unsupportedResponsesFeature("input_file parts");
|
|
955
1050
|
}
|
|
1051
|
+
if (type === "input_audio") {
|
|
1052
|
+
unsupportedResponsesFeature("input_audio parts");
|
|
1053
|
+
}
|
|
1054
|
+
unsupportedResponsesFeature(`content part type "${type || "unknown"}"`);
|
|
956
1055
|
}
|
|
957
1056
|
if (parts.length === 0) {
|
|
958
1057
|
return void 0;
|
|
@@ -962,11 +1061,38 @@ function chatMessageContent(content) {
|
|
|
962
1061
|
}
|
|
963
1062
|
return parts;
|
|
964
1063
|
}
|
|
965
|
-
function
|
|
966
|
-
if (
|
|
967
|
-
return prompt
|
|
1064
|
+
function legacyPromptToText(prompt) {
|
|
1065
|
+
if (typeof prompt === "string") {
|
|
1066
|
+
return prompt;
|
|
1067
|
+
}
|
|
1068
|
+
if (Array.isArray(prompt) && prompt.length === 1 && typeof prompt[0] === "string") {
|
|
1069
|
+
return prompt[0];
|
|
1070
|
+
}
|
|
1071
|
+
throw new OpenAICompatibilityError(
|
|
1072
|
+
"Hoopilot legacy completions compatibility supports exactly one string prompt per request."
|
|
1073
|
+
);
|
|
1074
|
+
}
|
|
1075
|
+
function assertSupportedLegacyCompletionRequest(request) {
|
|
1076
|
+
if (request.echo === true) {
|
|
1077
|
+
throw new OpenAICompatibilityError(
|
|
1078
|
+
"Hoopilot legacy completions compatibility does not support echo=true."
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
if (typeof request.best_of === "number" && request.best_of > 1) {
|
|
1082
|
+
throw new OpenAICompatibilityError(
|
|
1083
|
+
"Hoopilot legacy completions compatibility does not support best_of greater than 1."
|
|
1084
|
+
);
|
|
1085
|
+
}
|
|
1086
|
+
if (typeof request.logprobs === "number" && request.logprobs > 0) {
|
|
1087
|
+
throw new OpenAICompatibilityError(
|
|
1088
|
+
"Hoopilot legacy completions compatibility does not support legacy logprobs."
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
if (contentToText(request.suffix)) {
|
|
1092
|
+
throw new OpenAICompatibilityError(
|
|
1093
|
+
"Hoopilot legacy completions compatibility does not support suffix."
|
|
1094
|
+
);
|
|
968
1095
|
}
|
|
969
|
-
return contentToText(prompt);
|
|
970
1096
|
}
|
|
971
1097
|
function contentToText(content) {
|
|
972
1098
|
if (typeof content === "string") {
|
|
@@ -990,25 +1116,35 @@ function contentToText(content) {
|
|
|
990
1116
|
}
|
|
991
1117
|
return "";
|
|
992
1118
|
}
|
|
993
|
-
function
|
|
994
|
-
if (role
|
|
1119
|
+
function responsesRoleToChatRole(role) {
|
|
1120
|
+
if (!role) {
|
|
1121
|
+
return "user";
|
|
1122
|
+
}
|
|
1123
|
+
if (role === "assistant" || role === "developer" || role === "system" || role === "tool" || role === "user") {
|
|
995
1124
|
return role === "developer" ? "system" : role;
|
|
996
1125
|
}
|
|
997
|
-
|
|
1126
|
+
unsupportedResponsesFeature(`message role "${role}"`);
|
|
998
1127
|
}
|
|
999
1128
|
function chatTools(tools) {
|
|
1000
1129
|
if (!Array.isArray(tools)) {
|
|
1001
1130
|
return void 0;
|
|
1002
1131
|
}
|
|
1003
|
-
const converted = tools.map((tool) =>
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1132
|
+
const converted = tools.map((tool) => {
|
|
1133
|
+
const record = asRecord(tool);
|
|
1134
|
+
const type = contentToText(record.type);
|
|
1135
|
+
if (type !== "function") {
|
|
1136
|
+
unsupportedResponsesFeature(`tool type "${type || "unknown"}"`);
|
|
1137
|
+
}
|
|
1138
|
+
return {
|
|
1139
|
+
function: removeUndefined({
|
|
1140
|
+
description: record.description,
|
|
1141
|
+
name: record.name,
|
|
1142
|
+
parameters: record.parameters,
|
|
1143
|
+
strict: record.strict
|
|
1144
|
+
}),
|
|
1145
|
+
type: "function"
|
|
1146
|
+
};
|
|
1147
|
+
});
|
|
1012
1148
|
return converted.length > 0 ? converted : void 0;
|
|
1013
1149
|
}
|
|
1014
1150
|
function chatToolChoice(toolChoice) {
|
|
@@ -1016,10 +1152,16 @@ function chatToolChoice(toolChoice) {
|
|
|
1016
1152
|
return toolChoice;
|
|
1017
1153
|
}
|
|
1018
1154
|
const record = asRecord(toolChoice);
|
|
1019
|
-
|
|
1155
|
+
const type = contentToText(record.type);
|
|
1156
|
+
if (type === "function" && typeof record.name === "string") {
|
|
1020
1157
|
return { function: { name: record.name }, type: "function" };
|
|
1021
1158
|
}
|
|
1022
|
-
|
|
1159
|
+
unsupportedResponsesFeature(`tool_choice type "${type || "unknown"}"`);
|
|
1160
|
+
}
|
|
1161
|
+
function unsupportedResponsesFeature(feature) {
|
|
1162
|
+
throw new OpenAICompatibilityError(
|
|
1163
|
+
`Hoopilot Responses-to-chat compatibility does not support ${feature}.`
|
|
1164
|
+
);
|
|
1023
1165
|
}
|
|
1024
1166
|
function outputItemsFromMessage(message) {
|
|
1025
1167
|
const output = [];
|
|
@@ -1134,20 +1276,29 @@ function firstNumber(...values) {
|
|
|
1134
1276
|
return void 0;
|
|
1135
1277
|
}
|
|
1136
1278
|
function firstChoice(completion) {
|
|
1137
|
-
|
|
1138
|
-
return asRecord(choices[0]);
|
|
1279
|
+
return completionChoices(completion)[0] ?? {};
|
|
1139
1280
|
}
|
|
1140
|
-
function
|
|
1141
|
-
const
|
|
1142
|
-
|
|
1143
|
-
|
|
1281
|
+
function completionChoices(completion) {
|
|
1282
|
+
const choices = Array.isArray(completion.choices) ? completion.choices : [];
|
|
1283
|
+
return choices.map((choice) => asRecord(choice));
|
|
1284
|
+
}
|
|
1285
|
+
function processCompletionSseBlock(block, enqueue, markTerminal) {
|
|
1286
|
+
let event = "message";
|
|
1287
|
+
const dataLines = [];
|
|
1288
|
+
for (const line of block.split(/\r?\n/)) {
|
|
1289
|
+
const trimmed = line.trim();
|
|
1290
|
+
if (trimmed.startsWith("event:")) {
|
|
1291
|
+
event = trimmed.slice("event:".length).trim() || event;
|
|
1292
|
+
} else if (trimmed.startsWith("data:")) {
|
|
1293
|
+
dataLines.push(trimmed.slice("data:".length).trim());
|
|
1294
|
+
}
|
|
1144
1295
|
}
|
|
1145
|
-
const data =
|
|
1296
|
+
const data = dataLines.join("\n");
|
|
1146
1297
|
if (!data) {
|
|
1147
1298
|
return;
|
|
1148
1299
|
}
|
|
1149
1300
|
if (data === "[DONE]") {
|
|
1150
|
-
|
|
1301
|
+
markTerminal();
|
|
1151
1302
|
enqueue("[DONE]");
|
|
1152
1303
|
return;
|
|
1153
1304
|
}
|
|
@@ -1155,25 +1306,34 @@ function processCompletionSseLine(line, enqueue, markDone) {
|
|
|
1155
1306
|
if (!parsed) {
|
|
1156
1307
|
return;
|
|
1157
1308
|
}
|
|
1158
|
-
const
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1309
|
+
const error = completionStreamError(event, parsed);
|
|
1310
|
+
if (error) {
|
|
1311
|
+
markTerminal();
|
|
1312
|
+
enqueue({ error });
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
const choices = completionChoices(parsed).map((choice, index) => {
|
|
1316
|
+
const delta = asRecord(choice.delta);
|
|
1317
|
+
const text = contentToText(delta.content);
|
|
1318
|
+
const finishReason = choice.finish_reason ?? null;
|
|
1319
|
+
if (!text && finishReason === null) {
|
|
1320
|
+
return void 0;
|
|
1321
|
+
}
|
|
1322
|
+
return {
|
|
1323
|
+
finish_reason: finishReason,
|
|
1324
|
+
index: typeof choice.index === "number" ? choice.index : index,
|
|
1325
|
+
logprobs: choice.logprobs ?? null,
|
|
1326
|
+
text
|
|
1327
|
+
};
|
|
1328
|
+
}).filter((choice) => choice !== void 0);
|
|
1162
1329
|
const usage = asRecord(parsed.usage);
|
|
1163
1330
|
const hasUsage = Object.keys(usage).length > 0;
|
|
1164
|
-
if (
|
|
1331
|
+
if (choices.length === 0 && !hasUsage) {
|
|
1165
1332
|
return;
|
|
1166
1333
|
}
|
|
1167
1334
|
enqueue(
|
|
1168
1335
|
removeUndefined({
|
|
1169
|
-
choices
|
|
1170
|
-
{
|
|
1171
|
-
finish_reason: finishReason,
|
|
1172
|
-
index: typeof choice.index === "number" ? choice.index : 0,
|
|
1173
|
-
logprobs: null,
|
|
1174
|
-
text
|
|
1175
|
-
}
|
|
1176
|
-
] : [],
|
|
1336
|
+
choices,
|
|
1177
1337
|
created: typeof parsed.created === "number" ? parsed.created : epochSeconds(),
|
|
1178
1338
|
id: contentToText(parsed.id) || `cmpl_${randomId()}`,
|
|
1179
1339
|
model: contentToText(parsed.model) || DEFAULT_MODEL,
|
|
@@ -1182,6 +1342,22 @@ function processCompletionSseLine(line, enqueue, markDone) {
|
|
|
1182
1342
|
})
|
|
1183
1343
|
);
|
|
1184
1344
|
}
|
|
1345
|
+
function completionStreamError(event, parsed) {
|
|
1346
|
+
const responseError = asRecord(asRecord(parsed.response).error);
|
|
1347
|
+
const directError = asRecord(parsed.error);
|
|
1348
|
+
const error = Object.keys(directError).length > 0 ? directError : Object.keys(responseError).length > 0 ? responseError : void 0;
|
|
1349
|
+
if (error) {
|
|
1350
|
+
return error;
|
|
1351
|
+
}
|
|
1352
|
+
if (event === "error" || parsed.type === "response.failed") {
|
|
1353
|
+
return removeUndefined({
|
|
1354
|
+
code: contentToText(parsed.code) || void 0,
|
|
1355
|
+
message: contentToText(parsed.message) || "Upstream streaming request failed.",
|
|
1356
|
+
type: contentToText(parsed.type) || "upstream_stream_error"
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
return void 0;
|
|
1360
|
+
}
|
|
1185
1361
|
function processChatSseLine(line, handlers) {
|
|
1186
1362
|
const trimmed = line.trim();
|
|
1187
1363
|
if (!trimmed.startsWith("data:")) {
|
|
@@ -1631,10 +1807,19 @@ var DEFAULT_HOST = "127.0.0.1";
|
|
|
1631
1807
|
var DEFAULT_PORT = 4141;
|
|
1632
1808
|
var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Browser-origin requests require HOOPILOT_API_KEY unless the Origin is loopback.";
|
|
1633
1809
|
var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
|
|
1810
|
+
var MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024;
|
|
1811
|
+
var REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
1812
|
+
var REQUEST_TOO_LARGE_MESSAGE = `Request body must be ${MAX_REQUEST_BODY_BYTES} bytes or smaller.`;
|
|
1634
1813
|
var USAGE_CACHE_TTL_MS = 6e4;
|
|
1814
|
+
var RequestBodyTooLargeError = class extends Error {
|
|
1815
|
+
constructor() {
|
|
1816
|
+
super(REQUEST_TOO_LARGE_MESSAGE);
|
|
1817
|
+
this.name = "RequestBodyTooLargeError";
|
|
1818
|
+
}
|
|
1819
|
+
};
|
|
1635
1820
|
function createHoopilotHandler(options = {}) {
|
|
1636
1821
|
const client = new CopilotClient(options);
|
|
1637
|
-
const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
|
|
1822
|
+
const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
|
|
1638
1823
|
const logger = serverLogger(options);
|
|
1639
1824
|
const metrics = options.metrics ?? new MetricsRegistry();
|
|
1640
1825
|
const readUsage = createUsageReader(client, metrics);
|
|
@@ -1720,6 +1905,18 @@ function createHoopilotHandler(options = {}) {
|
|
|
1720
1905
|
"request body was invalid json"
|
|
1721
1906
|
);
|
|
1722
1907
|
return finish(jsonError(400, "invalid_request_error", message));
|
|
1908
|
+
} else if (error instanceof OpenAICompatibilityError) {
|
|
1909
|
+
requestLogger.warn(
|
|
1910
|
+
{ err: errorDetails(error), event: "http.request.failed" },
|
|
1911
|
+
"request body used unsupported OpenAI compatibility fields"
|
|
1912
|
+
);
|
|
1913
|
+
return finish(jsonError(400, "invalid_request_error", message));
|
|
1914
|
+
} else if (error instanceof RequestBodyTooLargeError) {
|
|
1915
|
+
requestLogger.warn(
|
|
1916
|
+
{ err: errorDetails(error), event: "http.request.failed" },
|
|
1917
|
+
"request body exceeded size limit"
|
|
1918
|
+
);
|
|
1919
|
+
return finish(jsonError(413, "request_too_large", message));
|
|
1723
1920
|
} else {
|
|
1724
1921
|
requestLogger.error(
|
|
1725
1922
|
{ err: errorDetails(error), event: "http.request.failed" },
|
|
@@ -1731,10 +1928,10 @@ function createHoopilotHandler(options = {}) {
|
|
|
1731
1928
|
};
|
|
1732
1929
|
}
|
|
1733
1930
|
function startHoopilotServer(options = {}) {
|
|
1734
|
-
const host = options.host ?? options.env?.HOST ?? DEFAULT_HOST;
|
|
1735
|
-
const port = normalizeServerPort(options.port ?? options.env?.PORT ?? DEFAULT_PORT);
|
|
1736
|
-
const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
|
|
1737
|
-
const allowUnauthenticated = options.allowUnauthenticated ?? options.env?.HOOPILOT_ALLOW_UNAUTHENTICATED === "1";
|
|
1931
|
+
const host = options.host ?? envValue(options.env?.HOST) ?? DEFAULT_HOST;
|
|
1932
|
+
const port = normalizeServerPort(options.port ?? envValue(options.env?.PORT) ?? DEFAULT_PORT);
|
|
1933
|
+
const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
|
|
1934
|
+
const allowUnauthenticated = options.allowUnauthenticated ?? envValue(options.env?.HOOPILOT_ALLOW_UNAUTHENTICATED) === "1";
|
|
1738
1935
|
if (!isLoopbackHost(host) && !apiKey && !allowUnauthenticated) {
|
|
1739
1936
|
throw new Error(
|
|
1740
1937
|
"Refusing to listen on a non-loopback host without HOOPILOT_API_KEY. Set an API key or pass --allow-unauthenticated."
|
|
@@ -1752,7 +1949,7 @@ function startHoopilotServer(options = {}) {
|
|
|
1752
1949
|
});
|
|
1753
1950
|
return {
|
|
1754
1951
|
server,
|
|
1755
|
-
url: `http://${host}:${server.port}`
|
|
1952
|
+
url: `http://${urlHost(host)}:${server.port}`
|
|
1756
1953
|
};
|
|
1757
1954
|
}
|
|
1758
1955
|
async function handleModels(client, metrics, signal, logger) {
|
|
@@ -1861,14 +2058,15 @@ function proxyResponse(upstream) {
|
|
|
1861
2058
|
});
|
|
1862
2059
|
}
|
|
1863
2060
|
async function readJson(request) {
|
|
2061
|
+
const text = await readRequestText(request);
|
|
1864
2062
|
try {
|
|
1865
|
-
return asRecord(
|
|
2063
|
+
return asRecord(JSON.parse(text));
|
|
1866
2064
|
} catch {
|
|
1867
2065
|
throw new Error(INVALID_JSON_MESSAGE);
|
|
1868
2066
|
}
|
|
1869
2067
|
}
|
|
1870
2068
|
async function readJsonText(request) {
|
|
1871
|
-
const text = await request
|
|
2069
|
+
const text = await readRequestText(request);
|
|
1872
2070
|
try {
|
|
1873
2071
|
JSON.parse(text);
|
|
1874
2072
|
return text;
|
|
@@ -1876,6 +2074,40 @@ async function readJsonText(request) {
|
|
|
1876
2074
|
throw new Error(INVALID_JSON_MESSAGE);
|
|
1877
2075
|
}
|
|
1878
2076
|
}
|
|
2077
|
+
async function readRequestText(request) {
|
|
2078
|
+
const contentLength = request.headers.get("content-length");
|
|
2079
|
+
if (contentLength) {
|
|
2080
|
+
const declaredBytes = Number(contentLength);
|
|
2081
|
+
if (Number.isFinite(declaredBytes) && declaredBytes > MAX_REQUEST_BODY_BYTES) {
|
|
2082
|
+
throw new RequestBodyTooLargeError();
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
const body = request.body;
|
|
2086
|
+
if (!body) {
|
|
2087
|
+
return "";
|
|
2088
|
+
}
|
|
2089
|
+
const reader = body.getReader();
|
|
2090
|
+
const decoder = new TextDecoder();
|
|
2091
|
+
let bytes = 0;
|
|
2092
|
+
let text = "";
|
|
2093
|
+
try {
|
|
2094
|
+
while (true) {
|
|
2095
|
+
const { done, value } = await reader.read();
|
|
2096
|
+
if (done) {
|
|
2097
|
+
return `${text}${decoder.decode()}`;
|
|
2098
|
+
}
|
|
2099
|
+
bytes += value.byteLength;
|
|
2100
|
+
if (bytes > MAX_REQUEST_BODY_BYTES) {
|
|
2101
|
+
await reader.cancel().catch(() => {
|
|
2102
|
+
});
|
|
2103
|
+
throw new RequestBodyTooLargeError();
|
|
2104
|
+
}
|
|
2105
|
+
text += decoder.decode(value, { stream: true });
|
|
2106
|
+
}
|
|
2107
|
+
} finally {
|
|
2108
|
+
reader.releaseLock();
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
1879
2111
|
function jsonResponse(body, status = 200) {
|
|
1880
2112
|
return new Response(JSON.stringify(body), {
|
|
1881
2113
|
headers: {
|
|
@@ -1948,6 +2180,9 @@ function upstreamAuthMessage(message) {
|
|
|
1948
2180
|
function isLoopbackHost(host) {
|
|
1949
2181
|
return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
|
|
1950
2182
|
}
|
|
2183
|
+
function urlHost(host) {
|
|
2184
|
+
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
2185
|
+
}
|
|
1951
2186
|
function isLoopbackOrigin(origin) {
|
|
1952
2187
|
try {
|
|
1953
2188
|
return isLoopbackHost(new URL(origin).hostname.toLowerCase());
|
|
@@ -2055,7 +2290,7 @@ function logRequestCompleted(logger, status, stream, durationMs) {
|
|
|
2055
2290
|
}
|
|
2056
2291
|
function requestIdFor(request) {
|
|
2057
2292
|
const existing = request.headers.get("x-request-id")?.trim();
|
|
2058
|
-
return existing
|
|
2293
|
+
return existing && REQUEST_ID_PATTERN.test(existing) ? existing : crypto.randomUUID();
|
|
2059
2294
|
}
|
|
2060
2295
|
function canonicalApiPath(path) {
|
|
2061
2296
|
const withoutTrailingSlash = path.length > 1 ? path.replace(/\/+$/, "") : path;
|