@openhoo/hoopilot 2.1.4 → 2.1.6
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 +4 -4
- package/dist/cli.js +199 -70
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +13 -7
- package/dist/index.js +190 -68
- package/dist/index.js.map +1 -1
- package/package.json +6 -2
package/dist/index.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ declare class MetricsRegistry {
|
|
|
12
12
|
now?: () => number;
|
|
13
13
|
});
|
|
14
14
|
/** Mark a request as started; pair with exactly one {@link observe}. */
|
|
15
|
-
startRequest(): void;
|
|
15
|
+
startRequest(route?: string): void;
|
|
16
16
|
/** Record a completed request and clear its in-flight slot. */
|
|
17
17
|
observe(observation: RequestObservation): void;
|
|
18
18
|
/**
|
|
@@ -35,16 +35,16 @@ declare class MetricsRegistry {
|
|
|
35
35
|
*/
|
|
36
36
|
recordGithubRateLimit(rateLimit: GithubRateLimit | undefined): void;
|
|
37
37
|
/** A JSON-friendly view of the current counters. */
|
|
38
|
-
snapshot(
|
|
38
|
+
snapshot(nowOrOptions?: (() => number) | MetricsSnapshotOptions): MetricsSnapshot;
|
|
39
39
|
/** Render the Prometheus text exposition format (version 0.0.4). */
|
|
40
40
|
renderPrometheus(now?: () => number): string;
|
|
41
41
|
}
|
|
42
42
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
43
|
+
* Wrap `response`'s body so the client receives unchanged bytes while the same
|
|
44
|
+
* read pass extracts token usage. Returns a new Response carrying the observed
|
|
45
|
+
* body and the original status/headers. Usage extraction never throws into the
|
|
46
|
+
* client stream: a parse failure or an aborted client simply yields no usage.
|
|
47
|
+
* When the body is absent the response is returned untouched.
|
|
48
48
|
*
|
|
49
49
|
* Pass the request's `signal` so a client disconnect cancels the observer
|
|
50
50
|
* branch; combined with the runtime cancelling the client branch, that releases
|
|
@@ -243,6 +243,12 @@ interface MetricsSnapshot {
|
|
|
243
243
|
};
|
|
244
244
|
uptimeSeconds: number;
|
|
245
245
|
}
|
|
246
|
+
/** Options for deriving a JSON metrics snapshot without changing raw counters. */
|
|
247
|
+
interface MetricsSnapshotOptions {
|
|
248
|
+
excludeRoutes?: readonly string[];
|
|
249
|
+
excludeUpstreamPaths?: readonly string[];
|
|
250
|
+
now?: () => number;
|
|
251
|
+
}
|
|
246
252
|
/** JSON body returned by the proxy's `/v1/usage` route. */
|
|
247
253
|
interface UsageResponseBody {
|
|
248
254
|
copilot: CopilotUsage | null;
|
package/dist/index.js
CHANGED
|
@@ -2630,6 +2630,7 @@ var MetricsRegistry = class {
|
|
|
2630
2630
|
#inFlight = 0;
|
|
2631
2631
|
#requests = /* @__PURE__ */ new Map();
|
|
2632
2632
|
#durations = /* @__PURE__ */ new Map();
|
|
2633
|
+
#inFlightByRoute = /* @__PURE__ */ new Map();
|
|
2633
2634
|
#tokens = /* @__PURE__ */ new Map();
|
|
2634
2635
|
#upstream = /* @__PURE__ */ new Map();
|
|
2635
2636
|
#copilotQuota;
|
|
@@ -2639,14 +2640,23 @@ var MetricsRegistry = class {
|
|
|
2639
2640
|
this.#startedAtMs = (options.now ?? Date.now)();
|
|
2640
2641
|
}
|
|
2641
2642
|
/** Mark a request as started; pair with exactly one {@link observe}. */
|
|
2642
|
-
startRequest() {
|
|
2643
|
+
startRequest(route) {
|
|
2643
2644
|
this.#inFlight += 1;
|
|
2645
|
+
if (route) {
|
|
2646
|
+
this.#inFlightByRoute.set(route, (this.#inFlightByRoute.get(route) ?? 0) + 1);
|
|
2647
|
+
}
|
|
2644
2648
|
}
|
|
2645
2649
|
/** Record a completed request and clear its in-flight slot. */
|
|
2646
2650
|
observe(observation) {
|
|
2647
2651
|
if (this.#inFlight > 0) {
|
|
2648
2652
|
this.#inFlight -= 1;
|
|
2649
2653
|
}
|
|
2654
|
+
const inFlightForRoute = this.#inFlightByRoute.get(observation.route) ?? 0;
|
|
2655
|
+
if (inFlightForRoute > 1) {
|
|
2656
|
+
this.#inFlightByRoute.set(observation.route, inFlightForRoute - 1);
|
|
2657
|
+
} else if (inFlightForRoute === 1) {
|
|
2658
|
+
this.#inFlightByRoute.delete(observation.route);
|
|
2659
|
+
}
|
|
2650
2660
|
const key = labelKey(observation.route, observation.method, String(observation.status));
|
|
2651
2661
|
this.#requests.set(key, (this.#requests.get(key) ?? 0) + 1);
|
|
2652
2662
|
this.#observeDuration(observation.route, observation.durationMs / 1e3);
|
|
@@ -2731,12 +2741,19 @@ var MetricsRegistry = class {
|
|
|
2731
2741
|
this.#durations.set(route, entry);
|
|
2732
2742
|
}
|
|
2733
2743
|
/** A JSON-friendly view of the current counters. */
|
|
2734
|
-
snapshot(
|
|
2744
|
+
snapshot(nowOrOptions = Date.now) {
|
|
2745
|
+
const options = typeof nowOrOptions === "function" ? { now: nowOrOptions } : nowOrOptions;
|
|
2746
|
+
const now = options.now ?? Date.now;
|
|
2747
|
+
const excludeRoutes = new Set(options.excludeRoutes ?? []);
|
|
2748
|
+
const excludeUpstreamPaths = new Set(options.excludeUpstreamPaths ?? []);
|
|
2735
2749
|
const byRoute = {};
|
|
2736
2750
|
const byStatus = {};
|
|
2737
2751
|
let requestsTotal = 0;
|
|
2738
2752
|
for (const [key, count] of this.#requests) {
|
|
2739
2753
|
const [route = "", , status = ""] = key.split(LABEL_SEPARATOR);
|
|
2754
|
+
if (excludeRoutes.has(route)) {
|
|
2755
|
+
continue;
|
|
2756
|
+
}
|
|
2740
2757
|
byRoute[route] = (byRoute[route] ?? 0) + count;
|
|
2741
2758
|
byStatus[status] = (byStatus[status] ?? 0) + count;
|
|
2742
2759
|
requestsTotal += count;
|
|
@@ -2754,8 +2771,12 @@ var MetricsRegistry = class {
|
|
|
2754
2771
|
let upstreamTotal = 0;
|
|
2755
2772
|
let upstreamErrors = 0;
|
|
2756
2773
|
for (const [key, count] of this.#upstream) {
|
|
2774
|
+
const [path = "", outcome = ""] = key.split(LABEL_SEPARATOR);
|
|
2775
|
+
if (excludeUpstreamPaths.has(path)) {
|
|
2776
|
+
continue;
|
|
2777
|
+
}
|
|
2757
2778
|
upstreamTotal += count;
|
|
2758
|
-
if (
|
|
2779
|
+
if (outcome === "error") {
|
|
2759
2780
|
upstreamErrors += count;
|
|
2760
2781
|
}
|
|
2761
2782
|
}
|
|
@@ -2765,8 +2786,8 @@ var MetricsRegistry = class {
|
|
|
2765
2786
|
}
|
|
2766
2787
|
return {
|
|
2767
2788
|
githubRateLimit,
|
|
2768
|
-
inFlight: this.#
|
|
2769
|
-
latency: this.#latencySnapshot(),
|
|
2789
|
+
inFlight: this.#filteredInFlight(excludeRoutes),
|
|
2790
|
+
latency: this.#latencySnapshot(excludeRoutes),
|
|
2770
2791
|
requests: { byRoute, byStatus, total: requestsTotal },
|
|
2771
2792
|
startedAt: new Date(this.#startedAtMs).toISOString(),
|
|
2772
2793
|
tokens: { byModel, extraction: { ...this.#extraction }, ...tokenTotals },
|
|
@@ -2774,15 +2795,30 @@ var MetricsRegistry = class {
|
|
|
2774
2795
|
uptimeSeconds: Math.max(0, Math.round((now() - this.#startedAtMs) / 1e3))
|
|
2775
2796
|
};
|
|
2776
2797
|
}
|
|
2798
|
+
#filteredInFlight(excludeRoutes) {
|
|
2799
|
+
if (excludeRoutes.size === 0) {
|
|
2800
|
+
return this.#inFlight;
|
|
2801
|
+
}
|
|
2802
|
+
let excluded = 0;
|
|
2803
|
+
for (const [route, count] of this.#inFlightByRoute) {
|
|
2804
|
+
if (excludeRoutes.has(route)) {
|
|
2805
|
+
excluded += count;
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2808
|
+
return Math.max(0, this.#inFlight - excluded);
|
|
2809
|
+
}
|
|
2777
2810
|
// Summarize the duration histogram into a JSON latency view: per-route count and
|
|
2778
2811
|
// exact average, plus overall average and estimated p50/p95. The percentiles come
|
|
2779
2812
|
// from the buckets aggregated across routes, so they share /metrics' resolution.
|
|
2780
|
-
#latencySnapshot() {
|
|
2813
|
+
#latencySnapshot(excludeRoutes = /* @__PURE__ */ new Set()) {
|
|
2781
2814
|
const byRoute = {};
|
|
2782
2815
|
const aggregateBuckets = new Array(DURATION_BUCKETS_SECONDS.length).fill(0);
|
|
2783
2816
|
let totalCount = 0;
|
|
2784
2817
|
let totalSum = 0;
|
|
2785
2818
|
for (const [route, entry] of this.#durations) {
|
|
2819
|
+
if (excludeRoutes.has(route)) {
|
|
2820
|
+
continue;
|
|
2821
|
+
}
|
|
2786
2822
|
byRoute[route] = {
|
|
2787
2823
|
avgMs: entry.count > 0 ? round2(entry.sum / entry.count * 1e3) : 0,
|
|
2788
2824
|
count: entry.count
|
|
@@ -3020,17 +3056,15 @@ function observeResponseUsage(response, fallbackModel, onUsage, signal, onOutcom
|
|
|
3020
3056
|
if (!body) {
|
|
3021
3057
|
return response;
|
|
3022
3058
|
}
|
|
3023
|
-
const [clientBranch, observerBranch] = body.tee();
|
|
3024
3059
|
const isSse = response.headers.get("content-type")?.includes("text/event-stream") ?? false;
|
|
3025
|
-
|
|
3026
|
-
(
|
|
3060
|
+
return new Response(
|
|
3061
|
+
streamWithUsageObservation(body, isSse, fallbackModel, onUsage, signal, onOutcome),
|
|
3062
|
+
{
|
|
3063
|
+
headers: response.headers,
|
|
3064
|
+
status: response.status,
|
|
3065
|
+
statusText: response.statusText
|
|
3027
3066
|
}
|
|
3028
3067
|
);
|
|
3029
|
-
return new Response(clientBranch, {
|
|
3030
|
-
headers: response.headers,
|
|
3031
|
-
status: response.status,
|
|
3032
|
-
statusText: response.statusText
|
|
3033
|
-
});
|
|
3034
3068
|
}
|
|
3035
3069
|
function recordResponseTextUsage(text, isSse, fallbackModel, onUsage, onOutcome) {
|
|
3036
3070
|
const accumulator = createUsageAccumulator(fallbackModel, onUsage, onOutcome);
|
|
@@ -3046,13 +3080,16 @@ function recordResponseTextUsage(text, isSse, fallbackModel, onUsage, onOutcome)
|
|
|
3046
3080
|
}
|
|
3047
3081
|
accumulator.finish();
|
|
3048
3082
|
}
|
|
3049
|
-
|
|
3083
|
+
function streamWithUsageObservation(stream, isSse, fallbackModel, onUsage, signal, onOutcome) {
|
|
3050
3084
|
const reader = stream.getReader();
|
|
3085
|
+
let aborted = signal?.aborted ?? false;
|
|
3086
|
+
let released = false;
|
|
3051
3087
|
const onAbort = () => {
|
|
3088
|
+
aborted = true;
|
|
3052
3089
|
reader.cancel().catch(() => {
|
|
3053
3090
|
});
|
|
3054
3091
|
};
|
|
3055
|
-
if (
|
|
3092
|
+
if (aborted) {
|
|
3056
3093
|
reader.cancel().catch(() => {
|
|
3057
3094
|
});
|
|
3058
3095
|
} else {
|
|
@@ -3060,7 +3097,7 @@ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal, onOut
|
|
|
3060
3097
|
}
|
|
3061
3098
|
const decoder = new TextDecoder();
|
|
3062
3099
|
const guardedOutcome = onOutcome ? (extracted) => {
|
|
3063
|
-
if (!
|
|
3100
|
+
if (!aborted) {
|
|
3064
3101
|
onOutcome(extracted);
|
|
3065
3102
|
}
|
|
3066
3103
|
} : void 0;
|
|
@@ -3068,33 +3105,40 @@ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal, onOut
|
|
|
3068
3105
|
let buffer = "";
|
|
3069
3106
|
let bufferedBytes = 0;
|
|
3070
3107
|
let overflowed = false;
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3108
|
+
const release = () => {
|
|
3109
|
+
if (released) {
|
|
3110
|
+
return;
|
|
3111
|
+
}
|
|
3112
|
+
released = true;
|
|
3113
|
+
signal?.removeEventListener("abort", onAbort);
|
|
3114
|
+
reader.releaseLock();
|
|
3115
|
+
};
|
|
3116
|
+
const observeChunk = (chunkBytes) => {
|
|
3117
|
+
const chunk = decoder.decode(chunkBytes, { stream: true });
|
|
3118
|
+
if (isSse) {
|
|
3119
|
+
buffer += chunk;
|
|
3120
|
+
const lines = buffer.split(/\r?\n/);
|
|
3121
|
+
buffer = lines.pop() ?? "";
|
|
3122
|
+
for (const line of lines) {
|
|
3123
|
+
considerSseLine(line, accumulator.consider);
|
|
3076
3124
|
}
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
buffer += chunk;
|
|
3080
|
-
const lines = buffer.split(/\r?\n/);
|
|
3081
|
-
buffer = lines.pop() ?? "";
|
|
3082
|
-
for (const line of lines) {
|
|
3083
|
-
considerSseLine(line, accumulator.consider);
|
|
3084
|
-
}
|
|
3085
|
-
if (buffer.length > USAGE_BUFFER_LIMIT_BYTES) {
|
|
3086
|
-
buffer = "";
|
|
3087
|
-
}
|
|
3088
|
-
} else if (!overflowed) {
|
|
3089
|
-
bufferedBytes += result.value.byteLength;
|
|
3090
|
-
if (bufferedBytes > USAGE_BUFFER_LIMIT_BYTES) {
|
|
3091
|
-
overflowed = true;
|
|
3092
|
-
buffer = "";
|
|
3093
|
-
} else {
|
|
3094
|
-
buffer += chunk;
|
|
3095
|
-
}
|
|
3125
|
+
if (buffer.length > USAGE_BUFFER_LIMIT_BYTES) {
|
|
3126
|
+
buffer = "";
|
|
3096
3127
|
}
|
|
3128
|
+
return;
|
|
3129
|
+
}
|
|
3130
|
+
if (overflowed) {
|
|
3131
|
+
return;
|
|
3132
|
+
}
|
|
3133
|
+
bufferedBytes += chunkBytes.byteLength;
|
|
3134
|
+
if (bufferedBytes > USAGE_BUFFER_LIMIT_BYTES) {
|
|
3135
|
+
overflowed = true;
|
|
3136
|
+
buffer = "";
|
|
3137
|
+
return;
|
|
3097
3138
|
}
|
|
3139
|
+
buffer += chunk;
|
|
3140
|
+
};
|
|
3141
|
+
const finishObservation = () => {
|
|
3098
3142
|
const finalBuffer = buffer + decoder.decode();
|
|
3099
3143
|
if (isSse) {
|
|
3100
3144
|
if (finalBuffer) {
|
|
@@ -3106,11 +3150,41 @@ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal, onOut
|
|
|
3106
3150
|
accumulator.consider(parsed);
|
|
3107
3151
|
}
|
|
3108
3152
|
}
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
}
|
|
3113
|
-
|
|
3153
|
+
if (!aborted) {
|
|
3154
|
+
safeFinishAccumulator(accumulator);
|
|
3155
|
+
}
|
|
3156
|
+
};
|
|
3157
|
+
return new ReadableStream({
|
|
3158
|
+
async pull(controller) {
|
|
3159
|
+
const result = await reader.read().catch((error) => {
|
|
3160
|
+
release();
|
|
3161
|
+
controller.error(error);
|
|
3162
|
+
return void 0;
|
|
3163
|
+
});
|
|
3164
|
+
if (!result) {
|
|
3165
|
+
return;
|
|
3166
|
+
}
|
|
3167
|
+
if (result.done) {
|
|
3168
|
+
finishObservation();
|
|
3169
|
+
controller.close();
|
|
3170
|
+
release();
|
|
3171
|
+
return;
|
|
3172
|
+
}
|
|
3173
|
+
try {
|
|
3174
|
+
observeChunk(result.value);
|
|
3175
|
+
} catch {
|
|
3176
|
+
}
|
|
3177
|
+
controller.enqueue(result.value);
|
|
3178
|
+
},
|
|
3179
|
+
async cancel(reason) {
|
|
3180
|
+
aborted = true;
|
|
3181
|
+
try {
|
|
3182
|
+
await reader.cancel(reason);
|
|
3183
|
+
} finally {
|
|
3184
|
+
release();
|
|
3185
|
+
}
|
|
3186
|
+
}
|
|
3187
|
+
});
|
|
3114
3188
|
}
|
|
3115
3189
|
function createUsageAccumulator(fallbackModel, onUsage, onOutcome) {
|
|
3116
3190
|
let model = fallbackModel;
|
|
@@ -3135,6 +3209,12 @@ function createUsageAccumulator(fallbackModel, onUsage, onOutcome) {
|
|
|
3135
3209
|
}
|
|
3136
3210
|
};
|
|
3137
3211
|
}
|
|
3212
|
+
function safeFinishAccumulator(accumulator) {
|
|
3213
|
+
try {
|
|
3214
|
+
accumulator.finish();
|
|
3215
|
+
} catch {
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3138
3218
|
function considerSseLine(line, consider) {
|
|
3139
3219
|
const trimmed = line.trim();
|
|
3140
3220
|
if (!trimmed.startsWith("data:")) {
|
|
@@ -3769,10 +3849,10 @@ footer.foot .end { margin-left:auto; }
|
|
|
3769
3849
|
pollGen += 1; var myGen = pollGen;
|
|
3770
3850
|
if (inflightFetch){ try { inflightFetch.abort(); } catch (e) {} }
|
|
3771
3851
|
var ctrl = new AbortController(); inflightFetch = ctrl;
|
|
3772
|
-
var to = setTimeout(function(){ try { ctrl.abort(); } catch (e) {} },
|
|
3852
|
+
var to = setTimeout(function(){ try { ctrl.abort(); } catch (e) {} }, Math.max(10000, intervalMs * 2));
|
|
3773
3853
|
var headers = { "accept":"application/json" };
|
|
3774
3854
|
if (apiKey) headers["x-api-key"] = apiKey;
|
|
3775
|
-
fetch("/v1/usage", { headers: headers, signal: ctrl.signal, cache:"no-store" }).then(function(res){
|
|
3855
|
+
fetch("/v1/usage?view=dashboard", { headers: headers, signal: ctrl.signal, cache:"no-store" }).then(function(res){
|
|
3776
3856
|
clearTimeout(to);
|
|
3777
3857
|
if (myGen !== pollGen) return null;
|
|
3778
3858
|
if (res.status === 401 || res.status === 403){ inflightFetch = null; showAuth(!!apiKey); return null; }
|
|
@@ -4065,13 +4145,33 @@ async function getVersion() {
|
|
|
4065
4145
|
var DEFAULT_HOST = "127.0.0.1";
|
|
4066
4146
|
var DEFAULT_PORT = 4141;
|
|
4067
4147
|
var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Cross-origin browser requests are blocked unless the Origin is loopback or listed in HOOPILOT_ALLOWED_ORIGINS.";
|
|
4068
|
-
var
|
|
4148
|
+
var MIN_NON_LOOPBACK_API_KEY_LENGTH = 24;
|
|
4149
|
+
var WELL_KNOWN_DEMO_API_KEYS = /* @__PURE__ */ new Set([
|
|
4150
|
+
"changeme",
|
|
4151
|
+
"demo",
|
|
4152
|
+
"example",
|
|
4153
|
+
"hoopilot",
|
|
4154
|
+
"local-key",
|
|
4155
|
+
"password",
|
|
4156
|
+
"password123",
|
|
4157
|
+
"secret",
|
|
4158
|
+
"test"
|
|
4159
|
+
]);
|
|
4069
4160
|
var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
|
|
4070
4161
|
var JSON_OBJECT_MESSAGE = "Request body must be a JSON object.";
|
|
4071
4162
|
var MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024;
|
|
4072
4163
|
var REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
4073
4164
|
var REQUEST_TOO_LARGE_MESSAGE = `Request body must be ${MAX_REQUEST_BODY_BYTES} bytes or smaller.`;
|
|
4074
4165
|
var USAGE_CACHE_TTL_MS = 6e4;
|
|
4166
|
+
var DASHBOARD_USAGE_VIEW = "dashboard";
|
|
4167
|
+
var DASHBOARD_EXCLUDED_ROUTES = [
|
|
4168
|
+
"cors.preflight",
|
|
4169
|
+
"dashboard",
|
|
4170
|
+
"health",
|
|
4171
|
+
"metrics",
|
|
4172
|
+
"usage"
|
|
4173
|
+
];
|
|
4174
|
+
var DASHBOARD_EXCLUDED_UPSTREAM_PATHS = ["/copilot_internal/user"];
|
|
4075
4175
|
var RequestBodyTooLargeError = class extends Error {
|
|
4076
4176
|
constructor() {
|
|
4077
4177
|
super(REQUEST_TOO_LARGE_MESSAGE);
|
|
@@ -4124,7 +4224,7 @@ function createHoopilotHandler(options = {}) {
|
|
|
4124
4224
|
requestId,
|
|
4125
4225
|
route
|
|
4126
4226
|
});
|
|
4127
|
-
metrics.startRequest();
|
|
4227
|
+
metrics.startRequest(route);
|
|
4128
4228
|
const origin = request.headers.get("origin")?.trim() || void 0;
|
|
4129
4229
|
const corsOrigin = resolveCorsAllowOrigin(origin, allowedOrigins);
|
|
4130
4230
|
const inner = normalizeInnerRequest(request, apiPath, url);
|
|
@@ -4247,7 +4347,7 @@ function buildApp(deps) {
|
|
|
4247
4347
|
}
|
|
4248
4348
|
logger.error({ err: errorDetails(error), event: "http.request.failed" }, "request failed");
|
|
4249
4349
|
return jsonError(500, "internal_error", message);
|
|
4250
|
-
}).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
|
|
4350
|
+
}).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(
|
|
4251
4351
|
"/v1/models",
|
|
4252
4352
|
({ request }) => handleModels(client, metrics, request.signal, loggerFor(request))
|
|
4253
4353
|
).get("/v1/responses", () => websocketUnsupportedResponse()).post(
|
|
@@ -4343,10 +4443,9 @@ function startHoopilotServer(options = {}) {
|
|
|
4343
4443
|
"Refusing to listen on a non-loopback host without HOOPILOT_API_KEY. Set an API key or pass --allow-unauthenticated."
|
|
4344
4444
|
);
|
|
4345
4445
|
}
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
);
|
|
4446
|
+
const rejection = apiKey ? apiKeyRejectionReason(apiKey) : void 0;
|
|
4447
|
+
if (rejection) {
|
|
4448
|
+
throw new Error(`Refusing to listen on a non-loopback host: ${rejection}`);
|
|
4350
4449
|
}
|
|
4351
4450
|
}
|
|
4352
4451
|
const server = Bun.serve({
|
|
@@ -4644,12 +4743,16 @@ async function readRequestText(request) {
|
|
|
4644
4743
|
const reader = body.getReader();
|
|
4645
4744
|
const decoder = new TextDecoder();
|
|
4646
4745
|
let bytes = 0;
|
|
4647
|
-
|
|
4746
|
+
const chunks = [];
|
|
4648
4747
|
try {
|
|
4649
4748
|
while (true) {
|
|
4650
4749
|
const { done, value } = await reader.read();
|
|
4651
4750
|
if (done) {
|
|
4652
|
-
|
|
4751
|
+
const tail = decoder.decode();
|
|
4752
|
+
if (tail) {
|
|
4753
|
+
chunks.push(tail);
|
|
4754
|
+
}
|
|
4755
|
+
return chunks.join("");
|
|
4653
4756
|
}
|
|
4654
4757
|
bytes += value.byteLength;
|
|
4655
4758
|
if (bytes > MAX_REQUEST_BODY_BYTES) {
|
|
@@ -4657,7 +4760,7 @@ async function readRequestText(request) {
|
|
|
4657
4760
|
});
|
|
4658
4761
|
throw new RequestBodyTooLargeError();
|
|
4659
4762
|
}
|
|
4660
|
-
|
|
4763
|
+
chunks.push(decoder.decode(value, { stream: true }));
|
|
4661
4764
|
}
|
|
4662
4765
|
} finally {
|
|
4663
4766
|
reader.releaseLock();
|
|
@@ -4754,8 +4857,18 @@ function resolveCorsAllowOrigin(origin, allowedOrigins) {
|
|
|
4754
4857
|
}
|
|
4755
4858
|
return isAllowedOrigin(origin, allowedOrigins) ? origin : void 0;
|
|
4756
4859
|
}
|
|
4757
|
-
function
|
|
4758
|
-
|
|
4860
|
+
function apiKeyRejectionReason(apiKey) {
|
|
4861
|
+
const normalized = apiKey.trim();
|
|
4862
|
+
if (WELL_KNOWN_DEMO_API_KEYS.has(normalized.toLowerCase())) {
|
|
4863
|
+
return "HOOPILOT_API_KEY is a well-known demo value. Set a strong, unique API key.";
|
|
4864
|
+
}
|
|
4865
|
+
if (normalized.length < MIN_NON_LOOPBACK_API_KEY_LENGTH) {
|
|
4866
|
+
return `HOOPILOT_API_KEY must be at least ${MIN_NON_LOOPBACK_API_KEY_LENGTH} characters when listening on a non-loopback host.`;
|
|
4867
|
+
}
|
|
4868
|
+
if (/^(.)\1+$/.test(normalized)) {
|
|
4869
|
+
return "HOOPILOT_API_KEY must not be a repeated single character. Set a strong, unique API key.";
|
|
4870
|
+
}
|
|
4871
|
+
return void 0;
|
|
4759
4872
|
}
|
|
4760
4873
|
function isUpstreamAuthStatus(status) {
|
|
4761
4874
|
return status === 401 || status === 403;
|
|
@@ -4992,9 +5105,13 @@ function dashboardResponse() {
|
|
|
4992
5105
|
status: 200
|
|
4993
5106
|
});
|
|
4994
5107
|
}
|
|
4995
|
-
async function handleUsage(metrics, readUsage,
|
|
4996
|
-
const
|
|
4997
|
-
const
|
|
5108
|
+
async function handleUsage(metrics, readUsage, request) {
|
|
5109
|
+
const view = new URL(request.url).searchParams.get("view");
|
|
5110
|
+
const { copilot, error } = await readUsage(request.signal);
|
|
5111
|
+
const proxy = view === DASHBOARD_USAGE_VIEW ? metrics.snapshot({
|
|
5112
|
+
excludeRoutes: DASHBOARD_EXCLUDED_ROUTES,
|
|
5113
|
+
excludeUpstreamPaths: DASHBOARD_EXCLUDED_UPSTREAM_PATHS
|
|
5114
|
+
}) : metrics.snapshot();
|
|
4998
5115
|
const body = {
|
|
4999
5116
|
copilot: copilot ?? null,
|
|
5000
5117
|
object: "usage",
|
|
@@ -5011,25 +5128,30 @@ function createUsageReader(client, metrics, now = Date.now, ttlMs = USAGE_CACHE_
|
|
|
5011
5128
|
let cache;
|
|
5012
5129
|
return async (signal) => {
|
|
5013
5130
|
if (cache && now() - cache.atMs < ttlMs) {
|
|
5014
|
-
return
|
|
5131
|
+
return cache.result;
|
|
5015
5132
|
}
|
|
5016
5133
|
try {
|
|
5017
5134
|
const upstream = await client.usage(signal);
|
|
5018
5135
|
metrics.recordUpstream(usagePath, upstream.ok);
|
|
5019
5136
|
metrics.recordGithubRateLimit(parseRateLimitHeaders(upstream.headers, now()));
|
|
5020
5137
|
if (!upstream.ok) {
|
|
5021
|
-
|
|
5138
|
+
const result2 = { error: `GitHub Copilot usage request failed with ${upstream.status}.` };
|
|
5139
|
+
cache = { atMs: now(), result: result2 };
|
|
5140
|
+
return result2;
|
|
5022
5141
|
}
|
|
5023
5142
|
const value = normalizeCopilotUsage(await upstream.json().catch(() => ({})));
|
|
5024
|
-
|
|
5143
|
+
const result = { copilot: value };
|
|
5144
|
+
cache = { atMs: now(), result };
|
|
5025
5145
|
metrics.recordCopilotQuota(value);
|
|
5026
|
-
return
|
|
5146
|
+
return result;
|
|
5027
5147
|
} catch (error) {
|
|
5028
5148
|
if (error instanceof CopilotAuthError) {
|
|
5029
5149
|
return { error: error.message };
|
|
5030
5150
|
}
|
|
5031
5151
|
metrics.recordUpstream(usagePath, false);
|
|
5032
|
-
|
|
5152
|
+
const result = { error: errorMessage(error) };
|
|
5153
|
+
cache = { atMs: now(), result };
|
|
5154
|
+
return result;
|
|
5033
5155
|
}
|
|
5034
5156
|
};
|
|
5035
5157
|
}
|