@openhoo/hoopilot 2.1.6 → 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/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-4ZG5QEYJ.js";
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) => encodeSse(name, data === "[DONE]" ? data : { ...data, sequence_number: sequenceNumber++ });
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.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trim()).join("");
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(encodeDataSse(data)));
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(encodeDataSse(data));
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
- let event = "message";
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(encodeSse2(event, data)));
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(encodeSse2(event, data));
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>
@@ -2881,130 +2876,368 @@ footer.foot .end { margin-left:auto; }
2881
2876
  </html>
2882
2877
  `;
2883
2878
 
2884
- // src/metrics.ts
2885
- var PROMETHEUS_CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8";
2886
- var DURATION_BUCKETS_SECONDS = [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60];
2887
- var USAGE_BUFFER_LIMIT_BYTES = 16 * 1024 * 1024;
2888
- var MAX_TRACKED_MODELS = 200;
2889
- var MAX_MODEL_LABEL_LENGTH = 200;
2890
- var MAX_TRACKED_RATELIMIT_RESOURCES = 32;
2891
- var LABEL_SEPARATOR = "";
2892
- var UNKNOWN_MODEL = "unknown";
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
- #inFlightByRoute = /* @__PURE__ */ new Map();
2902
- #tokens = /* @__PURE__ */ new Map();
2903
- #upstream = /* @__PURE__ */ new Map();
2904
- #copilotQuota;
2905
- #githubRateLimit = /* @__PURE__ */ new Map();
2906
- #extraction = { extracted: 0, missing: 0 };
2907
- constructor(options = {}) {
2908
- 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";
2909
2888
  }
2910
- /** Mark a request as started; pair with exactly one {@link observe}. */
2911
- startRequest(route) {
2912
- this.#inFlight += 1;
2913
- if (route) {
2914
- this.#inFlightByRoute.set(route, (this.#inFlightByRoute.get(route) ?? 0) + 1);
2915
- }
2889
+ };
2890
+ var InvalidJsonError = class extends Error {
2891
+ constructor() {
2892
+ super(INVALID_JSON_MESSAGE);
2893
+ this.name = "InvalidJsonError";
2916
2894
  }
2917
- /** Record a completed request and clear its in-flight slot. */
2918
- observe(observation) {
2919
- if (this.#inFlight > 0) {
2920
- this.#inFlight -= 1;
2921
- }
2922
- const inFlightForRoute = this.#inFlightByRoute.get(observation.route) ?? 0;
2923
- if (inFlightForRoute > 1) {
2924
- this.#inFlightByRoute.set(observation.route, inFlightForRoute - 1);
2925
- } else if (inFlightForRoute === 1) {
2926
- this.#inFlightByRoute.delete(observation.route);
2927
- }
2928
- const key = labelKey(observation.route, observation.method, String(observation.status));
2929
- this.#requests.set(key, (this.#requests.get(key) ?? 0) + 1);
2930
- 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";
2931
2900
  }
2932
- /**
2933
- * Record whether one upstream completion reported token usage. `missing`
2934
- * counts responses that carried no usage object — most often streamed Chat
2935
- * Completions sent without `stream_options: {"include_usage": true}` — so a
2936
- * rising miss rate flags clients whose token usage is going unaccounted.
2937
- */
2938
- recordTokenExtraction(extracted) {
2939
- if (extracted) {
2940
- this.#extraction.extracted += 1;
2941
- } else {
2942
- this.#extraction.missing += 1;
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();
2943
2916
  }
2944
2917
  }
2945
- /** Accumulate token counts for a model from one upstream completion. */
2946
- recordTokens(model, usage) {
2947
- const name = this.#modelLabel(model);
2948
- const totals = this.#tokens.get(name) ?? emptyModelTotals();
2949
- totals.requests += 1;
2950
- totals.prompt += nonNegative(usage.promptTokens);
2951
- totals.completion += nonNegative(usage.completionTokens);
2952
- totals.total += nonNegative(usage.totalTokens);
2953
- totals.reasoning += nonNegative(usage.reasoningTokens ?? 0);
2954
- totals.cached += nonNegative(usage.cachedTokens ?? 0);
2955
- this.#tokens.set(name, totals);
2956
- }
2957
- /** Record one upstream Copilot call and whether it succeeded. */
2958
- recordUpstream(path, ok) {
2959
- const key = labelKey(path, ok ? "ok" : "error");
2960
- this.#upstream.set(key, (this.#upstream.get(key) ?? 0) + 1);
2961
- }
2962
- /** Store the latest Copilot quota so /metrics can expose it as gauges. */
2963
- recordCopilotQuota(usage) {
2964
- this.#copilotQuota = usage;
2965
- }
2966
- /**
2967
- * Store the latest GitHub REST rate-limit budget, keyed by its resource bucket.
2968
- * A no-op when `rateLimit` is undefined (the response carried no rate-limit
2969
- * headers) so callers can pass {@link parseRateLimitHeaders} output directly.
2970
- */
2971
- recordGithubRateLimit(rateLimit) {
2972
- if (!rateLimit) {
2973
- return;
2974
- }
2975
- const resource = this.#rateLimitResource(rateLimit.resource);
2976
- this.#githubRateLimit.set(resource, { ...rateLimit, resource });
2918
+ const body = request.body;
2919
+ if (!body) {
2920
+ return "";
2977
2921
  }
2978
- // Clean a raw value into a bounded exposition-format label: cap its length,
2979
- // strip characters that would corrupt the format, and fold overflow past the
2980
- // cardinality limit into UNKNOWN_MODEL so the series count stays bounded.
2981
- #boundedLabel(value, tracked, maxEntries) {
2982
- const cleaned = cleanLabel(value).slice(0, MAX_MODEL_LABEL_LENGTH) || UNKNOWN_MODEL;
2983
- if (!tracked.has(cleaned) && tracked.size >= maxEntries) {
2984
- return UNKNOWN_MODEL;
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 }));
2985
2943
  }
2986
- return cleaned;
2944
+ } finally {
2945
+ reader.releaseLock();
2987
2946
  }
2988
- // The model can originate from a (possibly hostile) client request.
2989
- #modelLabel(model) {
2990
- return this.#boundedLabel(model, this.#tokens, MAX_TRACKED_MODELS);
2947
+ }
2948
+ function parseJsonObject2(text) {
2949
+ let parsed;
2950
+ try {
2951
+ parsed = JSON.parse(text);
2952
+ } catch {
2953
+ throw new InvalidJsonError();
2991
2954
  }
2992
- // The resource comes from a trusted upstream header, but is bounded the same way.
2993
- #rateLimitResource(resource) {
2994
- return this.#boundedLabel(resource, this.#githubRateLimit, MAX_TRACKED_RATELIMIT_RESOURCES);
2955
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2956
+ throw new JsonNotObjectError();
2995
2957
  }
2996
- #observeDuration(route, seconds) {
2997
- const value = Number.isFinite(seconds) && seconds >= 0 ? seconds : 0;
2998
- const entry = this.#durations.get(route) ?? {
2999
- buckets: new Array(DURATION_BUCKETS_SECONDS.length).fill(0),
3000
- count: 0,
3001
- sum: 0
3002
- };
3003
- entry.count += 1;
3004
- entry.sum += value;
3005
- const index = DURATION_BUCKETS_SECONDS.findIndex((bound) => value <= bound);
3006
- if (index !== -1) {
3007
- entry.buckets[index] = (entry.buckets[index] ?? 0) + 1;
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;
3008
3241
  }
3009
3242
  this.#durations.set(route, entry);
3010
3243
  }
@@ -3484,11 +3717,7 @@ function safeFinishAccumulator(accumulator) {
3484
3717
  }
3485
3718
  }
3486
3719
  function considerSseLine(line, consider) {
3487
- const trimmed = line.trim();
3488
- if (!trimmed.startsWith("data:")) {
3489
- return;
3490
- }
3491
- const data = trimmed.slice("data:".length).trim();
3720
+ const data = sseDataFromLine(line);
3492
3721
  if (!data || data === "[DONE]") {
3493
3722
  return;
3494
3723
  }
@@ -3600,24 +3829,7 @@ async function getVersion() {
3600
3829
  // src/server.ts
3601
3830
  var DEFAULT_HOST = "127.0.0.1";
3602
3831
  var DEFAULT_PORT = 4141;
3603
- var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Cross-origin browser requests are blocked unless the Origin is loopback or listed in HOOPILOT_ALLOWED_ORIGINS.";
3604
- var MIN_NON_LOOPBACK_API_KEY_LENGTH = 24;
3605
- var WELL_KNOWN_DEMO_API_KEYS = /* @__PURE__ */ new Set([
3606
- "changeme",
3607
- "demo",
3608
- "example",
3609
- "hoopilot",
3610
- "local-key",
3611
- "password",
3612
- "password123",
3613
- "secret",
3614
- "test"
3615
- ]);
3616
- var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
3617
- var JSON_OBJECT_MESSAGE = "Request body must be a JSON object.";
3618
- var MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024;
3619
3832
  var REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
3620
- var REQUEST_TOO_LARGE_MESSAGE = `Request body must be ${MAX_REQUEST_BODY_BYTES} bytes or smaller.`;
3621
3833
  var USAGE_CACHE_TTL_MS = 6e4;
3622
3834
  var DASHBOARD_USAGE_VIEW = "dashboard";
3623
3835
  var DASHBOARD_EXCLUDED_ROUTES = [
@@ -3628,24 +3840,6 @@ var DASHBOARD_EXCLUDED_ROUTES = [
3628
3840
  "usage"
3629
3841
  ];
3630
3842
  var DASHBOARD_EXCLUDED_UPSTREAM_PATHS = ["/copilot_internal/user"];
3631
- var RequestBodyTooLargeError = class extends Error {
3632
- constructor() {
3633
- super(REQUEST_TOO_LARGE_MESSAGE);
3634
- this.name = "RequestBodyTooLargeError";
3635
- }
3636
- };
3637
- var InvalidJsonError = class extends Error {
3638
- constructor() {
3639
- super(INVALID_JSON_MESSAGE);
3640
- this.name = "InvalidJsonError";
3641
- }
3642
- };
3643
- var JsonNotObjectError = class extends Error {
3644
- constructor() {
3645
- super(JSON_OBJECT_MESSAGE);
3646
- this.name = "JsonNotObjectError";
3647
- }
3648
- };
3649
3843
  function createHoopilotHandler(options = {}) {
3650
3844
  const client = new CopilotClient(options);
3651
3845
  const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
@@ -4128,13 +4322,6 @@ async function responseWithObservedUsage(response, fallbackModel, recordTokens,
4128
4322
  }
4129
4323
  return observeResponseUsage(response, fallbackModel, recordTokens, signal, recordExtraction);
4130
4324
  }
4131
- function responseFromText(source, text) {
4132
- return new Response(text, {
4133
- headers: source.headers,
4134
- status: source.status,
4135
- statusText: source.statusText
4136
- });
4137
- }
4138
4325
  async function proxyError(upstream, logger) {
4139
4326
  const text = await upstream.text();
4140
4327
  if (isUpstreamAuthStatus(upstream.status)) {
@@ -4150,201 +4337,12 @@ async function proxyError(upstream, logger) {
4150
4337
  );
4151
4338
  return upstreamErrorResponse(upstream.status, text || upstream.statusText);
4152
4339
  }
4153
- function proxyResponse(upstream) {
4154
- const headers = new Headers(upstream.headers);
4155
- headers.delete("content-encoding");
4156
- headers.delete("content-length");
4157
- headers.delete("transfer-encoding");
4158
- for (const [key, value] of Object.entries(corsHeaders())) {
4159
- headers.set(key, value);
4160
- }
4161
- return new Response(upstream.body, {
4162
- headers,
4163
- status: upstream.status,
4164
- statusText: upstream.statusText
4165
- });
4166
- }
4167
- async function readJson(request) {
4168
- const text = await readRequestText(request);
4169
- return parseJsonObject2(text);
4170
- }
4171
- function parseJsonObject2(text) {
4172
- let parsed;
4173
- try {
4174
- parsed = JSON.parse(text);
4175
- } catch {
4176
- throw new InvalidJsonError();
4177
- }
4178
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
4179
- throw new JsonNotObjectError();
4180
- }
4181
- return parsed;
4182
- }
4183
- async function readJsonText(request) {
4184
- const text = await readRequestText(request);
4185
- return { json: parseJsonObject2(text), text };
4186
- }
4187
- async function readRequestText(request) {
4188
- const contentLength = request.headers.get("content-length");
4189
- if (contentLength) {
4190
- const declaredBytes = Number(contentLength);
4191
- if (Number.isFinite(declaredBytes) && declaredBytes > MAX_REQUEST_BODY_BYTES) {
4192
- throw new RequestBodyTooLargeError();
4193
- }
4194
- }
4195
- const body = request.body;
4196
- if (!body) {
4197
- return "";
4198
- }
4199
- const reader = body.getReader();
4200
- const decoder = new TextDecoder();
4201
- let bytes = 0;
4202
- const chunks = [];
4203
- try {
4204
- while (true) {
4205
- const { done, value } = await reader.read();
4206
- if (done) {
4207
- const tail = decoder.decode();
4208
- if (tail) {
4209
- chunks.push(tail);
4210
- }
4211
- return chunks.join("");
4212
- }
4213
- bytes += value.byteLength;
4214
- if (bytes > MAX_REQUEST_BODY_BYTES) {
4215
- await reader.cancel().catch(() => {
4216
- });
4217
- throw new RequestBodyTooLargeError();
4218
- }
4219
- chunks.push(decoder.decode(value, { stream: true }));
4220
- }
4221
- } finally {
4222
- reader.releaseLock();
4223
- }
4224
- }
4225
- function jsonResponse(body, status = 200) {
4226
- return new Response(JSON.stringify(body), {
4227
- headers: {
4228
- ...corsHeaders(),
4229
- "content-type": "application/json; charset=utf-8"
4230
- },
4231
- status
4232
- });
4233
- }
4234
- function textResponse(body, contentType, status = 200) {
4235
- return new Response(body, {
4236
- headers: {
4237
- ...corsHeaders(),
4238
- "content-type": `${contentType}; charset=utf-8`
4239
- },
4240
- status
4241
- });
4242
- }
4243
- function jsonError(status, code, message) {
4244
- return jsonResponse(
4245
- {
4246
- error: {
4247
- code,
4248
- message,
4249
- type: code
4250
- }
4251
- },
4252
- status
4253
- );
4254
- }
4255
- function upstreamErrorResponse(status, text) {
4256
- const parsedError = asRecord(asRecord(safeJsonParse(text)).error);
4257
- if (Object.keys(parsedError).length > 0) {
4258
- return jsonResponse({ error: parsedError }, status);
4259
- }
4260
- return jsonError(status, "copilot_error", text);
4261
- }
4262
- function websocketUnsupportedResponse() {
4263
- const response = jsonError(
4264
- 426,
4265
- "websocket_not_supported",
4266
- "Hoopilot does not support Responses WebSocket transport; retry with HTTP Responses API."
4267
- );
4268
- response.headers.set("upgrade", "websocket");
4269
- return response;
4270
- }
4271
- function corsHeaders() {
4272
- return {
4273
- "access-control-allow-headers": "anthropic-beta, anthropic-dangerous-direct-browser-access, anthropic-version, authorization, content-type, x-api-key, x-request-id",
4274
- "access-control-allow-methods": "GET, POST, OPTIONS",
4275
- "access-control-expose-headers": "x-request-id"
4276
- };
4277
- }
4278
- function secretEquals(candidate, secret) {
4279
- const a = createHash("sha256").update(candidate).digest();
4280
- const b = createHash("sha256").update(secret).digest();
4281
- return timingSafeEqual(a, b);
4282
- }
4283
- function isAuthorized(request, apiKey) {
4284
- if (!apiKey) {
4285
- return true;
4286
- }
4287
- const authorization = request.headers.get("authorization") ?? "";
4288
- const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1];
4289
- return bearer !== void 0 && secretEquals(bearer, apiKey) || secretEquals(request.headers.get("x-api-key") ?? "", apiKey);
4290
- }
4291
- function forbiddenBrowserOrigin(origin, request, allowedOrigins) {
4292
- if (origin) {
4293
- return isAllowedOrigin(origin, allowedOrigins) ? void 0 : origin;
4294
- }
4295
- const fetchSite = request.headers.get("sec-fetch-site")?.toLowerCase();
4296
- return fetchSite === "cross-site" ? "cross-site" : void 0;
4297
- }
4298
- function parseAllowedOrigins(env) {
4299
- const raw = envValue(env?.HOOPILOT_ALLOWED_ORIGINS);
4300
- if (!raw) {
4301
- return /* @__PURE__ */ new Set();
4302
- }
4303
- return new Set(
4304
- raw.split(",").map((value) => value.trim().toLowerCase()).filter((value) => value.length > 0)
4305
- );
4306
- }
4307
- function isAllowedOrigin(origin, allowedOrigins) {
4308
- return isLoopbackOrigin(origin) || allowedOrigins.has(origin.toLowerCase());
4309
- }
4310
- function resolveCorsAllowOrigin(origin, allowedOrigins) {
4311
- if (!origin) {
4312
- return "*";
4313
- }
4314
- return isAllowedOrigin(origin, allowedOrigins) ? origin : void 0;
4315
- }
4316
- function apiKeyRejectionReason(apiKey) {
4317
- const normalized = apiKey.trim();
4318
- if (WELL_KNOWN_DEMO_API_KEYS.has(normalized.toLowerCase())) {
4319
- return "HOOPILOT_API_KEY is a well-known demo value. Set a strong, unique API key.";
4320
- }
4321
- if (normalized.length < MIN_NON_LOOPBACK_API_KEY_LENGTH) {
4322
- return `HOOPILOT_API_KEY must be at least ${MIN_NON_LOOPBACK_API_KEY_LENGTH} characters when listening on a non-loopback host.`;
4323
- }
4324
- if (/^(.)\1+$/.test(normalized)) {
4325
- return "HOOPILOT_API_KEY must not be a repeated single character. Set a strong, unique API key.";
4326
- }
4327
- return void 0;
4328
- }
4329
4340
  function isUpstreamAuthStatus(status) {
4330
4341
  return status === 401 || status === 403;
4331
4342
  }
4332
4343
  function upstreamAuthMessage(message) {
4333
4344
  return `GitHub Copilot rejected the credential or account access: ${message}`;
4334
4345
  }
4335
- function isLoopbackHost(host) {
4336
- return isLoopbackHostname(host);
4337
- }
4338
- function urlHost(host) {
4339
- return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
4340
- }
4341
- function isLoopbackOrigin(origin) {
4342
- try {
4343
- return isLoopbackHost(new URL(origin).hostname.toLowerCase());
4344
- } catch {
4345
- return false;
4346
- }
4347
- }
4348
4346
  function normalizeServerPort(value) {
4349
4347
  const port = Number(value);
4350
4348
  if (!Number.isInteger(port) || port < 0 || port > 65535) {
@@ -5180,6 +5178,7 @@ async function runUpdate(currentVersion, logger) {
5180
5178
  }
5181
5179
 
5182
5180
  // src/cli.ts
5181
+ var COPILOT_VERIFY_TIMEOUT_MS = 15e3;
5183
5182
  async function main2(argv = Bun.argv.slice(2)) {
5184
5183
  cleanupOldBinary();
5185
5184
  const command = argv[0];
@@ -5520,7 +5519,8 @@ async function verifyCopilotOAuthToken(token, options = {}) {
5520
5519
  const fetcher = options.fetch ?? fetch;
5521
5520
  const response = await fetcher(`${apiBaseUrl}/models`, {
5522
5521
  headers: applyCopilotHeaders(new Headers(), token),
5523
- method: "GET"
5522
+ method: "GET",
5523
+ signal: AbortSignal.timeout(options.verifyTimeoutMs ?? COPILOT_VERIFY_TIMEOUT_MS)
5524
5524
  });
5525
5525
  if (!response.ok) {
5526
5526
  await throwForCopilotResponse(response, "GitHub Copilot API verification");