@openhoo/hoopilot 2.1.5 → 2.1.7
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 +79 -4
- package/dist/{chunk-4ZG5QEYJ.js → chunk-2GLKVNAA.js} +5 -2
- package/dist/chunk-2GLKVNAA.js.map +1 -0
- package/dist/cli.js +479 -425
- package/dist/cli.js.map +1 -1
- package/dist/codexx.js +1 -1
- package/dist/index.d.ts +12 -66
- package/dist/index.js +2698 -3136
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-4ZG5QEYJ.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import {
|
|
3
|
+
DEFAULT_MODEL,
|
|
3
4
|
asRecord,
|
|
4
5
|
envValue,
|
|
5
6
|
errorMessage,
|
|
@@ -15,7 +16,7 @@ import {
|
|
|
15
16
|
safeJsonParse,
|
|
16
17
|
trimTrailingSlash,
|
|
17
18
|
truncatedResponseText
|
|
18
|
-
} from "./chunk-
|
|
19
|
+
} from "./chunk-2GLKVNAA.js";
|
|
19
20
|
|
|
20
21
|
// src/cli.ts
|
|
21
22
|
import { spawn } from "child_process";
|
|
@@ -785,11 +786,51 @@ function isLogLevel(value) {
|
|
|
785
786
|
}
|
|
786
787
|
|
|
787
788
|
// src/server.ts
|
|
788
|
-
import { createHash, timingSafeEqual } from "crypto";
|
|
789
789
|
import { Elysia } from "elysia";
|
|
790
790
|
|
|
791
|
+
// src/sse.ts
|
|
792
|
+
function parseSseBlock(block) {
|
|
793
|
+
let event = "message";
|
|
794
|
+
const data = [];
|
|
795
|
+
for (const line of block.split(/\r?\n/)) {
|
|
796
|
+
const trimmed = line.trim();
|
|
797
|
+
if (trimmed.startsWith("event:")) {
|
|
798
|
+
event = trimmed.slice("event:".length).trim() || event;
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
const value = sseDataFromLine(trimmed);
|
|
802
|
+
if (value !== void 0) {
|
|
803
|
+
data.push(value);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return { data: data.join("\n"), event };
|
|
807
|
+
}
|
|
808
|
+
function sseDataFromLine(line) {
|
|
809
|
+
const trimmed = line.trim();
|
|
810
|
+
if (!trimmed.startsWith("data:")) {
|
|
811
|
+
return void 0;
|
|
812
|
+
}
|
|
813
|
+
return trimmed.slice("data:".length).trim();
|
|
814
|
+
}
|
|
815
|
+
function encodeSseEvent(event, data) {
|
|
816
|
+
if (data === "[DONE]") {
|
|
817
|
+
return "data: [DONE]\n\n";
|
|
818
|
+
}
|
|
819
|
+
return `event: ${event}
|
|
820
|
+
data: ${JSON.stringify(data)}
|
|
821
|
+
|
|
822
|
+
`;
|
|
823
|
+
}
|
|
824
|
+
function encodeSseData(data) {
|
|
825
|
+
if (data === "[DONE]") {
|
|
826
|
+
return "data: [DONE]\n\n";
|
|
827
|
+
}
|
|
828
|
+
return `data: ${JSON.stringify(data)}
|
|
829
|
+
|
|
830
|
+
`;
|
|
831
|
+
}
|
|
832
|
+
|
|
791
833
|
// src/openai.ts
|
|
792
|
-
var DEFAULT_MODEL = "gpt-4.1";
|
|
793
834
|
var COMPACTION_SUMMARIZATION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task.
|
|
794
835
|
|
|
795
836
|
Include:
|
|
@@ -904,7 +945,7 @@ function responsesCompactionSseText(upstreamText, isSse, model) {
|
|
|
904
945
|
const item = compactionOutputItem(compactionSummaryText(upstreamText, isSse));
|
|
905
946
|
const createdAt = epochSeconds();
|
|
906
947
|
let sequenceNumber = 0;
|
|
907
|
-
const event = (name, data) =>
|
|
948
|
+
const event = (name, data) => encodeSseEvent(name, data === "[DONE]" ? data : { ...data, sequence_number: sequenceNumber++ });
|
|
908
949
|
return [
|
|
909
950
|
event("response.created", {
|
|
910
951
|
response: baseStreamResponse(responseId, model, createdAt, "in_progress", []),
|
|
@@ -942,7 +983,7 @@ function compactionSummaryTextFromResponsesSse(text) {
|
|
|
942
983
|
let deltas = "";
|
|
943
984
|
let completedResponse;
|
|
944
985
|
for (const block of text.split(/\r?\n\r?\n/)) {
|
|
945
|
-
const data = block
|
|
986
|
+
const { data } = parseSseBlock(block);
|
|
946
987
|
if (!data || data === "[DONE]") {
|
|
947
988
|
continue;
|
|
948
989
|
}
|
|
@@ -989,7 +1030,7 @@ function completionStreamFromChatStream(chatStream) {
|
|
|
989
1030
|
return new ReadableStream({
|
|
990
1031
|
async start(controller) {
|
|
991
1032
|
const enqueue = (data) => {
|
|
992
|
-
controller.enqueue(encoder.encode(
|
|
1033
|
+
controller.enqueue(encoder.encode(encodeSseData(data)));
|
|
993
1034
|
};
|
|
994
1035
|
const markTerminal = () => {
|
|
995
1036
|
sawTerminalEvent = true;
|
|
@@ -1030,7 +1071,7 @@ function completionSseTextFromChatSseText(text) {
|
|
|
1030
1071
|
const chunks = [];
|
|
1031
1072
|
let sawTerminalEvent = false;
|
|
1032
1073
|
const enqueue = (data) => {
|
|
1033
|
-
chunks.push(
|
|
1074
|
+
chunks.push(encodeSseData(data));
|
|
1034
1075
|
};
|
|
1035
1076
|
const markTerminal = () => {
|
|
1036
1077
|
sawTerminalEvent = true;
|
|
@@ -1233,17 +1274,7 @@ function completionChoices(completion) {
|
|
|
1233
1274
|
return choices.map((choice) => asRecord(choice));
|
|
1234
1275
|
}
|
|
1235
1276
|
function processCompletionSseBlock(block, enqueue, markTerminal) {
|
|
1236
|
-
|
|
1237
|
-
const dataLines = [];
|
|
1238
|
-
for (const line of block.split(/\r?\n/)) {
|
|
1239
|
-
const trimmed = line.trim();
|
|
1240
|
-
if (trimmed.startsWith("event:")) {
|
|
1241
|
-
event = trimmed.slice("event:".length).trim() || event;
|
|
1242
|
-
} else if (trimmed.startsWith("data:")) {
|
|
1243
|
-
dataLines.push(trimmed.slice("data:".length).trim());
|
|
1244
|
-
}
|
|
1245
|
-
}
|
|
1246
|
-
const data = dataLines.join("\n");
|
|
1277
|
+
const { data, event } = parseSseBlock(block);
|
|
1247
1278
|
if (!data) {
|
|
1248
1279
|
return;
|
|
1249
1280
|
}
|
|
@@ -1328,23 +1359,6 @@ function baseStreamResponse(id, model, createdAt, status, output) {
|
|
|
1328
1359
|
top_p: null
|
|
1329
1360
|
};
|
|
1330
1361
|
}
|
|
1331
|
-
function encodeSse(event, data) {
|
|
1332
|
-
if (data === "[DONE]") {
|
|
1333
|
-
return "data: [DONE]\n\n";
|
|
1334
|
-
}
|
|
1335
|
-
return `event: ${event}
|
|
1336
|
-
data: ${JSON.stringify(data)}
|
|
1337
|
-
|
|
1338
|
-
`;
|
|
1339
|
-
}
|
|
1340
|
-
function encodeDataSse(data) {
|
|
1341
|
-
if (data === "[DONE]") {
|
|
1342
|
-
return "data: [DONE]\n\n";
|
|
1343
|
-
}
|
|
1344
|
-
return `data: ${JSON.stringify(data)}
|
|
1345
|
-
|
|
1346
|
-
`;
|
|
1347
|
-
}
|
|
1348
1362
|
function epochSeconds() {
|
|
1349
1363
|
return Math.floor(Date.now() / 1e3);
|
|
1350
1364
|
}
|
|
@@ -1398,7 +1412,7 @@ function responsesStreamToAnthropicStream(stream, options) {
|
|
|
1398
1412
|
return new ReadableStream({
|
|
1399
1413
|
async start(controller) {
|
|
1400
1414
|
const enqueue = (event, data) => {
|
|
1401
|
-
controller.enqueue(encoder.encode(
|
|
1415
|
+
controller.enqueue(encoder.encode(encodeSseEvent(event, data)));
|
|
1402
1416
|
};
|
|
1403
1417
|
const reader = stream.getReader();
|
|
1404
1418
|
try {
|
|
@@ -1434,7 +1448,7 @@ function responsesSseTextToAnthropicSseText(text, options) {
|
|
|
1434
1448
|
const chunks = [];
|
|
1435
1449
|
const state = createAnthropicStreamState(options);
|
|
1436
1450
|
const enqueue = (event, data) => {
|
|
1437
|
-
chunks.push(
|
|
1451
|
+
chunks.push(encodeSseEvent(event, data));
|
|
1438
1452
|
};
|
|
1439
1453
|
for (const block of text.split(/\r?\n\r?\n/)) {
|
|
1440
1454
|
if (block.trim()) {
|
|
@@ -2018,19 +2032,6 @@ function stopBlock(block, enqueue) {
|
|
|
2018
2032
|
type: "content_block_stop"
|
|
2019
2033
|
});
|
|
2020
2034
|
}
|
|
2021
|
-
function parseSseBlock(block) {
|
|
2022
|
-
let event = "message";
|
|
2023
|
-
const data = [];
|
|
2024
|
-
for (const line of block.split(/\r?\n/)) {
|
|
2025
|
-
const trimmed = line.trim();
|
|
2026
|
-
if (trimmed.startsWith("event:")) {
|
|
2027
|
-
event = trimmed.slice("event:".length).trim() || event;
|
|
2028
|
-
} else if (trimmed.startsWith("data:")) {
|
|
2029
|
-
data.push(trimmed.slice("data:".length).trim());
|
|
2030
|
-
}
|
|
2031
|
-
}
|
|
2032
|
-
return { data: data.join("\n"), event };
|
|
2033
|
-
}
|
|
2034
2035
|
function parseToolInput(argumentsText) {
|
|
2035
2036
|
const parsed = parseJsonObject(argumentsText);
|
|
2036
2037
|
return parsed ?? {};
|
|
@@ -2065,12 +2066,6 @@ function textValue(value) {
|
|
|
2065
2066
|
function indexValue(value) {
|
|
2066
2067
|
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
2067
2068
|
}
|
|
2068
|
-
function encodeSse2(event, data) {
|
|
2069
|
-
return `event: ${event}
|
|
2070
|
-
data: ${JSON.stringify(data)}
|
|
2071
|
-
|
|
2072
|
-
`;
|
|
2073
|
-
}
|
|
2074
2069
|
|
|
2075
2070
|
// src/dashboard.ts
|
|
2076
2071
|
var DASHBOARD_HTML = `<!doctype html>
|
|
@@ -2613,10 +2608,10 @@ footer.foot .end { margin-left:auto; }
|
|
|
2613
2608
|
pollGen += 1; var myGen = pollGen;
|
|
2614
2609
|
if (inflightFetch){ try { inflightFetch.abort(); } catch (e) {} }
|
|
2615
2610
|
var ctrl = new AbortController(); inflightFetch = ctrl;
|
|
2616
|
-
var to = setTimeout(function(){ try { ctrl.abort(); } catch (e) {} },
|
|
2611
|
+
var to = setTimeout(function(){ try { ctrl.abort(); } catch (e) {} }, Math.max(10000, intervalMs * 2));
|
|
2617
2612
|
var headers = { "accept":"application/json" };
|
|
2618
2613
|
if (apiKey) headers["x-api-key"] = apiKey;
|
|
2619
|
-
fetch("/v1/usage", { headers: headers, signal: ctrl.signal, cache:"no-store" }).then(function(res){
|
|
2614
|
+
fetch("/v1/usage?view=dashboard", { headers: headers, signal: ctrl.signal, cache:"no-store" }).then(function(res){
|
|
2620
2615
|
clearTimeout(to);
|
|
2621
2616
|
if (myGen !== pollGen) return null;
|
|
2622
2617
|
if (res.status === 401 || res.status === 403){ inflightFetch = null; showAuth(!!apiKey); return null; }
|
|
@@ -2881,132 +2876,387 @@ footer.foot .end { margin-left:auto; }
|
|
|
2881
2876
|
</html>
|
|
2882
2877
|
`;
|
|
2883
2878
|
|
|
2884
|
-
// src/
|
|
2885
|
-
var
|
|
2886
|
-
var
|
|
2887
|
-
var
|
|
2888
|
-
var
|
|
2889
|
-
var
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
function emptyModelTotals() {
|
|
2894
|
-
return { cached: 0, completion: 0, prompt: 0, reasoning: 0, requests: 0, total: 0 };
|
|
2895
|
-
}
|
|
2896
|
-
var MetricsRegistry = class {
|
|
2897
|
-
#startedAtMs;
|
|
2898
|
-
#inFlight = 0;
|
|
2899
|
-
#requests = /* @__PURE__ */ new Map();
|
|
2900
|
-
#durations = /* @__PURE__ */ new Map();
|
|
2901
|
-
#tokens = /* @__PURE__ */ new Map();
|
|
2902
|
-
#upstream = /* @__PURE__ */ new Map();
|
|
2903
|
-
#copilotQuota;
|
|
2904
|
-
#githubRateLimit = /* @__PURE__ */ new Map();
|
|
2905
|
-
#extraction = { extracted: 0, missing: 0 };
|
|
2906
|
-
constructor(options = {}) {
|
|
2907
|
-
this.#startedAtMs = (options.now ?? Date.now)();
|
|
2879
|
+
// src/http/body.ts
|
|
2880
|
+
var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
|
|
2881
|
+
var JSON_OBJECT_MESSAGE = "Request body must be a JSON object.";
|
|
2882
|
+
var MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024;
|
|
2883
|
+
var REQUEST_TOO_LARGE_MESSAGE = `Request body must be ${MAX_REQUEST_BODY_BYTES} bytes or smaller.`;
|
|
2884
|
+
var RequestBodyTooLargeError = class extends Error {
|
|
2885
|
+
constructor() {
|
|
2886
|
+
super(REQUEST_TOO_LARGE_MESSAGE);
|
|
2887
|
+
this.name = "RequestBodyTooLargeError";
|
|
2908
2888
|
}
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2889
|
+
};
|
|
2890
|
+
var InvalidJsonError = class extends Error {
|
|
2891
|
+
constructor() {
|
|
2892
|
+
super(INVALID_JSON_MESSAGE);
|
|
2893
|
+
this.name = "InvalidJsonError";
|
|
2912
2894
|
}
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
const key = labelKey(observation.route, observation.method, String(observation.status));
|
|
2919
|
-
this.#requests.set(key, (this.#requests.get(key) ?? 0) + 1);
|
|
2920
|
-
this.#observeDuration(observation.route, observation.durationMs / 1e3);
|
|
2895
|
+
};
|
|
2896
|
+
var JsonNotObjectError = class extends Error {
|
|
2897
|
+
constructor() {
|
|
2898
|
+
super(JSON_OBJECT_MESSAGE);
|
|
2899
|
+
this.name = "JsonNotObjectError";
|
|
2921
2900
|
}
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2901
|
+
};
|
|
2902
|
+
async function readJson(request) {
|
|
2903
|
+
const text = await readRequestText(request);
|
|
2904
|
+
return parseJsonObject2(text);
|
|
2905
|
+
}
|
|
2906
|
+
async function readJsonText(request) {
|
|
2907
|
+
const text = await readRequestText(request);
|
|
2908
|
+
return { json: parseJsonObject2(text), text };
|
|
2909
|
+
}
|
|
2910
|
+
async function readRequestText(request) {
|
|
2911
|
+
const contentLength = request.headers.get("content-length");
|
|
2912
|
+
if (contentLength) {
|
|
2913
|
+
const declaredBytes = Number(contentLength);
|
|
2914
|
+
if (Number.isFinite(declaredBytes) && declaredBytes > MAX_REQUEST_BODY_BYTES) {
|
|
2915
|
+
throw new RequestBodyTooLargeError();
|
|
2933
2916
|
}
|
|
2934
2917
|
}
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
const totals = this.#tokens.get(name) ?? emptyModelTotals();
|
|
2939
|
-
totals.requests += 1;
|
|
2940
|
-
totals.prompt += nonNegative(usage.promptTokens);
|
|
2941
|
-
totals.completion += nonNegative(usage.completionTokens);
|
|
2942
|
-
totals.total += nonNegative(usage.totalTokens);
|
|
2943
|
-
totals.reasoning += nonNegative(usage.reasoningTokens ?? 0);
|
|
2944
|
-
totals.cached += nonNegative(usage.cachedTokens ?? 0);
|
|
2945
|
-
this.#tokens.set(name, totals);
|
|
2946
|
-
}
|
|
2947
|
-
/** Record one upstream Copilot call and whether it succeeded. */
|
|
2948
|
-
recordUpstream(path, ok) {
|
|
2949
|
-
const key = labelKey(path, ok ? "ok" : "error");
|
|
2950
|
-
this.#upstream.set(key, (this.#upstream.get(key) ?? 0) + 1);
|
|
2951
|
-
}
|
|
2952
|
-
/** Store the latest Copilot quota so /metrics can expose it as gauges. */
|
|
2953
|
-
recordCopilotQuota(usage) {
|
|
2954
|
-
this.#copilotQuota = usage;
|
|
2955
|
-
}
|
|
2956
|
-
/**
|
|
2957
|
-
* Store the latest GitHub REST rate-limit budget, keyed by its resource bucket.
|
|
2958
|
-
* A no-op when `rateLimit` is undefined (the response carried no rate-limit
|
|
2959
|
-
* headers) so callers can pass {@link parseRateLimitHeaders} output directly.
|
|
2960
|
-
*/
|
|
2961
|
-
recordGithubRateLimit(rateLimit) {
|
|
2962
|
-
if (!rateLimit) {
|
|
2963
|
-
return;
|
|
2964
|
-
}
|
|
2965
|
-
const resource = this.#rateLimitResource(rateLimit.resource);
|
|
2966
|
-
this.#githubRateLimit.set(resource, { ...rateLimit, resource });
|
|
2918
|
+
const body = request.body;
|
|
2919
|
+
if (!body) {
|
|
2920
|
+
return "";
|
|
2967
2921
|
}
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2922
|
+
const reader = body.getReader();
|
|
2923
|
+
const decoder = new TextDecoder();
|
|
2924
|
+
let bytes = 0;
|
|
2925
|
+
const chunks = [];
|
|
2926
|
+
try {
|
|
2927
|
+
while (true) {
|
|
2928
|
+
const { done, value } = await reader.read();
|
|
2929
|
+
if (done) {
|
|
2930
|
+
const tail = decoder.decode();
|
|
2931
|
+
if (tail) {
|
|
2932
|
+
chunks.push(tail);
|
|
2933
|
+
}
|
|
2934
|
+
return chunks.join("");
|
|
2935
|
+
}
|
|
2936
|
+
bytes += value.byteLength;
|
|
2937
|
+
if (bytes > MAX_REQUEST_BODY_BYTES) {
|
|
2938
|
+
await reader.cancel().catch(() => {
|
|
2939
|
+
});
|
|
2940
|
+
throw new RequestBodyTooLargeError();
|
|
2941
|
+
}
|
|
2942
|
+
chunks.push(decoder.decode(value, { stream: true }));
|
|
2975
2943
|
}
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
// The model can originate from a (possibly hostile) client request.
|
|
2979
|
-
#modelLabel(model) {
|
|
2980
|
-
return this.#boundedLabel(model, this.#tokens, MAX_TRACKED_MODELS);
|
|
2944
|
+
} finally {
|
|
2945
|
+
reader.releaseLock();
|
|
2981
2946
|
}
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2947
|
+
}
|
|
2948
|
+
function parseJsonObject2(text) {
|
|
2949
|
+
let parsed;
|
|
2950
|
+
try {
|
|
2951
|
+
parsed = JSON.parse(text);
|
|
2952
|
+
} catch {
|
|
2953
|
+
throw new InvalidJsonError();
|
|
2985
2954
|
}
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
const entry = this.#durations.get(route) ?? {
|
|
2989
|
-
buckets: new Array(DURATION_BUCKETS_SECONDS.length).fill(0),
|
|
2990
|
-
count: 0,
|
|
2991
|
-
sum: 0
|
|
2992
|
-
};
|
|
2993
|
-
entry.count += 1;
|
|
2994
|
-
entry.sum += value;
|
|
2995
|
-
const index = DURATION_BUCKETS_SECONDS.findIndex((bound) => value <= bound);
|
|
2996
|
-
if (index !== -1) {
|
|
2997
|
-
entry.buckets[index] = (entry.buckets[index] ?? 0) + 1;
|
|
2998
|
-
}
|
|
2999
|
-
this.#durations.set(route, entry);
|
|
2955
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
2956
|
+
throw new JsonNotObjectError();
|
|
3000
2957
|
}
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
2958
|
+
return parsed;
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
// src/http/security.ts
|
|
2962
|
+
import { createHash, timingSafeEqual } from "crypto";
|
|
2963
|
+
var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Cross-origin browser requests are blocked unless the Origin is loopback or listed in HOOPILOT_ALLOWED_ORIGINS.";
|
|
2964
|
+
var MIN_NON_LOOPBACK_API_KEY_LENGTH = 24;
|
|
2965
|
+
var WELL_KNOWN_DEMO_API_KEYS = /* @__PURE__ */ new Set([
|
|
2966
|
+
"changeme",
|
|
2967
|
+
"demo",
|
|
2968
|
+
"example",
|
|
2969
|
+
"hoopilot",
|
|
2970
|
+
"local-key",
|
|
2971
|
+
"password",
|
|
2972
|
+
"password123",
|
|
2973
|
+
"secret",
|
|
2974
|
+
"test"
|
|
2975
|
+
]);
|
|
2976
|
+
function corsHeaders() {
|
|
2977
|
+
return {
|
|
2978
|
+
"access-control-allow-headers": "anthropic-beta, anthropic-dangerous-direct-browser-access, anthropic-version, authorization, content-type, x-api-key, x-request-id",
|
|
2979
|
+
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
2980
|
+
"access-control-expose-headers": "x-request-id"
|
|
2981
|
+
};
|
|
2982
|
+
}
|
|
2983
|
+
function isAuthorized(request, apiKey) {
|
|
2984
|
+
if (!apiKey) {
|
|
2985
|
+
return true;
|
|
2986
|
+
}
|
|
2987
|
+
const authorization = request.headers.get("authorization") ?? "";
|
|
2988
|
+
const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1];
|
|
2989
|
+
return bearer !== void 0 && secretEquals(bearer, apiKey) || secretEquals(request.headers.get("x-api-key") ?? "", apiKey);
|
|
2990
|
+
}
|
|
2991
|
+
function forbiddenBrowserOrigin(origin, request, allowedOrigins) {
|
|
2992
|
+
if (origin) {
|
|
2993
|
+
return isAllowedOrigin(origin, allowedOrigins) ? void 0 : origin;
|
|
2994
|
+
}
|
|
2995
|
+
const fetchSite = request.headers.get("sec-fetch-site")?.toLowerCase();
|
|
2996
|
+
return fetchSite === "cross-site" ? "cross-site" : void 0;
|
|
2997
|
+
}
|
|
2998
|
+
function parseAllowedOrigins(env) {
|
|
2999
|
+
const raw = envValue(env?.HOOPILOT_ALLOWED_ORIGINS);
|
|
3000
|
+
if (!raw) {
|
|
3001
|
+
return /* @__PURE__ */ new Set();
|
|
3002
|
+
}
|
|
3003
|
+
return new Set(
|
|
3004
|
+
raw.split(",").map((value) => value.trim().toLowerCase()).filter((value) => value.length > 0)
|
|
3005
|
+
);
|
|
3006
|
+
}
|
|
3007
|
+
function resolveCorsAllowOrigin(origin, allowedOrigins) {
|
|
3008
|
+
if (!origin) {
|
|
3009
|
+
return "*";
|
|
3010
|
+
}
|
|
3011
|
+
return isAllowedOrigin(origin, allowedOrigins) ? origin : void 0;
|
|
3012
|
+
}
|
|
3013
|
+
function apiKeyRejectionReason(apiKey) {
|
|
3014
|
+
const normalized = apiKey.trim();
|
|
3015
|
+
if (WELL_KNOWN_DEMO_API_KEYS.has(normalized.toLowerCase())) {
|
|
3016
|
+
return "HOOPILOT_API_KEY is a well-known demo value. Set a strong, unique API key.";
|
|
3017
|
+
}
|
|
3018
|
+
if (normalized.length < MIN_NON_LOOPBACK_API_KEY_LENGTH) {
|
|
3019
|
+
return `HOOPILOT_API_KEY must be at least ${MIN_NON_LOOPBACK_API_KEY_LENGTH} characters when listening on a non-loopback host.`;
|
|
3020
|
+
}
|
|
3021
|
+
if (/^(.)\1+$/.test(normalized)) {
|
|
3022
|
+
return "HOOPILOT_API_KEY must not be a repeated single character. Set a strong, unique API key.";
|
|
3023
|
+
}
|
|
3024
|
+
return void 0;
|
|
3025
|
+
}
|
|
3026
|
+
function isLoopbackHost(host) {
|
|
3027
|
+
return isLoopbackHostname(host);
|
|
3028
|
+
}
|
|
3029
|
+
function urlHost(host) {
|
|
3030
|
+
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
3031
|
+
}
|
|
3032
|
+
function secretEquals(candidate, secret) {
|
|
3033
|
+
const a = createHash("sha256").update(candidate).digest();
|
|
3034
|
+
const b = createHash("sha256").update(secret).digest();
|
|
3035
|
+
return timingSafeEqual(a, b);
|
|
3036
|
+
}
|
|
3037
|
+
function isAllowedOrigin(origin, allowedOrigins) {
|
|
3038
|
+
return isLoopbackOrigin(origin) || allowedOrigins.has(origin.toLowerCase());
|
|
3039
|
+
}
|
|
3040
|
+
function isLoopbackOrigin(origin) {
|
|
3041
|
+
try {
|
|
3042
|
+
return isLoopbackHost(new URL(origin).hostname.toLowerCase());
|
|
3043
|
+
} catch {
|
|
3044
|
+
return false;
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
3047
|
+
|
|
3048
|
+
// src/http/responses.ts
|
|
3049
|
+
function jsonResponse(body, status = 200) {
|
|
3050
|
+
return new Response(JSON.stringify(body), {
|
|
3051
|
+
headers: {
|
|
3052
|
+
...corsHeaders(),
|
|
3053
|
+
"content-type": "application/json; charset=utf-8"
|
|
3054
|
+
},
|
|
3055
|
+
status
|
|
3056
|
+
});
|
|
3057
|
+
}
|
|
3058
|
+
function textResponse(body, contentType, status = 200) {
|
|
3059
|
+
return new Response(body, {
|
|
3060
|
+
headers: {
|
|
3061
|
+
...corsHeaders(),
|
|
3062
|
+
"content-type": `${contentType}; charset=utf-8`
|
|
3063
|
+
},
|
|
3064
|
+
status
|
|
3065
|
+
});
|
|
3066
|
+
}
|
|
3067
|
+
function jsonError(status, code, message) {
|
|
3068
|
+
return jsonResponse(
|
|
3069
|
+
{
|
|
3070
|
+
error: {
|
|
3071
|
+
code,
|
|
3072
|
+
message,
|
|
3073
|
+
type: code
|
|
3074
|
+
}
|
|
3075
|
+
},
|
|
3076
|
+
status
|
|
3077
|
+
);
|
|
3078
|
+
}
|
|
3079
|
+
function responseFromText(source, text) {
|
|
3080
|
+
return new Response(text, {
|
|
3081
|
+
headers: source.headers,
|
|
3082
|
+
status: source.status,
|
|
3083
|
+
statusText: source.statusText
|
|
3084
|
+
});
|
|
3085
|
+
}
|
|
3086
|
+
function proxyResponse(upstream) {
|
|
3087
|
+
const headers = new Headers(upstream.headers);
|
|
3088
|
+
headers.delete("content-encoding");
|
|
3089
|
+
headers.delete("content-length");
|
|
3090
|
+
headers.delete("transfer-encoding");
|
|
3091
|
+
for (const [key, value] of Object.entries(corsHeaders())) {
|
|
3092
|
+
headers.set(key, value);
|
|
3093
|
+
}
|
|
3094
|
+
return new Response(upstream.body, {
|
|
3095
|
+
headers,
|
|
3096
|
+
status: upstream.status,
|
|
3097
|
+
statusText: upstream.statusText
|
|
3098
|
+
});
|
|
3099
|
+
}
|
|
3100
|
+
function upstreamErrorResponse(status, text) {
|
|
3101
|
+
const parsedError = asRecord(asRecord(safeJsonParse(text)).error);
|
|
3102
|
+
if (Object.keys(parsedError).length > 0) {
|
|
3103
|
+
return jsonResponse({ error: parsedError }, status);
|
|
3104
|
+
}
|
|
3105
|
+
return jsonError(status, "copilot_error", text);
|
|
3106
|
+
}
|
|
3107
|
+
function websocketUnsupportedResponse() {
|
|
3108
|
+
const response = jsonError(
|
|
3109
|
+
426,
|
|
3110
|
+
"websocket_not_supported",
|
|
3111
|
+
"Hoopilot does not support Responses WebSocket transport; retry with HTTP Responses API."
|
|
3112
|
+
);
|
|
3113
|
+
response.headers.set("upgrade", "websocket");
|
|
3114
|
+
return response;
|
|
3115
|
+
}
|
|
3116
|
+
|
|
3117
|
+
// src/metrics.ts
|
|
3118
|
+
var PROMETHEUS_CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8";
|
|
3119
|
+
var DURATION_BUCKETS_SECONDS = [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60];
|
|
3120
|
+
var USAGE_BUFFER_LIMIT_BYTES = 16 * 1024 * 1024;
|
|
3121
|
+
var MAX_TRACKED_MODELS = 200;
|
|
3122
|
+
var MAX_MODEL_LABEL_LENGTH = 200;
|
|
3123
|
+
var MAX_TRACKED_RATELIMIT_RESOURCES = 32;
|
|
3124
|
+
var LABEL_SEPARATOR = "";
|
|
3125
|
+
var UNKNOWN_MODEL = "unknown";
|
|
3126
|
+
function emptyModelTotals() {
|
|
3127
|
+
return { cached: 0, completion: 0, prompt: 0, reasoning: 0, requests: 0, total: 0 };
|
|
3128
|
+
}
|
|
3129
|
+
var MetricsRegistry = class {
|
|
3130
|
+
#startedAtMs;
|
|
3131
|
+
#inFlight = 0;
|
|
3132
|
+
#requests = /* @__PURE__ */ new Map();
|
|
3133
|
+
#durations = /* @__PURE__ */ new Map();
|
|
3134
|
+
#inFlightByRoute = /* @__PURE__ */ new Map();
|
|
3135
|
+
#tokens = /* @__PURE__ */ new Map();
|
|
3136
|
+
#upstream = /* @__PURE__ */ new Map();
|
|
3137
|
+
#copilotQuota;
|
|
3138
|
+
#githubRateLimit = /* @__PURE__ */ new Map();
|
|
3139
|
+
#extraction = { extracted: 0, missing: 0 };
|
|
3140
|
+
constructor(options = {}) {
|
|
3141
|
+
this.#startedAtMs = (options.now ?? Date.now)();
|
|
3142
|
+
}
|
|
3143
|
+
/** Mark a request as started; pair with exactly one {@link observe}. */
|
|
3144
|
+
startRequest(route) {
|
|
3145
|
+
this.#inFlight += 1;
|
|
3146
|
+
if (route) {
|
|
3147
|
+
this.#inFlightByRoute.set(route, (this.#inFlightByRoute.get(route) ?? 0) + 1);
|
|
3148
|
+
}
|
|
3149
|
+
}
|
|
3150
|
+
/** Record a completed request and clear its in-flight slot. */
|
|
3151
|
+
observe(observation) {
|
|
3152
|
+
if (this.#inFlight > 0) {
|
|
3153
|
+
this.#inFlight -= 1;
|
|
3154
|
+
}
|
|
3155
|
+
const inFlightForRoute = this.#inFlightByRoute.get(observation.route) ?? 0;
|
|
3156
|
+
if (inFlightForRoute > 1) {
|
|
3157
|
+
this.#inFlightByRoute.set(observation.route, inFlightForRoute - 1);
|
|
3158
|
+
} else if (inFlightForRoute === 1) {
|
|
3159
|
+
this.#inFlightByRoute.delete(observation.route);
|
|
3160
|
+
}
|
|
3161
|
+
const key = labelKey(observation.route, observation.method, String(observation.status));
|
|
3162
|
+
this.#requests.set(key, (this.#requests.get(key) ?? 0) + 1);
|
|
3163
|
+
this.#observeDuration(observation.route, observation.durationMs / 1e3);
|
|
3164
|
+
}
|
|
3165
|
+
/**
|
|
3166
|
+
* Record whether one upstream completion reported token usage. `missing`
|
|
3167
|
+
* counts responses that carried no usage object — most often streamed Chat
|
|
3168
|
+
* Completions sent without `stream_options: {"include_usage": true}` — so a
|
|
3169
|
+
* rising miss rate flags clients whose token usage is going unaccounted.
|
|
3170
|
+
*/
|
|
3171
|
+
recordTokenExtraction(extracted) {
|
|
3172
|
+
if (extracted) {
|
|
3173
|
+
this.#extraction.extracted += 1;
|
|
3174
|
+
} else {
|
|
3175
|
+
this.#extraction.missing += 1;
|
|
3176
|
+
}
|
|
3177
|
+
}
|
|
3178
|
+
/** Accumulate token counts for a model from one upstream completion. */
|
|
3179
|
+
recordTokens(model, usage) {
|
|
3180
|
+
const name = this.#modelLabel(model);
|
|
3181
|
+
const totals = this.#tokens.get(name) ?? emptyModelTotals();
|
|
3182
|
+
totals.requests += 1;
|
|
3183
|
+
totals.prompt += nonNegative(usage.promptTokens);
|
|
3184
|
+
totals.completion += nonNegative(usage.completionTokens);
|
|
3185
|
+
totals.total += nonNegative(usage.totalTokens);
|
|
3186
|
+
totals.reasoning += nonNegative(usage.reasoningTokens ?? 0);
|
|
3187
|
+
totals.cached += nonNegative(usage.cachedTokens ?? 0);
|
|
3188
|
+
this.#tokens.set(name, totals);
|
|
3189
|
+
}
|
|
3190
|
+
/** Record one upstream Copilot call and whether it succeeded. */
|
|
3191
|
+
recordUpstream(path, ok) {
|
|
3192
|
+
const key = labelKey(path, ok ? "ok" : "error");
|
|
3193
|
+
this.#upstream.set(key, (this.#upstream.get(key) ?? 0) + 1);
|
|
3194
|
+
}
|
|
3195
|
+
/** Store the latest Copilot quota so /metrics can expose it as gauges. */
|
|
3196
|
+
recordCopilotQuota(usage) {
|
|
3197
|
+
this.#copilotQuota = usage;
|
|
3198
|
+
}
|
|
3199
|
+
/**
|
|
3200
|
+
* Store the latest GitHub REST rate-limit budget, keyed by its resource bucket.
|
|
3201
|
+
* A no-op when `rateLimit` is undefined (the response carried no rate-limit
|
|
3202
|
+
* headers) so callers can pass {@link parseRateLimitHeaders} output directly.
|
|
3203
|
+
*/
|
|
3204
|
+
recordGithubRateLimit(rateLimit) {
|
|
3205
|
+
if (!rateLimit) {
|
|
3206
|
+
return;
|
|
3207
|
+
}
|
|
3208
|
+
const resource = this.#rateLimitResource(rateLimit.resource);
|
|
3209
|
+
this.#githubRateLimit.set(resource, { ...rateLimit, resource });
|
|
3210
|
+
}
|
|
3211
|
+
// Clean a raw value into a bounded exposition-format label: cap its length,
|
|
3212
|
+
// strip characters that would corrupt the format, and fold overflow past the
|
|
3213
|
+
// cardinality limit into UNKNOWN_MODEL so the series count stays bounded.
|
|
3214
|
+
#boundedLabel(value, tracked, maxEntries) {
|
|
3215
|
+
const cleaned = cleanLabel(value).slice(0, MAX_MODEL_LABEL_LENGTH) || UNKNOWN_MODEL;
|
|
3216
|
+
if (!tracked.has(cleaned) && tracked.size >= maxEntries) {
|
|
3217
|
+
return UNKNOWN_MODEL;
|
|
3218
|
+
}
|
|
3219
|
+
return cleaned;
|
|
3220
|
+
}
|
|
3221
|
+
// The model can originate from a (possibly hostile) client request.
|
|
3222
|
+
#modelLabel(model) {
|
|
3223
|
+
return this.#boundedLabel(model, this.#tokens, MAX_TRACKED_MODELS);
|
|
3224
|
+
}
|
|
3225
|
+
// The resource comes from a trusted upstream header, but is bounded the same way.
|
|
3226
|
+
#rateLimitResource(resource) {
|
|
3227
|
+
return this.#boundedLabel(resource, this.#githubRateLimit, MAX_TRACKED_RATELIMIT_RESOURCES);
|
|
3228
|
+
}
|
|
3229
|
+
#observeDuration(route, seconds) {
|
|
3230
|
+
const value = Number.isFinite(seconds) && seconds >= 0 ? seconds : 0;
|
|
3231
|
+
const entry = this.#durations.get(route) ?? {
|
|
3232
|
+
buckets: new Array(DURATION_BUCKETS_SECONDS.length).fill(0),
|
|
3233
|
+
count: 0,
|
|
3234
|
+
sum: 0
|
|
3235
|
+
};
|
|
3236
|
+
entry.count += 1;
|
|
3237
|
+
entry.sum += value;
|
|
3238
|
+
const index = DURATION_BUCKETS_SECONDS.findIndex((bound) => value <= bound);
|
|
3239
|
+
if (index !== -1) {
|
|
3240
|
+
entry.buckets[index] = (entry.buckets[index] ?? 0) + 1;
|
|
3241
|
+
}
|
|
3242
|
+
this.#durations.set(route, entry);
|
|
3243
|
+
}
|
|
3244
|
+
/** A JSON-friendly view of the current counters. */
|
|
3245
|
+
snapshot(nowOrOptions = Date.now) {
|
|
3246
|
+
const options = typeof nowOrOptions === "function" ? { now: nowOrOptions } : nowOrOptions;
|
|
3247
|
+
const now = options.now ?? Date.now;
|
|
3248
|
+
const excludeRoutes = new Set(options.excludeRoutes ?? []);
|
|
3249
|
+
const excludeUpstreamPaths = new Set(options.excludeUpstreamPaths ?? []);
|
|
3250
|
+
const byRoute = {};
|
|
3251
|
+
const byStatus = {};
|
|
3252
|
+
let requestsTotal = 0;
|
|
3253
|
+
for (const [key, count] of this.#requests) {
|
|
3254
|
+
const [route = "", , status = ""] = key.split(LABEL_SEPARATOR);
|
|
3255
|
+
if (excludeRoutes.has(route)) {
|
|
3256
|
+
continue;
|
|
3257
|
+
}
|
|
3258
|
+
byRoute[route] = (byRoute[route] ?? 0) + count;
|
|
3259
|
+
byStatus[status] = (byStatus[status] ?? 0) + count;
|
|
3010
3260
|
requestsTotal += count;
|
|
3011
3261
|
}
|
|
3012
3262
|
const byModel = {};
|
|
@@ -3022,8 +3272,12 @@ var MetricsRegistry = class {
|
|
|
3022
3272
|
let upstreamTotal = 0;
|
|
3023
3273
|
let upstreamErrors = 0;
|
|
3024
3274
|
for (const [key, count] of this.#upstream) {
|
|
3275
|
+
const [path = "", outcome = ""] = key.split(LABEL_SEPARATOR);
|
|
3276
|
+
if (excludeUpstreamPaths.has(path)) {
|
|
3277
|
+
continue;
|
|
3278
|
+
}
|
|
3025
3279
|
upstreamTotal += count;
|
|
3026
|
-
if (
|
|
3280
|
+
if (outcome === "error") {
|
|
3027
3281
|
upstreamErrors += count;
|
|
3028
3282
|
}
|
|
3029
3283
|
}
|
|
@@ -3033,8 +3287,8 @@ var MetricsRegistry = class {
|
|
|
3033
3287
|
}
|
|
3034
3288
|
return {
|
|
3035
3289
|
githubRateLimit,
|
|
3036
|
-
inFlight: this.#
|
|
3037
|
-
latency: this.#latencySnapshot(),
|
|
3290
|
+
inFlight: this.#filteredInFlight(excludeRoutes),
|
|
3291
|
+
latency: this.#latencySnapshot(excludeRoutes),
|
|
3038
3292
|
requests: { byRoute, byStatus, total: requestsTotal },
|
|
3039
3293
|
startedAt: new Date(this.#startedAtMs).toISOString(),
|
|
3040
3294
|
tokens: { byModel, extraction: { ...this.#extraction }, ...tokenTotals },
|
|
@@ -3042,15 +3296,30 @@ var MetricsRegistry = class {
|
|
|
3042
3296
|
uptimeSeconds: Math.max(0, Math.round((now() - this.#startedAtMs) / 1e3))
|
|
3043
3297
|
};
|
|
3044
3298
|
}
|
|
3299
|
+
#filteredInFlight(excludeRoutes) {
|
|
3300
|
+
if (excludeRoutes.size === 0) {
|
|
3301
|
+
return this.#inFlight;
|
|
3302
|
+
}
|
|
3303
|
+
let excluded = 0;
|
|
3304
|
+
for (const [route, count] of this.#inFlightByRoute) {
|
|
3305
|
+
if (excludeRoutes.has(route)) {
|
|
3306
|
+
excluded += count;
|
|
3307
|
+
}
|
|
3308
|
+
}
|
|
3309
|
+
return Math.max(0, this.#inFlight - excluded);
|
|
3310
|
+
}
|
|
3045
3311
|
// Summarize the duration histogram into a JSON latency view: per-route count and
|
|
3046
3312
|
// exact average, plus overall average and estimated p50/p95. The percentiles come
|
|
3047
3313
|
// from the buckets aggregated across routes, so they share /metrics' resolution.
|
|
3048
|
-
#latencySnapshot() {
|
|
3314
|
+
#latencySnapshot(excludeRoutes = /* @__PURE__ */ new Set()) {
|
|
3049
3315
|
const byRoute = {};
|
|
3050
3316
|
const aggregateBuckets = new Array(DURATION_BUCKETS_SECONDS.length).fill(0);
|
|
3051
3317
|
let totalCount = 0;
|
|
3052
3318
|
let totalSum = 0;
|
|
3053
3319
|
for (const [route, entry] of this.#durations) {
|
|
3320
|
+
if (excludeRoutes.has(route)) {
|
|
3321
|
+
continue;
|
|
3322
|
+
}
|
|
3054
3323
|
byRoute[route] = {
|
|
3055
3324
|
avgMs: entry.count > 0 ? round2(entry.sum / entry.count * 1e3) : 0,
|
|
3056
3325
|
count: entry.count
|
|
@@ -3448,11 +3717,7 @@ function safeFinishAccumulator(accumulator) {
|
|
|
3448
3717
|
}
|
|
3449
3718
|
}
|
|
3450
3719
|
function considerSseLine(line, consider) {
|
|
3451
|
-
const
|
|
3452
|
-
if (!trimmed.startsWith("data:")) {
|
|
3453
|
-
return;
|
|
3454
|
-
}
|
|
3455
|
-
const data = trimmed.slice("data:".length).trim();
|
|
3720
|
+
const data = sseDataFromLine(line);
|
|
3456
3721
|
if (!data || data === "[DONE]") {
|
|
3457
3722
|
return;
|
|
3458
3723
|
}
|
|
@@ -3564,43 +3829,17 @@ async function getVersion() {
|
|
|
3564
3829
|
// src/server.ts
|
|
3565
3830
|
var DEFAULT_HOST = "127.0.0.1";
|
|
3566
3831
|
var DEFAULT_PORT = 4141;
|
|
3567
|
-
var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Cross-origin browser requests are blocked unless the Origin is loopback or listed in HOOPILOT_ALLOWED_ORIGINS.";
|
|
3568
|
-
var MIN_NON_LOOPBACK_API_KEY_LENGTH = 24;
|
|
3569
|
-
var WELL_KNOWN_DEMO_API_KEYS = /* @__PURE__ */ new Set([
|
|
3570
|
-
"changeme",
|
|
3571
|
-
"demo",
|
|
3572
|
-
"example",
|
|
3573
|
-
"hoopilot",
|
|
3574
|
-
"local-key",
|
|
3575
|
-
"password",
|
|
3576
|
-
"password123",
|
|
3577
|
-
"secret",
|
|
3578
|
-
"test"
|
|
3579
|
-
]);
|
|
3580
|
-
var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
|
|
3581
|
-
var JSON_OBJECT_MESSAGE = "Request body must be a JSON object.";
|
|
3582
|
-
var MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024;
|
|
3583
3832
|
var REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
3584
|
-
var REQUEST_TOO_LARGE_MESSAGE = `Request body must be ${MAX_REQUEST_BODY_BYTES} bytes or smaller.`;
|
|
3585
3833
|
var USAGE_CACHE_TTL_MS = 6e4;
|
|
3586
|
-
var
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
this.name = "InvalidJsonError";
|
|
3596
|
-
}
|
|
3597
|
-
};
|
|
3598
|
-
var JsonNotObjectError = class extends Error {
|
|
3599
|
-
constructor() {
|
|
3600
|
-
super(JSON_OBJECT_MESSAGE);
|
|
3601
|
-
this.name = "JsonNotObjectError";
|
|
3602
|
-
}
|
|
3603
|
-
};
|
|
3834
|
+
var DASHBOARD_USAGE_VIEW = "dashboard";
|
|
3835
|
+
var DASHBOARD_EXCLUDED_ROUTES = [
|
|
3836
|
+
"cors.preflight",
|
|
3837
|
+
"dashboard",
|
|
3838
|
+
"health",
|
|
3839
|
+
"metrics",
|
|
3840
|
+
"usage"
|
|
3841
|
+
];
|
|
3842
|
+
var DASHBOARD_EXCLUDED_UPSTREAM_PATHS = ["/copilot_internal/user"];
|
|
3604
3843
|
function createHoopilotHandler(options = {}) {
|
|
3605
3844
|
const client = new CopilotClient(options);
|
|
3606
3845
|
const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
|
|
@@ -3635,7 +3874,7 @@ function createHoopilotHandler(options = {}) {
|
|
|
3635
3874
|
requestId,
|
|
3636
3875
|
route
|
|
3637
3876
|
});
|
|
3638
|
-
metrics.startRequest();
|
|
3877
|
+
metrics.startRequest(route);
|
|
3639
3878
|
const origin = request.headers.get("origin")?.trim() || void 0;
|
|
3640
3879
|
const corsOrigin = resolveCorsAllowOrigin(origin, allowedOrigins);
|
|
3641
3880
|
const inner = normalizeInnerRequest(request, apiPath, url);
|
|
@@ -3758,7 +3997,7 @@ function buildApp(deps) {
|
|
|
3758
3997
|
}
|
|
3759
3998
|
logger.error({ err: errorDetails(error), event: "http.request.failed" }, "request failed");
|
|
3760
3999
|
return jsonError(500, "internal_error", message);
|
|
3761
|
-
}).get("/", () => jsonResponse({ name: "hoopilot", object: "health", status: "ok" })).get("/healthz", () => jsonResponse({ name: "hoopilot", object: "health", status: "ok" })).get("/metrics", () => metricsResponse(metrics)).get("/v1/usage", ({ request }) => handleUsage(metrics, readUsage, request
|
|
4000
|
+
}).get("/", () => jsonResponse({ name: "hoopilot", object: "health", status: "ok" })).get("/healthz", () => jsonResponse({ name: "hoopilot", object: "health", status: "ok" })).get("/metrics", () => metricsResponse(metrics)).get("/v1/usage", ({ request }) => handleUsage(metrics, readUsage, request)).get(
|
|
3762
4001
|
"/v1/models",
|
|
3763
4002
|
({ request }) => handleModels(client, metrics, request.signal, loggerFor(request))
|
|
3764
4003
|
).get("/v1/responses", () => websocketUnsupportedResponse()).post(
|
|
@@ -4083,13 +4322,6 @@ async function responseWithObservedUsage(response, fallbackModel, recordTokens,
|
|
|
4083
4322
|
}
|
|
4084
4323
|
return observeResponseUsage(response, fallbackModel, recordTokens, signal, recordExtraction);
|
|
4085
4324
|
}
|
|
4086
|
-
function responseFromText(source, text) {
|
|
4087
|
-
return new Response(text, {
|
|
4088
|
-
headers: source.headers,
|
|
4089
|
-
status: source.status,
|
|
4090
|
-
statusText: source.statusText
|
|
4091
|
-
});
|
|
4092
|
-
}
|
|
4093
4325
|
async function proxyError(upstream, logger) {
|
|
4094
4326
|
const text = await upstream.text();
|
|
4095
4327
|
if (isUpstreamAuthStatus(upstream.status)) {
|
|
@@ -4105,201 +4337,12 @@ async function proxyError(upstream, logger) {
|
|
|
4105
4337
|
);
|
|
4106
4338
|
return upstreamErrorResponse(upstream.status, text || upstream.statusText);
|
|
4107
4339
|
}
|
|
4108
|
-
function proxyResponse(upstream) {
|
|
4109
|
-
const headers = new Headers(upstream.headers);
|
|
4110
|
-
headers.delete("content-encoding");
|
|
4111
|
-
headers.delete("content-length");
|
|
4112
|
-
headers.delete("transfer-encoding");
|
|
4113
|
-
for (const [key, value] of Object.entries(corsHeaders())) {
|
|
4114
|
-
headers.set(key, value);
|
|
4115
|
-
}
|
|
4116
|
-
return new Response(upstream.body, {
|
|
4117
|
-
headers,
|
|
4118
|
-
status: upstream.status,
|
|
4119
|
-
statusText: upstream.statusText
|
|
4120
|
-
});
|
|
4121
|
-
}
|
|
4122
|
-
async function readJson(request) {
|
|
4123
|
-
const text = await readRequestText(request);
|
|
4124
|
-
return parseJsonObject2(text);
|
|
4125
|
-
}
|
|
4126
|
-
function parseJsonObject2(text) {
|
|
4127
|
-
let parsed;
|
|
4128
|
-
try {
|
|
4129
|
-
parsed = JSON.parse(text);
|
|
4130
|
-
} catch {
|
|
4131
|
-
throw new InvalidJsonError();
|
|
4132
|
-
}
|
|
4133
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
4134
|
-
throw new JsonNotObjectError();
|
|
4135
|
-
}
|
|
4136
|
-
return parsed;
|
|
4137
|
-
}
|
|
4138
|
-
async function readJsonText(request) {
|
|
4139
|
-
const text = await readRequestText(request);
|
|
4140
|
-
return { json: parseJsonObject2(text), text };
|
|
4141
|
-
}
|
|
4142
|
-
async function readRequestText(request) {
|
|
4143
|
-
const contentLength = request.headers.get("content-length");
|
|
4144
|
-
if (contentLength) {
|
|
4145
|
-
const declaredBytes = Number(contentLength);
|
|
4146
|
-
if (Number.isFinite(declaredBytes) && declaredBytes > MAX_REQUEST_BODY_BYTES) {
|
|
4147
|
-
throw new RequestBodyTooLargeError();
|
|
4148
|
-
}
|
|
4149
|
-
}
|
|
4150
|
-
const body = request.body;
|
|
4151
|
-
if (!body) {
|
|
4152
|
-
return "";
|
|
4153
|
-
}
|
|
4154
|
-
const reader = body.getReader();
|
|
4155
|
-
const decoder = new TextDecoder();
|
|
4156
|
-
let bytes = 0;
|
|
4157
|
-
const chunks = [];
|
|
4158
|
-
try {
|
|
4159
|
-
while (true) {
|
|
4160
|
-
const { done, value } = await reader.read();
|
|
4161
|
-
if (done) {
|
|
4162
|
-
const tail = decoder.decode();
|
|
4163
|
-
if (tail) {
|
|
4164
|
-
chunks.push(tail);
|
|
4165
|
-
}
|
|
4166
|
-
return chunks.join("");
|
|
4167
|
-
}
|
|
4168
|
-
bytes += value.byteLength;
|
|
4169
|
-
if (bytes > MAX_REQUEST_BODY_BYTES) {
|
|
4170
|
-
await reader.cancel().catch(() => {
|
|
4171
|
-
});
|
|
4172
|
-
throw new RequestBodyTooLargeError();
|
|
4173
|
-
}
|
|
4174
|
-
chunks.push(decoder.decode(value, { stream: true }));
|
|
4175
|
-
}
|
|
4176
|
-
} finally {
|
|
4177
|
-
reader.releaseLock();
|
|
4178
|
-
}
|
|
4179
|
-
}
|
|
4180
|
-
function jsonResponse(body, status = 200) {
|
|
4181
|
-
return new Response(JSON.stringify(body), {
|
|
4182
|
-
headers: {
|
|
4183
|
-
...corsHeaders(),
|
|
4184
|
-
"content-type": "application/json; charset=utf-8"
|
|
4185
|
-
},
|
|
4186
|
-
status
|
|
4187
|
-
});
|
|
4188
|
-
}
|
|
4189
|
-
function textResponse(body, contentType, status = 200) {
|
|
4190
|
-
return new Response(body, {
|
|
4191
|
-
headers: {
|
|
4192
|
-
...corsHeaders(),
|
|
4193
|
-
"content-type": `${contentType}; charset=utf-8`
|
|
4194
|
-
},
|
|
4195
|
-
status
|
|
4196
|
-
});
|
|
4197
|
-
}
|
|
4198
|
-
function jsonError(status, code, message) {
|
|
4199
|
-
return jsonResponse(
|
|
4200
|
-
{
|
|
4201
|
-
error: {
|
|
4202
|
-
code,
|
|
4203
|
-
message,
|
|
4204
|
-
type: code
|
|
4205
|
-
}
|
|
4206
|
-
},
|
|
4207
|
-
status
|
|
4208
|
-
);
|
|
4209
|
-
}
|
|
4210
|
-
function upstreamErrorResponse(status, text) {
|
|
4211
|
-
const parsedError = asRecord(asRecord(safeJsonParse(text)).error);
|
|
4212
|
-
if (Object.keys(parsedError).length > 0) {
|
|
4213
|
-
return jsonResponse({ error: parsedError }, status);
|
|
4214
|
-
}
|
|
4215
|
-
return jsonError(status, "copilot_error", text);
|
|
4216
|
-
}
|
|
4217
|
-
function websocketUnsupportedResponse() {
|
|
4218
|
-
const response = jsonError(
|
|
4219
|
-
426,
|
|
4220
|
-
"websocket_not_supported",
|
|
4221
|
-
"Hoopilot does not support Responses WebSocket transport; retry with HTTP Responses API."
|
|
4222
|
-
);
|
|
4223
|
-
response.headers.set("upgrade", "websocket");
|
|
4224
|
-
return response;
|
|
4225
|
-
}
|
|
4226
|
-
function corsHeaders() {
|
|
4227
|
-
return {
|
|
4228
|
-
"access-control-allow-headers": "anthropic-beta, anthropic-dangerous-direct-browser-access, anthropic-version, authorization, content-type, x-api-key, x-request-id",
|
|
4229
|
-
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
4230
|
-
"access-control-expose-headers": "x-request-id"
|
|
4231
|
-
};
|
|
4232
|
-
}
|
|
4233
|
-
function secretEquals(candidate, secret) {
|
|
4234
|
-
const a = createHash("sha256").update(candidate).digest();
|
|
4235
|
-
const b = createHash("sha256").update(secret).digest();
|
|
4236
|
-
return timingSafeEqual(a, b);
|
|
4237
|
-
}
|
|
4238
|
-
function isAuthorized(request, apiKey) {
|
|
4239
|
-
if (!apiKey) {
|
|
4240
|
-
return true;
|
|
4241
|
-
}
|
|
4242
|
-
const authorization = request.headers.get("authorization") ?? "";
|
|
4243
|
-
const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1];
|
|
4244
|
-
return bearer !== void 0 && secretEquals(bearer, apiKey) || secretEquals(request.headers.get("x-api-key") ?? "", apiKey);
|
|
4245
|
-
}
|
|
4246
|
-
function forbiddenBrowserOrigin(origin, request, allowedOrigins) {
|
|
4247
|
-
if (origin) {
|
|
4248
|
-
return isAllowedOrigin(origin, allowedOrigins) ? void 0 : origin;
|
|
4249
|
-
}
|
|
4250
|
-
const fetchSite = request.headers.get("sec-fetch-site")?.toLowerCase();
|
|
4251
|
-
return fetchSite === "cross-site" ? "cross-site" : void 0;
|
|
4252
|
-
}
|
|
4253
|
-
function parseAllowedOrigins(env) {
|
|
4254
|
-
const raw = envValue(env?.HOOPILOT_ALLOWED_ORIGINS);
|
|
4255
|
-
if (!raw) {
|
|
4256
|
-
return /* @__PURE__ */ new Set();
|
|
4257
|
-
}
|
|
4258
|
-
return new Set(
|
|
4259
|
-
raw.split(",").map((value) => value.trim().toLowerCase()).filter((value) => value.length > 0)
|
|
4260
|
-
);
|
|
4261
|
-
}
|
|
4262
|
-
function isAllowedOrigin(origin, allowedOrigins) {
|
|
4263
|
-
return isLoopbackOrigin(origin) || allowedOrigins.has(origin.toLowerCase());
|
|
4264
|
-
}
|
|
4265
|
-
function resolveCorsAllowOrigin(origin, allowedOrigins) {
|
|
4266
|
-
if (!origin) {
|
|
4267
|
-
return "*";
|
|
4268
|
-
}
|
|
4269
|
-
return isAllowedOrigin(origin, allowedOrigins) ? origin : void 0;
|
|
4270
|
-
}
|
|
4271
|
-
function apiKeyRejectionReason(apiKey) {
|
|
4272
|
-
const normalized = apiKey.trim();
|
|
4273
|
-
if (WELL_KNOWN_DEMO_API_KEYS.has(normalized.toLowerCase())) {
|
|
4274
|
-
return "HOOPILOT_API_KEY is a well-known demo value. Set a strong, unique API key.";
|
|
4275
|
-
}
|
|
4276
|
-
if (normalized.length < MIN_NON_LOOPBACK_API_KEY_LENGTH) {
|
|
4277
|
-
return `HOOPILOT_API_KEY must be at least ${MIN_NON_LOOPBACK_API_KEY_LENGTH} characters when listening on a non-loopback host.`;
|
|
4278
|
-
}
|
|
4279
|
-
if (/^(.)\1+$/.test(normalized)) {
|
|
4280
|
-
return "HOOPILOT_API_KEY must not be a repeated single character. Set a strong, unique API key.";
|
|
4281
|
-
}
|
|
4282
|
-
return void 0;
|
|
4283
|
-
}
|
|
4284
4340
|
function isUpstreamAuthStatus(status) {
|
|
4285
4341
|
return status === 401 || status === 403;
|
|
4286
4342
|
}
|
|
4287
4343
|
function upstreamAuthMessage(message) {
|
|
4288
4344
|
return `GitHub Copilot rejected the credential or account access: ${message}`;
|
|
4289
4345
|
}
|
|
4290
|
-
function isLoopbackHost(host) {
|
|
4291
|
-
return isLoopbackHostname(host);
|
|
4292
|
-
}
|
|
4293
|
-
function urlHost(host) {
|
|
4294
|
-
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
4295
|
-
}
|
|
4296
|
-
function isLoopbackOrigin(origin) {
|
|
4297
|
-
try {
|
|
4298
|
-
return isLoopbackHost(new URL(origin).hostname.toLowerCase());
|
|
4299
|
-
} catch {
|
|
4300
|
-
return false;
|
|
4301
|
-
}
|
|
4302
|
-
}
|
|
4303
4346
|
function normalizeServerPort(value) {
|
|
4304
4347
|
const port = Number(value);
|
|
4305
4348
|
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
@@ -4516,9 +4559,13 @@ function dashboardResponse() {
|
|
|
4516
4559
|
status: 200
|
|
4517
4560
|
});
|
|
4518
4561
|
}
|
|
4519
|
-
async function handleUsage(metrics, readUsage,
|
|
4520
|
-
const
|
|
4521
|
-
const
|
|
4562
|
+
async function handleUsage(metrics, readUsage, request) {
|
|
4563
|
+
const view = new URL(request.url).searchParams.get("view");
|
|
4564
|
+
const { copilot, error } = await readUsage(request.signal);
|
|
4565
|
+
const proxy = view === DASHBOARD_USAGE_VIEW ? metrics.snapshot({
|
|
4566
|
+
excludeRoutes: DASHBOARD_EXCLUDED_ROUTES,
|
|
4567
|
+
excludeUpstreamPaths: DASHBOARD_EXCLUDED_UPSTREAM_PATHS
|
|
4568
|
+
}) : metrics.snapshot();
|
|
4522
4569
|
const body = {
|
|
4523
4570
|
copilot: copilot ?? null,
|
|
4524
4571
|
object: "usage",
|
|
@@ -4535,25 +4582,30 @@ function createUsageReader(client, metrics, now = Date.now, ttlMs = USAGE_CACHE_
|
|
|
4535
4582
|
let cache;
|
|
4536
4583
|
return async (signal) => {
|
|
4537
4584
|
if (cache && now() - cache.atMs < ttlMs) {
|
|
4538
|
-
return
|
|
4585
|
+
return cache.result;
|
|
4539
4586
|
}
|
|
4540
4587
|
try {
|
|
4541
4588
|
const upstream = await client.usage(signal);
|
|
4542
4589
|
metrics.recordUpstream(usagePath, upstream.ok);
|
|
4543
4590
|
metrics.recordGithubRateLimit(parseRateLimitHeaders(upstream.headers, now()));
|
|
4544
4591
|
if (!upstream.ok) {
|
|
4545
|
-
|
|
4592
|
+
const result2 = { error: `GitHub Copilot usage request failed with ${upstream.status}.` };
|
|
4593
|
+
cache = { atMs: now(), result: result2 };
|
|
4594
|
+
return result2;
|
|
4546
4595
|
}
|
|
4547
4596
|
const value = normalizeCopilotUsage(await upstream.json().catch(() => ({})));
|
|
4548
|
-
|
|
4597
|
+
const result = { copilot: value };
|
|
4598
|
+
cache = { atMs: now(), result };
|
|
4549
4599
|
metrics.recordCopilotQuota(value);
|
|
4550
|
-
return
|
|
4600
|
+
return result;
|
|
4551
4601
|
} catch (error) {
|
|
4552
4602
|
if (error instanceof CopilotAuthError) {
|
|
4553
4603
|
return { error: error.message };
|
|
4554
4604
|
}
|
|
4555
4605
|
metrics.recordUpstream(usagePath, false);
|
|
4556
|
-
|
|
4606
|
+
const result = { error: errorMessage(error) };
|
|
4607
|
+
cache = { atMs: now(), result };
|
|
4608
|
+
return result;
|
|
4557
4609
|
}
|
|
4558
4610
|
};
|
|
4559
4611
|
}
|
|
@@ -5126,6 +5178,7 @@ async function runUpdate(currentVersion, logger) {
|
|
|
5126
5178
|
}
|
|
5127
5179
|
|
|
5128
5180
|
// src/cli.ts
|
|
5181
|
+
var COPILOT_VERIFY_TIMEOUT_MS = 15e3;
|
|
5129
5182
|
async function main2(argv = Bun.argv.slice(2)) {
|
|
5130
5183
|
cleanupOldBinary();
|
|
5131
5184
|
const command = argv[0];
|
|
@@ -5466,7 +5519,8 @@ async function verifyCopilotOAuthToken(token, options = {}) {
|
|
|
5466
5519
|
const fetcher = options.fetch ?? fetch;
|
|
5467
5520
|
const response = await fetcher(`${apiBaseUrl}/models`, {
|
|
5468
5521
|
headers: applyCopilotHeaders(new Headers(), token),
|
|
5469
|
-
method: "GET"
|
|
5522
|
+
method: "GET",
|
|
5523
|
+
signal: AbortSignal.timeout(options.verifyTimeoutMs ?? COPILOT_VERIFY_TIMEOUT_MS)
|
|
5470
5524
|
});
|
|
5471
5525
|
if (!response.ok) {
|
|
5472
5526
|
await throwForCopilotResponse(response, "GitHub Copilot API verification");
|