jh-web-gateway 2.1.1 → 2.2.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.
@@ -59,7 +59,7 @@ async function findOrOpenJhPage(browser) {
59
59
  const contexts = browser.contexts();
60
60
  const context = contexts.length > 0 ? contexts[0] : await browser.newContext();
61
61
  const page = await context.newPage();
62
- await page.goto(JH_URL);
62
+ await page.goto(JH_URL, { waitUntil: "domcontentloaded", timeout: 3e4 });
63
63
  return page;
64
64
  }
65
65
 
@@ -638,102 +638,103 @@ async function sendChatRequestInner(page, credentials, request, options, isRetry
638
638
  }
639
639
  };
640
640
  await page.route(streamPattern, routeHandler);
641
- const postResult = await page.evaluate(
642
- async ({
643
- apiBase,
644
- bearerToken,
645
- endpointPath,
646
- requestBody
647
- }) => {
648
- try {
649
- const res = await fetch(`${apiBase}/agents/chat/${endpointPath}`, {
650
- method: "POST",
651
- headers: {
652
- "Content-Type": "application/json",
653
- Accept: "text/event-stream",
654
- Authorization: `Bearer ${bearerToken}`
655
- },
656
- body: JSON.stringify(requestBody)
657
- });
658
- if (!res.ok) {
659
- return {
660
- error: true,
661
- status: res.status,
662
- statusText: res.statusText,
663
- body: (await res.text()).slice(0, 2e3),
664
- contentType: ""
665
- };
666
- }
667
- const ct = res.headers.get("content-type") ?? "";
668
- if (ct.includes("text/event-stream")) {
669
- const reader = res.body?.getReader();
670
- if (!reader) return { error: true, status: 500, statusText: "No body", body: "No SSE body", contentType: ct };
671
- const decoder = new TextDecoder();
672
- let text = "";
673
- while (true) {
674
- const { done, value } = await reader.read();
675
- if (done) break;
676
- text += decoder.decode(value, { stream: true });
641
+ let result;
642
+ try {
643
+ const postResult = await page.evaluate(
644
+ async ({
645
+ apiBase,
646
+ bearerToken,
647
+ endpointPath,
648
+ requestBody
649
+ }) => {
650
+ try {
651
+ const res = await fetch(`${apiBase}/agents/chat/${endpointPath}`, {
652
+ method: "POST",
653
+ headers: {
654
+ "Content-Type": "application/json",
655
+ Accept: "text/event-stream",
656
+ Authorization: `Bearer ${bearerToken}`
657
+ },
658
+ body: JSON.stringify(requestBody)
659
+ });
660
+ if (!res.ok) {
661
+ return {
662
+ error: true,
663
+ status: res.status,
664
+ statusText: res.statusText,
665
+ body: (await res.text()).slice(0, 2e3),
666
+ contentType: ""
667
+ };
677
668
  }
678
- return { error: false, status: 200, statusText: "OK", body: text, contentType: ct };
669
+ const ct = res.headers.get("content-type") ?? "";
670
+ if (ct.includes("text/event-stream")) {
671
+ const reader = res.body?.getReader();
672
+ if (!reader) return { error: true, status: 500, statusText: "No body", body: "No SSE body", contentType: ct };
673
+ const decoder = new TextDecoder();
674
+ let text = "";
675
+ while (true) {
676
+ const { done, value } = await reader.read();
677
+ if (done) break;
678
+ text += decoder.decode(value, { stream: true });
679
+ }
680
+ return { error: false, status: 200, statusText: "OK", body: text, contentType: ct };
681
+ }
682
+ const bodyText = await res.text();
683
+ console.log(`[gateway] POST response content-type: ${ct}, body: ${bodyText.slice(0, 500)}`);
684
+ return { error: false, status: 200, statusText: "OK", body: bodyText, contentType: ct };
685
+ } catch (err) {
686
+ return { error: true, status: 500, statusText: "fetch error", body: String(err), contentType: "" };
679
687
  }
680
- const bodyText = await res.text();
681
- console.log(`[gateway] POST response content-type: ${ct}, body: ${bodyText.slice(0, 500)}`);
682
- return { error: false, status: 200, statusText: "OK", body: bodyText, contentType: ct };
683
- } catch (err) {
684
- return { error: true, status: 500, statusText: "fetch error", body: String(err), contentType: "" };
688
+ },
689
+ {
690
+ apiBase: JH_API_BASE,
691
+ bearerToken: credentials.bearerToken,
692
+ endpointPath: endpoint,
693
+ requestBody: body
685
694
  }
686
- },
687
- {
688
- apiBase: JH_API_BASE,
689
- bearerToken: credentials.bearerToken,
690
- endpointPath: endpoint,
691
- requestBody: body
692
- }
693
- );
694
- let result;
695
- if (postResult.error) {
696
- result = { error: true, status: postResult.status, statusText: postResult.statusText ?? "", body: postResult.body };
697
- await page.unroute(streamPattern, routeHandler);
698
- } else if (postResult.contentType.includes("text/event-stream")) {
699
- result = { error: false, status: 200, statusText: "OK", body: postResult.body };
700
- await page.unroute(streamPattern, routeHandler);
701
- } else {
702
- let streamId;
703
- try {
704
- streamId = JSON.parse(postResult.body).streamId;
705
- } catch {
706
- }
707
- if (!streamId) {
695
+ );
696
+ if (postResult.error) {
697
+ result = { error: true, status: postResult.status, statusText: postResult.statusText ?? "", body: postResult.body };
698
+ } else if (postResult.contentType.includes("text/event-stream")) {
708
699
  result = { error: false, status: 200, statusText: "OK", body: postResult.body };
709
- await page.unroute(streamPattern, routeHandler);
710
700
  } else {
711
- const streamUrl = `${JH_API_BASE}/agents/chat/stream/${streamId}`;
712
- page.evaluate(
713
- async ({ url, token }) => {
714
- await new Promise((r) => setTimeout(r, 10));
715
- try {
716
- await fetch(url, {
717
- method: "GET",
718
- headers: { Accept: "text/event-stream", Authorization: `Bearer ${token}` }
719
- });
720
- } catch {
721
- }
722
- },
723
- { url: streamUrl, token: credentials.bearerToken }
724
- ).catch(() => {
725
- });
726
- const timeout = new Promise(
727
- (res) => setTimeout(() => {
728
- if (!sseResolved) {
729
- sseResolved = true;
730
- res({ error: true, status: 408, statusText: "timeout", body: "Stream capture timed out after 120s" });
731
- }
732
- }, 12e4)
733
- );
734
- result = await Promise.race([ssePromise, timeout]);
735
- await page.unroute(streamPattern, routeHandler);
701
+ let streamId;
702
+ try {
703
+ streamId = JSON.parse(postResult.body).streamId;
704
+ } catch {
705
+ }
706
+ if (!streamId) {
707
+ result = { error: false, status: 200, statusText: "OK", body: postResult.body };
708
+ } else {
709
+ const streamUrl = `${JH_API_BASE}/agents/chat/stream/${streamId}`;
710
+ page.evaluate(
711
+ async ({ url, token }) => {
712
+ await new Promise((r) => setTimeout(r, 10));
713
+ try {
714
+ await fetch(url, {
715
+ method: "GET",
716
+ headers: { Accept: "text/event-stream", Authorization: `Bearer ${token}` }
717
+ });
718
+ } catch {
719
+ }
720
+ },
721
+ { url: streamUrl, token: credentials.bearerToken }
722
+ ).catch(() => {
723
+ });
724
+ const timeout = new Promise(
725
+ (res) => setTimeout(() => {
726
+ if (!sseResolved) {
727
+ sseResolved = true;
728
+ res({ error: true, status: 408, statusText: "timeout", body: "Stream capture timed out after 120s" });
729
+ }
730
+ }, 12e4)
731
+ );
732
+ result = await Promise.race([ssePromise, timeout]);
733
+ }
736
734
  }
735
+ } finally {
736
+ await page.unroute(streamPattern, routeHandler).catch(() => {
737
+ });
737
738
  }
738
739
  if (result.error) {
739
740
  const status = result.status;
@@ -741,7 +742,7 @@ async function sendChatRequestInner(page, credentials, request, options, isRetry
741
742
  if (status === 401 && !isRetry) {
742
743
  const cdpUrl = options?.cdpUrl ?? "http://127.0.0.1:9222";
743
744
  try {
744
- await page.reload({ waitUntil: "networkidle" });
745
+ await page.reload({ waitUntil: "commit" });
745
746
  const fresh = await captureCredentials(cdpUrl, 3e4);
746
747
  const newCreds = {
747
748
  bearerToken: fresh.bearerToken,
@@ -849,11 +850,12 @@ function modelsRouter(_config) {
849
850
 
850
851
  // src/routes/health.ts
851
852
  import { Hono as Hono2 } from "hono";
852
- function healthRouter(config, startTime) {
853
+ function healthRouter(config, startTime, deps) {
853
854
  const app = new Hono2();
854
855
  app.get("/", (c) => {
855
856
  const uptime = (Date.now() - startTime) / 1e3;
856
- const tokenExpiry = config.credentials?.bearerToken ? getTokenExpiry(config.credentials.bearerToken) || null : null;
857
+ const liveCreds = deps?.getCredentials?.() ?? config.credentials;
858
+ const tokenExpiry = liveCreds?.bearerToken ? getTokenExpiry(liveCreds.bearerToken) || null : null;
857
859
  const tokenExpired = tokenExpiry !== null && Date.now() / 1e3 > tokenExpiry;
858
860
  return c.json({
859
861
  status: "ok",
@@ -958,6 +960,7 @@ import { randomBytes as randomBytes2 } from "crypto";
958
960
  // src/core/tool-parser.ts
959
961
  var TOOL_CALL_RE = /<tool_call\s+id="([^"]*)"\s+name="([^"]*)">([\s\S]*?)<\/tool_call>/g;
960
962
  var THINK_RE = /<think>([\s\S]*?)<\/think>/g;
963
+ var PARTIAL_TOOL_CALL_RE = /<tool_call\b[^>]*>(?:(?!<\/tool_call>)[\s\S])*$/;
961
964
  function parseToolsAndThinking(text) {
962
965
  const toolCalls = [];
963
966
  let thinking = null;
@@ -999,6 +1002,55 @@ function toOpenAIToolCalls(calls) {
999
1002
  }
1000
1003
  }));
1001
1004
  }
1005
+ var StreamingToolBuffer = class {
1006
+ buffer = "";
1007
+ /**
1008
+ * Push a text chunk. Returns an object with:
1009
+ * - `text`: safe-to-emit text (outside any partial tag)
1010
+ * - `completedCalls`: fully parsed tool calls from this chunk
1011
+ */
1012
+ push(chunk) {
1013
+ this.buffer += chunk;
1014
+ const completedCalls = [];
1015
+ let safeText = "";
1016
+ let match;
1017
+ const re = new RegExp(TOOL_CALL_RE.source, "g");
1018
+ let lastIndex = 0;
1019
+ while ((match = re.exec(this.buffer)) !== null) {
1020
+ safeText += this.buffer.slice(lastIndex, match.index);
1021
+ const id = match[1];
1022
+ const name = match[2];
1023
+ const rawArgs = match[3].trim();
1024
+ let args;
1025
+ try {
1026
+ JSON.parse(rawArgs);
1027
+ args = rawArgs;
1028
+ } catch {
1029
+ args = JSON.stringify(rawArgs);
1030
+ }
1031
+ completedCalls.push({ id, name, arguments: args });
1032
+ lastIndex = match.index + match[0].length;
1033
+ }
1034
+ const remainder = this.buffer.slice(lastIndex);
1035
+ if (PARTIAL_TOOL_CALL_RE.test(remainder)) {
1036
+ const partialStart = remainder.search(/<tool_call\b/);
1037
+ if (partialStart > 0) {
1038
+ safeText += remainder.slice(0, partialStart);
1039
+ }
1040
+ this.buffer = partialStart >= 0 ? remainder.slice(partialStart) : remainder;
1041
+ } else {
1042
+ safeText += remainder;
1043
+ this.buffer = "";
1044
+ }
1045
+ return { text: safeText, completedCalls };
1046
+ }
1047
+ /** Flush any remaining buffer content as raw text (end of stream). */
1048
+ flush() {
1049
+ const remaining = this.buffer;
1050
+ this.buffer = "";
1051
+ return remaining;
1052
+ }
1053
+ };
1002
1054
 
1003
1055
  // src/core/stream-translator.ts
1004
1056
  function parseSseEvents(rawSse) {
@@ -1107,6 +1159,9 @@ function translateToStream(rawSse, model, completionId) {
1107
1159
  const events = parseSseEvents(rawSse);
1108
1160
  const chunks = [];
1109
1161
  let lastMessageText = "";
1162
+ const toolBuf = new StreamingToolBuffer();
1163
+ let toolCallIndex = 0;
1164
+ let hasToolCalls = false;
1110
1165
  chunks.push({
1111
1166
  id,
1112
1167
  object: "chat.completion.chunk",
@@ -1115,6 +1170,37 @@ function translateToStream(rawSse, model, completionId) {
1115
1170
  choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }]
1116
1171
  });
1117
1172
  let gotDeltas = false;
1173
+ function processDelta(rawDelta) {
1174
+ const { text, completedCalls } = toolBuf.push(rawDelta);
1175
+ if (text) {
1176
+ chunks.push({
1177
+ id,
1178
+ object: "chat.completion.chunk",
1179
+ created,
1180
+ model,
1181
+ choices: [{ index: 0, delta: { content: text }, finish_reason: null }]
1182
+ });
1183
+ }
1184
+ for (const call of completedCalls) {
1185
+ hasToolCalls = true;
1186
+ const toolDelta = {
1187
+ index: toolCallIndex++,
1188
+ id: call.id,
1189
+ type: "function",
1190
+ function: {
1191
+ name: call.name,
1192
+ arguments: call.arguments
1193
+ }
1194
+ };
1195
+ chunks.push({
1196
+ id,
1197
+ object: "chat.completion.chunk",
1198
+ created,
1199
+ model,
1200
+ choices: [{ index: 0, delta: { tool_calls: [toolDelta] }, finish_reason: null }]
1201
+ });
1202
+ }
1203
+ }
1118
1204
  for (const ev of events) {
1119
1205
  const { type, parsed } = resolveEventType(ev);
1120
1206
  if (isUserEcho(parsed)) continue;
@@ -1123,13 +1209,7 @@ function translateToStream(rawSse, model, completionId) {
1123
1209
  const delta = extractDeltaText(parsed);
1124
1210
  if (delta !== null) {
1125
1211
  gotDeltas = true;
1126
- chunks.push({
1127
- id,
1128
- object: "chat.completion.chunk",
1129
- created,
1130
- model,
1131
- choices: [{ index: 0, delta: { content: delta }, finish_reason: null }]
1132
- });
1212
+ processDelta(delta);
1133
1213
  }
1134
1214
  }
1135
1215
  if (type === "message" || !type) {
@@ -1138,23 +1218,27 @@ function translateToStream(rawSse, model, completionId) {
1138
1218
  const delta = msgText.slice(lastMessageText.length);
1139
1219
  lastMessageText = msgText;
1140
1220
  if (!gotDeltas && delta) {
1141
- chunks.push({
1142
- id,
1143
- object: "chat.completion.chunk",
1144
- created,
1145
- model,
1146
- choices: [{ index: 0, delta: { content: delta }, finish_reason: null }]
1147
- });
1221
+ processDelta(delta);
1148
1222
  }
1149
1223
  }
1150
1224
  }
1151
1225
  }
1226
+ const flushed = toolBuf.flush();
1227
+ if (flushed) {
1228
+ chunks.push({
1229
+ id,
1230
+ object: "chat.completion.chunk",
1231
+ created,
1232
+ model,
1233
+ choices: [{ index: 0, delta: { content: flushed }, finish_reason: null }]
1234
+ });
1235
+ }
1152
1236
  chunks.push({
1153
1237
  id,
1154
1238
  object: "chat.completion.chunk",
1155
1239
  created,
1156
1240
  model,
1157
- choices: [{ index: 0, delta: {}, finish_reason: "stop" }]
1241
+ choices: [{ index: 0, delta: {}, finish_reason: hasToolCalls ? "tool_calls" : "stop" }]
1158
1242
  });
1159
1243
  return chunks;
1160
1244
  }
@@ -1177,7 +1261,7 @@ function translateToCompletion(rawSse, model, completionId) {
1177
1261
  object: "chat.completion",
1178
1262
  created,
1179
1263
  model,
1180
- choices: [{ index: 0, message, finish_reason: "stop" }],
1264
+ choices: [{ index: 0, message, finish_reason: parsed.toolCalls.length > 0 ? "tool_calls" : "stop" }],
1181
1265
  usage: {
1182
1266
  prompt_tokens: promptTokens,
1183
1267
  completion_tokens: completionTokens,
@@ -1288,11 +1372,14 @@ function chatCompletionsRouter(_config, deps) {
1288
1372
  const { page, queue, release } = acquired;
1289
1373
  const stats = pool.stats;
1290
1374
  console.log(`[chat] Acquired page (pool: ${stats.busy}/${stats.total} busy)`);
1375
+ const fullPrompt = built.systemPrompt ? `${built.systemPrompt}
1376
+
1377
+ ${built.prompt}` : built.prompt;
1291
1378
  try {
1292
1379
  const response = await queue.enqueue(
1293
1380
  () => sendChatRequest(page, credentials, {
1294
1381
  model,
1295
- prompt: built.prompt
1382
+ prompt: fullPrompt
1296
1383
  })
1297
1384
  );
1298
1385
  if (shouldStream) {
@@ -1349,7 +1436,7 @@ function chatCompletionsRouter(_config, deps) {
1349
1436
  const retryResponse = await retry.queue.enqueue(
1350
1437
  () => sendChatRequest(retry.page, freshCreds, {
1351
1438
  model,
1352
- prompt: built.prompt
1439
+ prompt: fullPrompt
1353
1440
  })
1354
1441
  );
1355
1442
  if (shouldStream) {
@@ -1559,7 +1646,7 @@ function createServer(config, deps) {
1559
1646
  });
1560
1647
  });
1561
1648
  app.route("/v1/models", modelsRouter(config));
1562
- app.route("/health", healthRouter(config, startTime));
1649
+ app.route("/health", healthRouter(config, startTime, deps ? { getCredentials: deps.getCredentials } : void 0));
1563
1650
  if (deps) {
1564
1651
  app.route(
1565
1652
  "/v1/chat/completions",
@@ -1638,14 +1725,6 @@ async function startServer(config, deps) {
1638
1725
  }
1639
1726
  console.log("Shutdown complete.");
1640
1727
  };
1641
- process.on("SIGINT", async () => {
1642
- await shutdown();
1643
- process.exit(0);
1644
- });
1645
- process.on("SIGTERM", async () => {
1646
- await shutdown();
1647
- process.exit(0);
1648
- });
1649
1728
  return { close: shutdown };
1650
1729
  }
1651
1730
 
@@ -1668,7 +1747,7 @@ var RequestQueue = class {
1668
1747
  return await task();
1669
1748
  } finally {
1670
1749
  if (this.queue.length > 0) {
1671
- await new Promise((r) => setTimeout(r, 300));
1750
+ await new Promise((r) => setTimeout(r, 100));
1672
1751
  }
1673
1752
  this.release();
1674
1753
  }
@@ -1718,6 +1797,7 @@ var PagePool = class {
1718
1797
  initPromise = null;
1719
1798
  pagesCreating = 0;
1720
1799
  warmedUp = false;
1800
+ disconnected = false;
1721
1801
  constructor(options = {}) {
1722
1802
  this.targetUrl = options.targetUrl ?? "https://chat.ai.jh.edu";
1723
1803
  this.maxPages = options.maxPages ?? 1;
@@ -1731,6 +1811,11 @@ var PagePool = class {
1731
1811
  }
1732
1812
  async _doInit(browser, seedPage) {
1733
1813
  this.browser = browser;
1814
+ this.disconnected = false;
1815
+ browser.on("disconnected", () => {
1816
+ console.warn("[PagePool] Browser disconnected \u2014 all pages are now invalid");
1817
+ this.disconnected = true;
1818
+ });
1734
1819
  this.pages.push({
1735
1820
  page: seedPage,
1736
1821
  queue: new RequestQueue(this.maxWaitMs),
@@ -1757,6 +1842,13 @@ var PagePool = class {
1757
1842
  * Call `markWarmedUp()` after the first successful request to enable scaling.
1758
1843
  */
1759
1844
  async acquire() {
1845
+ if (this.disconnected) {
1846
+ throw Object.assign(
1847
+ new Error("Browser has disconnected. Restart the gateway to reconnect."),
1848
+ { statusCode: 503 }
1849
+ );
1850
+ }
1851
+ this.evictDeadPages();
1760
1852
  let pooled = this.pages.find((p2) => !p2.inUse);
1761
1853
  if (!pooled && this.warmedUp && this.pages.length + this.pagesCreating < this.maxPages && this.browser) {
1762
1854
  this.pagesCreating++;
@@ -1767,6 +1859,12 @@ var PagePool = class {
1767
1859
  }
1768
1860
  }
1769
1861
  if (!pooled) {
1862
+ if (this.pages.length === 0) {
1863
+ throw Object.assign(
1864
+ new Error("No healthy browser pages available. Restart the gateway."),
1865
+ { statusCode: 503 }
1866
+ );
1867
+ }
1770
1868
  pooled = this.pages.reduce(
1771
1869
  (a, b) => a.queue.pending <= b.queue.pending ? a : b
1772
1870
  );
@@ -1781,6 +1879,9 @@ var PagePool = class {
1781
1879
  if (!this.warmedUp) {
1782
1880
  this.warmedUp = true;
1783
1881
  console.log(`[PagePool] Warm-up complete \u2014 page scaling enabled (max ${this.maxPages})`);
1882
+ if (this.maxPages > 1 && this.pages.length < this.maxPages && this.browser) {
1883
+ this.preWarmPage();
1884
+ }
1784
1885
  }
1785
1886
  }
1786
1887
  };
@@ -1803,7 +1904,7 @@ var PagePool = class {
1803
1904
  }
1804
1905
  const page = await context.newPage();
1805
1906
  try {
1806
- await page.goto(this.targetUrl, { waitUntil: "networkidle", timeout: 3e4 });
1907
+ await page.goto(this.targetUrl, { waitUntil: "domcontentloaded", timeout: 3e4 });
1807
1908
  const finalUrl = page.url();
1808
1909
  if (!finalUrl.includes("chat.ai.jh.edu")) {
1809
1910
  throw new Error(
@@ -1824,6 +1925,36 @@ var PagePool = class {
1824
1925
  throw err;
1825
1926
  }
1826
1927
  }
1928
+ /** Evict pages that have crashed or navigated away from JH */
1929
+ evictDeadPages() {
1930
+ const before = this.pages.length;
1931
+ this.pages = this.pages.filter((p) => {
1932
+ try {
1933
+ if (p.page.isClosed()) return false;
1934
+ const url = p.page.url();
1935
+ if (!url.includes("chat.ai.jh.edu")) {
1936
+ console.warn(`[PagePool] Evicting page \u2014 navigated away: ${url}`);
1937
+ p.page.close().catch(() => {
1938
+ });
1939
+ return false;
1940
+ }
1941
+ return true;
1942
+ } catch {
1943
+ return false;
1944
+ }
1945
+ });
1946
+ const evicted = before - this.pages.length;
1947
+ if (evicted > 0) {
1948
+ console.warn(`[PagePool] Evicted ${evicted} dead/stale page(s)`);
1949
+ }
1950
+ }
1951
+ /** Pre-warm a new page in the background (fire-and-forget) */
1952
+ preWarmPage() {
1953
+ this.pagesCreating++;
1954
+ this.createPage().then(() => console.log("[PagePool] Pre-warmed a new page")).catch((err) => console.warn(`[PagePool] Pre-warm failed: ${err.message}`)).finally(() => {
1955
+ this.pagesCreating--;
1956
+ });
1957
+ }
1827
1958
  /** Close all pages except the seed page */
1828
1959
  async drain() {
1829
1960
  const toClose = this.pages.slice(1);
@@ -2192,4 +2323,4 @@ export {
2192
2323
  TokenRefresher,
2193
2324
  ChromeManager
2194
2325
  };
2195
- //# sourceMappingURL=chunk-7H2RJZN3.js.map
2326
+ //# sourceMappingURL=chunk-Y2NMKJOG.js.map