@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.cjs
CHANGED
|
@@ -71,6 +71,56 @@ module.exports = __toCommonJS(index_exports);
|
|
|
71
71
|
// src/auth-store.ts
|
|
72
72
|
var import_node_fs = require("fs");
|
|
73
73
|
var import_node_path = require("path");
|
|
74
|
+
|
|
75
|
+
// src/util.ts
|
|
76
|
+
function trimTrailingSlash(value) {
|
|
77
|
+
return value.replace(/\/+$/, "");
|
|
78
|
+
}
|
|
79
|
+
function envValue(value) {
|
|
80
|
+
const trimmed = value?.trim();
|
|
81
|
+
return trimmed ? trimmed : void 0;
|
|
82
|
+
}
|
|
83
|
+
function isTrustedTokenBaseUrl(rawUrl, allowedHttpsHosts, allowUnsafeHttps = false) {
|
|
84
|
+
const url = parseUrl(rawUrl);
|
|
85
|
+
if (!url) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
if (url.pathname !== "" && url.pathname !== "/") {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
if (isLoopbackHttpUrl(url)) {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
if (url.protocol !== "https:") {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
const host = url.hostname.toLowerCase();
|
|
101
|
+
return allowedHttpsHosts.includes(host) || allowUnsafeHttps;
|
|
102
|
+
}
|
|
103
|
+
function parseUrl(rawUrl) {
|
|
104
|
+
let url;
|
|
105
|
+
try {
|
|
106
|
+
url = new URL(rawUrl);
|
|
107
|
+
} catch {
|
|
108
|
+
return void 0;
|
|
109
|
+
}
|
|
110
|
+
return url;
|
|
111
|
+
}
|
|
112
|
+
function isLoopbackHttpUrl(url) {
|
|
113
|
+
return url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1" || url.hostname === "[::1]");
|
|
114
|
+
}
|
|
115
|
+
async function truncatedResponseText(response, max = 500) {
|
|
116
|
+
const text = await response.text();
|
|
117
|
+
return text.slice(0, max);
|
|
118
|
+
}
|
|
119
|
+
function asRecord(value) {
|
|
120
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// src/auth-store.ts
|
|
74
124
|
var StoredCopilotAuthError = class extends Error {
|
|
75
125
|
constructor(message) {
|
|
76
126
|
super(message);
|
|
@@ -78,10 +128,25 @@ var StoredCopilotAuthError = class extends Error {
|
|
|
78
128
|
}
|
|
79
129
|
};
|
|
80
130
|
function authStorePath(env = process.env) {
|
|
81
|
-
|
|
82
|
-
|
|
131
|
+
const explicit = envValue(env.HOOPILOT_AUTH_FILE);
|
|
132
|
+
if (explicit) {
|
|
133
|
+
return explicit;
|
|
134
|
+
}
|
|
135
|
+
const xdg = envValue(env.XDG_CONFIG_HOME);
|
|
136
|
+
if (xdg) {
|
|
137
|
+
return (0, import_node_path.join)(xdg, "hoopilot", "auth.json");
|
|
138
|
+
}
|
|
139
|
+
const appdata = envValue(env.APPDATA);
|
|
140
|
+
if (appdata) {
|
|
141
|
+
return (0, import_node_path.join)(appdata, "hoopilot", "auth.json");
|
|
83
142
|
}
|
|
84
|
-
const
|
|
143
|
+
const home = envValue(env.HOME);
|
|
144
|
+
if (!home) {
|
|
145
|
+
throw new StoredCopilotAuthError(
|
|
146
|
+
"Cannot resolve Hoopilot auth file path without HOOPILOT_AUTH_FILE, XDG_CONFIG_HOME, APPDATA, or HOME."
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
const base = (0, import_node_path.join)(home, ".config");
|
|
85
150
|
return (0, import_node_path.join)(base, "hoopilot", "auth.json");
|
|
86
151
|
}
|
|
87
152
|
function readStoredCopilotAuth(path = authStorePath()) {
|
|
@@ -138,30 +203,6 @@ function writeStoredCopilotAuth(auth, path = authStorePath()) {
|
|
|
138
203
|
}
|
|
139
204
|
}
|
|
140
205
|
|
|
141
|
-
// src/util.ts
|
|
142
|
-
function trimTrailingSlash(value) {
|
|
143
|
-
return value.replace(/\/+$/, "");
|
|
144
|
-
}
|
|
145
|
-
function isHttpsOrLoopbackUrl(rawUrl) {
|
|
146
|
-
let url;
|
|
147
|
-
try {
|
|
148
|
-
url = new URL(rawUrl);
|
|
149
|
-
} catch {
|
|
150
|
-
return false;
|
|
151
|
-
}
|
|
152
|
-
if (url.protocol === "https:") {
|
|
153
|
-
return true;
|
|
154
|
-
}
|
|
155
|
-
return url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1" || url.hostname === "[::1]");
|
|
156
|
-
}
|
|
157
|
-
async function truncatedResponseText(response, max = 500) {
|
|
158
|
-
const text = await response.text();
|
|
159
|
-
return text.slice(0, max);
|
|
160
|
-
}
|
|
161
|
-
function asRecord(value) {
|
|
162
|
-
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
163
|
-
}
|
|
164
|
-
|
|
165
206
|
// src/auth.ts
|
|
166
207
|
var DEFAULT_COPILOT_API_BASE_URL = "https://api.githubcopilot.com";
|
|
167
208
|
var REFRESH_SKEW_MS = 6e4;
|
|
@@ -175,11 +216,15 @@ var CopilotAuthError = class extends Error {
|
|
|
175
216
|
var CopilotAuth = class {
|
|
176
217
|
#authStorePath;
|
|
177
218
|
#copilotApiBaseUrl;
|
|
219
|
+
#hasCopilotApiBaseUrlOverride;
|
|
178
220
|
#cachedAccess;
|
|
179
221
|
constructor(options = {}) {
|
|
180
|
-
|
|
222
|
+
const envAuthStorePath = envValue(options.env?.HOOPILOT_AUTH_FILE);
|
|
223
|
+
const envCopilotApiBaseUrl = envValue(options.env?.COPILOT_API_BASE_URL);
|
|
224
|
+
this.#authStorePath = options.authStorePath ?? envAuthStorePath;
|
|
225
|
+
this.#hasCopilotApiBaseUrlOverride = Boolean(options.copilotApiBaseUrl ?? envCopilotApiBaseUrl);
|
|
181
226
|
this.#copilotApiBaseUrl = trimTrailingSlash(
|
|
182
|
-
options.copilotApiBaseUrl ??
|
|
227
|
+
options.copilotApiBaseUrl ?? envCopilotApiBaseUrl ?? DEFAULT_COPILOT_API_BASE_URL
|
|
183
228
|
);
|
|
184
229
|
}
|
|
185
230
|
async getAccess() {
|
|
@@ -197,7 +242,9 @@ var CopilotAuth = class {
|
|
|
197
242
|
}
|
|
198
243
|
if (stored) {
|
|
199
244
|
return this.#cacheAccess({
|
|
200
|
-
apiBaseUrl: trimTrailingSlash(
|
|
245
|
+
apiBaseUrl: trimTrailingSlash(
|
|
246
|
+
this.#hasCopilotApiBaseUrlOverride ? this.#copilotApiBaseUrl : stored.apiBaseUrl ?? this.#copilotApiBaseUrl
|
|
247
|
+
),
|
|
201
248
|
expiresAtMs: Date.now() + STORED_TOKEN_TTL_MS,
|
|
202
249
|
source: "github-copilot-oauth",
|
|
203
250
|
token: stored.token
|
|
@@ -215,6 +262,8 @@ var CopilotAuth = class {
|
|
|
215
262
|
|
|
216
263
|
// src/copilot.ts
|
|
217
264
|
var DEFAULT_GITHUB_API_BASE_URL = "https://api.github.com";
|
|
265
|
+
var ALLOWED_COPILOT_API_HOSTS = ["api.githubcopilot.com"];
|
|
266
|
+
var ALLOWED_GITHUB_API_HOSTS = ["api.github.com"];
|
|
218
267
|
var COPILOT_USAGE_API_VERSION = "2025-04-01";
|
|
219
268
|
function applyCopilotHeaders(headers, token) {
|
|
220
269
|
headers.set("accept", headers.get("accept") ?? "application/json");
|
|
@@ -238,13 +287,15 @@ function applyGithubApiHeaders(headers, token) {
|
|
|
238
287
|
}
|
|
239
288
|
var CopilotClient = class {
|
|
240
289
|
#auth;
|
|
290
|
+
#allowUnsafeUpstream;
|
|
241
291
|
#fetch;
|
|
242
292
|
#githubApiBaseUrl;
|
|
243
293
|
constructor(options = {}) {
|
|
244
294
|
this.#auth = new CopilotAuth(options);
|
|
295
|
+
this.#allowUnsafeUpstream = envValue(options.env?.HOOPILOT_ALLOW_UNSAFE_UPSTREAM) === "1";
|
|
245
296
|
this.#fetch = options.fetch ?? fetch;
|
|
246
297
|
this.#githubApiBaseUrl = trimTrailingSlash(
|
|
247
|
-
options.githubApiBaseUrl ?? options.env?.HOOPILOT_GITHUB_API_BASE_URL ?? DEFAULT_GITHUB_API_BASE_URL
|
|
298
|
+
options.githubApiBaseUrl ?? envValue(options.env?.HOOPILOT_GITHUB_API_BASE_URL) ?? DEFAULT_GITHUB_API_BASE_URL
|
|
248
299
|
);
|
|
249
300
|
}
|
|
250
301
|
/**
|
|
@@ -253,9 +304,13 @@ var CopilotClient = class {
|
|
|
253
304
|
* accepted directly here — no Copilot token exchange is required to read quota.
|
|
254
305
|
*/
|
|
255
306
|
async usage(signal) {
|
|
256
|
-
if (!
|
|
307
|
+
if (!isTrustedTokenBaseUrl(
|
|
308
|
+
this.#githubApiBaseUrl,
|
|
309
|
+
ALLOWED_GITHUB_API_HOSTS,
|
|
310
|
+
this.#allowUnsafeUpstream
|
|
311
|
+
)) {
|
|
257
312
|
throw new Error(
|
|
258
|
-
`Refusing to send the GitHub OAuth token to
|
|
313
|
+
`Refusing to send the GitHub OAuth token to an untrusted GitHub API host: ${this.#githubApiBaseUrl}`
|
|
259
314
|
);
|
|
260
315
|
}
|
|
261
316
|
const access = await this.#auth.getAccess();
|
|
@@ -297,9 +352,13 @@ var CopilotClient = class {
|
|
|
297
352
|
}
|
|
298
353
|
async fetchCopilot(path, init) {
|
|
299
354
|
const access = await this.#auth.getAccess();
|
|
300
|
-
if (!
|
|
355
|
+
if (!isTrustedTokenBaseUrl(
|
|
356
|
+
access.apiBaseUrl,
|
|
357
|
+
ALLOWED_COPILOT_API_HOSTS,
|
|
358
|
+
this.#allowUnsafeUpstream
|
|
359
|
+
)) {
|
|
301
360
|
throw new Error(
|
|
302
|
-
`Refusing to send the GitHub OAuth token to
|
|
361
|
+
`Refusing to send the GitHub OAuth token to an untrusted Copilot API host: ${access.apiBaseUrl}`
|
|
303
362
|
);
|
|
304
363
|
}
|
|
305
364
|
const headers = applyCopilotHeaders(new Headers(init.headers), access.token);
|
|
@@ -385,9 +444,9 @@ async function githubCopilotDeviceLogin(options = {}) {
|
|
|
385
444
|
const fetcher = options.fetch ?? fetch;
|
|
386
445
|
const sleeper = options.sleep ?? import_promises.setTimeout;
|
|
387
446
|
const domain = normalizeDomain(
|
|
388
|
-
options.domain ?? env.HOOPILOT_GITHUB_DOMAIN ?? DEFAULT_GITHUB_DOMAIN
|
|
447
|
+
options.domain ?? envValue(env.HOOPILOT_GITHUB_DOMAIN) ?? DEFAULT_GITHUB_DOMAIN
|
|
389
448
|
);
|
|
390
|
-
const clientId = options.clientId ?? env.HOOPILOT_GITHUB_CLIENT_ID ?? env.COPILOT_GITHUB_CLIENT_ID ?? DEFAULT_GITHUB_COPILOT_CLIENT_ID;
|
|
449
|
+
const clientId = options.clientId ?? envValue(env.HOOPILOT_GITHUB_CLIENT_ID) ?? envValue(env.COPILOT_GITHUB_CLIENT_ID) ?? DEFAULT_GITHUB_COPILOT_CLIENT_ID;
|
|
391
450
|
const device = await requestDeviceCode(fetcher, domain, clientId);
|
|
392
451
|
const verificationUrl = device.verification_uri;
|
|
393
452
|
const userCode = device.user_code;
|
|
@@ -545,8 +604,8 @@ var noopLogger = {
|
|
|
545
604
|
};
|
|
546
605
|
function createHoopilotLogger(options = {}) {
|
|
547
606
|
const env = options.env ?? process.env;
|
|
548
|
-
const level = parseLogLevel(options.level ?? env.HOOPILOT_LOG_LEVEL);
|
|
549
|
-
const format = parseLogFormat(options.format ?? env.HOOPILOT_LOG_FORMAT);
|
|
607
|
+
const level = parseLogLevel(options.level ?? envValue(env.HOOPILOT_LOG_LEVEL));
|
|
608
|
+
const format = parseLogFormat(options.format ?? envValue(env.HOOPILOT_LOG_FORMAT));
|
|
550
609
|
const pinoOptions = {
|
|
551
610
|
base: {
|
|
552
611
|
service: "hoopilot",
|
|
@@ -596,7 +655,7 @@ function parseLogLevel(value) {
|
|
|
596
655
|
}
|
|
597
656
|
function shouldCreateLogger(options) {
|
|
598
657
|
return Boolean(
|
|
599
|
-
options.logger || options.logFormat || options.logLevel || options.env?.HOOPILOT_LOG_FORMAT || options.env?.HOOPILOT_LOG_LEVEL
|
|
658
|
+
options.logger || options.logFormat || options.logLevel || envValue(options.env?.HOOPILOT_LOG_FORMAT) || envValue(options.env?.HOOPILOT_LOG_LEVEL)
|
|
600
659
|
);
|
|
601
660
|
}
|
|
602
661
|
function errorDetails(error) {
|
|
@@ -618,6 +677,12 @@ function isLogLevel(value) {
|
|
|
618
677
|
|
|
619
678
|
// src/openai.ts
|
|
620
679
|
var DEFAULT_MODEL = "gpt-4.1";
|
|
680
|
+
var OpenAICompatibilityError = class extends Error {
|
|
681
|
+
constructor(message) {
|
|
682
|
+
super(message);
|
|
683
|
+
this.name = "OpenAICompatibilityError";
|
|
684
|
+
}
|
|
685
|
+
};
|
|
621
686
|
function responsesRequestToChatCompletion(request) {
|
|
622
687
|
const messages = [];
|
|
623
688
|
const instructions = contentToText(request.instructions);
|
|
@@ -651,13 +716,22 @@ function normalizeChatCompletionRequest(request) {
|
|
|
651
716
|
});
|
|
652
717
|
}
|
|
653
718
|
function completionsRequestToChatCompletion(request) {
|
|
719
|
+
assertSupportedLegacyCompletionRequest(request);
|
|
654
720
|
return removeUndefined({
|
|
721
|
+
frequency_penalty: request.frequency_penalty,
|
|
722
|
+
logit_bias: request.logit_bias,
|
|
655
723
|
max_tokens: request.max_tokens,
|
|
656
|
-
messages: [{ content:
|
|
724
|
+
messages: [{ content: legacyPromptToText(request.prompt), role: "user" }],
|
|
657
725
|
model: normalizeRequestedModel(request.model),
|
|
726
|
+
n: request.n,
|
|
727
|
+
presence_penalty: request.presence_penalty,
|
|
728
|
+
seed: request.seed,
|
|
729
|
+
stop: request.stop,
|
|
658
730
|
stream: request.stream === true,
|
|
731
|
+
stream_options: request.stream_options,
|
|
659
732
|
temperature: request.temperature,
|
|
660
|
-
top_p: request.top_p
|
|
733
|
+
top_p: request.top_p,
|
|
734
|
+
user: request.user
|
|
661
735
|
});
|
|
662
736
|
}
|
|
663
737
|
function normalizeRequestedModel(model) {
|
|
@@ -693,21 +767,21 @@ function chatCompletionToResponse(completion, responseId) {
|
|
|
693
767
|
});
|
|
694
768
|
}
|
|
695
769
|
function chatCompletionToCompletion(completion) {
|
|
696
|
-
const choice = firstChoice(completion);
|
|
697
|
-
const message = asRecord(choice.message);
|
|
698
770
|
return removeUndefined({
|
|
699
|
-
choices:
|
|
700
|
-
|
|
771
|
+
choices: completionChoices(completion).map((choice, index) => {
|
|
772
|
+
const message = asRecord(choice.message);
|
|
773
|
+
return {
|
|
701
774
|
finish_reason: choice.finish_reason ?? "stop",
|
|
702
|
-
index:
|
|
703
|
-
logprobs: null,
|
|
704
|
-
text: contentToText(message.content)
|
|
705
|
-
}
|
|
706
|
-
|
|
775
|
+
index: typeof choice.index === "number" ? choice.index : index,
|
|
776
|
+
logprobs: choice.logprobs ?? null,
|
|
777
|
+
text: contentToText(choice.text) || contentToText(message.content)
|
|
778
|
+
};
|
|
779
|
+
}),
|
|
707
780
|
created: completion.created ?? epochSeconds(),
|
|
708
781
|
id: completion.id ?? `cmpl_${randomId()}`,
|
|
709
782
|
model: completion.model ?? DEFAULT_MODEL,
|
|
710
783
|
object: "text_completion",
|
|
784
|
+
system_fingerprint: completion.system_fingerprint,
|
|
711
785
|
usage: completion.usage
|
|
712
786
|
});
|
|
713
787
|
}
|
|
@@ -715,12 +789,15 @@ function completionStreamFromChatStream(chatStream) {
|
|
|
715
789
|
const encoder = new TextEncoder();
|
|
716
790
|
const decoder = new TextDecoder();
|
|
717
791
|
let buffer = "";
|
|
718
|
-
let
|
|
792
|
+
let sawTerminalEvent = false;
|
|
719
793
|
return new ReadableStream({
|
|
720
794
|
async start(controller) {
|
|
721
795
|
const enqueue = (data) => {
|
|
722
796
|
controller.enqueue(encoder.encode(encodeDataSse(data)));
|
|
723
797
|
};
|
|
798
|
+
const markTerminal = () => {
|
|
799
|
+
sawTerminalEvent = true;
|
|
800
|
+
};
|
|
724
801
|
const reader = chatStream.getReader();
|
|
725
802
|
try {
|
|
726
803
|
while (true) {
|
|
@@ -729,20 +806,17 @@ function completionStreamFromChatStream(chatStream) {
|
|
|
729
806
|
break;
|
|
730
807
|
}
|
|
731
808
|
buffer += decoder.decode(result.value, { stream: true });
|
|
732
|
-
const
|
|
733
|
-
buffer =
|
|
734
|
-
for (const
|
|
735
|
-
|
|
736
|
-
sawDone = true;
|
|
737
|
-
});
|
|
809
|
+
const blocks = buffer.split(/\r?\n\r?\n/);
|
|
810
|
+
buffer = blocks.pop() ?? "";
|
|
811
|
+
for (const block of blocks) {
|
|
812
|
+
processCompletionSseBlock(block, enqueue, markTerminal);
|
|
738
813
|
}
|
|
739
814
|
}
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
});
|
|
815
|
+
const tail = `${buffer}${decoder.decode()}`;
|
|
816
|
+
if (tail.trim()) {
|
|
817
|
+
processCompletionSseBlock(tail, enqueue, markTerminal);
|
|
744
818
|
}
|
|
745
|
-
if (!
|
|
819
|
+
if (!sawTerminalEvent) {
|
|
746
820
|
enqueue("[DONE]");
|
|
747
821
|
}
|
|
748
822
|
controller.close();
|
|
@@ -971,7 +1045,8 @@ function inputToMessages(input) {
|
|
|
971
1045
|
const messages = [];
|
|
972
1046
|
for (const item of input) {
|
|
973
1047
|
const record = asRecord(item);
|
|
974
|
-
|
|
1048
|
+
const type = contentToText(record.type);
|
|
1049
|
+
if (type === "function_call_output") {
|
|
975
1050
|
messages.push({
|
|
976
1051
|
content: contentToText(record.output),
|
|
977
1052
|
role: "tool",
|
|
@@ -979,7 +1054,7 @@ function inputToMessages(input) {
|
|
|
979
1054
|
});
|
|
980
1055
|
continue;
|
|
981
1056
|
}
|
|
982
|
-
if (
|
|
1057
|
+
if (type === "function_call") {
|
|
983
1058
|
messages.push({
|
|
984
1059
|
role: "assistant",
|
|
985
1060
|
tool_calls: [
|
|
@@ -995,7 +1070,10 @@ function inputToMessages(input) {
|
|
|
995
1070
|
});
|
|
996
1071
|
continue;
|
|
997
1072
|
}
|
|
998
|
-
|
|
1073
|
+
if (type && type !== "message") {
|
|
1074
|
+
unsupportedResponsesFeature(`input item type "${type}"`);
|
|
1075
|
+
}
|
|
1076
|
+
const role = responsesRoleToChatRole(contentToText(record.role));
|
|
999
1077
|
const content = chatMessageContent(record.content);
|
|
1000
1078
|
if (role && content !== void 0) {
|
|
1001
1079
|
messages.push({ content, role });
|
|
@@ -1008,7 +1086,10 @@ function chatMessageContent(content) {
|
|
|
1008
1086
|
return content;
|
|
1009
1087
|
}
|
|
1010
1088
|
if (!Array.isArray(content)) {
|
|
1011
|
-
|
|
1089
|
+
if (content === void 0 || content === null) {
|
|
1090
|
+
return void 0;
|
|
1091
|
+
}
|
|
1092
|
+
unsupportedResponsesFeature("non-array message content objects");
|
|
1012
1093
|
}
|
|
1013
1094
|
const parts = [];
|
|
1014
1095
|
for (const part of content) {
|
|
@@ -1016,13 +1097,31 @@ function chatMessageContent(content) {
|
|
|
1016
1097
|
const type = contentToText(record.type);
|
|
1017
1098
|
if (type === "input_text" || type === "output_text" || type === "text") {
|
|
1018
1099
|
parts.push({ text: contentToText(record.text), type: "text" });
|
|
1100
|
+
continue;
|
|
1019
1101
|
}
|
|
1020
1102
|
if (type === "input_image") {
|
|
1103
|
+
if (contentToText(record.file_id)) {
|
|
1104
|
+
unsupportedResponsesFeature("input_image file_id parts");
|
|
1105
|
+
}
|
|
1021
1106
|
const imageUrl = contentToText(record.image_url);
|
|
1022
|
-
if (imageUrl) {
|
|
1023
|
-
|
|
1107
|
+
if (!imageUrl) {
|
|
1108
|
+
unsupportedResponsesFeature("input_image parts without image_url");
|
|
1024
1109
|
}
|
|
1110
|
+
const image = { url: imageUrl };
|
|
1111
|
+
const detail = contentToText(record.detail);
|
|
1112
|
+
if (detail) {
|
|
1113
|
+
image.detail = detail;
|
|
1114
|
+
}
|
|
1115
|
+
parts.push({ image_url: image, type: "image_url" });
|
|
1116
|
+
continue;
|
|
1117
|
+
}
|
|
1118
|
+
if (type === "input_file") {
|
|
1119
|
+
unsupportedResponsesFeature("input_file parts");
|
|
1025
1120
|
}
|
|
1121
|
+
if (type === "input_audio") {
|
|
1122
|
+
unsupportedResponsesFeature("input_audio parts");
|
|
1123
|
+
}
|
|
1124
|
+
unsupportedResponsesFeature(`content part type "${type || "unknown"}"`);
|
|
1026
1125
|
}
|
|
1027
1126
|
if (parts.length === 0) {
|
|
1028
1127
|
return void 0;
|
|
@@ -1032,11 +1131,38 @@ function chatMessageContent(content) {
|
|
|
1032
1131
|
}
|
|
1033
1132
|
return parts;
|
|
1034
1133
|
}
|
|
1035
|
-
function
|
|
1036
|
-
if (
|
|
1037
|
-
return prompt
|
|
1134
|
+
function legacyPromptToText(prompt) {
|
|
1135
|
+
if (typeof prompt === "string") {
|
|
1136
|
+
return prompt;
|
|
1137
|
+
}
|
|
1138
|
+
if (Array.isArray(prompt) && prompt.length === 1 && typeof prompt[0] === "string") {
|
|
1139
|
+
return prompt[0];
|
|
1140
|
+
}
|
|
1141
|
+
throw new OpenAICompatibilityError(
|
|
1142
|
+
"Hoopilot legacy completions compatibility supports exactly one string prompt per request."
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
function assertSupportedLegacyCompletionRequest(request) {
|
|
1146
|
+
if (request.echo === true) {
|
|
1147
|
+
throw new OpenAICompatibilityError(
|
|
1148
|
+
"Hoopilot legacy completions compatibility does not support echo=true."
|
|
1149
|
+
);
|
|
1150
|
+
}
|
|
1151
|
+
if (typeof request.best_of === "number" && request.best_of > 1) {
|
|
1152
|
+
throw new OpenAICompatibilityError(
|
|
1153
|
+
"Hoopilot legacy completions compatibility does not support best_of greater than 1."
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
if (typeof request.logprobs === "number" && request.logprobs > 0) {
|
|
1157
|
+
throw new OpenAICompatibilityError(
|
|
1158
|
+
"Hoopilot legacy completions compatibility does not support legacy logprobs."
|
|
1159
|
+
);
|
|
1160
|
+
}
|
|
1161
|
+
if (contentToText(request.suffix)) {
|
|
1162
|
+
throw new OpenAICompatibilityError(
|
|
1163
|
+
"Hoopilot legacy completions compatibility does not support suffix."
|
|
1164
|
+
);
|
|
1038
1165
|
}
|
|
1039
|
-
return contentToText(prompt);
|
|
1040
1166
|
}
|
|
1041
1167
|
function contentToText(content) {
|
|
1042
1168
|
if (typeof content === "string") {
|
|
@@ -1060,25 +1186,35 @@ function contentToText(content) {
|
|
|
1060
1186
|
}
|
|
1061
1187
|
return "";
|
|
1062
1188
|
}
|
|
1063
|
-
function
|
|
1064
|
-
if (role
|
|
1189
|
+
function responsesRoleToChatRole(role) {
|
|
1190
|
+
if (!role) {
|
|
1191
|
+
return "user";
|
|
1192
|
+
}
|
|
1193
|
+
if (role === "assistant" || role === "developer" || role === "system" || role === "tool" || role === "user") {
|
|
1065
1194
|
return role === "developer" ? "system" : role;
|
|
1066
1195
|
}
|
|
1067
|
-
|
|
1196
|
+
unsupportedResponsesFeature(`message role "${role}"`);
|
|
1068
1197
|
}
|
|
1069
1198
|
function chatTools(tools) {
|
|
1070
1199
|
if (!Array.isArray(tools)) {
|
|
1071
1200
|
return void 0;
|
|
1072
1201
|
}
|
|
1073
|
-
const converted = tools.map((tool) =>
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1202
|
+
const converted = tools.map((tool) => {
|
|
1203
|
+
const record = asRecord(tool);
|
|
1204
|
+
const type = contentToText(record.type);
|
|
1205
|
+
if (type !== "function") {
|
|
1206
|
+
unsupportedResponsesFeature(`tool type "${type || "unknown"}"`);
|
|
1207
|
+
}
|
|
1208
|
+
return {
|
|
1209
|
+
function: removeUndefined({
|
|
1210
|
+
description: record.description,
|
|
1211
|
+
name: record.name,
|
|
1212
|
+
parameters: record.parameters,
|
|
1213
|
+
strict: record.strict
|
|
1214
|
+
}),
|
|
1215
|
+
type: "function"
|
|
1216
|
+
};
|
|
1217
|
+
});
|
|
1082
1218
|
return converted.length > 0 ? converted : void 0;
|
|
1083
1219
|
}
|
|
1084
1220
|
function chatToolChoice(toolChoice) {
|
|
@@ -1086,10 +1222,16 @@ function chatToolChoice(toolChoice) {
|
|
|
1086
1222
|
return toolChoice;
|
|
1087
1223
|
}
|
|
1088
1224
|
const record = asRecord(toolChoice);
|
|
1089
|
-
|
|
1225
|
+
const type = contentToText(record.type);
|
|
1226
|
+
if (type === "function" && typeof record.name === "string") {
|
|
1090
1227
|
return { function: { name: record.name }, type: "function" };
|
|
1091
1228
|
}
|
|
1092
|
-
|
|
1229
|
+
unsupportedResponsesFeature(`tool_choice type "${type || "unknown"}"`);
|
|
1230
|
+
}
|
|
1231
|
+
function unsupportedResponsesFeature(feature) {
|
|
1232
|
+
throw new OpenAICompatibilityError(
|
|
1233
|
+
`Hoopilot Responses-to-chat compatibility does not support ${feature}.`
|
|
1234
|
+
);
|
|
1093
1235
|
}
|
|
1094
1236
|
function outputItemsFromMessage(message) {
|
|
1095
1237
|
const output = [];
|
|
@@ -1204,20 +1346,29 @@ function firstNumber(...values) {
|
|
|
1204
1346
|
return void 0;
|
|
1205
1347
|
}
|
|
1206
1348
|
function firstChoice(completion) {
|
|
1207
|
-
|
|
1208
|
-
return asRecord(choices[0]);
|
|
1349
|
+
return completionChoices(completion)[0] ?? {};
|
|
1209
1350
|
}
|
|
1210
|
-
function
|
|
1211
|
-
const
|
|
1212
|
-
|
|
1213
|
-
|
|
1351
|
+
function completionChoices(completion) {
|
|
1352
|
+
const choices = Array.isArray(completion.choices) ? completion.choices : [];
|
|
1353
|
+
return choices.map((choice) => asRecord(choice));
|
|
1354
|
+
}
|
|
1355
|
+
function processCompletionSseBlock(block, enqueue, markTerminal) {
|
|
1356
|
+
let event = "message";
|
|
1357
|
+
const dataLines = [];
|
|
1358
|
+
for (const line of block.split(/\r?\n/)) {
|
|
1359
|
+
const trimmed = line.trim();
|
|
1360
|
+
if (trimmed.startsWith("event:")) {
|
|
1361
|
+
event = trimmed.slice("event:".length).trim() || event;
|
|
1362
|
+
} else if (trimmed.startsWith("data:")) {
|
|
1363
|
+
dataLines.push(trimmed.slice("data:".length).trim());
|
|
1364
|
+
}
|
|
1214
1365
|
}
|
|
1215
|
-
const data =
|
|
1366
|
+
const data = dataLines.join("\n");
|
|
1216
1367
|
if (!data) {
|
|
1217
1368
|
return;
|
|
1218
1369
|
}
|
|
1219
1370
|
if (data === "[DONE]") {
|
|
1220
|
-
|
|
1371
|
+
markTerminal();
|
|
1221
1372
|
enqueue("[DONE]");
|
|
1222
1373
|
return;
|
|
1223
1374
|
}
|
|
@@ -1225,25 +1376,34 @@ function processCompletionSseLine(line, enqueue, markDone) {
|
|
|
1225
1376
|
if (!parsed) {
|
|
1226
1377
|
return;
|
|
1227
1378
|
}
|
|
1228
|
-
const
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1379
|
+
const error = completionStreamError(event, parsed);
|
|
1380
|
+
if (error) {
|
|
1381
|
+
markTerminal();
|
|
1382
|
+
enqueue({ error });
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
const choices = completionChoices(parsed).map((choice, index) => {
|
|
1386
|
+
const delta = asRecord(choice.delta);
|
|
1387
|
+
const text = contentToText(delta.content);
|
|
1388
|
+
const finishReason = choice.finish_reason ?? null;
|
|
1389
|
+
if (!text && finishReason === null) {
|
|
1390
|
+
return void 0;
|
|
1391
|
+
}
|
|
1392
|
+
return {
|
|
1393
|
+
finish_reason: finishReason,
|
|
1394
|
+
index: typeof choice.index === "number" ? choice.index : index,
|
|
1395
|
+
logprobs: choice.logprobs ?? null,
|
|
1396
|
+
text
|
|
1397
|
+
};
|
|
1398
|
+
}).filter((choice) => choice !== void 0);
|
|
1232
1399
|
const usage = asRecord(parsed.usage);
|
|
1233
1400
|
const hasUsage = Object.keys(usage).length > 0;
|
|
1234
|
-
if (
|
|
1401
|
+
if (choices.length === 0 && !hasUsage) {
|
|
1235
1402
|
return;
|
|
1236
1403
|
}
|
|
1237
1404
|
enqueue(
|
|
1238
1405
|
removeUndefined({
|
|
1239
|
-
choices
|
|
1240
|
-
{
|
|
1241
|
-
finish_reason: finishReason,
|
|
1242
|
-
index: typeof choice.index === "number" ? choice.index : 0,
|
|
1243
|
-
logprobs: null,
|
|
1244
|
-
text
|
|
1245
|
-
}
|
|
1246
|
-
] : [],
|
|
1406
|
+
choices,
|
|
1247
1407
|
created: typeof parsed.created === "number" ? parsed.created : epochSeconds(),
|
|
1248
1408
|
id: contentToText(parsed.id) || `cmpl_${randomId()}`,
|
|
1249
1409
|
model: contentToText(parsed.model) || DEFAULT_MODEL,
|
|
@@ -1252,6 +1412,22 @@ function processCompletionSseLine(line, enqueue, markDone) {
|
|
|
1252
1412
|
})
|
|
1253
1413
|
);
|
|
1254
1414
|
}
|
|
1415
|
+
function completionStreamError(event, parsed) {
|
|
1416
|
+
const responseError = asRecord(asRecord(parsed.response).error);
|
|
1417
|
+
const directError = asRecord(parsed.error);
|
|
1418
|
+
const error = Object.keys(directError).length > 0 ? directError : Object.keys(responseError).length > 0 ? responseError : void 0;
|
|
1419
|
+
if (error) {
|
|
1420
|
+
return error;
|
|
1421
|
+
}
|
|
1422
|
+
if (event === "error" || parsed.type === "response.failed") {
|
|
1423
|
+
return removeUndefined({
|
|
1424
|
+
code: contentToText(parsed.code) || void 0,
|
|
1425
|
+
message: contentToText(parsed.message) || "Upstream streaming request failed.",
|
|
1426
|
+
type: contentToText(parsed.type) || "upstream_stream_error"
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1429
|
+
return void 0;
|
|
1430
|
+
}
|
|
1255
1431
|
function processChatSseLine(line, handlers) {
|
|
1256
1432
|
const trimmed = line.trim();
|
|
1257
1433
|
if (!trimmed.startsWith("data:")) {
|
|
@@ -1701,10 +1877,19 @@ var DEFAULT_HOST = "127.0.0.1";
|
|
|
1701
1877
|
var DEFAULT_PORT = 4141;
|
|
1702
1878
|
var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Browser-origin requests require HOOPILOT_API_KEY unless the Origin is loopback.";
|
|
1703
1879
|
var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
|
|
1880
|
+
var MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024;
|
|
1881
|
+
var REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
1882
|
+
var REQUEST_TOO_LARGE_MESSAGE = `Request body must be ${MAX_REQUEST_BODY_BYTES} bytes or smaller.`;
|
|
1704
1883
|
var USAGE_CACHE_TTL_MS = 6e4;
|
|
1884
|
+
var RequestBodyTooLargeError = class extends Error {
|
|
1885
|
+
constructor() {
|
|
1886
|
+
super(REQUEST_TOO_LARGE_MESSAGE);
|
|
1887
|
+
this.name = "RequestBodyTooLargeError";
|
|
1888
|
+
}
|
|
1889
|
+
};
|
|
1705
1890
|
function createHoopilotHandler(options = {}) {
|
|
1706
1891
|
const client = new CopilotClient(options);
|
|
1707
|
-
const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
|
|
1892
|
+
const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
|
|
1708
1893
|
const logger = serverLogger(options);
|
|
1709
1894
|
const metrics = options.metrics ?? new MetricsRegistry();
|
|
1710
1895
|
const readUsage = createUsageReader(client, metrics);
|
|
@@ -1790,6 +1975,18 @@ function createHoopilotHandler(options = {}) {
|
|
|
1790
1975
|
"request body was invalid json"
|
|
1791
1976
|
);
|
|
1792
1977
|
return finish(jsonError(400, "invalid_request_error", message));
|
|
1978
|
+
} else if (error instanceof OpenAICompatibilityError) {
|
|
1979
|
+
requestLogger.warn(
|
|
1980
|
+
{ err: errorDetails(error), event: "http.request.failed" },
|
|
1981
|
+
"request body used unsupported OpenAI compatibility fields"
|
|
1982
|
+
);
|
|
1983
|
+
return finish(jsonError(400, "invalid_request_error", message));
|
|
1984
|
+
} else if (error instanceof RequestBodyTooLargeError) {
|
|
1985
|
+
requestLogger.warn(
|
|
1986
|
+
{ err: errorDetails(error), event: "http.request.failed" },
|
|
1987
|
+
"request body exceeded size limit"
|
|
1988
|
+
);
|
|
1989
|
+
return finish(jsonError(413, "request_too_large", message));
|
|
1793
1990
|
} else {
|
|
1794
1991
|
requestLogger.error(
|
|
1795
1992
|
{ err: errorDetails(error), event: "http.request.failed" },
|
|
@@ -1801,10 +1998,10 @@ function createHoopilotHandler(options = {}) {
|
|
|
1801
1998
|
};
|
|
1802
1999
|
}
|
|
1803
2000
|
function startHoopilotServer(options = {}) {
|
|
1804
|
-
const host = options.host ?? options.env?.HOST ?? DEFAULT_HOST;
|
|
1805
|
-
const port = normalizeServerPort(options.port ?? options.env?.PORT ?? DEFAULT_PORT);
|
|
1806
|
-
const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
|
|
1807
|
-
const allowUnauthenticated = options.allowUnauthenticated ?? options.env?.HOOPILOT_ALLOW_UNAUTHENTICATED === "1";
|
|
2001
|
+
const host = options.host ?? envValue(options.env?.HOST) ?? DEFAULT_HOST;
|
|
2002
|
+
const port = normalizeServerPort(options.port ?? envValue(options.env?.PORT) ?? DEFAULT_PORT);
|
|
2003
|
+
const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
|
|
2004
|
+
const allowUnauthenticated = options.allowUnauthenticated ?? envValue(options.env?.HOOPILOT_ALLOW_UNAUTHENTICATED) === "1";
|
|
1808
2005
|
if (!isLoopbackHost(host) && !apiKey && !allowUnauthenticated) {
|
|
1809
2006
|
throw new Error(
|
|
1810
2007
|
"Refusing to listen on a non-loopback host without HOOPILOT_API_KEY. Set an API key or pass --allow-unauthenticated."
|
|
@@ -1822,7 +2019,7 @@ function startHoopilotServer(options = {}) {
|
|
|
1822
2019
|
});
|
|
1823
2020
|
return {
|
|
1824
2021
|
server,
|
|
1825
|
-
url: `http://${host}:${server.port}`
|
|
2022
|
+
url: `http://${urlHost(host)}:${server.port}`
|
|
1826
2023
|
};
|
|
1827
2024
|
}
|
|
1828
2025
|
async function handleModels(client, metrics, signal, logger) {
|
|
@@ -1931,14 +2128,15 @@ function proxyResponse(upstream) {
|
|
|
1931
2128
|
});
|
|
1932
2129
|
}
|
|
1933
2130
|
async function readJson(request) {
|
|
2131
|
+
const text = await readRequestText(request);
|
|
1934
2132
|
try {
|
|
1935
|
-
return asRecord(
|
|
2133
|
+
return asRecord(JSON.parse(text));
|
|
1936
2134
|
} catch {
|
|
1937
2135
|
throw new Error(INVALID_JSON_MESSAGE);
|
|
1938
2136
|
}
|
|
1939
2137
|
}
|
|
1940
2138
|
async function readJsonText(request) {
|
|
1941
|
-
const text = await request
|
|
2139
|
+
const text = await readRequestText(request);
|
|
1942
2140
|
try {
|
|
1943
2141
|
JSON.parse(text);
|
|
1944
2142
|
return text;
|
|
@@ -1946,6 +2144,40 @@ async function readJsonText(request) {
|
|
|
1946
2144
|
throw new Error(INVALID_JSON_MESSAGE);
|
|
1947
2145
|
}
|
|
1948
2146
|
}
|
|
2147
|
+
async function readRequestText(request) {
|
|
2148
|
+
const contentLength = request.headers.get("content-length");
|
|
2149
|
+
if (contentLength) {
|
|
2150
|
+
const declaredBytes = Number(contentLength);
|
|
2151
|
+
if (Number.isFinite(declaredBytes) && declaredBytes > MAX_REQUEST_BODY_BYTES) {
|
|
2152
|
+
throw new RequestBodyTooLargeError();
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
const body = request.body;
|
|
2156
|
+
if (!body) {
|
|
2157
|
+
return "";
|
|
2158
|
+
}
|
|
2159
|
+
const reader = body.getReader();
|
|
2160
|
+
const decoder = new TextDecoder();
|
|
2161
|
+
let bytes = 0;
|
|
2162
|
+
let text = "";
|
|
2163
|
+
try {
|
|
2164
|
+
while (true) {
|
|
2165
|
+
const { done, value } = await reader.read();
|
|
2166
|
+
if (done) {
|
|
2167
|
+
return `${text}${decoder.decode()}`;
|
|
2168
|
+
}
|
|
2169
|
+
bytes += value.byteLength;
|
|
2170
|
+
if (bytes > MAX_REQUEST_BODY_BYTES) {
|
|
2171
|
+
await reader.cancel().catch(() => {
|
|
2172
|
+
});
|
|
2173
|
+
throw new RequestBodyTooLargeError();
|
|
2174
|
+
}
|
|
2175
|
+
text += decoder.decode(value, { stream: true });
|
|
2176
|
+
}
|
|
2177
|
+
} finally {
|
|
2178
|
+
reader.releaseLock();
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
1949
2181
|
function jsonResponse(body, status = 200) {
|
|
1950
2182
|
return new Response(JSON.stringify(body), {
|
|
1951
2183
|
headers: {
|
|
@@ -2018,6 +2250,9 @@ function upstreamAuthMessage(message) {
|
|
|
2018
2250
|
function isLoopbackHost(host) {
|
|
2019
2251
|
return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
|
|
2020
2252
|
}
|
|
2253
|
+
function urlHost(host) {
|
|
2254
|
+
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
2255
|
+
}
|
|
2021
2256
|
function isLoopbackOrigin(origin) {
|
|
2022
2257
|
try {
|
|
2023
2258
|
return isLoopbackHost(new URL(origin).hostname.toLowerCase());
|
|
@@ -2125,7 +2360,7 @@ function logRequestCompleted(logger, status, stream, durationMs) {
|
|
|
2125
2360
|
}
|
|
2126
2361
|
function requestIdFor(request) {
|
|
2127
2362
|
const existing = request.headers.get("x-request-id")?.trim();
|
|
2128
|
-
return existing
|
|
2363
|
+
return existing && REQUEST_ID_PATTERN.test(existing) ? existing : crypto.randomUUID();
|
|
2129
2364
|
}
|
|
2130
2365
|
function canonicalApiPath(path) {
|
|
2131
2366
|
const withoutTrailingSlash = path.length > 1 ? path.replace(/\/+$/, "") : path;
|