@openhoo/hoopilot 0.7.1 → 0.7.3
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 +6 -2
- package/dist/chunk-7GSQVYYT.js +221 -0
- package/dist/chunk-7GSQVYYT.js.map +1 -0
- package/dist/cli.js +473 -83
- package/dist/cli.js.map +1 -1
- package/dist/codexx.js +5 -161
- package/dist/codexx.js.map +1 -1
- package/dist/index.cjs +533 -155
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +532 -155
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -1,33 +1,117 @@
|
|
|
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
|
|
54
|
+
var StoredCopilotAuthError = class extends Error {
|
|
55
|
+
constructor(message) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.name = "StoredCopilotAuthError";
|
|
58
|
+
}
|
|
59
|
+
};
|
|
4
60
|
function authStorePath(env = process.env) {
|
|
5
|
-
|
|
6
|
-
|
|
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");
|
|
72
|
+
}
|
|
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
|
+
);
|
|
7
78
|
}
|
|
8
|
-
const base =
|
|
79
|
+
const base = join(home, ".config");
|
|
9
80
|
return join(base, "hoopilot", "auth.json");
|
|
10
81
|
}
|
|
11
82
|
function readStoredCopilotAuth(path = authStorePath()) {
|
|
83
|
+
let text;
|
|
12
84
|
try {
|
|
13
|
-
|
|
14
|
-
|
|
85
|
+
text = readFileSync(path, "utf8");
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error.code === "ENOENT") {
|
|
15
88
|
return void 0;
|
|
16
89
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
apiBaseUrl: typeof parsed.apiBaseUrl === "string" ? parsed.apiBaseUrl : void 0,
|
|
23
|
-
createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : void 0,
|
|
24
|
-
githubDomain: typeof parsed.githubDomain === "string" ? parsed.githubDomain : void 0,
|
|
25
|
-
source: typeof parsed.source === "string" ? parsed.source : void 0,
|
|
26
|
-
token
|
|
27
|
-
};
|
|
90
|
+
throw new StoredCopilotAuthError(`Could not read Hoopilot auth file at ${path}.`);
|
|
91
|
+
}
|
|
92
|
+
let parsed;
|
|
93
|
+
try {
|
|
94
|
+
parsed = JSON.parse(text);
|
|
28
95
|
} catch {
|
|
29
|
-
|
|
96
|
+
throw new StoredCopilotAuthError(
|
|
97
|
+
`Hoopilot auth file at ${path} is not valid JSON. Run \`hoopilot login\` to replace it.`
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
101
|
+
throw new StoredCopilotAuthError(`Hoopilot auth file at ${path} must contain a JSON object.`);
|
|
30
102
|
}
|
|
103
|
+
const record = parsed;
|
|
104
|
+
const token = typeof record.token === "string" ? record.token.trim() : "";
|
|
105
|
+
if (!token) {
|
|
106
|
+
throw new StoredCopilotAuthError(`Hoopilot auth file at ${path} does not contain a token.`);
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
apiBaseUrl: typeof record.apiBaseUrl === "string" ? record.apiBaseUrl : void 0,
|
|
110
|
+
createdAt: typeof record.createdAt === "string" ? record.createdAt : void 0,
|
|
111
|
+
githubDomain: typeof record.githubDomain === "string" ? record.githubDomain : void 0,
|
|
112
|
+
source: typeof record.source === "string" ? record.source : void 0,
|
|
113
|
+
token
|
|
114
|
+
};
|
|
31
115
|
}
|
|
32
116
|
function writeStoredCopilotAuth(auth, path = authStorePath()) {
|
|
33
117
|
mkdirSync(dirname(path), { recursive: true });
|
|
@@ -49,18 +133,6 @@ function writeStoredCopilotAuth(auth, path = authStorePath()) {
|
|
|
49
133
|
}
|
|
50
134
|
}
|
|
51
135
|
|
|
52
|
-
// src/util.ts
|
|
53
|
-
function trimTrailingSlash(value) {
|
|
54
|
-
return value.replace(/\/+$/, "");
|
|
55
|
-
}
|
|
56
|
-
async function truncatedResponseText(response, max = 500) {
|
|
57
|
-
const text = await response.text();
|
|
58
|
-
return text.slice(0, max);
|
|
59
|
-
}
|
|
60
|
-
function asRecord(value) {
|
|
61
|
-
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
62
|
-
}
|
|
63
|
-
|
|
64
136
|
// src/auth.ts
|
|
65
137
|
var DEFAULT_COPILOT_API_BASE_URL = "https://api.githubcopilot.com";
|
|
66
138
|
var REFRESH_SKEW_MS = 6e4;
|
|
@@ -74,21 +146,35 @@ var CopilotAuthError = class extends Error {
|
|
|
74
146
|
var CopilotAuth = class {
|
|
75
147
|
#authStorePath;
|
|
76
148
|
#copilotApiBaseUrl;
|
|
149
|
+
#hasCopilotApiBaseUrlOverride;
|
|
77
150
|
#cachedAccess;
|
|
78
151
|
constructor(options = {}) {
|
|
79
|
-
|
|
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);
|
|
80
156
|
this.#copilotApiBaseUrl = trimTrailingSlash(
|
|
81
|
-
options.copilotApiBaseUrl ??
|
|
157
|
+
options.copilotApiBaseUrl ?? envCopilotApiBaseUrl ?? DEFAULT_COPILOT_API_BASE_URL
|
|
82
158
|
);
|
|
83
159
|
}
|
|
84
160
|
async getAccess() {
|
|
85
161
|
if (this.#cachedAccess && this.#cachedAccess.expiresAtMs - REFRESH_SKEW_MS > Date.now()) {
|
|
86
162
|
return this.#cachedAccess;
|
|
87
163
|
}
|
|
88
|
-
|
|
164
|
+
let stored;
|
|
165
|
+
try {
|
|
166
|
+
stored = readStoredCopilotAuth(this.#authStorePath);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (error instanceof StoredCopilotAuthError) {
|
|
169
|
+
throw new CopilotAuthError(error.message);
|
|
170
|
+
}
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
89
173
|
if (stored) {
|
|
90
174
|
return this.#cacheAccess({
|
|
91
|
-
apiBaseUrl: trimTrailingSlash(
|
|
175
|
+
apiBaseUrl: trimTrailingSlash(
|
|
176
|
+
this.#hasCopilotApiBaseUrlOverride ? this.#copilotApiBaseUrl : stored.apiBaseUrl ?? this.#copilotApiBaseUrl
|
|
177
|
+
),
|
|
92
178
|
expiresAtMs: Date.now() + STORED_TOKEN_TTL_MS,
|
|
93
179
|
source: "github-copilot-oauth",
|
|
94
180
|
token: stored.token
|
|
@@ -106,6 +192,8 @@ var CopilotAuth = class {
|
|
|
106
192
|
|
|
107
193
|
// src/copilot.ts
|
|
108
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"];
|
|
109
197
|
var COPILOT_USAGE_API_VERSION = "2025-04-01";
|
|
110
198
|
function applyCopilotHeaders(headers, token) {
|
|
111
199
|
headers.set("accept", headers.get("accept") ?? "application/json");
|
|
@@ -129,13 +217,15 @@ function applyGithubApiHeaders(headers, token) {
|
|
|
129
217
|
}
|
|
130
218
|
var CopilotClient = class {
|
|
131
219
|
#auth;
|
|
220
|
+
#allowUnsafeUpstream;
|
|
132
221
|
#fetch;
|
|
133
222
|
#githubApiBaseUrl;
|
|
134
223
|
constructor(options = {}) {
|
|
135
224
|
this.#auth = new CopilotAuth(options);
|
|
225
|
+
this.#allowUnsafeUpstream = envValue(options.env?.HOOPILOT_ALLOW_UNSAFE_UPSTREAM) === "1";
|
|
136
226
|
this.#fetch = options.fetch ?? fetch;
|
|
137
227
|
this.#githubApiBaseUrl = trimTrailingSlash(
|
|
138
|
-
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
|
|
139
229
|
);
|
|
140
230
|
}
|
|
141
231
|
/**
|
|
@@ -144,9 +234,13 @@ var CopilotClient = class {
|
|
|
144
234
|
* accepted directly here — no Copilot token exchange is required to read quota.
|
|
145
235
|
*/
|
|
146
236
|
async usage(signal) {
|
|
147
|
-
if (!
|
|
237
|
+
if (!isTrustedTokenBaseUrl(
|
|
238
|
+
this.#githubApiBaseUrl,
|
|
239
|
+
ALLOWED_GITHUB_API_HOSTS,
|
|
240
|
+
this.#allowUnsafeUpstream
|
|
241
|
+
)) {
|
|
148
242
|
throw new Error(
|
|
149
|
-
`Refusing to send the GitHub OAuth token to
|
|
243
|
+
`Refusing to send the GitHub OAuth token to an untrusted GitHub API host: ${this.#githubApiBaseUrl}`
|
|
150
244
|
);
|
|
151
245
|
}
|
|
152
246
|
const access = await this.#auth.getAccess();
|
|
@@ -188,6 +282,15 @@ var CopilotClient = class {
|
|
|
188
282
|
}
|
|
189
283
|
async fetchCopilot(path, init) {
|
|
190
284
|
const access = await this.#auth.getAccess();
|
|
285
|
+
if (!isTrustedTokenBaseUrl(
|
|
286
|
+
access.apiBaseUrl,
|
|
287
|
+
ALLOWED_COPILOT_API_HOSTS,
|
|
288
|
+
this.#allowUnsafeUpstream
|
|
289
|
+
)) {
|
|
290
|
+
throw new Error(
|
|
291
|
+
`Refusing to send the GitHub OAuth token to an untrusted Copilot API host: ${access.apiBaseUrl}`
|
|
292
|
+
);
|
|
293
|
+
}
|
|
191
294
|
const headers = applyCopilotHeaders(new Headers(init.headers), access.token);
|
|
192
295
|
return this.#fetch(`${access.apiBaseUrl}${path}`, {
|
|
193
296
|
...init,
|
|
@@ -243,18 +346,6 @@ function usedFrom(entitlement, remaining) {
|
|
|
243
346
|
}
|
|
244
347
|
return Math.max(0, entitlement - remaining);
|
|
245
348
|
}
|
|
246
|
-
function isHttpsOrLoopback(rawUrl) {
|
|
247
|
-
let url;
|
|
248
|
-
try {
|
|
249
|
-
url = new URL(rawUrl);
|
|
250
|
-
} catch {
|
|
251
|
-
return false;
|
|
252
|
-
}
|
|
253
|
-
if (url.protocol === "https:") {
|
|
254
|
-
return true;
|
|
255
|
-
}
|
|
256
|
-
return url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1");
|
|
257
|
-
}
|
|
258
349
|
function numberOrUndefined(value) {
|
|
259
350
|
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
260
351
|
}
|
|
@@ -283,9 +374,9 @@ async function githubCopilotDeviceLogin(options = {}) {
|
|
|
283
374
|
const fetcher = options.fetch ?? fetch;
|
|
284
375
|
const sleeper = options.sleep ?? sleep;
|
|
285
376
|
const domain = normalizeDomain(
|
|
286
|
-
options.domain ?? env.HOOPILOT_GITHUB_DOMAIN ?? DEFAULT_GITHUB_DOMAIN
|
|
377
|
+
options.domain ?? envValue(env.HOOPILOT_GITHUB_DOMAIN) ?? DEFAULT_GITHUB_DOMAIN
|
|
287
378
|
);
|
|
288
|
-
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;
|
|
289
380
|
const device = await requestDeviceCode(fetcher, domain, clientId);
|
|
290
381
|
const verificationUrl = device.verification_uri;
|
|
291
382
|
const userCode = device.user_code;
|
|
@@ -443,8 +534,8 @@ var noopLogger = {
|
|
|
443
534
|
};
|
|
444
535
|
function createHoopilotLogger(options = {}) {
|
|
445
536
|
const env = options.env ?? process.env;
|
|
446
|
-
const level = parseLogLevel(options.level ?? env.HOOPILOT_LOG_LEVEL);
|
|
447
|
-
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));
|
|
448
539
|
const pinoOptions = {
|
|
449
540
|
base: {
|
|
450
541
|
service: "hoopilot",
|
|
@@ -494,7 +585,7 @@ function parseLogLevel(value) {
|
|
|
494
585
|
}
|
|
495
586
|
function shouldCreateLogger(options) {
|
|
496
587
|
return Boolean(
|
|
497
|
-
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)
|
|
498
589
|
);
|
|
499
590
|
}
|
|
500
591
|
function errorDetails(error) {
|
|
@@ -609,6 +700,51 @@ function chatCompletionToCompletion(completion) {
|
|
|
609
700
|
usage: completion.usage
|
|
610
701
|
});
|
|
611
702
|
}
|
|
703
|
+
function completionStreamFromChatStream(chatStream) {
|
|
704
|
+
const encoder = new TextEncoder();
|
|
705
|
+
const decoder = new TextDecoder();
|
|
706
|
+
let buffer = "";
|
|
707
|
+
let sawTerminalEvent = false;
|
|
708
|
+
return new ReadableStream({
|
|
709
|
+
async start(controller) {
|
|
710
|
+
const enqueue = (data) => {
|
|
711
|
+
controller.enqueue(encoder.encode(encodeDataSse(data)));
|
|
712
|
+
};
|
|
713
|
+
const markTerminal = () => {
|
|
714
|
+
sawTerminalEvent = true;
|
|
715
|
+
};
|
|
716
|
+
const reader = chatStream.getReader();
|
|
717
|
+
try {
|
|
718
|
+
while (true) {
|
|
719
|
+
const result = await reader.read();
|
|
720
|
+
if (result.done) {
|
|
721
|
+
break;
|
|
722
|
+
}
|
|
723
|
+
buffer += decoder.decode(result.value, { stream: true });
|
|
724
|
+
const blocks = buffer.split(/\r?\n\r?\n/);
|
|
725
|
+
buffer = blocks.pop() ?? "";
|
|
726
|
+
for (const block of blocks) {
|
|
727
|
+
processCompletionSseBlock(block, enqueue, markTerminal);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
const tail = `${buffer}${decoder.decode()}`;
|
|
731
|
+
if (tail.trim()) {
|
|
732
|
+
processCompletionSseBlock(tail, enqueue, markTerminal);
|
|
733
|
+
}
|
|
734
|
+
if (!sawTerminalEvent) {
|
|
735
|
+
enqueue("[DONE]");
|
|
736
|
+
}
|
|
737
|
+
controller.close();
|
|
738
|
+
} catch (error) {
|
|
739
|
+
await reader.cancel(error).catch(() => {
|
|
740
|
+
});
|
|
741
|
+
controller.error(error);
|
|
742
|
+
} finally {
|
|
743
|
+
reader.releaseLock();
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
}
|
|
612
748
|
function normalizeModelsResponse(upstream) {
|
|
613
749
|
const record = asRecord(upstream);
|
|
614
750
|
const data = Array.isArray(record.data) ? record.data : Array.isArray(upstream) ? upstream : [];
|
|
@@ -641,38 +777,99 @@ function responsesStreamFromChatStream(chatStream, options) {
|
|
|
641
777
|
const createdAt = epochSeconds();
|
|
642
778
|
let buffer = "";
|
|
643
779
|
let text = "";
|
|
780
|
+
let messageOutputIndex;
|
|
781
|
+
let nextOutputIndex = 0;
|
|
782
|
+
let sequenceNumber = 0;
|
|
644
783
|
const tools = /* @__PURE__ */ new Map();
|
|
645
784
|
return new ReadableStream({
|
|
646
785
|
async start(controller) {
|
|
647
786
|
const enqueue = (event, data) => {
|
|
648
|
-
controller.enqueue(
|
|
787
|
+
controller.enqueue(
|
|
788
|
+
encoder.encode(
|
|
789
|
+
encodeSse(
|
|
790
|
+
event,
|
|
791
|
+
data === "[DONE]" ? data : { ...data, sequence_number: sequenceNumber++ }
|
|
792
|
+
)
|
|
793
|
+
)
|
|
794
|
+
);
|
|
649
795
|
};
|
|
650
796
|
enqueue("response.created", {
|
|
651
797
|
response: baseStreamResponse(responseId, options.model, createdAt, "in_progress", []),
|
|
652
798
|
type: "response.created"
|
|
653
799
|
});
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
800
|
+
const ensureMessageStarted = () => {
|
|
801
|
+
if (messageOutputIndex !== void 0) {
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
messageOutputIndex = nextOutputIndex++;
|
|
805
|
+
enqueue("response.output_item.added", {
|
|
806
|
+
item: {
|
|
807
|
+
content: [],
|
|
808
|
+
id: messageId,
|
|
809
|
+
role: "assistant",
|
|
810
|
+
status: "in_progress",
|
|
811
|
+
type: "message"
|
|
812
|
+
},
|
|
813
|
+
output_index: messageOutputIndex,
|
|
814
|
+
type: "response.output_item.added"
|
|
815
|
+
});
|
|
816
|
+
enqueue("response.content_part.added", {
|
|
817
|
+
content_index: 0,
|
|
818
|
+
item_id: messageId,
|
|
819
|
+
output_index: messageOutputIndex,
|
|
820
|
+
part: {
|
|
821
|
+
annotations: [],
|
|
822
|
+
text: "",
|
|
823
|
+
type: "output_text"
|
|
824
|
+
},
|
|
825
|
+
type: "response.content_part.added"
|
|
826
|
+
});
|
|
827
|
+
};
|
|
828
|
+
const appendText = (delta) => {
|
|
829
|
+
ensureMessageStarted();
|
|
830
|
+
text += delta;
|
|
831
|
+
enqueue("response.output_text.delta", {
|
|
832
|
+
content_index: 0,
|
|
833
|
+
delta,
|
|
834
|
+
item_id: messageId,
|
|
835
|
+
output_index: messageOutputIndex ?? 0,
|
|
836
|
+
type: "response.output_text.delta"
|
|
837
|
+
});
|
|
838
|
+
};
|
|
839
|
+
const appendToolCall = (toolCall) => {
|
|
840
|
+
const fn = asRecord(toolCall.function);
|
|
841
|
+
const index = typeof toolCall.index === "number" ? toolCall.index : tools.size;
|
|
842
|
+
let existing = tools.get(index);
|
|
843
|
+
const isNew = !existing;
|
|
844
|
+
existing ??= {
|
|
845
|
+
arguments: "",
|
|
846
|
+
id: contentToText(toolCall.id) || `call_${randomId()}`,
|
|
847
|
+
index,
|
|
848
|
+
itemId: `fc_${randomId()}`,
|
|
849
|
+
name: "",
|
|
850
|
+
outputIndex: nextOutputIndex++
|
|
851
|
+
};
|
|
852
|
+
existing.id = contentToText(toolCall.id) || existing.id;
|
|
853
|
+
existing.name += contentToText(fn.name);
|
|
854
|
+
tools.set(index, existing);
|
|
855
|
+
if (isNew) {
|
|
856
|
+
enqueue("response.output_item.added", {
|
|
857
|
+
item: functionCallItem(existing, "in_progress"),
|
|
858
|
+
output_index: existing.outputIndex ?? 0,
|
|
859
|
+
type: "response.output_item.added"
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
const argumentDelta = contentToText(fn.arguments);
|
|
863
|
+
if (argumentDelta) {
|
|
864
|
+
existing.arguments += argumentDelta;
|
|
865
|
+
enqueue("response.function_call_arguments.delta", {
|
|
866
|
+
delta: argumentDelta,
|
|
867
|
+
item_id: existing.itemId,
|
|
868
|
+
output_index: existing.outputIndex ?? 0,
|
|
869
|
+
type: "response.function_call_arguments.delta"
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
};
|
|
676
873
|
const reader = chatStream.getReader();
|
|
677
874
|
try {
|
|
678
875
|
while (true) {
|
|
@@ -684,50 +881,48 @@ function responsesStreamFromChatStream(chatStream, options) {
|
|
|
684
881
|
const lines = buffer.split(/\r?\n/);
|
|
685
882
|
buffer = lines.pop() ?? "";
|
|
686
883
|
for (const line of lines) {
|
|
687
|
-
processChatSseLine(
|
|
688
|
-
text += delta;
|
|
689
|
-
});
|
|
884
|
+
processChatSseLine(line, { appendText, appendToolCall });
|
|
690
885
|
}
|
|
691
886
|
}
|
|
692
887
|
if (buffer) {
|
|
693
|
-
processChatSseLine(
|
|
694
|
-
text += delta;
|
|
695
|
-
});
|
|
888
|
+
processChatSseLine(buffer, { appendText, appendToolCall });
|
|
696
889
|
}
|
|
697
|
-
const
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
});
|
|
706
|
-
enqueue("response.content_part.done", {
|
|
707
|
-
content_index: 0,
|
|
708
|
-
item_id: messageId,
|
|
709
|
-
output_index: 0,
|
|
710
|
-
part: {
|
|
711
|
-
annotations: [],
|
|
890
|
+
const outputEntries = [];
|
|
891
|
+
if (messageOutputIndex !== void 0) {
|
|
892
|
+
const item = messageOutputItem(text, messageId);
|
|
893
|
+
outputEntries.push([messageOutputIndex, item]);
|
|
894
|
+
enqueue("response.output_text.done", {
|
|
895
|
+
content_index: 0,
|
|
896
|
+
item_id: messageId,
|
|
897
|
+
output_index: messageOutputIndex,
|
|
712
898
|
text,
|
|
713
|
-
type: "output_text"
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
899
|
+
type: "response.output_text.done"
|
|
900
|
+
});
|
|
901
|
+
enqueue("response.content_part.done", {
|
|
902
|
+
content_index: 0,
|
|
903
|
+
item_id: messageId,
|
|
904
|
+
output_index: messageOutputIndex,
|
|
905
|
+
part: {
|
|
906
|
+
annotations: [],
|
|
907
|
+
text,
|
|
908
|
+
type: "output_text"
|
|
909
|
+
},
|
|
910
|
+
type: "response.content_part.done"
|
|
911
|
+
});
|
|
912
|
+
enqueue("response.output_item.done", {
|
|
725
913
|
item,
|
|
726
|
-
output_index:
|
|
727
|
-
type: "response.output_item.
|
|
914
|
+
output_index: messageOutputIndex,
|
|
915
|
+
type: "response.output_item.done"
|
|
728
916
|
});
|
|
917
|
+
}
|
|
918
|
+
for (const tool of [...tools.values()].sort(
|
|
919
|
+
(a, b) => (a.outputIndex ?? 0) - (b.outputIndex ?? 0)
|
|
920
|
+
)) {
|
|
921
|
+
const item = functionCallItem(tool);
|
|
922
|
+
const outputIndex = tool.outputIndex ?? 0;
|
|
923
|
+
outputEntries.push([outputIndex, item]);
|
|
729
924
|
enqueue("response.function_call_arguments.done", {
|
|
730
|
-
arguments:
|
|
925
|
+
arguments: tool.arguments,
|
|
731
926
|
item_id: item.id,
|
|
732
927
|
output_index: outputIndex,
|
|
733
928
|
type: "response.function_call_arguments.done"
|
|
@@ -737,7 +932,8 @@ function responsesStreamFromChatStream(chatStream, options) {
|
|
|
737
932
|
output_index: outputIndex,
|
|
738
933
|
type: "response.output_item.done"
|
|
739
934
|
});
|
|
740
|
-
}
|
|
935
|
+
}
|
|
936
|
+
const output = outputEntries.sort(([left], [right]) => left - right).map(([, item]) => item);
|
|
741
937
|
enqueue("response.completed", {
|
|
742
938
|
response: baseStreamResponse(responseId, options.model, createdAt, "completed", output),
|
|
743
939
|
type: "response.completed"
|
|
@@ -920,13 +1116,13 @@ function messageOutputItem(text, id = `msg_${randomId()}`) {
|
|
|
920
1116
|
type: "message"
|
|
921
1117
|
};
|
|
922
1118
|
}
|
|
923
|
-
function functionCallItem(tool) {
|
|
1119
|
+
function functionCallItem(tool, status = "completed") {
|
|
924
1120
|
return {
|
|
925
1121
|
arguments: tool.arguments,
|
|
926
1122
|
call_id: tool.id,
|
|
927
|
-
id: `fc_${randomId()}`,
|
|
1123
|
+
id: tool.itemId ?? `fc_${randomId()}`,
|
|
928
1124
|
name: tool.name,
|
|
929
|
-
status
|
|
1125
|
+
status,
|
|
930
1126
|
type: "function_call"
|
|
931
1127
|
};
|
|
932
1128
|
}
|
|
@@ -941,14 +1137,27 @@ function responseUsage(usage) {
|
|
|
941
1137
|
if (Object.keys(record).length === 0) {
|
|
942
1138
|
return null;
|
|
943
1139
|
}
|
|
1140
|
+
const inputTokens = record.prompt_tokens;
|
|
1141
|
+
const outputTokens = record.completion_tokens;
|
|
944
1142
|
return removeUndefined({
|
|
945
|
-
input_tokens:
|
|
946
|
-
input_tokens_details: record.prompt_tokens_details,
|
|
947
|
-
|
|
948
|
-
|
|
1143
|
+
input_tokens: inputTokens,
|
|
1144
|
+
input_tokens_details: responseUsageDetails(record.prompt_tokens_details, inputTokens, {
|
|
1145
|
+
cached_tokens: 0
|
|
1146
|
+
}),
|
|
1147
|
+
output_tokens: outputTokens,
|
|
1148
|
+
output_tokens_details: responseUsageDetails(record.completion_tokens_details, outputTokens, {
|
|
1149
|
+
reasoning_tokens: 0
|
|
1150
|
+
}),
|
|
949
1151
|
total_tokens: record.total_tokens
|
|
950
1152
|
});
|
|
951
1153
|
}
|
|
1154
|
+
function responseUsageDetails(value, tokenCount, fallback) {
|
|
1155
|
+
const record = asRecord(value);
|
|
1156
|
+
if (Object.keys(record).length > 0) {
|
|
1157
|
+
return record;
|
|
1158
|
+
}
|
|
1159
|
+
return typeof tokenCount === "number" && Number.isFinite(tokenCount) ? fallback : void 0;
|
|
1160
|
+
}
|
|
952
1161
|
function extractTokenUsage(usage) {
|
|
953
1162
|
const record = asRecord(usage);
|
|
954
1163
|
const prompt = firstNumber(record.prompt_tokens, record.input_tokens);
|
|
@@ -987,7 +1196,80 @@ function firstChoice(completion) {
|
|
|
987
1196
|
const choices = Array.isArray(completion.choices) ? completion.choices : [];
|
|
988
1197
|
return asRecord(choices[0]);
|
|
989
1198
|
}
|
|
990
|
-
function
|
|
1199
|
+
function processCompletionSseBlock(block, enqueue, markTerminal) {
|
|
1200
|
+
let event = "message";
|
|
1201
|
+
const dataLines = [];
|
|
1202
|
+
for (const line of block.split(/\r?\n/)) {
|
|
1203
|
+
const trimmed = line.trim();
|
|
1204
|
+
if (trimmed.startsWith("event:")) {
|
|
1205
|
+
event = trimmed.slice("event:".length).trim() || event;
|
|
1206
|
+
} else if (trimmed.startsWith("data:")) {
|
|
1207
|
+
dataLines.push(trimmed.slice("data:".length).trim());
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
const data = dataLines.join("\n");
|
|
1211
|
+
if (!data) {
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
if (data === "[DONE]") {
|
|
1215
|
+
markTerminal();
|
|
1216
|
+
enqueue("[DONE]");
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
const parsed = parseJson(data);
|
|
1220
|
+
if (!parsed) {
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
const error = completionStreamError(event, parsed);
|
|
1224
|
+
if (error) {
|
|
1225
|
+
markTerminal();
|
|
1226
|
+
enqueue({ error });
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
const choice = firstChoice(parsed);
|
|
1230
|
+
const delta = asRecord(choice.delta);
|
|
1231
|
+
const text = contentToText(delta.content);
|
|
1232
|
+
const finishReason = choice.finish_reason ?? null;
|
|
1233
|
+
const usage = asRecord(parsed.usage);
|
|
1234
|
+
const hasUsage = Object.keys(usage).length > 0;
|
|
1235
|
+
if (!text && finishReason === null && !hasUsage) {
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
enqueue(
|
|
1239
|
+
removeUndefined({
|
|
1240
|
+
choices: text || finishReason !== null ? [
|
|
1241
|
+
{
|
|
1242
|
+
finish_reason: finishReason,
|
|
1243
|
+
index: typeof choice.index === "number" ? choice.index : 0,
|
|
1244
|
+
logprobs: null,
|
|
1245
|
+
text
|
|
1246
|
+
}
|
|
1247
|
+
] : [],
|
|
1248
|
+
created: typeof parsed.created === "number" ? parsed.created : epochSeconds(),
|
|
1249
|
+
id: contentToText(parsed.id) || `cmpl_${randomId()}`,
|
|
1250
|
+
model: contentToText(parsed.model) || DEFAULT_MODEL,
|
|
1251
|
+
object: "text_completion",
|
|
1252
|
+
usage: hasUsage ? usage : void 0
|
|
1253
|
+
})
|
|
1254
|
+
);
|
|
1255
|
+
}
|
|
1256
|
+
function completionStreamError(event, parsed) {
|
|
1257
|
+
const responseError = asRecord(asRecord(parsed.response).error);
|
|
1258
|
+
const directError = asRecord(parsed.error);
|
|
1259
|
+
const error = Object.keys(directError).length > 0 ? directError : Object.keys(responseError).length > 0 ? responseError : void 0;
|
|
1260
|
+
if (error) {
|
|
1261
|
+
return error;
|
|
1262
|
+
}
|
|
1263
|
+
if (event === "error" || parsed.type === "response.failed") {
|
|
1264
|
+
return removeUndefined({
|
|
1265
|
+
code: contentToText(parsed.code) || void 0,
|
|
1266
|
+
message: contentToText(parsed.message) || "Upstream streaming request failed.",
|
|
1267
|
+
type: contentToText(parsed.type) || "upstream_stream_error"
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
return void 0;
|
|
1271
|
+
}
|
|
1272
|
+
function processChatSseLine(line, handlers) {
|
|
991
1273
|
const trimmed = line.trim();
|
|
992
1274
|
if (!trimmed.startsWith("data:")) {
|
|
993
1275
|
return;
|
|
@@ -1004,30 +1286,11 @@ function processChatSseLine(messageId, line, enqueue, tools, appendText) {
|
|
|
1004
1286
|
const delta = asRecord(choice.delta);
|
|
1005
1287
|
const content = contentToText(delta.content);
|
|
1006
1288
|
if (content) {
|
|
1007
|
-
appendText(content);
|
|
1008
|
-
enqueue("response.output_text.delta", {
|
|
1009
|
-
content_index: 0,
|
|
1010
|
-
delta: content,
|
|
1011
|
-
item_id: messageId,
|
|
1012
|
-
output_index: 0,
|
|
1013
|
-
type: "response.output_text.delta"
|
|
1014
|
-
});
|
|
1289
|
+
handlers.appendText(content);
|
|
1015
1290
|
}
|
|
1016
1291
|
const toolCalls = Array.isArray(delta.tool_calls) ? delta.tool_calls : [];
|
|
1017
1292
|
for (const toolCall of toolCalls) {
|
|
1018
|
-
|
|
1019
|
-
const fn = asRecord(record.function);
|
|
1020
|
-
const index = typeof record.index === "number" ? record.index : tools.size;
|
|
1021
|
-
const existing = tools.get(index) ?? {
|
|
1022
|
-
arguments: "",
|
|
1023
|
-
id: contentToText(record.id) || `call_${randomId()}`,
|
|
1024
|
-
index,
|
|
1025
|
-
name: ""
|
|
1026
|
-
};
|
|
1027
|
-
existing.id = contentToText(record.id) || existing.id;
|
|
1028
|
-
existing.name += contentToText(fn.name);
|
|
1029
|
-
existing.arguments += contentToText(fn.arguments);
|
|
1030
|
-
tools.set(index, existing);
|
|
1293
|
+
handlers.appendToolCall(asRecord(toolCall));
|
|
1031
1294
|
}
|
|
1032
1295
|
}
|
|
1033
1296
|
function baseStreamResponse(id, model, createdAt, status, output) {
|
|
@@ -1057,6 +1320,14 @@ function encodeSse(event, data) {
|
|
|
1057
1320
|
return `event: ${event}
|
|
1058
1321
|
data: ${JSON.stringify(data)}
|
|
1059
1322
|
|
|
1323
|
+
`;
|
|
1324
|
+
}
|
|
1325
|
+
function encodeDataSse(data) {
|
|
1326
|
+
if (data === "[DONE]") {
|
|
1327
|
+
return "data: [DONE]\n\n";
|
|
1328
|
+
}
|
|
1329
|
+
return `data: ${JSON.stringify(data)}
|
|
1330
|
+
|
|
1060
1331
|
`;
|
|
1061
1332
|
}
|
|
1062
1333
|
function parseJson(data) {
|
|
@@ -1445,11 +1716,21 @@ function formatNumber(value) {
|
|
|
1445
1716
|
// src/server.ts
|
|
1446
1717
|
var DEFAULT_HOST = "127.0.0.1";
|
|
1447
1718
|
var DEFAULT_PORT = 4141;
|
|
1719
|
+
var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Browser-origin requests require HOOPILOT_API_KEY unless the Origin is loopback.";
|
|
1448
1720
|
var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
|
|
1721
|
+
var MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024;
|
|
1722
|
+
var REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
1723
|
+
var REQUEST_TOO_LARGE_MESSAGE = `Request body must be ${MAX_REQUEST_BODY_BYTES} bytes or smaller.`;
|
|
1449
1724
|
var USAGE_CACHE_TTL_MS = 6e4;
|
|
1725
|
+
var RequestBodyTooLargeError = class extends Error {
|
|
1726
|
+
constructor() {
|
|
1727
|
+
super(REQUEST_TOO_LARGE_MESSAGE);
|
|
1728
|
+
this.name = "RequestBodyTooLargeError";
|
|
1729
|
+
}
|
|
1730
|
+
};
|
|
1450
1731
|
function createHoopilotHandler(options = {}) {
|
|
1451
1732
|
const client = new CopilotClient(options);
|
|
1452
|
-
const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
|
|
1733
|
+
const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
|
|
1453
1734
|
const logger = serverLogger(options);
|
|
1454
1735
|
const metrics = options.metrics ?? new MetricsRegistry();
|
|
1455
1736
|
const readUsage = createUsageReader(client, metrics);
|
|
@@ -1475,6 +1756,14 @@ function createHoopilotHandler(options = {}) {
|
|
|
1475
1756
|
route,
|
|
1476
1757
|
startedAt
|
|
1477
1758
|
});
|
|
1759
|
+
const browserOrigin = forbiddenBrowserOrigin(request, apiKey);
|
|
1760
|
+
if (browserOrigin) {
|
|
1761
|
+
requestLogger.warn(
|
|
1762
|
+
{ event: "http.request.forbidden_origin", origin: browserOrigin },
|
|
1763
|
+
"blocked unauthenticated browser-origin request"
|
|
1764
|
+
);
|
|
1765
|
+
return finish(jsonError(403, "forbidden_origin", FORBIDDEN_BROWSER_ORIGIN_MESSAGE));
|
|
1766
|
+
}
|
|
1478
1767
|
if (request.method === "OPTIONS") {
|
|
1479
1768
|
return finish(new Response(null, { headers: corsHeaders() }));
|
|
1480
1769
|
}
|
|
@@ -1527,6 +1816,12 @@ function createHoopilotHandler(options = {}) {
|
|
|
1527
1816
|
"request body was invalid json"
|
|
1528
1817
|
);
|
|
1529
1818
|
return finish(jsonError(400, "invalid_request_error", message));
|
|
1819
|
+
} else if (error instanceof RequestBodyTooLargeError) {
|
|
1820
|
+
requestLogger.warn(
|
|
1821
|
+
{ err: errorDetails(error), event: "http.request.failed" },
|
|
1822
|
+
"request body exceeded size limit"
|
|
1823
|
+
);
|
|
1824
|
+
return finish(jsonError(413, "request_too_large", message));
|
|
1530
1825
|
} else {
|
|
1531
1826
|
requestLogger.error(
|
|
1532
1827
|
{ err: errorDetails(error), event: "http.request.failed" },
|
|
@@ -1538,10 +1833,10 @@ function createHoopilotHandler(options = {}) {
|
|
|
1538
1833
|
};
|
|
1539
1834
|
}
|
|
1540
1835
|
function startHoopilotServer(options = {}) {
|
|
1541
|
-
const host = options.host ?? options.env?.HOST ?? DEFAULT_HOST;
|
|
1542
|
-
const port =
|
|
1543
|
-
const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
|
|
1544
|
-
const allowUnauthenticated = options.allowUnauthenticated ?? options.env?.HOOPILOT_ALLOW_UNAUTHENTICATED === "1";
|
|
1836
|
+
const host = options.host ?? envValue(options.env?.HOST) ?? DEFAULT_HOST;
|
|
1837
|
+
const port = normalizeServerPort(options.port ?? envValue(options.env?.PORT) ?? DEFAULT_PORT);
|
|
1838
|
+
const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
|
|
1839
|
+
const allowUnauthenticated = options.allowUnauthenticated ?? envValue(options.env?.HOOPILOT_ALLOW_UNAUTHENTICATED) === "1";
|
|
1545
1840
|
if (!isLoopbackHost(host) && !apiKey && !allowUnauthenticated) {
|
|
1546
1841
|
throw new Error(
|
|
1547
1842
|
"Refusing to listen on a non-loopback host without HOOPILOT_API_KEY. Set an API key or pass --allow-unauthenticated."
|
|
@@ -1559,7 +1854,7 @@ function startHoopilotServer(options = {}) {
|
|
|
1559
1854
|
});
|
|
1560
1855
|
return {
|
|
1561
1856
|
server,
|
|
1562
|
-
url: `http://${host}:${server.port}`
|
|
1857
|
+
url: `http://${urlHost(host)}:${server.port}`
|
|
1563
1858
|
};
|
|
1564
1859
|
}
|
|
1565
1860
|
async function handleModels(client, metrics, signal, logger) {
|
|
@@ -1605,8 +1900,19 @@ async function handleCompletions(client, metrics, recordTokens, request, logger)
|
|
|
1605
1900
|
}
|
|
1606
1901
|
logUpstreamSuccess(logger, "/chat/completions", upstream.status);
|
|
1607
1902
|
const model = normalizeRequestedModel(body.model);
|
|
1608
|
-
if (isStreamingResponse(upstream)) {
|
|
1609
|
-
return proxyResponse(
|
|
1903
|
+
if (isStreamingResponse(upstream) && upstream.body) {
|
|
1904
|
+
return proxyResponse(
|
|
1905
|
+
observeResponseUsage(
|
|
1906
|
+
new Response(completionStreamFromChatStream(upstream.body), {
|
|
1907
|
+
headers: upstream.headers,
|
|
1908
|
+
status: upstream.status,
|
|
1909
|
+
statusText: upstream.statusText
|
|
1910
|
+
}),
|
|
1911
|
+
model,
|
|
1912
|
+
recordTokens,
|
|
1913
|
+
request.signal
|
|
1914
|
+
)
|
|
1915
|
+
);
|
|
1610
1916
|
}
|
|
1611
1917
|
const completion = asRecord(await upstream.json());
|
|
1612
1918
|
const usage = extractTokenUsage(completion.usage);
|
|
@@ -1640,7 +1946,7 @@ async function proxyError(upstream, logger) {
|
|
|
1640
1946
|
{ event: "copilot.request.failed", upstreamStatus: upstream.status },
|
|
1641
1947
|
"copilot upstream request failed"
|
|
1642
1948
|
);
|
|
1643
|
-
return
|
|
1949
|
+
return upstreamErrorResponse(upstream.status, text || upstream.statusText);
|
|
1644
1950
|
}
|
|
1645
1951
|
function proxyResponse(upstream) {
|
|
1646
1952
|
const headers = new Headers(upstream.headers);
|
|
@@ -1657,14 +1963,15 @@ function proxyResponse(upstream) {
|
|
|
1657
1963
|
});
|
|
1658
1964
|
}
|
|
1659
1965
|
async function readJson(request) {
|
|
1966
|
+
const text = await readRequestText(request);
|
|
1660
1967
|
try {
|
|
1661
|
-
return asRecord(
|
|
1968
|
+
return asRecord(JSON.parse(text));
|
|
1662
1969
|
} catch {
|
|
1663
1970
|
throw new Error(INVALID_JSON_MESSAGE);
|
|
1664
1971
|
}
|
|
1665
1972
|
}
|
|
1666
1973
|
async function readJsonText(request) {
|
|
1667
|
-
const text = await request
|
|
1974
|
+
const text = await readRequestText(request);
|
|
1668
1975
|
try {
|
|
1669
1976
|
JSON.parse(text);
|
|
1670
1977
|
return text;
|
|
@@ -1672,6 +1979,40 @@ async function readJsonText(request) {
|
|
|
1672
1979
|
throw new Error(INVALID_JSON_MESSAGE);
|
|
1673
1980
|
}
|
|
1674
1981
|
}
|
|
1982
|
+
async function readRequestText(request) {
|
|
1983
|
+
const contentLength = request.headers.get("content-length");
|
|
1984
|
+
if (contentLength) {
|
|
1985
|
+
const declaredBytes = Number(contentLength);
|
|
1986
|
+
if (Number.isFinite(declaredBytes) && declaredBytes > MAX_REQUEST_BODY_BYTES) {
|
|
1987
|
+
throw new RequestBodyTooLargeError();
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
const body = request.body;
|
|
1991
|
+
if (!body) {
|
|
1992
|
+
return "";
|
|
1993
|
+
}
|
|
1994
|
+
const reader = body.getReader();
|
|
1995
|
+
const decoder = new TextDecoder();
|
|
1996
|
+
let bytes = 0;
|
|
1997
|
+
let text = "";
|
|
1998
|
+
try {
|
|
1999
|
+
while (true) {
|
|
2000
|
+
const { done, value } = await reader.read();
|
|
2001
|
+
if (done) {
|
|
2002
|
+
return `${text}${decoder.decode()}`;
|
|
2003
|
+
}
|
|
2004
|
+
bytes += value.byteLength;
|
|
2005
|
+
if (bytes > MAX_REQUEST_BODY_BYTES) {
|
|
2006
|
+
await reader.cancel().catch(() => {
|
|
2007
|
+
});
|
|
2008
|
+
throw new RequestBodyTooLargeError();
|
|
2009
|
+
}
|
|
2010
|
+
text += decoder.decode(value, { stream: true });
|
|
2011
|
+
}
|
|
2012
|
+
} finally {
|
|
2013
|
+
reader.releaseLock();
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
1675
2016
|
function jsonResponse(body, status = 200) {
|
|
1676
2017
|
return new Response(JSON.stringify(body), {
|
|
1677
2018
|
headers: {
|
|
@@ -1693,6 +2034,13 @@ function jsonError(status, code, message) {
|
|
|
1693
2034
|
status
|
|
1694
2035
|
);
|
|
1695
2036
|
}
|
|
2037
|
+
function upstreamErrorResponse(status, text) {
|
|
2038
|
+
const parsedError = asRecord(asRecord(safeParseJson(text)).error);
|
|
2039
|
+
if (Object.keys(parsedError).length > 0) {
|
|
2040
|
+
return jsonResponse({ error: parsedError }, status);
|
|
2041
|
+
}
|
|
2042
|
+
return jsonError(status, "copilot_error", text);
|
|
2043
|
+
}
|
|
1696
2044
|
function websocketUnsupportedResponse() {
|
|
1697
2045
|
const response = jsonError(
|
|
1698
2046
|
426,
|
|
@@ -1717,6 +2065,17 @@ function isAuthorized(request, apiKey) {
|
|
|
1717
2065
|
const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1];
|
|
1718
2066
|
return bearer === apiKey || request.headers.get("x-api-key") === apiKey;
|
|
1719
2067
|
}
|
|
2068
|
+
function forbiddenBrowserOrigin(request, apiKey) {
|
|
2069
|
+
if (apiKey) {
|
|
2070
|
+
return void 0;
|
|
2071
|
+
}
|
|
2072
|
+
const origin = request.headers.get("origin")?.trim();
|
|
2073
|
+
if (origin) {
|
|
2074
|
+
return isLoopbackOrigin(origin) ? void 0 : origin;
|
|
2075
|
+
}
|
|
2076
|
+
const fetchSite = request.headers.get("sec-fetch-site")?.toLowerCase();
|
|
2077
|
+
return fetchSite === "cross-site" ? "cross-site" : void 0;
|
|
2078
|
+
}
|
|
1720
2079
|
function isUpstreamAuthStatus(status) {
|
|
1721
2080
|
return status === 401 || status === 403;
|
|
1722
2081
|
}
|
|
@@ -1724,7 +2083,24 @@ function upstreamAuthMessage(message) {
|
|
|
1724
2083
|
return `GitHub Copilot rejected the credential or account access: ${message}`;
|
|
1725
2084
|
}
|
|
1726
2085
|
function isLoopbackHost(host) {
|
|
1727
|
-
return host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
2086
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
|
|
2087
|
+
}
|
|
2088
|
+
function urlHost(host) {
|
|
2089
|
+
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
2090
|
+
}
|
|
2091
|
+
function isLoopbackOrigin(origin) {
|
|
2092
|
+
try {
|
|
2093
|
+
return isLoopbackHost(new URL(origin).hostname.toLowerCase());
|
|
2094
|
+
} catch {
|
|
2095
|
+
return false;
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
function normalizeServerPort(value) {
|
|
2099
|
+
const port = Number(value);
|
|
2100
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
2101
|
+
throw new Error(`Invalid port: ${value}.`);
|
|
2102
|
+
}
|
|
2103
|
+
return port;
|
|
1728
2104
|
}
|
|
1729
2105
|
function errorMessage(error) {
|
|
1730
2106
|
return error instanceof Error ? error.message : String(error);
|
|
@@ -1819,7 +2195,7 @@ function logRequestCompleted(logger, status, stream, durationMs) {
|
|
|
1819
2195
|
}
|
|
1820
2196
|
function requestIdFor(request) {
|
|
1821
2197
|
const existing = request.headers.get("x-request-id")?.trim();
|
|
1822
|
-
return existing
|
|
2198
|
+
return existing && REQUEST_ID_PATTERN.test(existing) ? existing : crypto.randomUUID();
|
|
1823
2199
|
}
|
|
1824
2200
|
function canonicalApiPath(path) {
|
|
1825
2201
|
const withoutTrailingSlash = path.length > 1 ? path.replace(/\/+$/, "") : path;
|
|
@@ -1948,6 +2324,7 @@ export {
|
|
|
1948
2324
|
authStorePath,
|
|
1949
2325
|
chatCompletionToCompletion,
|
|
1950
2326
|
chatCompletionToResponse,
|
|
2327
|
+
completionStreamFromChatStream,
|
|
1951
2328
|
completionsRequestToChatCompletion,
|
|
1952
2329
|
createHoopilotHandler,
|
|
1953
2330
|
createHoopilotLogger,
|