@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.cjs
CHANGED
|
@@ -45,6 +45,7 @@ __export(index_exports, {
|
|
|
45
45
|
authStorePath: () => authStorePath,
|
|
46
46
|
chatCompletionToCompletion: () => chatCompletionToCompletion,
|
|
47
47
|
chatCompletionToResponse: () => chatCompletionToResponse,
|
|
48
|
+
completionStreamFromChatStream: () => completionStreamFromChatStream,
|
|
48
49
|
completionsRequestToChatCompletion: () => completionsRequestToChatCompletion,
|
|
49
50
|
createHoopilotHandler: () => createHoopilotHandler,
|
|
50
51
|
createHoopilotLogger: () => createHoopilotLogger,
|
|
@@ -70,33 +71,117 @@ module.exports = __toCommonJS(index_exports);
|
|
|
70
71
|
// src/auth-store.ts
|
|
71
72
|
var import_node_fs = require("fs");
|
|
72
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
|
|
124
|
+
var StoredCopilotAuthError = class extends Error {
|
|
125
|
+
constructor(message) {
|
|
126
|
+
super(message);
|
|
127
|
+
this.name = "StoredCopilotAuthError";
|
|
128
|
+
}
|
|
129
|
+
};
|
|
73
130
|
function authStorePath(env = process.env) {
|
|
74
|
-
|
|
75
|
-
|
|
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");
|
|
142
|
+
}
|
|
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
|
+
);
|
|
76
148
|
}
|
|
77
|
-
const base =
|
|
149
|
+
const base = (0, import_node_path.join)(home, ".config");
|
|
78
150
|
return (0, import_node_path.join)(base, "hoopilot", "auth.json");
|
|
79
151
|
}
|
|
80
152
|
function readStoredCopilotAuth(path = authStorePath()) {
|
|
153
|
+
let text;
|
|
81
154
|
try {
|
|
82
|
-
|
|
83
|
-
|
|
155
|
+
text = (0, import_node_fs.readFileSync)(path, "utf8");
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (error.code === "ENOENT") {
|
|
84
158
|
return void 0;
|
|
85
159
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
apiBaseUrl: typeof parsed.apiBaseUrl === "string" ? parsed.apiBaseUrl : void 0,
|
|
92
|
-
createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : void 0,
|
|
93
|
-
githubDomain: typeof parsed.githubDomain === "string" ? parsed.githubDomain : void 0,
|
|
94
|
-
source: typeof parsed.source === "string" ? parsed.source : void 0,
|
|
95
|
-
token
|
|
96
|
-
};
|
|
160
|
+
throw new StoredCopilotAuthError(`Could not read Hoopilot auth file at ${path}.`);
|
|
161
|
+
}
|
|
162
|
+
let parsed;
|
|
163
|
+
try {
|
|
164
|
+
parsed = JSON.parse(text);
|
|
97
165
|
} catch {
|
|
98
|
-
|
|
166
|
+
throw new StoredCopilotAuthError(
|
|
167
|
+
`Hoopilot auth file at ${path} is not valid JSON. Run \`hoopilot login\` to replace it.`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
171
|
+
throw new StoredCopilotAuthError(`Hoopilot auth file at ${path} must contain a JSON object.`);
|
|
99
172
|
}
|
|
173
|
+
const record = parsed;
|
|
174
|
+
const token = typeof record.token === "string" ? record.token.trim() : "";
|
|
175
|
+
if (!token) {
|
|
176
|
+
throw new StoredCopilotAuthError(`Hoopilot auth file at ${path} does not contain a token.`);
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
apiBaseUrl: typeof record.apiBaseUrl === "string" ? record.apiBaseUrl : void 0,
|
|
180
|
+
createdAt: typeof record.createdAt === "string" ? record.createdAt : void 0,
|
|
181
|
+
githubDomain: typeof record.githubDomain === "string" ? record.githubDomain : void 0,
|
|
182
|
+
source: typeof record.source === "string" ? record.source : void 0,
|
|
183
|
+
token
|
|
184
|
+
};
|
|
100
185
|
}
|
|
101
186
|
function writeStoredCopilotAuth(auth, path = authStorePath()) {
|
|
102
187
|
(0, import_node_fs.mkdirSync)((0, import_node_path.dirname)(path), { recursive: true });
|
|
@@ -118,18 +203,6 @@ function writeStoredCopilotAuth(auth, path = authStorePath()) {
|
|
|
118
203
|
}
|
|
119
204
|
}
|
|
120
205
|
|
|
121
|
-
// src/util.ts
|
|
122
|
-
function trimTrailingSlash(value) {
|
|
123
|
-
return value.replace(/\/+$/, "");
|
|
124
|
-
}
|
|
125
|
-
async function truncatedResponseText(response, max = 500) {
|
|
126
|
-
const text = await response.text();
|
|
127
|
-
return text.slice(0, max);
|
|
128
|
-
}
|
|
129
|
-
function asRecord(value) {
|
|
130
|
-
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
131
|
-
}
|
|
132
|
-
|
|
133
206
|
// src/auth.ts
|
|
134
207
|
var DEFAULT_COPILOT_API_BASE_URL = "https://api.githubcopilot.com";
|
|
135
208
|
var REFRESH_SKEW_MS = 6e4;
|
|
@@ -143,21 +216,35 @@ var CopilotAuthError = class extends Error {
|
|
|
143
216
|
var CopilotAuth = class {
|
|
144
217
|
#authStorePath;
|
|
145
218
|
#copilotApiBaseUrl;
|
|
219
|
+
#hasCopilotApiBaseUrlOverride;
|
|
146
220
|
#cachedAccess;
|
|
147
221
|
constructor(options = {}) {
|
|
148
|
-
|
|
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);
|
|
149
226
|
this.#copilotApiBaseUrl = trimTrailingSlash(
|
|
150
|
-
options.copilotApiBaseUrl ??
|
|
227
|
+
options.copilotApiBaseUrl ?? envCopilotApiBaseUrl ?? DEFAULT_COPILOT_API_BASE_URL
|
|
151
228
|
);
|
|
152
229
|
}
|
|
153
230
|
async getAccess() {
|
|
154
231
|
if (this.#cachedAccess && this.#cachedAccess.expiresAtMs - REFRESH_SKEW_MS > Date.now()) {
|
|
155
232
|
return this.#cachedAccess;
|
|
156
233
|
}
|
|
157
|
-
|
|
234
|
+
let stored;
|
|
235
|
+
try {
|
|
236
|
+
stored = readStoredCopilotAuth(this.#authStorePath);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if (error instanceof StoredCopilotAuthError) {
|
|
239
|
+
throw new CopilotAuthError(error.message);
|
|
240
|
+
}
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
158
243
|
if (stored) {
|
|
159
244
|
return this.#cacheAccess({
|
|
160
|
-
apiBaseUrl: trimTrailingSlash(
|
|
245
|
+
apiBaseUrl: trimTrailingSlash(
|
|
246
|
+
this.#hasCopilotApiBaseUrlOverride ? this.#copilotApiBaseUrl : stored.apiBaseUrl ?? this.#copilotApiBaseUrl
|
|
247
|
+
),
|
|
161
248
|
expiresAtMs: Date.now() + STORED_TOKEN_TTL_MS,
|
|
162
249
|
source: "github-copilot-oauth",
|
|
163
250
|
token: stored.token
|
|
@@ -175,6 +262,8 @@ var CopilotAuth = class {
|
|
|
175
262
|
|
|
176
263
|
// src/copilot.ts
|
|
177
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"];
|
|
178
267
|
var COPILOT_USAGE_API_VERSION = "2025-04-01";
|
|
179
268
|
function applyCopilotHeaders(headers, token) {
|
|
180
269
|
headers.set("accept", headers.get("accept") ?? "application/json");
|
|
@@ -198,13 +287,15 @@ function applyGithubApiHeaders(headers, token) {
|
|
|
198
287
|
}
|
|
199
288
|
var CopilotClient = class {
|
|
200
289
|
#auth;
|
|
290
|
+
#allowUnsafeUpstream;
|
|
201
291
|
#fetch;
|
|
202
292
|
#githubApiBaseUrl;
|
|
203
293
|
constructor(options = {}) {
|
|
204
294
|
this.#auth = new CopilotAuth(options);
|
|
295
|
+
this.#allowUnsafeUpstream = envValue(options.env?.HOOPILOT_ALLOW_UNSAFE_UPSTREAM) === "1";
|
|
205
296
|
this.#fetch = options.fetch ?? fetch;
|
|
206
297
|
this.#githubApiBaseUrl = trimTrailingSlash(
|
|
207
|
-
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
|
|
208
299
|
);
|
|
209
300
|
}
|
|
210
301
|
/**
|
|
@@ -213,9 +304,13 @@ var CopilotClient = class {
|
|
|
213
304
|
* accepted directly here — no Copilot token exchange is required to read quota.
|
|
214
305
|
*/
|
|
215
306
|
async usage(signal) {
|
|
216
|
-
if (!
|
|
307
|
+
if (!isTrustedTokenBaseUrl(
|
|
308
|
+
this.#githubApiBaseUrl,
|
|
309
|
+
ALLOWED_GITHUB_API_HOSTS,
|
|
310
|
+
this.#allowUnsafeUpstream
|
|
311
|
+
)) {
|
|
217
312
|
throw new Error(
|
|
218
|
-
`Refusing to send the GitHub OAuth token to
|
|
313
|
+
`Refusing to send the GitHub OAuth token to an untrusted GitHub API host: ${this.#githubApiBaseUrl}`
|
|
219
314
|
);
|
|
220
315
|
}
|
|
221
316
|
const access = await this.#auth.getAccess();
|
|
@@ -257,6 +352,15 @@ var CopilotClient = class {
|
|
|
257
352
|
}
|
|
258
353
|
async fetchCopilot(path, init) {
|
|
259
354
|
const access = await this.#auth.getAccess();
|
|
355
|
+
if (!isTrustedTokenBaseUrl(
|
|
356
|
+
access.apiBaseUrl,
|
|
357
|
+
ALLOWED_COPILOT_API_HOSTS,
|
|
358
|
+
this.#allowUnsafeUpstream
|
|
359
|
+
)) {
|
|
360
|
+
throw new Error(
|
|
361
|
+
`Refusing to send the GitHub OAuth token to an untrusted Copilot API host: ${access.apiBaseUrl}`
|
|
362
|
+
);
|
|
363
|
+
}
|
|
260
364
|
const headers = applyCopilotHeaders(new Headers(init.headers), access.token);
|
|
261
365
|
return this.#fetch(`${access.apiBaseUrl}${path}`, {
|
|
262
366
|
...init,
|
|
@@ -312,18 +416,6 @@ function usedFrom(entitlement, remaining) {
|
|
|
312
416
|
}
|
|
313
417
|
return Math.max(0, entitlement - remaining);
|
|
314
418
|
}
|
|
315
|
-
function isHttpsOrLoopback(rawUrl) {
|
|
316
|
-
let url;
|
|
317
|
-
try {
|
|
318
|
-
url = new URL(rawUrl);
|
|
319
|
-
} catch {
|
|
320
|
-
return false;
|
|
321
|
-
}
|
|
322
|
-
if (url.protocol === "https:") {
|
|
323
|
-
return true;
|
|
324
|
-
}
|
|
325
|
-
return url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1");
|
|
326
|
-
}
|
|
327
419
|
function numberOrUndefined(value) {
|
|
328
420
|
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
329
421
|
}
|
|
@@ -352,9 +444,9 @@ async function githubCopilotDeviceLogin(options = {}) {
|
|
|
352
444
|
const fetcher = options.fetch ?? fetch;
|
|
353
445
|
const sleeper = options.sleep ?? import_promises.setTimeout;
|
|
354
446
|
const domain = normalizeDomain(
|
|
355
|
-
options.domain ?? env.HOOPILOT_GITHUB_DOMAIN ?? DEFAULT_GITHUB_DOMAIN
|
|
447
|
+
options.domain ?? envValue(env.HOOPILOT_GITHUB_DOMAIN) ?? DEFAULT_GITHUB_DOMAIN
|
|
356
448
|
);
|
|
357
|
-
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;
|
|
358
450
|
const device = await requestDeviceCode(fetcher, domain, clientId);
|
|
359
451
|
const verificationUrl = device.verification_uri;
|
|
360
452
|
const userCode = device.user_code;
|
|
@@ -512,8 +604,8 @@ var noopLogger = {
|
|
|
512
604
|
};
|
|
513
605
|
function createHoopilotLogger(options = {}) {
|
|
514
606
|
const env = options.env ?? process.env;
|
|
515
|
-
const level = parseLogLevel(options.level ?? env.HOOPILOT_LOG_LEVEL);
|
|
516
|
-
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));
|
|
517
609
|
const pinoOptions = {
|
|
518
610
|
base: {
|
|
519
611
|
service: "hoopilot",
|
|
@@ -563,7 +655,7 @@ function parseLogLevel(value) {
|
|
|
563
655
|
}
|
|
564
656
|
function shouldCreateLogger(options) {
|
|
565
657
|
return Boolean(
|
|
566
|
-
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)
|
|
567
659
|
);
|
|
568
660
|
}
|
|
569
661
|
function errorDetails(error) {
|
|
@@ -678,6 +770,51 @@ function chatCompletionToCompletion(completion) {
|
|
|
678
770
|
usage: completion.usage
|
|
679
771
|
});
|
|
680
772
|
}
|
|
773
|
+
function completionStreamFromChatStream(chatStream) {
|
|
774
|
+
const encoder = new TextEncoder();
|
|
775
|
+
const decoder = new TextDecoder();
|
|
776
|
+
let buffer = "";
|
|
777
|
+
let sawTerminalEvent = false;
|
|
778
|
+
return new ReadableStream({
|
|
779
|
+
async start(controller) {
|
|
780
|
+
const enqueue = (data) => {
|
|
781
|
+
controller.enqueue(encoder.encode(encodeDataSse(data)));
|
|
782
|
+
};
|
|
783
|
+
const markTerminal = () => {
|
|
784
|
+
sawTerminalEvent = true;
|
|
785
|
+
};
|
|
786
|
+
const reader = chatStream.getReader();
|
|
787
|
+
try {
|
|
788
|
+
while (true) {
|
|
789
|
+
const result = await reader.read();
|
|
790
|
+
if (result.done) {
|
|
791
|
+
break;
|
|
792
|
+
}
|
|
793
|
+
buffer += decoder.decode(result.value, { stream: true });
|
|
794
|
+
const blocks = buffer.split(/\r?\n\r?\n/);
|
|
795
|
+
buffer = blocks.pop() ?? "";
|
|
796
|
+
for (const block of blocks) {
|
|
797
|
+
processCompletionSseBlock(block, enqueue, markTerminal);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
const tail = `${buffer}${decoder.decode()}`;
|
|
801
|
+
if (tail.trim()) {
|
|
802
|
+
processCompletionSseBlock(tail, enqueue, markTerminal);
|
|
803
|
+
}
|
|
804
|
+
if (!sawTerminalEvent) {
|
|
805
|
+
enqueue("[DONE]");
|
|
806
|
+
}
|
|
807
|
+
controller.close();
|
|
808
|
+
} catch (error) {
|
|
809
|
+
await reader.cancel(error).catch(() => {
|
|
810
|
+
});
|
|
811
|
+
controller.error(error);
|
|
812
|
+
} finally {
|
|
813
|
+
reader.releaseLock();
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
});
|
|
817
|
+
}
|
|
681
818
|
function normalizeModelsResponse(upstream) {
|
|
682
819
|
const record = asRecord(upstream);
|
|
683
820
|
const data = Array.isArray(record.data) ? record.data : Array.isArray(upstream) ? upstream : [];
|
|
@@ -710,38 +847,99 @@ function responsesStreamFromChatStream(chatStream, options) {
|
|
|
710
847
|
const createdAt = epochSeconds();
|
|
711
848
|
let buffer = "";
|
|
712
849
|
let text = "";
|
|
850
|
+
let messageOutputIndex;
|
|
851
|
+
let nextOutputIndex = 0;
|
|
852
|
+
let sequenceNumber = 0;
|
|
713
853
|
const tools = /* @__PURE__ */ new Map();
|
|
714
854
|
return new ReadableStream({
|
|
715
855
|
async start(controller) {
|
|
716
856
|
const enqueue = (event, data) => {
|
|
717
|
-
controller.enqueue(
|
|
857
|
+
controller.enqueue(
|
|
858
|
+
encoder.encode(
|
|
859
|
+
encodeSse(
|
|
860
|
+
event,
|
|
861
|
+
data === "[DONE]" ? data : { ...data, sequence_number: sequenceNumber++ }
|
|
862
|
+
)
|
|
863
|
+
)
|
|
864
|
+
);
|
|
718
865
|
};
|
|
719
866
|
enqueue("response.created", {
|
|
720
867
|
response: baseStreamResponse(responseId, options.model, createdAt, "in_progress", []),
|
|
721
868
|
type: "response.created"
|
|
722
869
|
});
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
870
|
+
const ensureMessageStarted = () => {
|
|
871
|
+
if (messageOutputIndex !== void 0) {
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
messageOutputIndex = nextOutputIndex++;
|
|
875
|
+
enqueue("response.output_item.added", {
|
|
876
|
+
item: {
|
|
877
|
+
content: [],
|
|
878
|
+
id: messageId,
|
|
879
|
+
role: "assistant",
|
|
880
|
+
status: "in_progress",
|
|
881
|
+
type: "message"
|
|
882
|
+
},
|
|
883
|
+
output_index: messageOutputIndex,
|
|
884
|
+
type: "response.output_item.added"
|
|
885
|
+
});
|
|
886
|
+
enqueue("response.content_part.added", {
|
|
887
|
+
content_index: 0,
|
|
888
|
+
item_id: messageId,
|
|
889
|
+
output_index: messageOutputIndex,
|
|
890
|
+
part: {
|
|
891
|
+
annotations: [],
|
|
892
|
+
text: "",
|
|
893
|
+
type: "output_text"
|
|
894
|
+
},
|
|
895
|
+
type: "response.content_part.added"
|
|
896
|
+
});
|
|
897
|
+
};
|
|
898
|
+
const appendText = (delta) => {
|
|
899
|
+
ensureMessageStarted();
|
|
900
|
+
text += delta;
|
|
901
|
+
enqueue("response.output_text.delta", {
|
|
902
|
+
content_index: 0,
|
|
903
|
+
delta,
|
|
904
|
+
item_id: messageId,
|
|
905
|
+
output_index: messageOutputIndex ?? 0,
|
|
906
|
+
type: "response.output_text.delta"
|
|
907
|
+
});
|
|
908
|
+
};
|
|
909
|
+
const appendToolCall = (toolCall) => {
|
|
910
|
+
const fn = asRecord(toolCall.function);
|
|
911
|
+
const index = typeof toolCall.index === "number" ? toolCall.index : tools.size;
|
|
912
|
+
let existing = tools.get(index);
|
|
913
|
+
const isNew = !existing;
|
|
914
|
+
existing ??= {
|
|
915
|
+
arguments: "",
|
|
916
|
+
id: contentToText(toolCall.id) || `call_${randomId()}`,
|
|
917
|
+
index,
|
|
918
|
+
itemId: `fc_${randomId()}`,
|
|
919
|
+
name: "",
|
|
920
|
+
outputIndex: nextOutputIndex++
|
|
921
|
+
};
|
|
922
|
+
existing.id = contentToText(toolCall.id) || existing.id;
|
|
923
|
+
existing.name += contentToText(fn.name);
|
|
924
|
+
tools.set(index, existing);
|
|
925
|
+
if (isNew) {
|
|
926
|
+
enqueue("response.output_item.added", {
|
|
927
|
+
item: functionCallItem(existing, "in_progress"),
|
|
928
|
+
output_index: existing.outputIndex ?? 0,
|
|
929
|
+
type: "response.output_item.added"
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
const argumentDelta = contentToText(fn.arguments);
|
|
933
|
+
if (argumentDelta) {
|
|
934
|
+
existing.arguments += argumentDelta;
|
|
935
|
+
enqueue("response.function_call_arguments.delta", {
|
|
936
|
+
delta: argumentDelta,
|
|
937
|
+
item_id: existing.itemId,
|
|
938
|
+
output_index: existing.outputIndex ?? 0,
|
|
939
|
+
type: "response.function_call_arguments.delta"
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
};
|
|
745
943
|
const reader = chatStream.getReader();
|
|
746
944
|
try {
|
|
747
945
|
while (true) {
|
|
@@ -753,50 +951,48 @@ function responsesStreamFromChatStream(chatStream, options) {
|
|
|
753
951
|
const lines = buffer.split(/\r?\n/);
|
|
754
952
|
buffer = lines.pop() ?? "";
|
|
755
953
|
for (const line of lines) {
|
|
756
|
-
processChatSseLine(
|
|
757
|
-
text += delta;
|
|
758
|
-
});
|
|
954
|
+
processChatSseLine(line, { appendText, appendToolCall });
|
|
759
955
|
}
|
|
760
956
|
}
|
|
761
957
|
if (buffer) {
|
|
762
|
-
processChatSseLine(
|
|
763
|
-
text += delta;
|
|
764
|
-
});
|
|
958
|
+
processChatSseLine(buffer, { appendText, appendToolCall });
|
|
765
959
|
}
|
|
766
|
-
const
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
});
|
|
775
|
-
enqueue("response.content_part.done", {
|
|
776
|
-
content_index: 0,
|
|
777
|
-
item_id: messageId,
|
|
778
|
-
output_index: 0,
|
|
779
|
-
part: {
|
|
780
|
-
annotations: [],
|
|
960
|
+
const outputEntries = [];
|
|
961
|
+
if (messageOutputIndex !== void 0) {
|
|
962
|
+
const item = messageOutputItem(text, messageId);
|
|
963
|
+
outputEntries.push([messageOutputIndex, item]);
|
|
964
|
+
enqueue("response.output_text.done", {
|
|
965
|
+
content_index: 0,
|
|
966
|
+
item_id: messageId,
|
|
967
|
+
output_index: messageOutputIndex,
|
|
781
968
|
text,
|
|
782
|
-
type: "output_text"
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
969
|
+
type: "response.output_text.done"
|
|
970
|
+
});
|
|
971
|
+
enqueue("response.content_part.done", {
|
|
972
|
+
content_index: 0,
|
|
973
|
+
item_id: messageId,
|
|
974
|
+
output_index: messageOutputIndex,
|
|
975
|
+
part: {
|
|
976
|
+
annotations: [],
|
|
977
|
+
text,
|
|
978
|
+
type: "output_text"
|
|
979
|
+
},
|
|
980
|
+
type: "response.content_part.done"
|
|
981
|
+
});
|
|
982
|
+
enqueue("response.output_item.done", {
|
|
794
983
|
item,
|
|
795
|
-
output_index:
|
|
796
|
-
type: "response.output_item.
|
|
984
|
+
output_index: messageOutputIndex,
|
|
985
|
+
type: "response.output_item.done"
|
|
797
986
|
});
|
|
987
|
+
}
|
|
988
|
+
for (const tool of [...tools.values()].sort(
|
|
989
|
+
(a, b) => (a.outputIndex ?? 0) - (b.outputIndex ?? 0)
|
|
990
|
+
)) {
|
|
991
|
+
const item = functionCallItem(tool);
|
|
992
|
+
const outputIndex = tool.outputIndex ?? 0;
|
|
993
|
+
outputEntries.push([outputIndex, item]);
|
|
798
994
|
enqueue("response.function_call_arguments.done", {
|
|
799
|
-
arguments:
|
|
995
|
+
arguments: tool.arguments,
|
|
800
996
|
item_id: item.id,
|
|
801
997
|
output_index: outputIndex,
|
|
802
998
|
type: "response.function_call_arguments.done"
|
|
@@ -806,7 +1002,8 @@ function responsesStreamFromChatStream(chatStream, options) {
|
|
|
806
1002
|
output_index: outputIndex,
|
|
807
1003
|
type: "response.output_item.done"
|
|
808
1004
|
});
|
|
809
|
-
}
|
|
1005
|
+
}
|
|
1006
|
+
const output = outputEntries.sort(([left], [right]) => left - right).map(([, item]) => item);
|
|
810
1007
|
enqueue("response.completed", {
|
|
811
1008
|
response: baseStreamResponse(responseId, options.model, createdAt, "completed", output),
|
|
812
1009
|
type: "response.completed"
|
|
@@ -989,13 +1186,13 @@ function messageOutputItem(text, id = `msg_${randomId()}`) {
|
|
|
989
1186
|
type: "message"
|
|
990
1187
|
};
|
|
991
1188
|
}
|
|
992
|
-
function functionCallItem(tool) {
|
|
1189
|
+
function functionCallItem(tool, status = "completed") {
|
|
993
1190
|
return {
|
|
994
1191
|
arguments: tool.arguments,
|
|
995
1192
|
call_id: tool.id,
|
|
996
|
-
id: `fc_${randomId()}`,
|
|
1193
|
+
id: tool.itemId ?? `fc_${randomId()}`,
|
|
997
1194
|
name: tool.name,
|
|
998
|
-
status
|
|
1195
|
+
status,
|
|
999
1196
|
type: "function_call"
|
|
1000
1197
|
};
|
|
1001
1198
|
}
|
|
@@ -1010,14 +1207,27 @@ function responseUsage(usage) {
|
|
|
1010
1207
|
if (Object.keys(record).length === 0) {
|
|
1011
1208
|
return null;
|
|
1012
1209
|
}
|
|
1210
|
+
const inputTokens = record.prompt_tokens;
|
|
1211
|
+
const outputTokens = record.completion_tokens;
|
|
1013
1212
|
return removeUndefined({
|
|
1014
|
-
input_tokens:
|
|
1015
|
-
input_tokens_details: record.prompt_tokens_details,
|
|
1016
|
-
|
|
1017
|
-
|
|
1213
|
+
input_tokens: inputTokens,
|
|
1214
|
+
input_tokens_details: responseUsageDetails(record.prompt_tokens_details, inputTokens, {
|
|
1215
|
+
cached_tokens: 0
|
|
1216
|
+
}),
|
|
1217
|
+
output_tokens: outputTokens,
|
|
1218
|
+
output_tokens_details: responseUsageDetails(record.completion_tokens_details, outputTokens, {
|
|
1219
|
+
reasoning_tokens: 0
|
|
1220
|
+
}),
|
|
1018
1221
|
total_tokens: record.total_tokens
|
|
1019
1222
|
});
|
|
1020
1223
|
}
|
|
1224
|
+
function responseUsageDetails(value, tokenCount, fallback) {
|
|
1225
|
+
const record = asRecord(value);
|
|
1226
|
+
if (Object.keys(record).length > 0) {
|
|
1227
|
+
return record;
|
|
1228
|
+
}
|
|
1229
|
+
return typeof tokenCount === "number" && Number.isFinite(tokenCount) ? fallback : void 0;
|
|
1230
|
+
}
|
|
1021
1231
|
function extractTokenUsage(usage) {
|
|
1022
1232
|
const record = asRecord(usage);
|
|
1023
1233
|
const prompt = firstNumber(record.prompt_tokens, record.input_tokens);
|
|
@@ -1056,7 +1266,80 @@ function firstChoice(completion) {
|
|
|
1056
1266
|
const choices = Array.isArray(completion.choices) ? completion.choices : [];
|
|
1057
1267
|
return asRecord(choices[0]);
|
|
1058
1268
|
}
|
|
1059
|
-
function
|
|
1269
|
+
function processCompletionSseBlock(block, enqueue, markTerminal) {
|
|
1270
|
+
let event = "message";
|
|
1271
|
+
const dataLines = [];
|
|
1272
|
+
for (const line of block.split(/\r?\n/)) {
|
|
1273
|
+
const trimmed = line.trim();
|
|
1274
|
+
if (trimmed.startsWith("event:")) {
|
|
1275
|
+
event = trimmed.slice("event:".length).trim() || event;
|
|
1276
|
+
} else if (trimmed.startsWith("data:")) {
|
|
1277
|
+
dataLines.push(trimmed.slice("data:".length).trim());
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
const data = dataLines.join("\n");
|
|
1281
|
+
if (!data) {
|
|
1282
|
+
return;
|
|
1283
|
+
}
|
|
1284
|
+
if (data === "[DONE]") {
|
|
1285
|
+
markTerminal();
|
|
1286
|
+
enqueue("[DONE]");
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
const parsed = parseJson(data);
|
|
1290
|
+
if (!parsed) {
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
const error = completionStreamError(event, parsed);
|
|
1294
|
+
if (error) {
|
|
1295
|
+
markTerminal();
|
|
1296
|
+
enqueue({ error });
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
const choice = firstChoice(parsed);
|
|
1300
|
+
const delta = asRecord(choice.delta);
|
|
1301
|
+
const text = contentToText(delta.content);
|
|
1302
|
+
const finishReason = choice.finish_reason ?? null;
|
|
1303
|
+
const usage = asRecord(parsed.usage);
|
|
1304
|
+
const hasUsage = Object.keys(usage).length > 0;
|
|
1305
|
+
if (!text && finishReason === null && !hasUsage) {
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
enqueue(
|
|
1309
|
+
removeUndefined({
|
|
1310
|
+
choices: text || finishReason !== null ? [
|
|
1311
|
+
{
|
|
1312
|
+
finish_reason: finishReason,
|
|
1313
|
+
index: typeof choice.index === "number" ? choice.index : 0,
|
|
1314
|
+
logprobs: null,
|
|
1315
|
+
text
|
|
1316
|
+
}
|
|
1317
|
+
] : [],
|
|
1318
|
+
created: typeof parsed.created === "number" ? parsed.created : epochSeconds(),
|
|
1319
|
+
id: contentToText(parsed.id) || `cmpl_${randomId()}`,
|
|
1320
|
+
model: contentToText(parsed.model) || DEFAULT_MODEL,
|
|
1321
|
+
object: "text_completion",
|
|
1322
|
+
usage: hasUsage ? usage : void 0
|
|
1323
|
+
})
|
|
1324
|
+
);
|
|
1325
|
+
}
|
|
1326
|
+
function completionStreamError(event, parsed) {
|
|
1327
|
+
const responseError = asRecord(asRecord(parsed.response).error);
|
|
1328
|
+
const directError = asRecord(parsed.error);
|
|
1329
|
+
const error = Object.keys(directError).length > 0 ? directError : Object.keys(responseError).length > 0 ? responseError : void 0;
|
|
1330
|
+
if (error) {
|
|
1331
|
+
return error;
|
|
1332
|
+
}
|
|
1333
|
+
if (event === "error" || parsed.type === "response.failed") {
|
|
1334
|
+
return removeUndefined({
|
|
1335
|
+
code: contentToText(parsed.code) || void 0,
|
|
1336
|
+
message: contentToText(parsed.message) || "Upstream streaming request failed.",
|
|
1337
|
+
type: contentToText(parsed.type) || "upstream_stream_error"
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
return void 0;
|
|
1341
|
+
}
|
|
1342
|
+
function processChatSseLine(line, handlers) {
|
|
1060
1343
|
const trimmed = line.trim();
|
|
1061
1344
|
if (!trimmed.startsWith("data:")) {
|
|
1062
1345
|
return;
|
|
@@ -1073,30 +1356,11 @@ function processChatSseLine(messageId, line, enqueue, tools, appendText) {
|
|
|
1073
1356
|
const delta = asRecord(choice.delta);
|
|
1074
1357
|
const content = contentToText(delta.content);
|
|
1075
1358
|
if (content) {
|
|
1076
|
-
appendText(content);
|
|
1077
|
-
enqueue("response.output_text.delta", {
|
|
1078
|
-
content_index: 0,
|
|
1079
|
-
delta: content,
|
|
1080
|
-
item_id: messageId,
|
|
1081
|
-
output_index: 0,
|
|
1082
|
-
type: "response.output_text.delta"
|
|
1083
|
-
});
|
|
1359
|
+
handlers.appendText(content);
|
|
1084
1360
|
}
|
|
1085
1361
|
const toolCalls = Array.isArray(delta.tool_calls) ? delta.tool_calls : [];
|
|
1086
1362
|
for (const toolCall of toolCalls) {
|
|
1087
|
-
|
|
1088
|
-
const fn = asRecord(record.function);
|
|
1089
|
-
const index = typeof record.index === "number" ? record.index : tools.size;
|
|
1090
|
-
const existing = tools.get(index) ?? {
|
|
1091
|
-
arguments: "",
|
|
1092
|
-
id: contentToText(record.id) || `call_${randomId()}`,
|
|
1093
|
-
index,
|
|
1094
|
-
name: ""
|
|
1095
|
-
};
|
|
1096
|
-
existing.id = contentToText(record.id) || existing.id;
|
|
1097
|
-
existing.name += contentToText(fn.name);
|
|
1098
|
-
existing.arguments += contentToText(fn.arguments);
|
|
1099
|
-
tools.set(index, existing);
|
|
1363
|
+
handlers.appendToolCall(asRecord(toolCall));
|
|
1100
1364
|
}
|
|
1101
1365
|
}
|
|
1102
1366
|
function baseStreamResponse(id, model, createdAt, status, output) {
|
|
@@ -1126,6 +1390,14 @@ function encodeSse(event, data) {
|
|
|
1126
1390
|
return `event: ${event}
|
|
1127
1391
|
data: ${JSON.stringify(data)}
|
|
1128
1392
|
|
|
1393
|
+
`;
|
|
1394
|
+
}
|
|
1395
|
+
function encodeDataSse(data) {
|
|
1396
|
+
if (data === "[DONE]") {
|
|
1397
|
+
return "data: [DONE]\n\n";
|
|
1398
|
+
}
|
|
1399
|
+
return `data: ${JSON.stringify(data)}
|
|
1400
|
+
|
|
1129
1401
|
`;
|
|
1130
1402
|
}
|
|
1131
1403
|
function parseJson(data) {
|
|
@@ -1514,11 +1786,21 @@ function formatNumber(value) {
|
|
|
1514
1786
|
// src/server.ts
|
|
1515
1787
|
var DEFAULT_HOST = "127.0.0.1";
|
|
1516
1788
|
var DEFAULT_PORT = 4141;
|
|
1789
|
+
var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Browser-origin requests require HOOPILOT_API_KEY unless the Origin is loopback.";
|
|
1517
1790
|
var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
|
|
1791
|
+
var MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024;
|
|
1792
|
+
var REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
1793
|
+
var REQUEST_TOO_LARGE_MESSAGE = `Request body must be ${MAX_REQUEST_BODY_BYTES} bytes or smaller.`;
|
|
1518
1794
|
var USAGE_CACHE_TTL_MS = 6e4;
|
|
1795
|
+
var RequestBodyTooLargeError = class extends Error {
|
|
1796
|
+
constructor() {
|
|
1797
|
+
super(REQUEST_TOO_LARGE_MESSAGE);
|
|
1798
|
+
this.name = "RequestBodyTooLargeError";
|
|
1799
|
+
}
|
|
1800
|
+
};
|
|
1519
1801
|
function createHoopilotHandler(options = {}) {
|
|
1520
1802
|
const client = new CopilotClient(options);
|
|
1521
|
-
const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
|
|
1803
|
+
const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
|
|
1522
1804
|
const logger = serverLogger(options);
|
|
1523
1805
|
const metrics = options.metrics ?? new MetricsRegistry();
|
|
1524
1806
|
const readUsage = createUsageReader(client, metrics);
|
|
@@ -1544,6 +1826,14 @@ function createHoopilotHandler(options = {}) {
|
|
|
1544
1826
|
route,
|
|
1545
1827
|
startedAt
|
|
1546
1828
|
});
|
|
1829
|
+
const browserOrigin = forbiddenBrowserOrigin(request, apiKey);
|
|
1830
|
+
if (browserOrigin) {
|
|
1831
|
+
requestLogger.warn(
|
|
1832
|
+
{ event: "http.request.forbidden_origin", origin: browserOrigin },
|
|
1833
|
+
"blocked unauthenticated browser-origin request"
|
|
1834
|
+
);
|
|
1835
|
+
return finish(jsonError(403, "forbidden_origin", FORBIDDEN_BROWSER_ORIGIN_MESSAGE));
|
|
1836
|
+
}
|
|
1547
1837
|
if (request.method === "OPTIONS") {
|
|
1548
1838
|
return finish(new Response(null, { headers: corsHeaders() }));
|
|
1549
1839
|
}
|
|
@@ -1596,6 +1886,12 @@ function createHoopilotHandler(options = {}) {
|
|
|
1596
1886
|
"request body was invalid json"
|
|
1597
1887
|
);
|
|
1598
1888
|
return finish(jsonError(400, "invalid_request_error", message));
|
|
1889
|
+
} else if (error instanceof RequestBodyTooLargeError) {
|
|
1890
|
+
requestLogger.warn(
|
|
1891
|
+
{ err: errorDetails(error), event: "http.request.failed" },
|
|
1892
|
+
"request body exceeded size limit"
|
|
1893
|
+
);
|
|
1894
|
+
return finish(jsonError(413, "request_too_large", message));
|
|
1599
1895
|
} else {
|
|
1600
1896
|
requestLogger.error(
|
|
1601
1897
|
{ err: errorDetails(error), event: "http.request.failed" },
|
|
@@ -1607,10 +1903,10 @@ function createHoopilotHandler(options = {}) {
|
|
|
1607
1903
|
};
|
|
1608
1904
|
}
|
|
1609
1905
|
function startHoopilotServer(options = {}) {
|
|
1610
|
-
const host = options.host ?? options.env?.HOST ?? DEFAULT_HOST;
|
|
1611
|
-
const port =
|
|
1612
|
-
const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
|
|
1613
|
-
const allowUnauthenticated = options.allowUnauthenticated ?? options.env?.HOOPILOT_ALLOW_UNAUTHENTICATED === "1";
|
|
1906
|
+
const host = options.host ?? envValue(options.env?.HOST) ?? DEFAULT_HOST;
|
|
1907
|
+
const port = normalizeServerPort(options.port ?? envValue(options.env?.PORT) ?? DEFAULT_PORT);
|
|
1908
|
+
const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
|
|
1909
|
+
const allowUnauthenticated = options.allowUnauthenticated ?? envValue(options.env?.HOOPILOT_ALLOW_UNAUTHENTICATED) === "1";
|
|
1614
1910
|
if (!isLoopbackHost(host) && !apiKey && !allowUnauthenticated) {
|
|
1615
1911
|
throw new Error(
|
|
1616
1912
|
"Refusing to listen on a non-loopback host without HOOPILOT_API_KEY. Set an API key or pass --allow-unauthenticated."
|
|
@@ -1628,7 +1924,7 @@ function startHoopilotServer(options = {}) {
|
|
|
1628
1924
|
});
|
|
1629
1925
|
return {
|
|
1630
1926
|
server,
|
|
1631
|
-
url: `http://${host}:${server.port}`
|
|
1927
|
+
url: `http://${urlHost(host)}:${server.port}`
|
|
1632
1928
|
};
|
|
1633
1929
|
}
|
|
1634
1930
|
async function handleModels(client, metrics, signal, logger) {
|
|
@@ -1674,8 +1970,19 @@ async function handleCompletions(client, metrics, recordTokens, request, logger)
|
|
|
1674
1970
|
}
|
|
1675
1971
|
logUpstreamSuccess(logger, "/chat/completions", upstream.status);
|
|
1676
1972
|
const model = normalizeRequestedModel(body.model);
|
|
1677
|
-
if (isStreamingResponse(upstream)) {
|
|
1678
|
-
return proxyResponse(
|
|
1973
|
+
if (isStreamingResponse(upstream) && upstream.body) {
|
|
1974
|
+
return proxyResponse(
|
|
1975
|
+
observeResponseUsage(
|
|
1976
|
+
new Response(completionStreamFromChatStream(upstream.body), {
|
|
1977
|
+
headers: upstream.headers,
|
|
1978
|
+
status: upstream.status,
|
|
1979
|
+
statusText: upstream.statusText
|
|
1980
|
+
}),
|
|
1981
|
+
model,
|
|
1982
|
+
recordTokens,
|
|
1983
|
+
request.signal
|
|
1984
|
+
)
|
|
1985
|
+
);
|
|
1679
1986
|
}
|
|
1680
1987
|
const completion = asRecord(await upstream.json());
|
|
1681
1988
|
const usage = extractTokenUsage(completion.usage);
|
|
@@ -1709,7 +2016,7 @@ async function proxyError(upstream, logger) {
|
|
|
1709
2016
|
{ event: "copilot.request.failed", upstreamStatus: upstream.status },
|
|
1710
2017
|
"copilot upstream request failed"
|
|
1711
2018
|
);
|
|
1712
|
-
return
|
|
2019
|
+
return upstreamErrorResponse(upstream.status, text || upstream.statusText);
|
|
1713
2020
|
}
|
|
1714
2021
|
function proxyResponse(upstream) {
|
|
1715
2022
|
const headers = new Headers(upstream.headers);
|
|
@@ -1726,14 +2033,15 @@ function proxyResponse(upstream) {
|
|
|
1726
2033
|
});
|
|
1727
2034
|
}
|
|
1728
2035
|
async function readJson(request) {
|
|
2036
|
+
const text = await readRequestText(request);
|
|
1729
2037
|
try {
|
|
1730
|
-
return asRecord(
|
|
2038
|
+
return asRecord(JSON.parse(text));
|
|
1731
2039
|
} catch {
|
|
1732
2040
|
throw new Error(INVALID_JSON_MESSAGE);
|
|
1733
2041
|
}
|
|
1734
2042
|
}
|
|
1735
2043
|
async function readJsonText(request) {
|
|
1736
|
-
const text = await request
|
|
2044
|
+
const text = await readRequestText(request);
|
|
1737
2045
|
try {
|
|
1738
2046
|
JSON.parse(text);
|
|
1739
2047
|
return text;
|
|
@@ -1741,6 +2049,40 @@ async function readJsonText(request) {
|
|
|
1741
2049
|
throw new Error(INVALID_JSON_MESSAGE);
|
|
1742
2050
|
}
|
|
1743
2051
|
}
|
|
2052
|
+
async function readRequestText(request) {
|
|
2053
|
+
const contentLength = request.headers.get("content-length");
|
|
2054
|
+
if (contentLength) {
|
|
2055
|
+
const declaredBytes = Number(contentLength);
|
|
2056
|
+
if (Number.isFinite(declaredBytes) && declaredBytes > MAX_REQUEST_BODY_BYTES) {
|
|
2057
|
+
throw new RequestBodyTooLargeError();
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
const body = request.body;
|
|
2061
|
+
if (!body) {
|
|
2062
|
+
return "";
|
|
2063
|
+
}
|
|
2064
|
+
const reader = body.getReader();
|
|
2065
|
+
const decoder = new TextDecoder();
|
|
2066
|
+
let bytes = 0;
|
|
2067
|
+
let text = "";
|
|
2068
|
+
try {
|
|
2069
|
+
while (true) {
|
|
2070
|
+
const { done, value } = await reader.read();
|
|
2071
|
+
if (done) {
|
|
2072
|
+
return `${text}${decoder.decode()}`;
|
|
2073
|
+
}
|
|
2074
|
+
bytes += value.byteLength;
|
|
2075
|
+
if (bytes > MAX_REQUEST_BODY_BYTES) {
|
|
2076
|
+
await reader.cancel().catch(() => {
|
|
2077
|
+
});
|
|
2078
|
+
throw new RequestBodyTooLargeError();
|
|
2079
|
+
}
|
|
2080
|
+
text += decoder.decode(value, { stream: true });
|
|
2081
|
+
}
|
|
2082
|
+
} finally {
|
|
2083
|
+
reader.releaseLock();
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
1744
2086
|
function jsonResponse(body, status = 200) {
|
|
1745
2087
|
return new Response(JSON.stringify(body), {
|
|
1746
2088
|
headers: {
|
|
@@ -1762,6 +2104,13 @@ function jsonError(status, code, message) {
|
|
|
1762
2104
|
status
|
|
1763
2105
|
);
|
|
1764
2106
|
}
|
|
2107
|
+
function upstreamErrorResponse(status, text) {
|
|
2108
|
+
const parsedError = asRecord(asRecord(safeParseJson(text)).error);
|
|
2109
|
+
if (Object.keys(parsedError).length > 0) {
|
|
2110
|
+
return jsonResponse({ error: parsedError }, status);
|
|
2111
|
+
}
|
|
2112
|
+
return jsonError(status, "copilot_error", text);
|
|
2113
|
+
}
|
|
1765
2114
|
function websocketUnsupportedResponse() {
|
|
1766
2115
|
const response = jsonError(
|
|
1767
2116
|
426,
|
|
@@ -1786,6 +2135,17 @@ function isAuthorized(request, apiKey) {
|
|
|
1786
2135
|
const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1];
|
|
1787
2136
|
return bearer === apiKey || request.headers.get("x-api-key") === apiKey;
|
|
1788
2137
|
}
|
|
2138
|
+
function forbiddenBrowserOrigin(request, apiKey) {
|
|
2139
|
+
if (apiKey) {
|
|
2140
|
+
return void 0;
|
|
2141
|
+
}
|
|
2142
|
+
const origin = request.headers.get("origin")?.trim();
|
|
2143
|
+
if (origin) {
|
|
2144
|
+
return isLoopbackOrigin(origin) ? void 0 : origin;
|
|
2145
|
+
}
|
|
2146
|
+
const fetchSite = request.headers.get("sec-fetch-site")?.toLowerCase();
|
|
2147
|
+
return fetchSite === "cross-site" ? "cross-site" : void 0;
|
|
2148
|
+
}
|
|
1789
2149
|
function isUpstreamAuthStatus(status) {
|
|
1790
2150
|
return status === 401 || status === 403;
|
|
1791
2151
|
}
|
|
@@ -1793,7 +2153,24 @@ function upstreamAuthMessage(message) {
|
|
|
1793
2153
|
return `GitHub Copilot rejected the credential or account access: ${message}`;
|
|
1794
2154
|
}
|
|
1795
2155
|
function isLoopbackHost(host) {
|
|
1796
|
-
return host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
2156
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
|
|
2157
|
+
}
|
|
2158
|
+
function urlHost(host) {
|
|
2159
|
+
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
2160
|
+
}
|
|
2161
|
+
function isLoopbackOrigin(origin) {
|
|
2162
|
+
try {
|
|
2163
|
+
return isLoopbackHost(new URL(origin).hostname.toLowerCase());
|
|
2164
|
+
} catch {
|
|
2165
|
+
return false;
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
function normalizeServerPort(value) {
|
|
2169
|
+
const port = Number(value);
|
|
2170
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
2171
|
+
throw new Error(`Invalid port: ${value}.`);
|
|
2172
|
+
}
|
|
2173
|
+
return port;
|
|
1797
2174
|
}
|
|
1798
2175
|
function errorMessage(error) {
|
|
1799
2176
|
return error instanceof Error ? error.message : String(error);
|
|
@@ -1888,7 +2265,7 @@ function logRequestCompleted(logger, status, stream, durationMs) {
|
|
|
1888
2265
|
}
|
|
1889
2266
|
function requestIdFor(request) {
|
|
1890
2267
|
const existing = request.headers.get("x-request-id")?.trim();
|
|
1891
|
-
return existing
|
|
2268
|
+
return existing && REQUEST_ID_PATTERN.test(existing) ? existing : crypto.randomUUID();
|
|
1892
2269
|
}
|
|
1893
2270
|
function canonicalApiPath(path) {
|
|
1894
2271
|
const withoutTrailingSlash = path.length > 1 ? path.replace(/\/+$/, "") : path;
|
|
@@ -2018,6 +2395,7 @@ function safeParseJson(text) {
|
|
|
2018
2395
|
authStorePath,
|
|
2019
2396
|
chatCompletionToCompletion,
|
|
2020
2397
|
chatCompletionToResponse,
|
|
2398
|
+
completionStreamFromChatStream,
|
|
2021
2399
|
completionsRequestToChatCompletion,
|
|
2022
2400
|
createHoopilotHandler,
|
|
2023
2401
|
createHoopilotLogger,
|