proxitor 0.16.0 → 0.17.0
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 -2
- package/dist/cli.mjs +542 -137
- package/dist/cli.mjs.map +1 -1
- package/package.json +1 -1
package/dist/cli.mjs
CHANGED
|
@@ -7,12 +7,13 @@ import tty, { ReadStream } from "node:tty";
|
|
|
7
7
|
import { formatWithOptions, isDeepStrictEqual, styleText } from "node:util";
|
|
8
8
|
import * as l$1 from "node:readline";
|
|
9
9
|
import l__default from "node:readline";
|
|
10
|
-
import { existsSync, mkdirSync, readFileSync,
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, unwatchFile, watchFile, writeFileSync } from "node:fs";
|
|
11
11
|
import { dirname, join, resolve, sep } from "node:path";
|
|
12
12
|
import { createServer } from "node:net";
|
|
13
13
|
import { STATUS_CODES, createServer as createServer$1 } from "node:http";
|
|
14
14
|
import { Http2ServerRequest, constants } from "node:http2";
|
|
15
15
|
import { Readable } from "node:stream";
|
|
16
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
16
17
|
import { createHash } from "node:crypto";
|
|
17
18
|
//#region \0rolldown/runtime.js
|
|
18
19
|
var __defProp = Object.defineProperty;
|
|
@@ -16271,6 +16272,20 @@ const ttlSchema = _enum([
|
|
|
16271
16272
|
"omit",
|
|
16272
16273
|
"skip"
|
|
16273
16274
|
]);
|
|
16275
|
+
const OBSERVABILITY_DEFAULTS = {
|
|
16276
|
+
routerMetadata: true,
|
|
16277
|
+
hitThreshold: 80,
|
|
16278
|
+
sideMaxTokens: 4096,
|
|
16279
|
+
sessionMaxEntries: 4096,
|
|
16280
|
+
sessionTtlMs: 6e5
|
|
16281
|
+
};
|
|
16282
|
+
const observabilityConfigSchema = object({
|
|
16283
|
+
routerMetadata: boolean().default(OBSERVABILITY_DEFAULTS.routerMetadata),
|
|
16284
|
+
hitThreshold: number$1().int().min(0).max(100).default(OBSERVABILITY_DEFAULTS.hitThreshold),
|
|
16285
|
+
sideMaxTokens: number$1().int().positive().default(OBSERVABILITY_DEFAULTS.sideMaxTokens),
|
|
16286
|
+
sessionMaxEntries: number$1().int().positive().default(OBSERVABILITY_DEFAULTS.sessionMaxEntries),
|
|
16287
|
+
sessionTtlMs: number$1().int().positive().default(OBSERVABILITY_DEFAULTS.sessionTtlMs)
|
|
16288
|
+
}).default(OBSERVABILITY_DEFAULTS);
|
|
16274
16289
|
const modelOverrideSchema = object({
|
|
16275
16290
|
provider: providerConfigSchema.optional(),
|
|
16276
16291
|
headers: record(string$1(), string$1()).optional(),
|
|
@@ -16298,6 +16313,7 @@ const proxyConfigSchema = object({
|
|
|
16298
16313
|
rewriteBlockTtl: triStateSchema.default("skip"),
|
|
16299
16314
|
sessionId: triStateSchema.default("auto"),
|
|
16300
16315
|
normalizeVolatileSystem: boolean().default(false),
|
|
16316
|
+
observability: observabilityConfigSchema,
|
|
16301
16317
|
modelOverrides: record(string$1().min(1), modelOverrideSchema).optional()
|
|
16302
16318
|
}).strict();
|
|
16303
16319
|
const DEFAULTS = proxyConfigSchema.parse({});
|
|
@@ -19554,7 +19570,7 @@ async function runConfigMenu(client) {
|
|
|
19554
19570
|
}
|
|
19555
19571
|
//#endregion
|
|
19556
19572
|
//#region src/version.ts
|
|
19557
|
-
const version = "0.
|
|
19573
|
+
const version = "0.17.0";
|
|
19558
19574
|
//#endregion
|
|
19559
19575
|
//#region src/commands/doctor.ts
|
|
19560
19576
|
const DEFAULT_TIMEOUT_MS = 3e3;
|
|
@@ -19898,6 +19914,7 @@ var FileWatchingConfigSource = class {
|
|
|
19898
19914
|
pending = false;
|
|
19899
19915
|
watching = false;
|
|
19900
19916
|
lastCollisionSig = "";
|
|
19917
|
+
listeners = [];
|
|
19901
19918
|
constructor(options) {
|
|
19902
19919
|
this.current = options.initial;
|
|
19903
19920
|
this.warnSlugCollisions(options.initial.modelOverrides);
|
|
@@ -19912,6 +19929,20 @@ var FileWatchingConfigSource = class {
|
|
|
19912
19929
|
get() {
|
|
19913
19930
|
return this.current;
|
|
19914
19931
|
}
|
|
19932
|
+
subscribe(listener) {
|
|
19933
|
+
this.listeners.push(listener);
|
|
19934
|
+
return () => {
|
|
19935
|
+
const i = this.listeners.indexOf(listener);
|
|
19936
|
+
if (i !== -1) this.listeners.splice(i, 1);
|
|
19937
|
+
};
|
|
19938
|
+
}
|
|
19939
|
+
/** Notify subscribers after a successful reload. A throwing subscriber
|
|
19940
|
+
* must not abort the reload or skip the remaining subscribers. */
|
|
19941
|
+
notify(config) {
|
|
19942
|
+
for (const listener of this.listeners) try {
|
|
19943
|
+
listener(config);
|
|
19944
|
+
} catch {}
|
|
19945
|
+
}
|
|
19915
19946
|
/** Warn only when the collision set changes, so reloading an unchanged config doesn't re-log. */
|
|
19916
19947
|
warnSlugCollisions(overrides) {
|
|
19917
19948
|
const collisions = detectSlugCollisions(overrides ?? void 0);
|
|
@@ -19943,6 +19974,7 @@ var FileWatchingConfigSource = class {
|
|
|
19943
19974
|
this.warnSlugCollisions(next.modelOverrides);
|
|
19944
19975
|
if (restartNeeded) logger.warn("host/port changed — restart proxitor to apply (live reload does not re-bind the socket)");
|
|
19945
19976
|
logger.info(`Config reloaded${diff ? ` — ${diff}` : " (no material changes)"}`);
|
|
19977
|
+
this.notify(next);
|
|
19946
19978
|
return { ok: true };
|
|
19947
19979
|
} catch (error) {
|
|
19948
19980
|
const msg = error instanceof Error ? error.message : String(error);
|
|
@@ -22779,6 +22811,12 @@ function withJsonContentType(headers) {
|
|
|
22779
22811
|
"content-type": "application/json"
|
|
22780
22812
|
};
|
|
22781
22813
|
}
|
|
22814
|
+
function withRouterMetadata(headers, enabled) {
|
|
22815
|
+
return enabled ? {
|
|
22816
|
+
...headers,
|
|
22817
|
+
"x-openrouter-metadata": "enabled"
|
|
22818
|
+
} : headers;
|
|
22819
|
+
}
|
|
22782
22820
|
const buildUpstreamReq = createMiddleware(async (c, next) => {
|
|
22783
22821
|
c.set("forwardBody", resolveForwardBody({
|
|
22784
22822
|
reqId: c.var.reqId,
|
|
@@ -22793,6 +22831,7 @@ const buildUpstreamReq = createMiddleware(async (c, next) => {
|
|
|
22793
22831
|
});
|
|
22794
22832
|
if (c.var.effectiveSessionId !== void 0) headers = withSessionId(headers, c.var.effectiveSessionId);
|
|
22795
22833
|
if (c.var.bodyMutated) headers = withJsonContentType(headers);
|
|
22834
|
+
headers = withRouterMetadata(headers, c.var.config.observability.routerMetadata);
|
|
22796
22835
|
c.set("upstreamHeaders", headers);
|
|
22797
22836
|
await next();
|
|
22798
22837
|
});
|
|
@@ -22838,9 +22877,10 @@ function parseForwardBody(body) {
|
|
|
22838
22877
|
return null;
|
|
22839
22878
|
}
|
|
22840
22879
|
}
|
|
22841
|
-
/** `response` is filled later by dumpResponse
|
|
22880
|
+
/** Writes the request half; `response` is filled later by dumpResponse. Returns
|
|
22881
|
+
* the file path so the response half can find it without a reqId→path lookup. */
|
|
22842
22882
|
function dumpRequest(meta) {
|
|
22843
|
-
if (!dumpEnabled()) return;
|
|
22883
|
+
if (!dumpEnabled()) return void 0;
|
|
22844
22884
|
ensureDir(dumpDir());
|
|
22845
22885
|
const record = {
|
|
22846
22886
|
reqId: meta.reqId,
|
|
@@ -22851,156 +22891,313 @@ function dumpRequest(meta) {
|
|
|
22851
22891
|
request: parseForwardBody(meta.forwardBody),
|
|
22852
22892
|
response: null
|
|
22853
22893
|
};
|
|
22854
|
-
|
|
22894
|
+
const path = filePath(meta.reqId, meta.model);
|
|
22895
|
+
writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`);
|
|
22896
|
+
return path;
|
|
22855
22897
|
}
|
|
22856
|
-
function dumpResponse(
|
|
22898
|
+
async function dumpResponse(obs) {
|
|
22857
22899
|
if (!dumpEnabled()) return;
|
|
22858
|
-
const
|
|
22859
|
-
if (
|
|
22860
|
-
const name = readdirSync(dir).find((f) => f.endsWith(`${reqId}.json`));
|
|
22861
|
-
if (!name) return;
|
|
22862
|
-
const path = join(dir, name);
|
|
22900
|
+
const path = obs.dumpPath;
|
|
22901
|
+
if (path === void 0) return;
|
|
22863
22902
|
try {
|
|
22864
|
-
const record = JSON.parse(
|
|
22865
|
-
const
|
|
22903
|
+
const record = JSON.parse(await readFile(path, "utf-8"));
|
|
22904
|
+
const r = obs.routing;
|
|
22866
22905
|
record.response = {
|
|
22867
|
-
status,
|
|
22868
|
-
|
|
22869
|
-
|
|
22870
|
-
|
|
22871
|
-
|
|
22906
|
+
status: obs.status,
|
|
22907
|
+
label: obs.outcome.label,
|
|
22908
|
+
requestType: obs.requestType,
|
|
22909
|
+
model: obs.model,
|
|
22910
|
+
sessionId: obs.sessionId ?? null,
|
|
22911
|
+
toolsCount: obs.toolsCount,
|
|
22912
|
+
inputTokens: obs.usage.inputTokens,
|
|
22913
|
+
cacheRead: obs.usage.cacheRead,
|
|
22914
|
+
cacheCreate: obs.usage.cacheCreate,
|
|
22915
|
+
hitPct: obs.outcome.hitPct,
|
|
22916
|
+
...r ? {
|
|
22917
|
+
provider: r.provider,
|
|
22918
|
+
strategy: r.strategy,
|
|
22919
|
+
region: r.region ?? null,
|
|
22920
|
+
attempt: r.attempt,
|
|
22921
|
+
fallback: r.fallback,
|
|
22922
|
+
generationId: r.generationId ?? null
|
|
22923
|
+
} : {
|
|
22924
|
+
provider: null,
|
|
22925
|
+
strategy: null,
|
|
22926
|
+
region: null,
|
|
22927
|
+
attempt: null,
|
|
22928
|
+
fallback: false,
|
|
22929
|
+
generationId: null
|
|
22930
|
+
}
|
|
22872
22931
|
};
|
|
22873
|
-
|
|
22932
|
+
await writeFile(path, `${JSON.stringify(record, null, 2)}\n`);
|
|
22874
22933
|
} catch {}
|
|
22875
22934
|
}
|
|
22876
22935
|
//#endregion
|
|
22877
|
-
//#region src/proxy/
|
|
22878
|
-
function
|
|
22879
|
-
|
|
22880
|
-
|
|
22881
|
-
|
|
22882
|
-
|
|
22883
|
-
|
|
22884
|
-
|
|
22885
|
-
|
|
22886
|
-
|
|
22887
|
-
|
|
22888
|
-
|
|
22889
|
-
|
|
22890
|
-
|
|
22891
|
-
|
|
22892
|
-
|
|
22893
|
-
if (typeof
|
|
22894
|
-
|
|
22895
|
-
|
|
22896
|
-
|
|
22897
|
-
|
|
22898
|
-
|
|
22899
|
-
|
|
22900
|
-
|
|
22901
|
-
|
|
22902
|
-
|
|
22903
|
-
|
|
22904
|
-
|
|
22905
|
-
|
|
22906
|
-
|
|
22907
|
-
|
|
22936
|
+
//#region src/proxy/observability/extract.ts
|
|
22937
|
+
function num(v) {
|
|
22938
|
+
return typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
22939
|
+
}
|
|
22940
|
+
/** First non-empty candidate — the generation id may live at the root or under message/response. */
|
|
22941
|
+
function firstStringId(vals) {
|
|
22942
|
+
for (const v of vals) if (typeof v === "string" && v.length > 0) return v;
|
|
22943
|
+
}
|
|
22944
|
+
function applyAnthropic(usage, r, cr, cc) {
|
|
22945
|
+
if (cr !== void 0) r.cacheRead = cr;
|
|
22946
|
+
if (cc !== void 0) r.cacheCreate = cc;
|
|
22947
|
+
const inp = num(usage.input_tokens);
|
|
22948
|
+
if (inp !== void 0) r.inputTokens = inp + r.cacheRead + r.cacheCreate;
|
|
22949
|
+
}
|
|
22950
|
+
function applyOpenAI(usage, r) {
|
|
22951
|
+
const details = usage.prompt_tokens_details ?? usage.input_tokens_details;
|
|
22952
|
+
if (details && typeof details === "object") {
|
|
22953
|
+
const cached = num(details.cached_tokens);
|
|
22954
|
+
if (cached !== void 0) r.cacheRead = cached;
|
|
22955
|
+
const write = num(details.cache_write_tokens);
|
|
22956
|
+
if (write !== void 0) r.cacheCreate = write;
|
|
22957
|
+
}
|
|
22958
|
+
const prompt = num(usage.prompt_tokens) ?? num(usage.input_tokens);
|
|
22959
|
+
if (prompt !== void 0) r.inputTokens = prompt;
|
|
22960
|
+
}
|
|
22961
|
+
/** Find the usage object, unwrapping Anthropic `message`/`response` SSE containers. */
|
|
22962
|
+
function usageObject(parsed) {
|
|
22963
|
+
for (const c of [
|
|
22964
|
+
parsed.message,
|
|
22965
|
+
parsed.response,
|
|
22966
|
+
parsed
|
|
22967
|
+
]) if (c && typeof c === "object" && "usage" in c) {
|
|
22968
|
+
const u = c.usage;
|
|
22969
|
+
if (u && typeof u === "object") return u;
|
|
22970
|
+
}
|
|
22971
|
+
}
|
|
22972
|
+
function parseUsage(parsed) {
|
|
22973
|
+
if (!parsed || typeof parsed !== "object") return void 0;
|
|
22974
|
+
const usage = usageObject(parsed);
|
|
22975
|
+
if (usage === void 0) return void 0;
|
|
22976
|
+
const r = {
|
|
22977
|
+
present: true,
|
|
22978
|
+
inputTokens: 0,
|
|
22979
|
+
cacheRead: 0,
|
|
22980
|
+
cacheCreate: 0
|
|
22981
|
+
};
|
|
22982
|
+
const cr = num(usage.cache_read_input_tokens);
|
|
22983
|
+
const cc = num(usage.cache_creation_input_tokens);
|
|
22984
|
+
if (cr !== void 0 || cc !== void 0) applyAnthropic(usage, r, cr, cc);
|
|
22985
|
+
else applyOpenAI(usage, r);
|
|
22986
|
+
return r;
|
|
22987
|
+
}
|
|
22988
|
+
/** Provider from endpoints[].available (selected first), then the last attempt; '' is absent. */
|
|
22989
|
+
function resolveProvider(meta) {
|
|
22990
|
+
const avail = meta.endpoints?.available;
|
|
22991
|
+
if (Array.isArray(avail)) {
|
|
22992
|
+
const selected = avail.find((e) => e.selected === true);
|
|
22993
|
+
const selProvider = selected ? selected.provider : avail[0]?.provider;
|
|
22994
|
+
if (selProvider) return selProvider;
|
|
22995
|
+
}
|
|
22996
|
+
const attempts = meta.attempts;
|
|
22997
|
+
if (Array.isArray(attempts)) {
|
|
22998
|
+
const last = attempts.at(-1)?.provider;
|
|
22999
|
+
if (last) return last;
|
|
23000
|
+
}
|
|
23001
|
+
}
|
|
23002
|
+
function parseRouting(parsed) {
|
|
23003
|
+
if (!parsed || typeof parsed !== "object") return void 0;
|
|
23004
|
+
const root = parsed;
|
|
23005
|
+
const meta = root.openrouter_metadata ?? root.message?.openrouter_metadata ?? root.response?.openrouter_metadata;
|
|
23006
|
+
if (!meta || typeof meta !== "object") return void 0;
|
|
23007
|
+
const provider = resolveProvider(meta);
|
|
23008
|
+
if (!provider) return void 0;
|
|
23009
|
+
const attempt = num(meta.attempt) ?? 1;
|
|
23010
|
+
const generationId = firstStringId([
|
|
23011
|
+
root.id,
|
|
23012
|
+
root.message?.id,
|
|
23013
|
+
root.response?.id
|
|
23014
|
+
]);
|
|
23015
|
+
return {
|
|
23016
|
+
provider,
|
|
23017
|
+
strategy: typeof meta.strategy === "string" ? meta.strategy : "unknown",
|
|
23018
|
+
region: typeof meta.region === "string" ? meta.region : void 0,
|
|
23019
|
+
attempt,
|
|
23020
|
+
fallback: attempt > 1,
|
|
23021
|
+
generationId
|
|
23022
|
+
};
|
|
22908
23023
|
}
|
|
22909
|
-
|
|
22910
|
-
|
|
23024
|
+
function fold(parsed, acc) {
|
|
23025
|
+
const u = parseUsage(parsed);
|
|
23026
|
+
if (u) if (!acc.usage) acc.usage = { ...u };
|
|
23027
|
+
else {
|
|
23028
|
+
const a = acc.usage;
|
|
23029
|
+
if (u.cacheRead > 0) a.cacheRead = u.cacheRead;
|
|
23030
|
+
if (u.cacheCreate > 0) a.cacheCreate = u.cacheCreate;
|
|
23031
|
+
if (u.inputTokens > 0) a.inputTokens = u.inputTokens;
|
|
23032
|
+
}
|
|
23033
|
+
const rt = parseRouting(parsed);
|
|
23034
|
+
if (rt) acc.routing = rt;
|
|
23035
|
+
}
|
|
23036
|
+
/** Strip a leading UTF-8 BOM (U+FEFF) — trim() no longer removes it (ES2019). */
|
|
23037
|
+
function stripBom(s) {
|
|
23038
|
+
return s.charCodeAt(0) === 65279 ? s.slice(1) : s;
|
|
23039
|
+
}
|
|
23040
|
+
/** Parse one SSE `data:` line into the accumulator. Shared by the stateless and streaming folds. */
|
|
23041
|
+
function parseDataLine(line, acc) {
|
|
23042
|
+
const trimmed = stripBom(line).trim();
|
|
23043
|
+
if (!trimmed.startsWith("data:")) return;
|
|
23044
|
+
const payload = trimmed.slice(5).trim();
|
|
23045
|
+
if (payload === "" || payload === "[DONE]") return;
|
|
22911
23046
|
try {
|
|
22912
|
-
|
|
22913
|
-
|
|
22914
|
-
|
|
22915
|
-
|
|
22916
|
-
|
|
22917
|
-
|
|
22918
|
-
|
|
22919
|
-
|
|
23047
|
+
fold(JSON.parse(payload), acc);
|
|
23048
|
+
} catch {}
|
|
23049
|
+
}
|
|
23050
|
+
function extractFromFullText(text, isSSE) {
|
|
23051
|
+
if (!isSSE) try {
|
|
23052
|
+
const parsed = JSON.parse(stripBom(text));
|
|
23053
|
+
return {
|
|
23054
|
+
usage: parseUsage(parsed),
|
|
23055
|
+
routing: parseRouting(parsed)
|
|
22920
23056
|
};
|
|
22921
|
-
extractFromUsage(usage, result);
|
|
22922
|
-
return result;
|
|
22923
23057
|
} catch {
|
|
22924
|
-
return;
|
|
23058
|
+
return {};
|
|
23059
|
+
}
|
|
23060
|
+
const acc = {};
|
|
23061
|
+
for (const line of text.split("\n")) parseDataLine(line, acc);
|
|
23062
|
+
return acc;
|
|
23063
|
+
}
|
|
23064
|
+
/** Stateful fold over an SSE byte stream — O(1) memory, fragmentation-safe. */
|
|
23065
|
+
var SseUsageAccumulator = class {
|
|
23066
|
+
buffer = "";
|
|
23067
|
+
offset = 0;
|
|
23068
|
+
done = false;
|
|
23069
|
+
decoder = new TextDecoder();
|
|
23070
|
+
acc = {};
|
|
23071
|
+
feed(chunk) {
|
|
23072
|
+
if (this.done) return;
|
|
23073
|
+
this.buffer += this.decoder.decode(chunk, { stream: true });
|
|
23074
|
+
let nl = this.buffer.indexOf("\n", this.offset);
|
|
23075
|
+
while (nl !== -1) {
|
|
23076
|
+
this.process(this.buffer.slice(this.offset, nl));
|
|
23077
|
+
this.offset = nl + 1;
|
|
23078
|
+
nl = this.buffer.indexOf("\n", this.offset);
|
|
23079
|
+
}
|
|
23080
|
+
if (this.offset > 0) {
|
|
23081
|
+
this.buffer = this.buffer.slice(this.offset);
|
|
23082
|
+
this.offset = 0;
|
|
23083
|
+
}
|
|
22925
23084
|
}
|
|
22926
|
-
|
|
22927
|
-
|
|
22928
|
-
|
|
22929
|
-
|
|
22930
|
-
|
|
22931
|
-
|
|
22932
|
-
|
|
22933
|
-
|
|
22934
|
-
return result.cacheRead + result.cacheCreate > before;
|
|
22935
|
-
}
|
|
22936
|
-
/** @internal */
|
|
22937
|
-
function extractCacheUsageFromSSE(fullText) {
|
|
22938
|
-
const result = {
|
|
22939
|
-
cacheRead: 0,
|
|
22940
|
-
cacheCreate: 0,
|
|
22941
|
-
inputTokens: 0
|
|
22942
|
-
};
|
|
22943
|
-
let found = false;
|
|
22944
|
-
for (const line of fullText.split("\n")) {
|
|
22945
|
-
if (!line.startsWith("data:")) continue;
|
|
22946
|
-
const payload = line.slice(5).trim();
|
|
22947
|
-
if (payload === "[DONE]") continue;
|
|
22948
|
-
try {
|
|
22949
|
-
if (extractFromEvent(JSON.parse(payload), result)) found = true;
|
|
22950
|
-
} catch {}
|
|
23085
|
+
result() {
|
|
23086
|
+
if (this.done) return this.acc;
|
|
23087
|
+
this.done = true;
|
|
23088
|
+
this.buffer += this.decoder.decode();
|
|
23089
|
+
if (this.buffer.length > 0) this.process(this.buffer.slice(this.offset));
|
|
23090
|
+
this.buffer = "";
|
|
23091
|
+
this.offset = 0;
|
|
23092
|
+
return this.acc;
|
|
22951
23093
|
}
|
|
22952
|
-
|
|
22953
|
-
|
|
22954
|
-
|
|
22955
|
-
|
|
22956
|
-
|
|
22957
|
-
|
|
22958
|
-
|
|
22959
|
-
|
|
22960
|
-
|
|
22961
|
-
|
|
22962
|
-
|
|
22963
|
-
|
|
22964
|
-
const
|
|
22965
|
-
|
|
22966
|
-
|
|
22967
|
-
|
|
22968
|
-
|
|
22969
|
-
|
|
23094
|
+
process(line) {
|
|
23095
|
+
parseDataLine(line, this.acc);
|
|
23096
|
+
}
|
|
23097
|
+
};
|
|
23098
|
+
//#endregion
|
|
23099
|
+
//#region src/proxy/cache-logging.ts
|
|
23100
|
+
/**
|
|
23101
|
+
* Wraps the upstream body so usage/routing is observed once on ANY termination
|
|
23102
|
+
* path (clean close, client cancel, upstream error) via finalize() — a bare
|
|
23103
|
+
* TransformStream skips flush() on upstream errors, orphaning the dump.
|
|
23104
|
+
*/
|
|
23105
|
+
function createLoggingStream(source, contentType, status, ctx) {
|
|
23106
|
+
const accumulator = contentType.toLowerCase().includes("text/event-stream") ? new SseUsageAccumulator() : void 0;
|
|
23107
|
+
const decoder = new TextDecoder();
|
|
23108
|
+
const chunks = [];
|
|
23109
|
+
let finalized = false;
|
|
23110
|
+
const finalize = () => {
|
|
23111
|
+
if (finalized) return;
|
|
23112
|
+
finalized = true;
|
|
23113
|
+
try {
|
|
23114
|
+
let extracted;
|
|
23115
|
+
if (accumulator) extracted = accumulator.result();
|
|
22970
23116
|
else {
|
|
22971
|
-
|
|
22972
|
-
|
|
22973
|
-
|
|
23117
|
+
let text = "";
|
|
23118
|
+
for (const c of chunks) text += decoder.decode(c, { stream: true });
|
|
23119
|
+
text += decoder.decode();
|
|
23120
|
+
extracted = extractFromFullText(text, false);
|
|
22974
23121
|
}
|
|
22975
|
-
|
|
22976
|
-
|
|
23122
|
+
ctx.observability.observe(ctx.reqCtx, extracted, status);
|
|
23123
|
+
} catch (err) {
|
|
23124
|
+
logger.debug(withReq(ctx.reqCtx.reqId, `Cache observability failed: ${err instanceof Error ? err.message : err}`));
|
|
23125
|
+
}
|
|
23126
|
+
};
|
|
23127
|
+
const reader = source.getReader();
|
|
23128
|
+
return new ReadableStream({
|
|
23129
|
+
async pull(controller) {
|
|
22977
23130
|
try {
|
|
22978
|
-
const
|
|
22979
|
-
|
|
22980
|
-
|
|
22981
|
-
|
|
22982
|
-
|
|
23131
|
+
const { done, value } = await reader.read();
|
|
23132
|
+
if (done) {
|
|
23133
|
+
finalize();
|
|
23134
|
+
controller.close();
|
|
23135
|
+
return;
|
|
23136
|
+
}
|
|
23137
|
+
if (value) {
|
|
23138
|
+
if (accumulator) accumulator.feed(value);
|
|
23139
|
+
else chunks.push(value);
|
|
23140
|
+
controller.enqueue(value);
|
|
23141
|
+
}
|
|
22983
23142
|
} catch (err) {
|
|
22984
|
-
|
|
23143
|
+
finalize();
|
|
23144
|
+
controller.error(err);
|
|
22985
23145
|
}
|
|
23146
|
+
},
|
|
23147
|
+
cancel() {
|
|
23148
|
+
finalize();
|
|
23149
|
+
reader.cancel().catch(() => {});
|
|
22986
23150
|
}
|
|
22987
23151
|
});
|
|
22988
23152
|
}
|
|
22989
|
-
function buildUpstreamResponseWithLogging(upstream, method,
|
|
23153
|
+
function buildUpstreamResponseWithLogging(upstream, method, ctx) {
|
|
22990
23154
|
const headers = buildResponseHeaders(upstream.headers);
|
|
22991
|
-
if (method === "HEAD" || !upstream.body)
|
|
22992
|
-
|
|
22993
|
-
|
|
22994
|
-
|
|
23155
|
+
if (method === "HEAD" || !upstream.body) {
|
|
23156
|
+
ctx.observability.observe(ctx.reqCtx, {}, upstream.status);
|
|
23157
|
+
return new Response(null, {
|
|
23158
|
+
status: upstream.status,
|
|
23159
|
+
headers
|
|
23160
|
+
});
|
|
23161
|
+
}
|
|
22995
23162
|
const contentType = upstream.headers.get("content-type") ?? "";
|
|
22996
23163
|
const lower = contentType.toLowerCase();
|
|
22997
|
-
|
|
23164
|
+
if (!(lower.includes("application/json") || lower.includes("text/event-stream"))) {
|
|
23165
|
+
ctx.observability.observe(ctx.reqCtx, {}, upstream.status);
|
|
23166
|
+
return new Response(upstream.body, {
|
|
23167
|
+
status: upstream.status,
|
|
23168
|
+
headers
|
|
23169
|
+
});
|
|
23170
|
+
}
|
|
23171
|
+
const body = createLoggingStream(upstream.body, contentType, upstream.status, ctx);
|
|
22998
23172
|
return new Response(body, {
|
|
22999
23173
|
status: upstream.status,
|
|
23000
23174
|
headers
|
|
23001
23175
|
});
|
|
23002
23176
|
}
|
|
23003
23177
|
//#endregion
|
|
23178
|
+
//#region src/proxy/observability/classify.ts
|
|
23179
|
+
function classifyRequestType(ctx, opts) {
|
|
23180
|
+
const budget = ctx.maxTokens === void 0 || ctx.maxTokens <= 0 ? Number.POSITIVE_INFINITY : ctx.maxTokens;
|
|
23181
|
+
return ctx.toolsCount === 0 && budget <= opts.sideMaxTokens ? "side" : "main";
|
|
23182
|
+
}
|
|
23183
|
+
function classifyCacheOutcome(usage, ctx, opts) {
|
|
23184
|
+
if (!usage.present) return {
|
|
23185
|
+
label: "NOUSAGE",
|
|
23186
|
+
type: ctx.requestType,
|
|
23187
|
+
hitPct: 0
|
|
23188
|
+
};
|
|
23189
|
+
const raw = usage.inputTokens > 0 ? usage.cacheRead / usage.inputTokens * 100 : 0;
|
|
23190
|
+
const hitPct = Math.min(100, raw);
|
|
23191
|
+
let label;
|
|
23192
|
+
if (usage.cacheRead === 0) label = ctx.isFirstForSession ? "COLD" : "MISS";
|
|
23193
|
+
else label = hitPct >= opts.hitThresholdPct ? "HIT" : "PARTIAL";
|
|
23194
|
+
return {
|
|
23195
|
+
label,
|
|
23196
|
+
type: ctx.requestType,
|
|
23197
|
+
hitPct: Number(hitPct.toFixed(1))
|
|
23198
|
+
};
|
|
23199
|
+
}
|
|
23200
|
+
//#endregion
|
|
23004
23201
|
//#region src/proxy/utils/error.ts
|
|
23005
23202
|
function formatMetadata(meta) {
|
|
23006
23203
|
const parts = [];
|
|
@@ -23031,7 +23228,7 @@ function extractErrorDetail(bodyText) {
|
|
|
23031
23228
|
//#endregion
|
|
23032
23229
|
//#region src/proxy/middleware/forward-request.ts
|
|
23033
23230
|
const DUPLEX_HALF = { duplex: "half" };
|
|
23034
|
-
function buildErrorResponse(err, ctx) {
|
|
23231
|
+
function buildErrorResponse(err, ctx, observeUnhandled) {
|
|
23035
23232
|
if (err instanceof TypeError) {
|
|
23036
23233
|
logger.error(withReq(ctx.reqId, "Upstream fetch error:"), err);
|
|
23037
23234
|
return Response.json({ error: {
|
|
@@ -23043,6 +23240,7 @@ function buildErrorResponse(err, ctx) {
|
|
|
23043
23240
|
logger.warn(withReq(ctx.reqId, `Aborted: ${ctx.method} ${ctx.path}`));
|
|
23044
23241
|
return new Response(null, { status: 499 });
|
|
23045
23242
|
}
|
|
23243
|
+
observeUnhandled?.();
|
|
23046
23244
|
throw err;
|
|
23047
23245
|
}
|
|
23048
23246
|
async function buildUpstreamErrorResponse(upstream, ctx) {
|
|
@@ -23051,14 +23249,16 @@ async function buildUpstreamErrorResponse(upstream, ctx) {
|
|
|
23051
23249
|
const truncated = detail.length > 300 ? `${detail.slice(0, 300)}…` : detail;
|
|
23052
23250
|
(upstream.status >= 500 ? logger.error : logger.warn)(withReq(ctx.reqId, `${ctx.method} ${ctx.path} ← ${upstream.status} (${Date.now() - ctx.startedAt}ms): ${truncated}`));
|
|
23053
23251
|
const responseHeaders = buildResponseHeaders(upstream.headers);
|
|
23054
|
-
|
|
23055
|
-
|
|
23056
|
-
|
|
23057
|
-
|
|
23058
|
-
|
|
23059
|
-
|
|
23060
|
-
|
|
23061
|
-
|
|
23252
|
+
return {
|
|
23253
|
+
response: ctx.method === "HEAD" ? new Response(null, {
|
|
23254
|
+
status: upstream.status,
|
|
23255
|
+
headers: responseHeaders
|
|
23256
|
+
}) : new Response(bodyText, {
|
|
23257
|
+
status: upstream.status,
|
|
23258
|
+
headers: responseHeaders
|
|
23259
|
+
}),
|
|
23260
|
+
extracted: extractFromFullText(bodyText, false)
|
|
23261
|
+
};
|
|
23062
23262
|
}
|
|
23063
23263
|
const forwardRequest = createMiddleware(async (c) => {
|
|
23064
23264
|
const { upstreamUrl, forwardBody, upstreamHeaders, reqId, path, startedAt, method } = c.var;
|
|
@@ -23075,13 +23275,30 @@ const forwardRequest = createMiddleware(async (c) => {
|
|
|
23075
23275
|
const upstreamShort = upstreamUrl.replace(/^https?:\/\//, "");
|
|
23076
23276
|
const modelLog = c.var.modelName ? ` model=${c.var.modelName}` : "";
|
|
23077
23277
|
logger.info(withReq(reqId, `${method} ${path} → ${upstreamShort}${ctx.bodyMutated ? " [inject]" : ""}${modelLog}`));
|
|
23078
|
-
|
|
23278
|
+
const dumpPath = dumpEnabled() ? dumpRequest({
|
|
23079
23279
|
reqId,
|
|
23080
23280
|
method,
|
|
23081
23281
|
path,
|
|
23082
23282
|
model: c.var.modelName,
|
|
23083
23283
|
forwardBody
|
|
23084
|
-
});
|
|
23284
|
+
}) : void 0;
|
|
23285
|
+
const parsedBody = c.var.parsedBody;
|
|
23286
|
+
const toolsCount = Array.isArray(parsedBody?.tools) ? parsedBody.tools.length : 0;
|
|
23287
|
+
const maxTokens = parsedBody?.max_tokens ?? parsedBody?.max_completion_tokens ?? parsedBody?.max_output_tokens;
|
|
23288
|
+
const requestType = classifyRequestType({
|
|
23289
|
+
toolsCount,
|
|
23290
|
+
maxTokens
|
|
23291
|
+
}, { sideMaxTokens: c.var.config.observability.sideMaxTokens });
|
|
23292
|
+
const reqCtx = {
|
|
23293
|
+
reqId,
|
|
23294
|
+
model: c.var.modelName ?? "",
|
|
23295
|
+
sessionId: c.var.effectiveSessionId,
|
|
23296
|
+
toolsCount,
|
|
23297
|
+
maxTokens,
|
|
23298
|
+
requestType,
|
|
23299
|
+
dumpPath
|
|
23300
|
+
};
|
|
23301
|
+
const observability = c.var.observability;
|
|
23085
23302
|
let upstream;
|
|
23086
23303
|
try {
|
|
23087
23304
|
upstream = await fetch(upstreamUrl, {
|
|
@@ -23092,13 +23309,32 @@ const forwardRequest = createMiddleware(async (c) => {
|
|
|
23092
23309
|
...forwardBody ? DUPLEX_HALF : {}
|
|
23093
23310
|
});
|
|
23094
23311
|
} catch (err) {
|
|
23095
|
-
|
|
23312
|
+
const response = buildErrorResponse(err, ctx, () => observability.observe(reqCtx, {}, 500));
|
|
23313
|
+
observability.observe(reqCtx, {}, response.status);
|
|
23314
|
+
return response;
|
|
23096
23315
|
} finally {
|
|
23097
23316
|
c.req.raw.signal.removeEventListener("abort", onClientAbort);
|
|
23098
23317
|
}
|
|
23099
|
-
if (upstream.status >= 400)
|
|
23318
|
+
if (upstream.status >= 400) {
|
|
23319
|
+
let extracted = {};
|
|
23320
|
+
let response;
|
|
23321
|
+
try {
|
|
23322
|
+
({response, extracted} = await buildUpstreamErrorResponse(upstream, ctx));
|
|
23323
|
+
} catch {
|
|
23324
|
+
logger.debug(withReq(reqId, `upstream ${upstream.status} error body unreadable; observing without detail`));
|
|
23325
|
+
response = new Response(null, {
|
|
23326
|
+
status: upstream.status,
|
|
23327
|
+
headers: buildResponseHeaders(upstream.headers)
|
|
23328
|
+
});
|
|
23329
|
+
}
|
|
23330
|
+
observability.observe(reqCtx, extracted, upstream.status);
|
|
23331
|
+
return response;
|
|
23332
|
+
}
|
|
23100
23333
|
logger.info(withReq(reqId, `${method} ${path} ← ${upstream.status} (${Date.now() - startedAt}ms)`));
|
|
23101
|
-
return buildUpstreamResponseWithLogging(upstream, method,
|
|
23334
|
+
return buildUpstreamResponseWithLogging(upstream, method, {
|
|
23335
|
+
reqCtx,
|
|
23336
|
+
observability
|
|
23337
|
+
});
|
|
23102
23338
|
});
|
|
23103
23339
|
//#endregion
|
|
23104
23340
|
//#region src/proxy/paths.ts
|
|
@@ -23435,6 +23671,172 @@ const setupRequest = createMiddleware(async (c, next) => {
|
|
|
23435
23671
|
await next();
|
|
23436
23672
|
});
|
|
23437
23673
|
//#endregion
|
|
23674
|
+
//#region src/proxy/observability/session-tracker.ts
|
|
23675
|
+
var SessionTracker = class {
|
|
23676
|
+
seen = /* @__PURE__ */ new Map();
|
|
23677
|
+
constructor(opts, now = Date.now) {
|
|
23678
|
+
this.opts = opts;
|
|
23679
|
+
this.now = now;
|
|
23680
|
+
}
|
|
23681
|
+
opts;
|
|
23682
|
+
now;
|
|
23683
|
+
isFirstAndRemember(sessionId) {
|
|
23684
|
+
if (sessionId === void 0) return true;
|
|
23685
|
+
const t = this.now();
|
|
23686
|
+
const prev = this.seen.get(sessionId);
|
|
23687
|
+
const fresh = prev !== void 0 && t - prev < this.opts.ttlMs;
|
|
23688
|
+
if (prev !== void 0) this.seen.delete(sessionId);
|
|
23689
|
+
else if (this.seen.size >= this.opts.maxEntries) this.evictOne();
|
|
23690
|
+
this.seen.set(sessionId, t);
|
|
23691
|
+
return !fresh;
|
|
23692
|
+
}
|
|
23693
|
+
/** Drop the LRU entry (first by Map order). A refresh moves the key to the
|
|
23694
|
+
* end, so iteration order is lastSeen order — the first entry is most stale. */
|
|
23695
|
+
evictOne() {
|
|
23696
|
+
const oldest = this.seen.keys().next().value;
|
|
23697
|
+
if (oldest !== void 0) this.seen.delete(oldest);
|
|
23698
|
+
}
|
|
23699
|
+
/** Apply a reloaded capacity/TTL without discarding remembered sessions —
|
|
23700
|
+
* shrinking evicts LRU entries, growing is a no-op, TTL applies on the next
|
|
23701
|
+
* freshness check. Rebuilding would wipe the map and misclassify as COLD. */
|
|
23702
|
+
applyConfig(opts) {
|
|
23703
|
+
this.opts = opts;
|
|
23704
|
+
while (this.seen.size > opts.maxEntries) this.evictOne();
|
|
23705
|
+
}
|
|
23706
|
+
};
|
|
23707
|
+
//#endregion
|
|
23708
|
+
//#region src/proxy/observability/sinks.ts
|
|
23709
|
+
const wrap = (code) => (s) => `\x1b[${code}m${s}\x1b[0m`;
|
|
23710
|
+
const PAINT = {
|
|
23711
|
+
HIT: wrap("32"),
|
|
23712
|
+
PARTIAL: wrap("33"),
|
|
23713
|
+
MISS: wrap("31"),
|
|
23714
|
+
COLD: wrap("2"),
|
|
23715
|
+
NOUSAGE: wrap("90")
|
|
23716
|
+
};
|
|
23717
|
+
function colorizeLabel(label, useColor) {
|
|
23718
|
+
return useColor ? PAINT[label](label) : label;
|
|
23719
|
+
}
|
|
23720
|
+
function formatLine(obs, useColor = false) {
|
|
23721
|
+
const { label, hitPct } = obs.outcome;
|
|
23722
|
+
const parts = [colorizeLabel(label, useColor)];
|
|
23723
|
+
if (label === "HIT" || label === "PARTIAL") parts.push(`${hitPct.toFixed(0)}%`);
|
|
23724
|
+
if (obs.usage.cacheRead > 0) parts.push(`read ${obs.usage.cacheRead}`);
|
|
23725
|
+
if (obs.usage.cacheCreate > 0) parts.push(`write ${obs.usage.cacheCreate}`);
|
|
23726
|
+
if (obs.usage.present) parts.push(`in ${obs.usage.inputTokens}`);
|
|
23727
|
+
if (obs.routing) parts.push(`provider=${obs.routing.provider}`);
|
|
23728
|
+
if (obs.model) parts.push(obs.model);
|
|
23729
|
+
parts.push(`[${obs.requestType}]`);
|
|
23730
|
+
return withReq(obs.reqId, parts.join(" "));
|
|
23731
|
+
}
|
|
23732
|
+
var LiveLineSink = class {
|
|
23733
|
+
useColor;
|
|
23734
|
+
constructor(useColor = () => process.stdout.isTTY === true) {
|
|
23735
|
+
this.useColor = useColor;
|
|
23736
|
+
}
|
|
23737
|
+
emit(obs) {
|
|
23738
|
+
logger.info(formatLine(obs, this.useColor()));
|
|
23739
|
+
}
|
|
23740
|
+
};
|
|
23741
|
+
var DumpSink = class {
|
|
23742
|
+
inflight = 0;
|
|
23743
|
+
waiters = [];
|
|
23744
|
+
maxConcurrent;
|
|
23745
|
+
maxWaiters;
|
|
23746
|
+
dump;
|
|
23747
|
+
enabled;
|
|
23748
|
+
constructor(deps = {}) {
|
|
23749
|
+
this.maxConcurrent = deps.maxConcurrent ?? 16;
|
|
23750
|
+
this.maxWaiters = deps.maxWaiters ?? 256;
|
|
23751
|
+
this.dump = deps.dump ?? dumpResponse;
|
|
23752
|
+
this.enabled = deps.enabled ?? dumpEnabled;
|
|
23753
|
+
}
|
|
23754
|
+
emit(obs) {
|
|
23755
|
+
if (!this.enabled()) return;
|
|
23756
|
+
const run = () => {
|
|
23757
|
+
this.inflight += 1;
|
|
23758
|
+
this.dump(obs).catch((err) => {
|
|
23759
|
+
logger.debug(withReq(obs.reqId, `DumpSink failed: ${err instanceof Error ? err.message : err}`));
|
|
23760
|
+
}).finally(() => {
|
|
23761
|
+
this.inflight -= 1;
|
|
23762
|
+
const next = this.waiters.shift();
|
|
23763
|
+
if (next) next();
|
|
23764
|
+
});
|
|
23765
|
+
};
|
|
23766
|
+
if (this.inflight < this.maxConcurrent) run();
|
|
23767
|
+
else if (this.waiters.length < this.maxWaiters) this.waiters.push(run);
|
|
23768
|
+
else logger.debug(withReq(obs.reqId, "DumpSink queue full — dump dropped"));
|
|
23769
|
+
}
|
|
23770
|
+
};
|
|
23771
|
+
//#endregion
|
|
23772
|
+
//#region src/proxy/observability/observability.ts
|
|
23773
|
+
var Observability = class {
|
|
23774
|
+
tracker;
|
|
23775
|
+
sinks;
|
|
23776
|
+
hitThresholdPct;
|
|
23777
|
+
constructor(tracker, sinks, hitThresholdPct) {
|
|
23778
|
+
this.tracker = tracker;
|
|
23779
|
+
this.sinks = sinks;
|
|
23780
|
+
this.hitThresholdPct = hitThresholdPct;
|
|
23781
|
+
}
|
|
23782
|
+
/** Classify and dispatch one observation to every sink. Never throws: the
|
|
23783
|
+
* response pipeline calls this on many termination paths, so a failure must
|
|
23784
|
+
* degrade to a debug log, not escape to the client. */
|
|
23785
|
+
observe(req, extracted, status) {
|
|
23786
|
+
try {
|
|
23787
|
+
const usage = extracted.usage ?? {
|
|
23788
|
+
present: false,
|
|
23789
|
+
inputTokens: 0,
|
|
23790
|
+
cacheRead: 0,
|
|
23791
|
+
cacheCreate: 0
|
|
23792
|
+
};
|
|
23793
|
+
const isFirst = this.tracker.isFirstAndRemember(req.sessionId);
|
|
23794
|
+
const outcome = classifyCacheOutcome(usage, {
|
|
23795
|
+
requestType: req.requestType,
|
|
23796
|
+
isFirstForSession: isFirst
|
|
23797
|
+
}, { hitThresholdPct: this.hitThresholdPct });
|
|
23798
|
+
const obs = {
|
|
23799
|
+
dumpPath: req.dumpPath,
|
|
23800
|
+
reqId: req.reqId,
|
|
23801
|
+
status,
|
|
23802
|
+
model: req.model,
|
|
23803
|
+
sessionId: req.sessionId,
|
|
23804
|
+
requestType: req.requestType,
|
|
23805
|
+
toolsCount: req.toolsCount,
|
|
23806
|
+
usage,
|
|
23807
|
+
outcome,
|
|
23808
|
+
routing: extracted.routing
|
|
23809
|
+
};
|
|
23810
|
+
for (const sink of this.sinks) try {
|
|
23811
|
+
sink.emit(obs);
|
|
23812
|
+
} catch (err) {
|
|
23813
|
+
logger.debug(withReq(obs.reqId, `Observability sink failed: ${err instanceof Error ? err.message : err}`));
|
|
23814
|
+
}
|
|
23815
|
+
} catch (err) {
|
|
23816
|
+
logger.debug(withReq(req.reqId, `Observability observe failed: ${err instanceof Error ? err.message : err}`));
|
|
23817
|
+
}
|
|
23818
|
+
}
|
|
23819
|
+
/** Apply a hot-reloaded config in place — must not discard remembered
|
|
23820
|
+
* sessions, or an unrelated reload misclassifies the next request as COLD. */
|
|
23821
|
+
reconfigure(config) {
|
|
23822
|
+
const o = config.observability;
|
|
23823
|
+
this.hitThresholdPct = o.hitThreshold;
|
|
23824
|
+
this.tracker.applyConfig({
|
|
23825
|
+
maxEntries: o.sessionMaxEntries,
|
|
23826
|
+
ttlMs: o.sessionTtlMs
|
|
23827
|
+
});
|
|
23828
|
+
}
|
|
23829
|
+
};
|
|
23830
|
+
function createObservability(config, sinks) {
|
|
23831
|
+
const o = config.observability;
|
|
23832
|
+
const tracker = new SessionTracker({
|
|
23833
|
+
maxEntries: o.sessionMaxEntries,
|
|
23834
|
+
ttlMs: o.sessionTtlMs
|
|
23835
|
+
});
|
|
23836
|
+
const built = [new LiveLineSink(), new DumpSink()];
|
|
23837
|
+
return new Observability(tracker, sinks ?? built, o.hitThreshold);
|
|
23838
|
+
}
|
|
23839
|
+
//#endregion
|
|
23438
23840
|
//#region src/proxy.ts
|
|
23439
23841
|
const injectChain = [
|
|
23440
23842
|
parseBody,
|
|
@@ -23446,8 +23848,11 @@ const injectChain = [
|
|
|
23446
23848
|
];
|
|
23447
23849
|
function createProxyServer(source, onReady) {
|
|
23448
23850
|
const app = new Hono();
|
|
23851
|
+
const observability = createObservability(source.get());
|
|
23852
|
+
source.subscribe((config) => observability.reconfigure(config));
|
|
23449
23853
|
app.use("*", async (c, next) => {
|
|
23450
23854
|
c.set("config", source.get());
|
|
23855
|
+
c.set("observability", observability);
|
|
23451
23856
|
await next();
|
|
23452
23857
|
});
|
|
23453
23858
|
app.get("/health", (c) => {
|