@openhoo/hoopilot 2.1.4 → 2.1.5
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 +127 -52
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.js +118 -50
- package/dist/index.js.map +1 -1
- package/package.json +6 -2
package/dist/index.d.ts
CHANGED
|
@@ -40,11 +40,11 @@ declare class MetricsRegistry {
|
|
|
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
|
package/dist/index.js
CHANGED
|
@@ -3020,17 +3020,15 @@ function observeResponseUsage(response, fallbackModel, onUsage, signal, onOutcom
|
|
|
3020
3020
|
if (!body) {
|
|
3021
3021
|
return response;
|
|
3022
3022
|
}
|
|
3023
|
-
const [clientBranch, observerBranch] = body.tee();
|
|
3024
3023
|
const isSse = response.headers.get("content-type")?.includes("text/event-stream") ?? false;
|
|
3025
|
-
|
|
3026
|
-
(
|
|
3024
|
+
return new Response(
|
|
3025
|
+
streamWithUsageObservation(body, isSse, fallbackModel, onUsage, signal, onOutcome),
|
|
3026
|
+
{
|
|
3027
|
+
headers: response.headers,
|
|
3028
|
+
status: response.status,
|
|
3029
|
+
statusText: response.statusText
|
|
3027
3030
|
}
|
|
3028
3031
|
);
|
|
3029
|
-
return new Response(clientBranch, {
|
|
3030
|
-
headers: response.headers,
|
|
3031
|
-
status: response.status,
|
|
3032
|
-
statusText: response.statusText
|
|
3033
|
-
});
|
|
3034
3032
|
}
|
|
3035
3033
|
function recordResponseTextUsage(text, isSse, fallbackModel, onUsage, onOutcome) {
|
|
3036
3034
|
const accumulator = createUsageAccumulator(fallbackModel, onUsage, onOutcome);
|
|
@@ -3046,13 +3044,16 @@ function recordResponseTextUsage(text, isSse, fallbackModel, onUsage, onOutcome)
|
|
|
3046
3044
|
}
|
|
3047
3045
|
accumulator.finish();
|
|
3048
3046
|
}
|
|
3049
|
-
|
|
3047
|
+
function streamWithUsageObservation(stream, isSse, fallbackModel, onUsage, signal, onOutcome) {
|
|
3050
3048
|
const reader = stream.getReader();
|
|
3049
|
+
let aborted = signal?.aborted ?? false;
|
|
3050
|
+
let released = false;
|
|
3051
3051
|
const onAbort = () => {
|
|
3052
|
+
aborted = true;
|
|
3052
3053
|
reader.cancel().catch(() => {
|
|
3053
3054
|
});
|
|
3054
3055
|
};
|
|
3055
|
-
if (
|
|
3056
|
+
if (aborted) {
|
|
3056
3057
|
reader.cancel().catch(() => {
|
|
3057
3058
|
});
|
|
3058
3059
|
} else {
|
|
@@ -3060,7 +3061,7 @@ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal, onOut
|
|
|
3060
3061
|
}
|
|
3061
3062
|
const decoder = new TextDecoder();
|
|
3062
3063
|
const guardedOutcome = onOutcome ? (extracted) => {
|
|
3063
|
-
if (!
|
|
3064
|
+
if (!aborted) {
|
|
3064
3065
|
onOutcome(extracted);
|
|
3065
3066
|
}
|
|
3066
3067
|
} : void 0;
|
|
@@ -3068,33 +3069,40 @@ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal, onOut
|
|
|
3068
3069
|
let buffer = "";
|
|
3069
3070
|
let bufferedBytes = 0;
|
|
3070
3071
|
let overflowed = false;
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3072
|
+
const release = () => {
|
|
3073
|
+
if (released) {
|
|
3074
|
+
return;
|
|
3075
|
+
}
|
|
3076
|
+
released = true;
|
|
3077
|
+
signal?.removeEventListener("abort", onAbort);
|
|
3078
|
+
reader.releaseLock();
|
|
3079
|
+
};
|
|
3080
|
+
const observeChunk = (chunkBytes) => {
|
|
3081
|
+
const chunk = decoder.decode(chunkBytes, { stream: true });
|
|
3082
|
+
if (isSse) {
|
|
3083
|
+
buffer += chunk;
|
|
3084
|
+
const lines = buffer.split(/\r?\n/);
|
|
3085
|
+
buffer = lines.pop() ?? "";
|
|
3086
|
+
for (const line of lines) {
|
|
3087
|
+
considerSseLine(line, accumulator.consider);
|
|
3076
3088
|
}
|
|
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
|
-
}
|
|
3089
|
+
if (buffer.length > USAGE_BUFFER_LIMIT_BYTES) {
|
|
3090
|
+
buffer = "";
|
|
3096
3091
|
}
|
|
3092
|
+
return;
|
|
3093
|
+
}
|
|
3094
|
+
if (overflowed) {
|
|
3095
|
+
return;
|
|
3097
3096
|
}
|
|
3097
|
+
bufferedBytes += chunkBytes.byteLength;
|
|
3098
|
+
if (bufferedBytes > USAGE_BUFFER_LIMIT_BYTES) {
|
|
3099
|
+
overflowed = true;
|
|
3100
|
+
buffer = "";
|
|
3101
|
+
return;
|
|
3102
|
+
}
|
|
3103
|
+
buffer += chunk;
|
|
3104
|
+
};
|
|
3105
|
+
const finishObservation = () => {
|
|
3098
3106
|
const finalBuffer = buffer + decoder.decode();
|
|
3099
3107
|
if (isSse) {
|
|
3100
3108
|
if (finalBuffer) {
|
|
@@ -3106,11 +3114,41 @@ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal, onOut
|
|
|
3106
3114
|
accumulator.consider(parsed);
|
|
3107
3115
|
}
|
|
3108
3116
|
}
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
}
|
|
3113
|
-
|
|
3117
|
+
if (!aborted) {
|
|
3118
|
+
safeFinishAccumulator(accumulator);
|
|
3119
|
+
}
|
|
3120
|
+
};
|
|
3121
|
+
return new ReadableStream({
|
|
3122
|
+
async pull(controller) {
|
|
3123
|
+
const result = await reader.read().catch((error) => {
|
|
3124
|
+
release();
|
|
3125
|
+
controller.error(error);
|
|
3126
|
+
return void 0;
|
|
3127
|
+
});
|
|
3128
|
+
if (!result) {
|
|
3129
|
+
return;
|
|
3130
|
+
}
|
|
3131
|
+
if (result.done) {
|
|
3132
|
+
finishObservation();
|
|
3133
|
+
controller.close();
|
|
3134
|
+
release();
|
|
3135
|
+
return;
|
|
3136
|
+
}
|
|
3137
|
+
try {
|
|
3138
|
+
observeChunk(result.value);
|
|
3139
|
+
} catch {
|
|
3140
|
+
}
|
|
3141
|
+
controller.enqueue(result.value);
|
|
3142
|
+
},
|
|
3143
|
+
async cancel(reason) {
|
|
3144
|
+
aborted = true;
|
|
3145
|
+
try {
|
|
3146
|
+
await reader.cancel(reason);
|
|
3147
|
+
} finally {
|
|
3148
|
+
release();
|
|
3149
|
+
}
|
|
3150
|
+
}
|
|
3151
|
+
});
|
|
3114
3152
|
}
|
|
3115
3153
|
function createUsageAccumulator(fallbackModel, onUsage, onOutcome) {
|
|
3116
3154
|
let model = fallbackModel;
|
|
@@ -3135,6 +3173,12 @@ function createUsageAccumulator(fallbackModel, onUsage, onOutcome) {
|
|
|
3135
3173
|
}
|
|
3136
3174
|
};
|
|
3137
3175
|
}
|
|
3176
|
+
function safeFinishAccumulator(accumulator) {
|
|
3177
|
+
try {
|
|
3178
|
+
accumulator.finish();
|
|
3179
|
+
} catch {
|
|
3180
|
+
}
|
|
3181
|
+
}
|
|
3138
3182
|
function considerSseLine(line, consider) {
|
|
3139
3183
|
const trimmed = line.trim();
|
|
3140
3184
|
if (!trimmed.startsWith("data:")) {
|
|
@@ -4065,7 +4109,18 @@ async function getVersion() {
|
|
|
4065
4109
|
var DEFAULT_HOST = "127.0.0.1";
|
|
4066
4110
|
var DEFAULT_PORT = 4141;
|
|
4067
4111
|
var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Cross-origin browser requests are blocked unless the Origin is loopback or listed in HOOPILOT_ALLOWED_ORIGINS.";
|
|
4068
|
-
var
|
|
4112
|
+
var MIN_NON_LOOPBACK_API_KEY_LENGTH = 24;
|
|
4113
|
+
var WELL_KNOWN_DEMO_API_KEYS = /* @__PURE__ */ new Set([
|
|
4114
|
+
"changeme",
|
|
4115
|
+
"demo",
|
|
4116
|
+
"example",
|
|
4117
|
+
"hoopilot",
|
|
4118
|
+
"local-key",
|
|
4119
|
+
"password",
|
|
4120
|
+
"password123",
|
|
4121
|
+
"secret",
|
|
4122
|
+
"test"
|
|
4123
|
+
]);
|
|
4069
4124
|
var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
|
|
4070
4125
|
var JSON_OBJECT_MESSAGE = "Request body must be a JSON object.";
|
|
4071
4126
|
var MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024;
|
|
@@ -4343,10 +4398,9 @@ function startHoopilotServer(options = {}) {
|
|
|
4343
4398
|
"Refusing to listen on a non-loopback host without HOOPILOT_API_KEY. Set an API key or pass --allow-unauthenticated."
|
|
4344
4399
|
);
|
|
4345
4400
|
}
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
);
|
|
4401
|
+
const rejection = apiKey ? apiKeyRejectionReason(apiKey) : void 0;
|
|
4402
|
+
if (rejection) {
|
|
4403
|
+
throw new Error(`Refusing to listen on a non-loopback host: ${rejection}`);
|
|
4350
4404
|
}
|
|
4351
4405
|
}
|
|
4352
4406
|
const server = Bun.serve({
|
|
@@ -4644,12 +4698,16 @@ async function readRequestText(request) {
|
|
|
4644
4698
|
const reader = body.getReader();
|
|
4645
4699
|
const decoder = new TextDecoder();
|
|
4646
4700
|
let bytes = 0;
|
|
4647
|
-
|
|
4701
|
+
const chunks = [];
|
|
4648
4702
|
try {
|
|
4649
4703
|
while (true) {
|
|
4650
4704
|
const { done, value } = await reader.read();
|
|
4651
4705
|
if (done) {
|
|
4652
|
-
|
|
4706
|
+
const tail = decoder.decode();
|
|
4707
|
+
if (tail) {
|
|
4708
|
+
chunks.push(tail);
|
|
4709
|
+
}
|
|
4710
|
+
return chunks.join("");
|
|
4653
4711
|
}
|
|
4654
4712
|
bytes += value.byteLength;
|
|
4655
4713
|
if (bytes > MAX_REQUEST_BODY_BYTES) {
|
|
@@ -4657,7 +4715,7 @@ async function readRequestText(request) {
|
|
|
4657
4715
|
});
|
|
4658
4716
|
throw new RequestBodyTooLargeError();
|
|
4659
4717
|
}
|
|
4660
|
-
|
|
4718
|
+
chunks.push(decoder.decode(value, { stream: true }));
|
|
4661
4719
|
}
|
|
4662
4720
|
} finally {
|
|
4663
4721
|
reader.releaseLock();
|
|
@@ -4754,8 +4812,18 @@ function resolveCorsAllowOrigin(origin, allowedOrigins) {
|
|
|
4754
4812
|
}
|
|
4755
4813
|
return isAllowedOrigin(origin, allowedOrigins) ? origin : void 0;
|
|
4756
4814
|
}
|
|
4757
|
-
function
|
|
4758
|
-
|
|
4815
|
+
function apiKeyRejectionReason(apiKey) {
|
|
4816
|
+
const normalized = apiKey.trim();
|
|
4817
|
+
if (WELL_KNOWN_DEMO_API_KEYS.has(normalized.toLowerCase())) {
|
|
4818
|
+
return "HOOPILOT_API_KEY is a well-known demo value. Set a strong, unique API key.";
|
|
4819
|
+
}
|
|
4820
|
+
if (normalized.length < MIN_NON_LOOPBACK_API_KEY_LENGTH) {
|
|
4821
|
+
return `HOOPILOT_API_KEY must be at least ${MIN_NON_LOOPBACK_API_KEY_LENGTH} characters when listening on a non-loopback host.`;
|
|
4822
|
+
}
|
|
4823
|
+
if (/^(.)\1+$/.test(normalized)) {
|
|
4824
|
+
return "HOOPILOT_API_KEY must not be a repeated single character. Set a strong, unique API key.";
|
|
4825
|
+
}
|
|
4826
|
+
return void 0;
|
|
4759
4827
|
}
|
|
4760
4828
|
function isUpstreamAuthStatus(status) {
|
|
4761
4829
|
return status === 401 || status === 403;
|