@remnic/core 10.0.34 → 10.0.36

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.
Files changed (94) hide show
  1. package/dist/access-admin-ops-surface.js +5 -5
  2. package/dist/access-authorization-probe.js +6 -6
  3. package/dist/access-boundary.js +5 -5
  4. package/dist/access-cli.js +23 -23
  5. package/dist/access-extraction-force-flush.d.ts.map +1 -1
  6. package/dist/access-extraction-force-flush.js +5 -5
  7. package/dist/access-http-briefing.js +6 -6
  8. package/dist/access-http-query.js +5 -5
  9. package/dist/access-http.js +17 -17
  10. package/dist/access-identity-continuity-surface.js +5 -5
  11. package/dist/access-lcm-surface.js +5 -5
  12. package/dist/access-mcp.js +12 -12
  13. package/dist/access-namespace-preflight.js +5 -5
  14. package/dist/access-observe-write-surface.js +5 -5
  15. package/dist/access-operations-batch.js +6 -6
  16. package/dist/access-operations.js +11 -11
  17. package/dist/access-recall-concurrency.js +5 -5
  18. package/dist/access-recall-response.js +5 -5
  19. package/dist/access-recall-surface.js +5 -5
  20. package/dist/access-service.js +5 -5
  21. package/dist/access-surface-catalog.js +7 -7
  22. package/dist/calibration.js +3 -3
  23. package/dist/{chunk-GSN77SST.js → chunk-4YYIZ475.js} +10 -5
  24. package/dist/{chunk-GSN77SST.js.map → chunk-4YYIZ475.js.map} +1 -1
  25. package/dist/{chunk-YXNVNMHB.js → chunk-5XTSIZ3L.js} +3 -3
  26. package/dist/{chunk-JUXOTY6J.js → chunk-5YQXWVCP.js} +9 -2
  27. package/dist/chunk-5YQXWVCP.js.map +1 -0
  28. package/dist/{chunk-GK56GNNS.js → chunk-65JLKIVA.js} +2 -2
  29. package/dist/{chunk-SQ554O3K.js → chunk-74AY3S7U.js} +3 -3
  30. package/dist/{chunk-JPQCHBTK.js → chunk-AD3ZB72W.js} +2 -2
  31. package/dist/{chunk-JKJTXIDI.js → chunk-F2JLBXMG.js} +2 -2
  32. package/dist/{chunk-D75ET36O.js → chunk-FL6SLVAJ.js} +2 -2
  33. package/dist/{chunk-IV2HEXK3.js → chunk-IKCGNTGS.js} +2 -2
  34. package/dist/{chunk-KRH4KQGP.js → chunk-KQUN4RSX.js} +8 -8
  35. package/dist/{chunk-SGLEHJ4P.js → chunk-LJSBF6PW.js} +2 -2
  36. package/dist/{chunk-66NLI5NK.js → chunk-NBH7IRME.js} +4 -4
  37. package/dist/{chunk-DMLCETTO.js → chunk-NDJ42GKB.js} +2 -2
  38. package/dist/{chunk-WRFXSJGW.js → chunk-NIANATGL.js} +2 -2
  39. package/dist/{chunk-6WR4UGJN.js → chunk-OHMGR6QN.js} +2 -2
  40. package/dist/{chunk-UTMVZPAV.js → chunk-PDYP7SUC.js} +2 -2
  41. package/dist/{chunk-TVM47JQB.js → chunk-RYUSWAAU.js} +1 -1
  42. package/dist/{chunk-MLED254J.js → chunk-T6OZW2WR.js} +8 -2
  43. package/dist/chunk-T6OZW2WR.js.map +1 -0
  44. package/dist/{chunk-ZRU7636R.js → chunk-V3JZZJ2E.js} +3 -3
  45. package/dist/{chunk-FOTV6L22.js → chunk-VKB2FGNW.js} +9 -9
  46. package/dist/{chunk-MWJ25UMW.js → chunk-X544TW54.js} +2 -2
  47. package/dist/{chunk-YIMDCBNV.js → chunk-XO4HOBM2.js} +2 -2
  48. package/dist/{chunk-QJFNB6Z5.js → chunk-ZSAQSLKL.js} +2 -2
  49. package/dist/cli.js +18 -18
  50. package/dist/compounding/engine.js +1 -1
  51. package/dist/external-wiki-access.js +6 -6
  52. package/dist/extraction.js +4 -4
  53. package/dist/index.js +23 -23
  54. package/dist/local-llm-helpers.d.ts.map +1 -1
  55. package/dist/local-llm-helpers.js +2 -1
  56. package/dist/local-llm-transport.d.ts +1 -0
  57. package/dist/local-llm-transport.d.ts.map +1 -1
  58. package/dist/local-llm-transport.js +5 -3
  59. package/dist/local-llm.js +3 -3
  60. package/dist/memory-browse-operations.js +6 -6
  61. package/dist/orchestration/maintenance.js +5 -5
  62. package/dist/orchestrator.js +23 -23
  63. package/dist/recall-navigation-operations.js +6 -6
  64. package/dist/recall-navigation.js +5 -5
  65. package/dist/summarizer.js +4 -4
  66. package/dist/support-passport/index.js +8 -8
  67. package/package.json +2 -2
  68. package/src/access-extraction-force-flush.ts +9 -2
  69. package/src/access-service-observe-lcm-parity.test.ts +5 -1
  70. package/src/local-llm-headers-timeout.test.ts +64 -0
  71. package/src/local-llm-helpers.ts +6 -1
  72. package/src/local-llm-transport.ts +14 -1
  73. package/dist/chunk-JUXOTY6J.js.map +0 -1
  74. package/dist/chunk-MLED254J.js.map +0 -1
  75. /package/dist/{chunk-YXNVNMHB.js.map → chunk-5XTSIZ3L.js.map} +0 -0
  76. /package/dist/{chunk-GK56GNNS.js.map → chunk-65JLKIVA.js.map} +0 -0
  77. /package/dist/{chunk-SQ554O3K.js.map → chunk-74AY3S7U.js.map} +0 -0
  78. /package/dist/{chunk-JPQCHBTK.js.map → chunk-AD3ZB72W.js.map} +0 -0
  79. /package/dist/{chunk-JKJTXIDI.js.map → chunk-F2JLBXMG.js.map} +0 -0
  80. /package/dist/{chunk-D75ET36O.js.map → chunk-FL6SLVAJ.js.map} +0 -0
  81. /package/dist/{chunk-IV2HEXK3.js.map → chunk-IKCGNTGS.js.map} +0 -0
  82. /package/dist/{chunk-KRH4KQGP.js.map → chunk-KQUN4RSX.js.map} +0 -0
  83. /package/dist/{chunk-SGLEHJ4P.js.map → chunk-LJSBF6PW.js.map} +0 -0
  84. /package/dist/{chunk-66NLI5NK.js.map → chunk-NBH7IRME.js.map} +0 -0
  85. /package/dist/{chunk-DMLCETTO.js.map → chunk-NDJ42GKB.js.map} +0 -0
  86. /package/dist/{chunk-WRFXSJGW.js.map → chunk-NIANATGL.js.map} +0 -0
  87. /package/dist/{chunk-6WR4UGJN.js.map → chunk-OHMGR6QN.js.map} +0 -0
  88. /package/dist/{chunk-UTMVZPAV.js.map → chunk-PDYP7SUC.js.map} +0 -0
  89. /package/dist/{chunk-TVM47JQB.js.map → chunk-RYUSWAAU.js.map} +0 -0
  90. /package/dist/{chunk-ZRU7636R.js.map → chunk-V3JZZJ2E.js.map} +0 -0
  91. /package/dist/{chunk-FOTV6L22.js.map → chunk-VKB2FGNW.js.map} +0 -0
  92. /package/dist/{chunk-MWJ25UMW.js.map → chunk-X544TW54.js.map} +0 -0
  93. /package/dist/{chunk-YIMDCBNV.js.map → chunk-XO4HOBM2.js.map} +0 -0
  94. /package/dist/{chunk-QJFNB6Z5.js.map → chunk-ZSAQSLKL.js.map} +0 -0
@@ -42,6 +42,10 @@ export async function extractionForceFlush(
42
42
  const deadlineAt = typeof request.deadlineMs === "number"
43
43
  ? (request.deadlineMs > 1e11 ? request.deadlineMs : Date.now() + request.deadlineMs)
44
44
  : undefined;
45
+ const drainAbort = new AbortController();
46
+ const drainSignal = request.abortSignal
47
+ ? AbortSignal.any([request.abortSignal, drainAbort.signal])
48
+ : drainAbort.signal;
45
49
 
46
50
  const authenticatedPrincipal = request.authenticatedPrincipal?.trim();
47
51
  throwIfAborted(request.abortSignal, "extraction force-flush aborted");
@@ -154,7 +158,7 @@ export async function extractionForceFlush(
154
158
  () =>
155
159
  deps.orchestrator.flushSession(request.sessionKey, {
156
160
  reason: "access_force_flush",
157
- abortSignal: request.abortSignal,
161
+ abortSignal: drainSignal,
158
162
  failOnExtractionFailure: true,
159
163
  extractionDeadlineMs: deadlineAt,
160
164
  writeNamespaceOverride: scope.writeNamespace,
@@ -171,7 +175,10 @@ export async function extractionForceFlush(
171
175
  extractionDeadlineMs: deadlineAt,
172
176
  reason: "access_force_flush",
173
177
  deadlineStage: "buffer_drain",
174
- onDeadline: cancelScopedPendingObserveExtractions,
178
+ onDeadline: () => {
179
+ drainAbort.abort();
180
+ cancelScopedPendingObserveExtractions();
181
+ },
175
182
  }
176
183
  );
177
184
  // A flush with no buffered turns still consumes the lifecycle write;
@@ -465,7 +465,11 @@ test("#2128: extraction force-flush uses observe's scoped target even when LCM i
465
465
  "the orchestrator deadline is the duration converted at force-flush entry",
466
466
  );
467
467
  assert.equal(call.options.failOnExtractionFailure, true);
468
- assert.equal(call.options.abortSignal, abortController.signal);
468
+ const signal = call.options.abortSignal;
469
+ assert.ok(signal);
470
+ assert.equal(signal.aborted, false);
471
+ abortController.abort();
472
+ assert.equal(signal.aborted, true);
469
473
  assert.equal(call.options.writeNamespaceOverride, expectedNamespace);
470
474
  assert.equal(
471
475
  probe.orch.getCodingContextForSession("pi-geek:force-flush"),
@@ -12,6 +12,7 @@ import {
12
12
  } from "undici";
13
13
 
14
14
  import { LocalLlmClient } from "./local-llm.js";
15
+ import { probeFetch } from "./local-llm-helpers.js";
15
16
  import { classifyAnalysisProviderError } from "./activity/timeline/analysis-provider.js";
16
17
  import { ChatTransport } from "./local-llm-transport.js";
17
18
  import { initLogger, resetLogger } from "./logger.js";
@@ -466,6 +467,69 @@ test("a process-wide custom dispatcher is left in place", async () => {
466
467
  await restored.close();
467
468
  });
468
469
 
470
+ test("an explicit opt-in widens the pool even when a custom global dispatcher is installed", async () => {
471
+ // Some hosts install a process-wide dispatcher (for example an agent runtime's
472
+ // managed proxy) that is unrelated to the LAN model server. There the 300s
473
+ // header cap stood silently and every slow completion died with `fetch failed`
474
+ // at 301s. REMNIC_LOCAL_LLM_DIRECT=1 declares that the local endpoint is
475
+ // reached directly, so the transport uses its own pool. Without the flag the
476
+ // previous behavior (leave the custom dispatcher alone) is unchanged.
477
+ const original = getGlobalDispatcher();
478
+ const proxy = new ProxyAgent("http://127.0.0.1:9");
479
+ const priorFlag = process.env.REMNIC_LOCAL_LLM_DIRECT;
480
+ try {
481
+ setGlobalDispatcher(proxy);
482
+ delete process.env.REMNIC_LOCAL_LLM_DIRECT;
483
+ assert.equal(new ChatTransport().dispatcherFor(900_000), undefined, "no flag: custom dispatcher stays");
484
+
485
+ process.env.REMNIC_LOCAL_LLM_DIRECT = "1";
486
+ const transport = new ChatTransport();
487
+ const agent = transport.dispatcherFor(900_000);
488
+ assert.ok(agent instanceof Agent, "flag set: the widened pool must be used");
489
+ assert.ok(!(agent instanceof ProxyAgent), "flag set: the proxy dispatcher must not be used");
490
+ await agent.close();
491
+
492
+ process.env.REMNIC_LOCAL_LLM_DIRECT = "0";
493
+ assert.equal(new ChatTransport().dispatcherFor(900_000), undefined, "flag 0: custom dispatcher stays");
494
+ } finally {
495
+ if (priorFlag === undefined) delete process.env.REMNIC_LOCAL_LLM_DIRECT;
496
+ else process.env.REMNIC_LOCAL_LLM_DIRECT = priorFlag;
497
+ setGlobalDispatcher(original);
498
+ await proxy.close();
499
+ }
500
+ });
501
+
502
+ test("REMNIC_LOCAL_LLM_DIRECT=1 also sends availability probes around a global proxy dispatcher", async () => {
503
+ // A host whose global dispatcher cannot reach the LAN model server would fail
504
+ // checkAvailability() before any chat request runs, so direct mode must cover
505
+ // the probe as well as ChatTransport.post.
506
+ const server = http.createServer((_req, res) => {
507
+ res.setHeader("content-type", "application/json");
508
+ res.end('{"data":[]}');
509
+ });
510
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
511
+ // SAFETY: listen(0, "127.0.0.1") above binds a TCP port, so address() is an AddressInfo, never a pipe string or null.
512
+ const { port } = server.address() as AddressInfo;
513
+ const url = `http://127.0.0.1:${port}/v1/models`;
514
+ const original = getGlobalDispatcher();
515
+ const priorFlag = process.env.REMNIC_LOCAL_LLM_DIRECT;
516
+ try {
517
+ setGlobalDispatcher(new ProxyAgent("http://127.0.0.1:9"));
518
+ delete process.env.REMNIC_LOCAL_LLM_DIRECT;
519
+ const viaProxy = await probeFetch(url, { timeoutMs: 2_000, headers: {} });
520
+ assert.equal(viaProxy.ok, false, "no flag: the probe goes through the (unreachable) proxy");
521
+
522
+ process.env.REMNIC_LOCAL_LLM_DIRECT = "1";
523
+ const direct = await probeFetch(url, { timeoutMs: 2_000, headers: {} });
524
+ assert.equal(direct.ok, true, "flag set: the probe reaches the endpoint directly");
525
+ } finally {
526
+ if (priorFlag === undefined) delete process.env.REMNIC_LOCAL_LLM_DIRECT;
527
+ else process.env.REMNIC_LOCAL_LLM_DIRECT = priorFlag;
528
+ setGlobalDispatcher(original);
529
+ await new Promise<void>((resolve) => server.close(() => resolve()));
530
+ }
531
+ });
532
+
469
533
  test("aborting during a retry backoff stops the local request lane", async () => {
470
534
  const original = globalThis.fetch;
471
535
  const abortController = new AbortController();
@@ -1,5 +1,7 @@
1
1
  import type { ResponseFormatJSONSchema } from "openai/resources/shared";
2
+ import type { Agent } from "undici";
2
3
  import { log } from "./logger.js";
4
+ import { directProbeDispatcher } from "./local-llm-transport.js";
3
5
  import type { LocalLlmRequestPriority } from "./local-llm.js";
4
6
 
5
7
  export interface LocalLlmChatCompletionOptions {
@@ -167,7 +169,10 @@ export async function probeFetch(
167
169
  : controller.signal;
168
170
  const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
169
171
  try {
170
- const response = await fetch(url, { signal: requestSignal, headers: options.headers });
172
+ const init: RequestInit & { dispatcher?: Agent } = { signal: requestSignal, headers: options.headers };
173
+ const dispatcher = directProbeDispatcher();
174
+ if (dispatcher) init.dispatcher = dispatcher;
175
+ const response = await fetch(url, init);
171
176
  clearTimeout(timeout);
172
177
  if (!response.ok) {
173
178
  log.debug(`local LLM probe: ${url} returned HTTP ${response.status}`);
@@ -40,6 +40,18 @@ function isPlainPoolingAgent(dispatcher: unknown): boolean {
40
40
  return ctor.name === "Agent";
41
41
  }
42
42
 
43
+ /**
44
+ * REMNIC_LOCAL_LLM_DIRECT=1 declares that the local endpoint is reached
45
+ * directly. Availability probes use this shared plain pool in that mode so they
46
+ * take the same route as chat requests; `undefined` keeps the global dispatcher.
47
+ */
48
+ let directProbeAgent: Agent | null = null;
49
+ export function directProbeDispatcher(): Agent | undefined {
50
+ if (process.env.REMNIC_LOCAL_LLM_DIRECT !== "1") return undefined;
51
+ directProbeAgent ??= new Agent();
52
+ return directProbeAgent;
53
+ }
54
+
43
55
  /**
44
56
  * Connection pool whose header/body inactivity budgets track the configured
45
57
  * request budget.
@@ -72,7 +84,8 @@ export class ChatTransport {
72
84
  * because only the dispatcher's owner knows how to rebuild it.
73
85
  */
74
86
  dispatcherFor(budgetMs: number): Agent | undefined {
75
- if (!isPlainPoolingAgent(getGlobalDispatcher())) {
87
+ // REMNIC_LOCAL_LLM_DIRECT=1: the operator declares the local endpoint is reached directly even though the process installed a global dispatcher for other traffic (an agent runtime's managed proxy). Without it the 300s header cap stands silently on such hosts and every completion slower than five minutes dies with `fetch failed`. Off by default so deployments that must use a proxy keep it.
88
+ if (!isPlainPoolingAgent(getGlobalDispatcher()) && process.env.REMNIC_LOCAL_LLM_DIRECT !== "1") {
76
89
  if (!this.warnedAboutCustomDispatcher) {
77
90
  this.warnedAboutCustomDispatcher = true;
78
91
  log.debug(
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/local-llm-transport.ts"],"sourcesContent":["import { Agent, getGlobalDispatcher } from \"undici\";\n\nimport { log } from \"./logger.js\";\n\n/**\n * Wire transport for local LLM chat completions.\n *\n * Split out of `local-llm.ts` so the client module keeps prompt and\n * response semantics while connection budgets live in one place.\n */\n\nexport interface ChatCompletionRequest {\n url: string;\n headers: Record<string, string>;\n body: string;\n signal: AbortSignal;\n /**\n * Ceiling for this endpoint, i.e. `localLlmTimeoutMs`. Sizes the\n * connection's inactivity budgets. Per-request budgets below the ceiling\n * stay enforced by `signal`.\n */\n budgetMs: number;\n}\n\n/**\n * Is this undici's stock pooling `Agent` — the transport we may widen?\n *\n * `instanceof` alone is not enough: Node's global fetch is backed by its\n * OWN bundled copy of undici, so the dispatcher parked on the well-known\n * global symbol is an `Agent` from a different copy than the one we import,\n * and cross-copy `instanceof` is false. Fall back to the constructor name,\n * which is stable across copies and still distinguishes `ProxyAgent` and\n * `MockAgent` from the stock pool.\n */\nfunction isPlainPoolingAgent(dispatcher: unknown): boolean {\n if (dispatcher instanceof Agent) return true;\n if (!dispatcher || typeof dispatcher !== \"object\") return false;\n const ctor = (dispatcher as { constructor?: unknown }).constructor;\n if (!ctor || typeof ctor !== \"function\") return false;\n return ctor.name === \"Agent\";\n}\n\n/**\n * Connection pool whose header/body inactivity budgets track the configured\n * request budget.\n *\n * Node's global fetch runs on undici, which applies a default\n * `headersTimeout` and `bodyTimeout` of 300s. A non-streaming completion\n * sends its response headers only once generation finishes, so any\n * completion slower than 300s died with a bare `fetch failed` no matter how\n * high `localLlmTimeoutMs` was set — the configured budget was dead letter\n * past five minutes (issue #2148).\n *\n * One pool per client is enough: it is keyed on the ceiling and rebuilt when\n * that changes.\n */\nexport class ChatTransport {\n private agent: Agent | null = null;\n private agentBudgetMs: number | null = null;\n private warnedAboutCustomDispatcher = false;\n\n /**\n * The widened pool, or `undefined` when the process installed its own\n * transport that we must not displace.\n *\n * A deployment can route the local endpoint through a process-wide\n * dispatcher — a `ProxyAgent` for an HTTP proxy, or a custom\n * connect/TLS/DNS dispatcher. Swapping in our own pool would bypass the\n * proxy entirely and break the request, so those are left alone. A\n * `MockAgent` is excluded by the same check, so test transports keep\n * working. The trade-off is explicit: on those setups the 300s cap stands,\n * because only the dispatcher's owner knows how to rebuild it.\n */\n dispatcherFor(budgetMs: number): Agent | undefined {\n if (!isPlainPoolingAgent(getGlobalDispatcher())) {\n if (!this.warnedAboutCustomDispatcher) {\n this.warnedAboutCustomDispatcher = true;\n log.debug(\n \"local LLM: a custom global dispatcher is installed; leaving it in place. \" +\n \"localLlmTimeoutMs above that dispatcher's headersTimeout will not take effect.\",\n );\n }\n return undefined;\n }\n const normalized = Number.isFinite(budgetMs) ? Math.max(0, budgetMs) : 0;\n if (this.agent && this.agentBudgetMs === normalized) return this.agent;\n void this.agent?.close().catch(() => {\n /* pool teardown is best-effort */\n });\n this.agent = new Agent({\n headersTimeout: normalized,\n bodyTimeout: normalized,\n });\n this.agentBudgetMs = normalized;\n return this.agent;\n }\n\n async post(request: ChatCompletionRequest): Promise<Response> {\n const init: RequestInit & { dispatcher?: Agent } = {\n method: \"POST\",\n headers: request.headers,\n body: request.body,\n signal: request.signal,\n };\n const dispatcher = this.dispatcherFor(request.budgetMs);\n if (dispatcher) init.dispatcher = dispatcher;\n return await fetch(request.url, init);\n }\n}\n"],"mappings":";;;;;AAAA,SAAS,OAAO,2BAA2B;AAkC3C,SAAS,oBAAoB,YAA8B;AACzD,MAAI,sBAAsB,MAAO,QAAO;AACxC,MAAI,CAAC,cAAc,OAAO,eAAe,SAAU,QAAO;AAC1D,QAAM,OAAQ,WAAyC;AACvD,MAAI,CAAC,QAAQ,OAAO,SAAS,WAAY,QAAO;AAChD,SAAO,KAAK,SAAS;AACvB;AAgBO,IAAM,gBAAN,MAAoB;AAAA,EACjB,QAAsB;AAAA,EACtB,gBAA+B;AAAA,EAC/B,8BAA8B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EActC,cAAc,UAAqC;AACjD,QAAI,CAAC,oBAAoB,oBAAoB,CAAC,GAAG;AAC/C,UAAI,CAAC,KAAK,6BAA6B;AACrC,aAAK,8BAA8B;AACnC,YAAI;AAAA,UACF;AAAA,QAEF;AAAA,MACF;AACA,aAAO;AAAA,IACT;AACA,UAAM,aAAa,OAAO,SAAS,QAAQ,IAAI,KAAK,IAAI,GAAG,QAAQ,IAAI;AACvE,QAAI,KAAK,SAAS,KAAK,kBAAkB,WAAY,QAAO,KAAK;AACjE,SAAK,KAAK,OAAO,MAAM,EAAE,MAAM,MAAM;AAAA,IAErC,CAAC;AACD,SAAK,QAAQ,IAAI,MAAM;AAAA,MACrB,gBAAgB;AAAA,MAChB,aAAa;AAAA,IACf,CAAC;AACD,SAAK,gBAAgB;AACrB,WAAO,KAAK;AAAA,EACd;AAAA,EAEA,MAAM,KAAK,SAAmD;AAC5D,UAAM,OAA6C;AAAA,MACjD,QAAQ;AAAA,MACR,SAAS,QAAQ;AAAA,MACjB,MAAM,QAAQ;AAAA,MACd,QAAQ,QAAQ;AAAA,IAClB;AACA,UAAM,aAAa,KAAK,cAAc,QAAQ,QAAQ;AACtD,QAAI,WAAY,MAAK,aAAa;AAClC,WAAO,MAAM,MAAM,QAAQ,KAAK,IAAI;AAAA,EACtC;AACF;","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/local-llm-helpers.ts"],"sourcesContent":["import type { ResponseFormatJSONSchema } from \"openai/resources/shared\";\nimport { log } from \"./logger.js\";\nimport type { LocalLlmRequestPriority } from \"./local-llm.js\";\n\nexport interface LocalLlmChatCompletionOptions {\n temperature?: number;\n maxTokens?: number;\n responseFormat?: { type: string } | ResponseFormatJSONSchema;\n timeoutMs?: number;\n operation?: string;\n forceDisableThinking?: boolean;\n disableThinking?: boolean;\n priority?: LocalLlmRequestPriority;\n signal?: AbortSignal;\n redactProviderErrors?: boolean;\n /** Per-request model; defaults to config.localLlmModel. */\n model?: string;\n /**\n * Optional out-box for the terminal failure cause (issue #2891): on a\n * failed request the client records the last provider error here so\n * callers can classify it (e.g. HTTP 429 → rate-limited) in memory\n * instead of seeing only null. Never logged.\n */\n failureDiag?: { lastError?: unknown };\n}\n\nexport interface LocalLlmChatCompletionResult {\n content: string;\n usage?: { promptTokens: number; completionTokens: number; totalTokens: number };\n}\nexport function isAbortError(err: unknown): boolean {\n if (!err || typeof err !== \"object\") return false;\n const maybe = err as { name?: string; message?: string };\n return (\n maybe.name === \"AbortError\" ||\n maybe.message === \"This operation was aborted\" ||\n maybe.message === \"The operation was aborted\"\n );\n}\n\nexport function waitForRetryBackoff(backoffMs: number, signal?: AbortSignal): Promise<boolean> {\n return new Promise((resolve) => {\n let settled = false;\n let timer: NodeJS.Timeout | undefined;\n const onAbort = (): void => {\n if (settled) return;\n settled = true;\n if (timer) clearTimeout(timer);\n signal?.removeEventListener(\"abort\", onAbort);\n resolve(false);\n };\n const onTimer = (): void => {\n if (settled) return;\n settled = true;\n signal?.removeEventListener(\"abort\", onAbort);\n resolve(true);\n };\n timer = setTimeout(onTimer, backoffMs);\n if (signal) {\n if (signal.aborted) onAbort();\n else signal.addEventListener(\"abort\", onAbort, { once: true });\n }\n });\n}\n\nexport function normalizeBackendTripReason(reason: string): string {\n const cleaned = reason.replace(/\\s+/g, \" \").replace(/^[-:–—\\s]+/, \"\").trim();\n if (!cleaned) return \"unknown local backend failure\";\n return cleaned.length > 160 ? `${cleaned.slice(0, 157)}...` : cleaned;\n}\n\nexport function extractNonRecoverableBackendReason(reason: string): string | null {\n const match = reason.match(\n /Failed to load model|Library not loaded|different Team IDs|code signature|llm_engine_mlx_amphibian/i,\n );\n return match?.[0] ?? null;\n}\n\nexport function extractNonRecoverableBackendReasonFromErrorText(errorText: string): string | null {\n const directReason = extractNonRecoverableBackendReason(errorText);\n if (directReason) return directReason;\n try {\n const parsed = JSON.parse(errorText) as { error?: { message?: string } };\n return extractNonRecoverableBackendReason(parsed?.error?.message ?? \"\");\n } catch {\n return null;\n }\n}\n\n/**\n * Flatten a fetch rejection into one line, including the `cause` undici hides\n * the real reason behind (`fetch failed` alone says nothing).\n */\nexport function describeFetchFailure(err: unknown): string {\n const name = err instanceof Error ? err.name : \"\";\n const message = err instanceof Error ? err.message : String(err);\n const cause: unknown = err instanceof Error ? (err as Error & { cause?: unknown }).cause : undefined;\n const causeText = cause instanceof Error\n ? ` (cause: ${cause.name}: ${cause.message}${\n typeof (cause as Error & { code?: unknown }).code === \"string\"\n ? ` [${String((cause as Error & { code?: unknown }).code)}]`\n : \"\"\n })`\n : cause === undefined\n ? \"\"\n : ` (cause: ${String(cause)})`;\n return `${name ? `${name}: ` : \"\"}${message}${causeText}`;\n}\n\n/**\n * What to record when every availability probe failed.\n *\n * A probe that TIMED OUT is not evidence the backend is down: the budget is a\n * fixed 2s and a busy event loop can burn it before the socket is scheduled,\n * so the backend looks dead while it answers other callers in single-digit ms.\n * Caching that verdict took extraction offline for a whole health-check\n * interval at a time (issue #2210), so a timeout leaves availability UNKNOWN —\n * this call fails, the next one re-probes instead of reading a stale false.\n *\n * A backend that ANSWERED and said no is a real verdict and stays cached, or\n * the daemon re-probes a known-bad endpoint on every request.\n */\nexport function resolveUnavailableVerdict(args: {\n baseUrl: string;\n sawAbortedProbe: boolean;\n wasAvailable: boolean | null;\n}): { cacheVerdict: boolean; warning: string | null } {\n if (args.sawAbortedProbe) {\n return {\n cacheVerdict: false,\n warning:\n `local LLM availability probe timed out at ${args.baseUrl} (event loop busy?); treating availability as ` +\n \"unknown and re-probing on the next request rather than marking the backend down\",\n };\n }\n return {\n cacheVerdict: true,\n // Surfaced at warn, not debug: extraction stops silently when this flips,\n // and the daemon emits no debug at all unless configured for it.\n warning: args.wasAvailable !== false ? `local LLM became unavailable at ${args.baseUrl}` : null,\n };\n}\n\nexport interface ProbeFetchResult {\n ok: boolean;\n data: unknown;\n status: number | null;\n /** The probe hit its own budget rather than getting an answer. */\n aborted?: boolean;\n}\n\n/**\n * One health-probe request.\n *\n * Every failure is logged with its cause: a transport error and a 404 both\n * returned `ok: false` with nothing recorded, so an unreachable backend was\n * indistinguishable from a wrong path and an availability failure had no\n * diagnosable reason at all (§22, issue #2210).\n */\nexport async function probeFetch(\n url: string,\n options: { timeoutMs: number; headers: Record<string, string>; signal?: AbortSignal },\n): Promise<ProbeFetchResult> {\n const controller = new AbortController();\n const requestSignal = options.signal\n ? AbortSignal.any([options.signal, controller.signal])\n : controller.signal;\n const timeout = setTimeout(() => controller.abort(), options.timeoutMs);\n try {\n const response = await fetch(url, { signal: requestSignal, headers: options.headers });\n clearTimeout(timeout);\n if (!response.ok) {\n log.debug(`local LLM probe: ${url} returned HTTP ${response.status}`);\n return { ok: false, data: null, status: response.status };\n }\n const contentType = response.headers.get(\"content-type\");\n const data = contentType?.includes(\"application/json\") ? await response.json() : await response.text();\n return { ok: true, data, status: response.status };\n } catch (err) {\n clearTimeout(timeout);\n const aborted = isAbortError(err);\n log.debug(\n `local LLM probe: ${url} ${aborted ? `timed out after ${options.timeoutMs}ms` : \"failed\"}: ${describeFetchFailure(err)}`,\n );\n return { ok: false, data: null, status: null, aborted };\n }\n}\n\ninterface PendingProbe {\n promise: Promise<boolean>;\n controller: AbortController;\n /** Callers still awaiting this sequence; at zero the sequence is aborted. */\n waiters: number;\n}\n\n/**\n * Collapses concurrent probe sequences onto one in-flight run.\n *\n * A timed-out availability probe deliberately caches nothing (issue #2210),\n * which removed the false verdict that used to absorb concurrent callers:\n * every queued request would then run its own full probe sequence, on a host\n * already slow enough to blow the probe budget.\n *\n * Cancellation is refcounted rather than shared: one caller walking away must\n * not abort a sequence the others are still waiting on, so the underlying task\n * is aborted only once every waiter has gone.\n */\nexport class SingleFlightProbe {\n private pending: PendingProbe | null = null;\n\n run(task: (signal: AbortSignal) => Promise<boolean>, signal?: AbortSignal): Promise<boolean> {\n let active = this.pending;\n if (!active) {\n const controller = new AbortController();\n const created: PendingProbe = { promise: Promise.resolve(false), controller, waiters: 0 };\n created.promise = task(controller.signal).finally(() => {\n if (this.pending === created) this.pending = null;\n });\n this.pending = created;\n active = created;\n }\n const entry = active;\n entry.waiters += 1;\n let released = false;\n const release = () => {\n if (released) return;\n released = true;\n entry.waiters -= 1;\n if (entry.waiters > 0) return;\n // Detach BEFORE aborting. `pending` would otherwise still point at this\n // doomed entry until the task's own `finally` runs a microtask later, and\n // a caller arriving in that window would join an aborted sequence and\n // read its `false` as a verdict.\n if (this.pending === entry) this.pending = null;\n entry.controller.abort();\n };\n if (!signal) return entry.promise.finally(release);\n return new Promise<boolean>((resolve, reject) => {\n const onAbort = () => {\n release();\n resolve(false);\n };\n signal.addEventListener(\"abort\", onAbort, { once: true });\n entry.promise.then(\n (value) => {\n signal.removeEventListener(\"abort\", onAbort);\n release();\n resolve(value);\n },\n (err: unknown) => {\n signal.removeEventListener(\"abort\", onAbort);\n release();\n reject(err instanceof Error ? err : new Error(String(err)));\n },\n );\n });\n }\n}\n"],"mappings":"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","names":[]}