bare-agent 0.34.0 → 0.35.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.
@@ -1,7 +1,7 @@
1
1
  # bareagent — Integration Guide
2
2
 
3
3
  > For AI assistants and developers wiring bareagent into a project.
4
- > v0.34.0 | Node.js >= 18 | zero required deps (`bareguard >=0.9.0 <0.13.0` optional peer for governance) | Apache 2.0
4
+ > v0.35.0 | Node.js >= 18 | zero required deps (`bareguard >=0.9.0 <0.13.0` optional peer for governance) | Apache 2.0
5
5
  >
6
6
  > Full human guide with composition examples, design philosophy, and recipes: [Usage Guide](docs/02-features/usage-guide.md)
7
7
 
@@ -829,7 +829,9 @@ All return `{ text, toolCalls, usage: { inputTokens, outputTokens }, model?, cos
829
829
 
830
830
  **Error body (v0.11.0):** on an HTTP error the OpenAI/Anthropic/Ollama providers throw a `ProviderError` whose `message` carries the upstream error string. The full parsed response is **not** attached to `err.body` by default (so an unexpected field can't leak through logs that dump the error object). Pass `{ exposeErrorBody: true }` to attach it for debugging.
831
831
 
832
- **Request/idle timeout (BA-18, v0.34.0):** the four http(s) providers (Anthropic, OpenAI, Gemini, Ollama) accept a `timeoutMs` option — constructor default **600000 (10 min)**, overridable per call via `generate(..., { timeoutMs })`, and `0`/`Infinity` disables it. Before this they wired only `req.on('error')`, so a socket the server silently dropped — or a response that never starts — hung `generate()` until the OS TCP timeout (~2h): a hang, not an error, so retry/casualty policy above it never fired. `timeoutMs` bounds on socket **inactivity** (`req.setTimeout`), so a slow-but-streaming response is not killed — only a silent/never-answering socket trips it; the 10-min default clears any single non-streaming completion (TTFB ≈ generation time). On trip, `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`, `retryable: true`). **Retry is caller-side and already wired:** `new Loop({ provider, retry: new Retry() })` wraps `provider.generate`, and `DEFAULT_RETRY_ON` classifies `ETIMEDOUT` (and `ECONNRESET`/`ENOTFOUND`/429/5xx) as transient — so a wired `Retry` retries a timed-out request and rethrows under `retryOn: () => false`, with no extra wiring (`run-plan`'s `stepRetry` is a second consumer of the same seam). CLIPipe already bounded its child process (`timeout`, default 30000 for one-shot) and is unchanged.
832
+ **Request/idle timeout (BA-18, v0.34.0):** the four http(s) providers (Anthropic, OpenAI, Gemini, Ollama) accept a `timeoutMs` option — constructor default **600000 (10 min)**, overridable per call via `generate(..., { timeoutMs })`, and `0`/`Infinity` disables it. Before this they wired only `req.on('error')`, so a socket the server silently dropped — or a response that never starts — hung `generate()` until the OS TCP timeout (~2h): a hang, not an error, so retry/casualty policy above it never fired. `timeoutMs` bounds on socket **inactivity** (`req.setTimeout`), so a slow-but-streaming response is not killed — only a silent/never-answering socket trips it; the 10-min default clears any single non-streaming completion (TTFB ≈ generation time). On trip, `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`, `context.bound: 'idle'`, `retryable: true`). **Retry is caller-side and already wired:** `new Loop({ provider, retry: new Retry() })` wraps `provider.generate`, and `DEFAULT_RETRY_ON` classifies `ETIMEDOUT` (and `ECONNRESET`/`ENOTFOUND`/429/5xx) as transient — so a wired `Retry` retries a timed-out request and rethrows under `retryOn: () => false`, with no extra wiring (`run-plan`'s `stepRetry` is a second consumer of the same seam). CLIPipe already bounded its child process (`timeout`, default 30000 for one-shot) and is unchanged.
833
+
834
+ **Total call-duration deadline (BA-19, v0.35.0):** `timeoutMs` bounds socket *inactivity*, and `req.setTimeout` resets on any activity by design — so a "zombie stream" that trickles a byte forever (bytes arriving, the response never completing) never trips it and hangs the caller for hours (an adopter saw one `generate()` run **274 min** and end in `ECONNRESET`, not a `TimeoutError`: the reset proves bytes *were* flowing, so the idle timer never fired). The four http(s) providers now also accept a `deadlineMs` option — an absolute, **non-resetting** wall-clock ceiling on the whole request. **Disabled by default** (a deliberately long single call — large `maxTokens`, slow model — is legitimate; a default here would kill it), overridable per call via `generate(..., { deadlineMs })`, `0`/`Infinity` disable. An *unset* deadline resolves to disabled, but an *explicitly-set* garbage value (`NaN`, a non-numeric string) throws a `ValidationError` at resolve time rather than silently disabling the bound and running unbounded — unlike `timeoutMs`, the deadline has no safe default to fall back to, so a config mistake must surface loudly. On trip, `generate()` rejects with a **terminal** `TimeoutError` distinguishable from the idle trip: `code: 'EDEADLINE'`, `context.bound: 'deadline'`, `retryable: false` — a deadline is a hard ceiling meant to STOP, so it is *not* auto-retried (retrying would re-spend up to another full `deadlineMs`); a consumer that wants retry opts in via `retryOn`. When both are armed and `timeoutMs < deadlineMs`, a silent socket trips the idle bound first; only a still-active-but-never-completing stream reaches the deadline. The idle bound (BA-18) and the deadline (BA-19) are two independent failure modes — a silent socket vs a zombie stream.
833
835
 
834
836
  **Plaintext-key warning (Unreleased):** the OpenAI provider's `baseUrl` accepts `http://` (for local/OpenAI-compatible endpoints), but a `Bearer` key sent over plaintext http to a **non-loopback** host is exposed on the wire. The provider now warns once when that happens. Loopback hosts (`localhost`/`127.0.0.0/8`/`::1` — local proxies, Ollama-style endpoints) stay silent, since that's the legitimate keyless-local case. The header is **not** stripped (some local proxies want a key), so use `https` for any remote endpoint, or drop `apiKey` when the local endpoint needs none.
835
837
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "files": [
5
5
  "index.js",
6
6
  "index.d.ts",
@@ -33,9 +33,13 @@ export type AnthropicOptions = {
33
33
  */
34
34
  exposeErrorBody?: boolean | undefined;
35
35
  /**
36
- * - BA-18: request/idle timeout in ms. A silent or never-answering socket (dropped by the server, or a response that never starts) was otherwise bounded only by the OS TCP timeout (~2h) — a hang, not an error, so every retry policy above it was inert. Bounds on socket INACTIVITY (the timer resets on activity, so a slow-but-streaming response is not killed); on trip, `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`) that a wired `Retry` (`Loop({ retry })`) retries. Default 10 min sits above any single non-streaming completion; `0` or `Infinity` disables it (pre-BA-18 behaviour). Overridable per call via `generate(..., { timeoutMs })`.
36
+ * - BA-18: request/idle timeout in ms. A silent or never-answering socket (dropped by the server, or a response that never starts) was otherwise bounded only by the OS TCP timeout (~2h) — a hang, not an error, so every retry policy above it was inert. Bounds on socket INACTIVITY (the timer resets on activity, so a slow-but-streaming response is not killed); on trip, `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`, `context.bound: 'idle'`) that a wired `Retry` (`Loop({ retry })`) retries. Default 10 min sits above any single non-streaming completion; `0` or `Infinity` disables it (pre-BA-18 behaviour). Overridable per call via `generate(..., { timeoutMs })`.
37
37
  */
38
38
  timeoutMs?: number | undefined;
39
+ /**
40
+ * - BA-19: TOTAL call-duration deadline in ms, beside `timeoutMs`. The idle bound resets on any socket activity, so a response that trickles a byte forever (a "zombie stream") never trips it and hangs the caller for hours. This is an absolute, non-resetting wall-clock ceiling; on trip, `generate()` rejects with a TERMINAL `TimeoutError` (`code: 'EDEADLINE'`, `context.bound: 'deadline'`, `retryable: false` — a hard ceiling meant to STOP, not re-spend). DISABLED by default (a deliberately long single call is legitimate); `0`/`Infinity` disable. When both are set with `timeoutMs < deadlineMs`, a silent socket trips the idle bound first. Overridable per call via `generate(..., { deadlineMs })`.
41
+ */
42
+ deadlineMs?: number | undefined;
39
43
  };
40
44
  /** @typedef {import('../types').Message} Message */
41
45
  /** @typedef {import('../types').ToolDef} ToolDef */
@@ -51,7 +55,8 @@ export type AnthropicOptions = {
51
55
  *
52
56
  * **MEASURED CAVEAT — this option does not "turn thinking on".** On `claude-sonnet-5` adaptive thinking is ALREADY the default: sending this changed the observed thinking rate not at all (2/10 rounds with it vs 3/10 without — `poc/ba7-adaptive-default.mjs`). Its real use is pinning the mode and reaching `display`/`effort`. The change that mattered is that thinking blocks are now PRESERVED and replayed (see `Message.providerBlocks`), which happens whether or not you ever set this.
53
57
  * @property {boolean} [exposeErrorBody=false] - Attach the full upstream response to `err.body` on HTTP errors (off by default to avoid leaking unexpected fields through error logs; `err.message` still carries the API error).
54
- * @property {number} [timeoutMs=600000] - BA-18: request/idle timeout in ms. A silent or never-answering socket (dropped by the server, or a response that never starts) was otherwise bounded only by the OS TCP timeout (~2h) — a hang, not an error, so every retry policy above it was inert. Bounds on socket INACTIVITY (the timer resets on activity, so a slow-but-streaming response is not killed); on trip, `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`) that a wired `Retry` (`Loop({ retry })`) retries. Default 10 min sits above any single non-streaming completion; `0` or `Infinity` disables it (pre-BA-18 behaviour). Overridable per call via `generate(..., { timeoutMs })`.
58
+ * @property {number} [timeoutMs=600000] - BA-18: request/idle timeout in ms. A silent or never-answering socket (dropped by the server, or a response that never starts) was otherwise bounded only by the OS TCP timeout (~2h) — a hang, not an error, so every retry policy above it was inert. Bounds on socket INACTIVITY (the timer resets on activity, so a slow-but-streaming response is not killed); on trip, `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`, `context.bound: 'idle'`) that a wired `Retry` (`Loop({ retry })`) retries. Default 10 min sits above any single non-streaming completion; `0` or `Infinity` disables it (pre-BA-18 behaviour). Overridable per call via `generate(..., { timeoutMs })`.
59
+ * @property {number} [deadlineMs=0] - BA-19: TOTAL call-duration deadline in ms, beside `timeoutMs`. The idle bound resets on any socket activity, so a response that trickles a byte forever (a "zombie stream") never trips it and hangs the caller for hours. This is an absolute, non-resetting wall-clock ceiling; on trip, `generate()` rejects with a TERMINAL `TimeoutError` (`code: 'EDEADLINE'`, `context.bound: 'deadline'`, `retryable: false` — a hard ceiling meant to STOP, not re-spend). DISABLED by default (a deliberately long single call is legitimate); `0`/`Infinity` disable. When both are set with `timeoutMs < deadlineMs`, a silent socket trips the idle bound first. Overridable per call via `generate(..., { deadlineMs })`.
55
60
  */
56
61
  export class AnthropicProvider {
57
62
  /**
@@ -67,11 +72,12 @@ export class AnthropicProvider {
67
72
  thinking: any;
68
73
  exposeErrorBody: boolean;
69
74
  timeoutMs: number | undefined;
75
+ deadlineMs: number | undefined;
70
76
  /**
71
77
  * Generate a response from the Anthropic API.
72
78
  * @param {Message[]} messages - Conversation messages (OpenAI format, auto-converted).
73
79
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
74
- * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens, system, timeoutMs — a per-call override of the constructor's `timeoutMs`; see BA-18).
80
+ * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens, system, timeoutMs — a per-call override of the constructor's `timeoutMs`, see BA-18; deadlineMs — a per-call override of the constructor's `deadlineMs`, see BA-19).
75
81
  * @returns {Promise<GenerateResult>}
76
82
  * @throws {Error} `[AnthropicProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
77
83
  */
@@ -101,8 +107,9 @@ export class AnthropicProvider {
101
107
  /**
102
108
  * @param {Record<string, any>} body
103
109
  * @param {number} [timeoutMs=0] - Idle-socket timeout (ms); 0 disables. See BA-18 / provider-http.
110
+ * @param {number} [deadlineMs=0] - Total call-duration deadline (ms); 0 disables. See BA-19 / provider-http.
104
111
  * @returns {Promise<any>}
105
112
  */
106
- _request(body: Record<string, any>, timeoutMs?: number): Promise<any>;
113
+ _request(body: Record<string, any>, timeoutMs?: number, deadlineMs?: number): Promise<any>;
107
114
  _warnedInsecure: boolean | undefined;
108
115
  }
@@ -5,7 +5,7 @@ const http = require('http');
5
5
  const { ProviderError } = require('./errors');
6
6
  const { requestWithTemperatureFallback } = require('./provider-temperature');
7
7
  const { normalizeStopReason } = require('./provider-stop-reason');
8
- const { resolveTimeoutMs, applyRequestTimeout } = require('./provider-http');
8
+ const { resolveTimeoutMs, applyRequestBounds } = require('./provider-http');
9
9
 
10
10
  /** @param {string} hostname @returns {boolean} */
11
11
  function isLoopbackHost(hostname) {
@@ -28,7 +28,8 @@ function isLoopbackHost(hostname) {
28
28
  *
29
29
  * **MEASURED CAVEAT — this option does not "turn thinking on".** On `claude-sonnet-5` adaptive thinking is ALREADY the default: sending this changed the observed thinking rate not at all (2/10 rounds with it vs 3/10 without — `poc/ba7-adaptive-default.mjs`). Its real use is pinning the mode and reaching `display`/`effort`. The change that mattered is that thinking blocks are now PRESERVED and replayed (see `Message.providerBlocks`), which happens whether or not you ever set this.
30
30
  * @property {boolean} [exposeErrorBody=false] - Attach the full upstream response to `err.body` on HTTP errors (off by default to avoid leaking unexpected fields through error logs; `err.message` still carries the API error).
31
- * @property {number} [timeoutMs=600000] - BA-18: request/idle timeout in ms. A silent or never-answering socket (dropped by the server, or a response that never starts) was otherwise bounded only by the OS TCP timeout (~2h) — a hang, not an error, so every retry policy above it was inert. Bounds on socket INACTIVITY (the timer resets on activity, so a slow-but-streaming response is not killed); on trip, `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`) that a wired `Retry` (`Loop({ retry })`) retries. Default 10 min sits above any single non-streaming completion; `0` or `Infinity` disables it (pre-BA-18 behaviour). Overridable per call via `generate(..., { timeoutMs })`.
31
+ * @property {number} [timeoutMs=600000] - BA-18: request/idle timeout in ms. A silent or never-answering socket (dropped by the server, or a response that never starts) was otherwise bounded only by the OS TCP timeout (~2h) — a hang, not an error, so every retry policy above it was inert. Bounds on socket INACTIVITY (the timer resets on activity, so a slow-but-streaming response is not killed); on trip, `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`, `context.bound: 'idle'`) that a wired `Retry` (`Loop({ retry })`) retries. Default 10 min sits above any single non-streaming completion; `0` or `Infinity` disables it (pre-BA-18 behaviour). Overridable per call via `generate(..., { timeoutMs })`.
32
+ * @property {number} [deadlineMs=0] - BA-19: TOTAL call-duration deadline in ms, beside `timeoutMs`. The idle bound resets on any socket activity, so a response that trickles a byte forever (a "zombie stream") never trips it and hangs the caller for hours. This is an absolute, non-resetting wall-clock ceiling; on trip, `generate()` rejects with a TERMINAL `TimeoutError` (`code: 'EDEADLINE'`, `context.bound: 'deadline'`, `retryable: false` — a hard ceiling meant to STOP, not re-spend). DISABLED by default (a deliberately long single call is legitimate); `0`/`Infinity` disable. When both are set with `timeoutMs < deadlineMs`, a silent socket trips the idle bound first. Overridable per call via `generate(..., { deadlineMs })`.
32
33
  */
33
34
 
34
35
  class AnthropicProvider {
@@ -56,13 +57,15 @@ class AnthropicProvider {
56
57
  this.exposeErrorBody = options.exposeErrorBody === true;
57
58
  // BA-18: request/idle timeout (ms). Resolved at call time (default 600000; 0/Infinity disable).
58
59
  this.timeoutMs = options.timeoutMs;
60
+ // BA-19: total call-duration deadline (ms). Resolved at call time (default 0 = disabled).
61
+ this.deadlineMs = options.deadlineMs;
59
62
  }
60
63
 
61
64
  /**
62
65
  * Generate a response from the Anthropic API.
63
66
  * @param {Message[]} messages - Conversation messages (OpenAI format, auto-converted).
64
67
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
65
- * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens, system, timeoutMs — a per-call override of the constructor's `timeoutMs`; see BA-18).
68
+ * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens, system, timeoutMs — a per-call override of the constructor's `timeoutMs`, see BA-18; deadlineMs — a per-call override of the constructor's `deadlineMs`, see BA-19).
66
69
  * @returns {Promise<GenerateResult>}
67
70
  * @throws {Error} `[AnthropicProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
68
71
  */
@@ -147,8 +150,9 @@ class AnthropicProvider {
147
150
  // and retry once rather than let the whole call fail. `temperatureDropped` flows back so an upstream
148
151
  // receipt (recurse's refineLeaf) can report the effective temperature, not the one the model ignored.
149
152
  const timeoutMs = resolveTimeoutMs(this.timeoutMs, options.timeoutMs);
153
+ const deadlineMs = resolveTimeoutMs(this.deadlineMs, options.deadlineMs, 0, 'deadlineMs');
150
154
  const { data, temperatureDropped } = await requestWithTemperatureFallback({
151
- request: () => this._request(body, timeoutMs),
155
+ request: () => this._request(body, timeoutMs, deadlineMs),
152
156
  hadTemperature: () => body.temperature != null,
153
157
  stripTemperature: () => { delete body.temperature; },
154
158
  warnOnce: () => this._warnTemperatureDropped(),
@@ -281,9 +285,10 @@ class AnthropicProvider {
281
285
  /**
282
286
  * @param {Record<string, any>} body
283
287
  * @param {number} [timeoutMs=0] - Idle-socket timeout (ms); 0 disables. See BA-18 / provider-http.
288
+ * @param {number} [deadlineMs=0] - Total call-duration deadline (ms); 0 disables. See BA-19 / provider-http.
284
289
  * @returns {Promise<any>}
285
290
  */
286
- _request(body, timeoutMs = 0) {
291
+ _request(body, timeoutMs = 0, deadlineMs = 0) {
287
292
  return new Promise((resolve, reject) => {
288
293
  const payload = JSON.stringify(body);
289
294
  const url = new URL(this.baseUrl + '/messages');
@@ -320,7 +325,7 @@ class AnthropicProvider {
320
325
  }
321
326
  });
322
327
  });
323
- applyRequestTimeout(req, timeoutMs, 'AnthropicProvider');
328
+ applyRequestBounds(req, { timeoutMs, deadlineMs }, 'AnthropicProvider');
324
329
  req.on('error', reject);
325
330
  req.write(payload);
326
331
  req.end();
@@ -20,9 +20,13 @@ export type GeminiOptions = {
20
20
  */
21
21
  exposeErrorBody?: boolean | undefined;
22
22
  /**
23
- * - BA-18: request/idle timeout in ms. Bounds a silent or never-answering socket on inactivity so `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`) instead of hanging until the OS TCP timeout (~2h). `0`/`Infinity` disables it (pre-BA-18 behaviour). Overridable per call via `generate(..., { timeoutMs })`.
23
+ * - BA-18: request/idle timeout in ms. Bounds a silent or never-answering socket on inactivity so `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`, `context.bound: 'idle'`) instead of hanging until the OS TCP timeout (~2h). `0`/`Infinity` disables it (pre-BA-18 behaviour). Overridable per call via `generate(..., { timeoutMs })`.
24
24
  */
25
25
  timeoutMs?: number | undefined;
26
+ /**
27
+ * - BA-19: TOTAL call-duration deadline in ms, beside `timeoutMs`. The idle bound resets on any socket activity, so a response that trickles a byte forever never trips it and hangs for hours. This is an absolute, non-resetting ceiling; on trip, `generate()` rejects with a TERMINAL `TimeoutError` (`code: 'EDEADLINE'`, `context.bound: 'deadline'`, `retryable: false`). DISABLED by default; `0`/`Infinity` disable. Overridable per call via `generate(..., { deadlineMs })`.
28
+ */
29
+ deadlineMs?: number | undefined;
26
30
  };
27
31
  /**
28
32
  * @typedef {object} GeminiOptions
@@ -30,7 +34,8 @@ export type GeminiOptions = {
30
34
  * @property {string} [model='gemini-2.5-flash'] - Model ID.
31
35
  * @property {string} [baseUrl='https://generativelanguage.googleapis.com/v1beta'] - API base (override for proxies; posts to `${baseUrl}/models/${model}:generateContent`).
32
36
  * @property {boolean} [exposeErrorBody=false] - Attach the full upstream response to `err.body` on HTTP errors (off by default; `err.message` still carries the API error).
33
- * @property {number} [timeoutMs=600000] - BA-18: request/idle timeout in ms. Bounds a silent or never-answering socket on inactivity so `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`) instead of hanging until the OS TCP timeout (~2h). `0`/`Infinity` disables it (pre-BA-18 behaviour). Overridable per call via `generate(..., { timeoutMs })`.
37
+ * @property {number} [timeoutMs=600000] - BA-18: request/idle timeout in ms. Bounds a silent or never-answering socket on inactivity so `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`, `context.bound: 'idle'`) instead of hanging until the OS TCP timeout (~2h). `0`/`Infinity` disables it (pre-BA-18 behaviour). Overridable per call via `generate(..., { timeoutMs })`.
38
+ * @property {number} [deadlineMs=0] - BA-19: TOTAL call-duration deadline in ms, beside `timeoutMs`. The idle bound resets on any socket activity, so a response that trickles a byte forever never trips it and hangs for hours. This is an absolute, non-resetting ceiling; on trip, `generate()` rejects with a TERMINAL `TimeoutError` (`code: 'EDEADLINE'`, `context.bound: 'deadline'`, `retryable: false`). DISABLED by default; `0`/`Infinity` disable. Overridable per call via `generate(..., { deadlineMs })`.
34
39
  */
35
40
  /**
36
41
  * Google Gemini provider (native `generateContent` API, NOT the OpenAI-compat endpoint — that endpoint
@@ -47,11 +52,12 @@ export class GeminiProvider {
47
52
  baseUrl: string;
48
53
  exposeErrorBody: boolean;
49
54
  timeoutMs: number | undefined;
55
+ deadlineMs: number | undefined;
50
56
  /**
51
57
  * Generate a response from the Gemini API.
52
58
  * @param {Message[]} messages - Conversation messages (OpenAI format, auto-converted).
53
59
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
54
- * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens, timeoutMs — a per-call override of the constructor's `timeoutMs`; see BA-18).
60
+ * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens, timeoutMs — a per-call override of the constructor's `timeoutMs`, see BA-18; deadlineMs — a per-call override of the constructor's `deadlineMs`, see BA-19).
55
61
  * @returns {Promise<GenerateResult>}
56
62
  * @throws {Error} `[GeminiProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
57
63
  */
@@ -73,8 +79,9 @@ export class GeminiProvider {
73
79
  * @param {string} path
74
80
  * @param {Record<string, any>} body
75
81
  * @param {number} [timeoutMs=0] - Idle-socket timeout (ms); 0 disables. See BA-18 / provider-http.
82
+ * @param {number} [deadlineMs=0] - Total call-duration deadline (ms); 0 disables. See BA-19 / provider-http.
76
83
  * @returns {Promise<any>}
77
84
  */
78
- _request(path: string, body: Record<string, any>, timeoutMs?: number): Promise<any>;
85
+ _request(path: string, body: Record<string, any>, timeoutMs?: number, deadlineMs?: number): Promise<any>;
79
86
  _warnedInsecure: boolean | undefined;
80
87
  }
@@ -5,7 +5,7 @@ const http = require('http');
5
5
  const { ProviderError } = require('./errors');
6
6
  const { requestWithTemperatureFallback } = require('./provider-temperature');
7
7
  const { normalizeStopReason } = require('./provider-stop-reason');
8
- const { resolveTimeoutMs, applyRequestTimeout } = require('./provider-http');
8
+ const { resolveTimeoutMs, applyRequestBounds } = require('./provider-http');
9
9
 
10
10
  /** @typedef {import('../types').Message} Message */
11
11
  /** @typedef {import('../types').ToolDef} ToolDef */
@@ -24,7 +24,8 @@ function isLoopbackHost(hostname) {
24
24
  * @property {string} [model='gemini-2.5-flash'] - Model ID.
25
25
  * @property {string} [baseUrl='https://generativelanguage.googleapis.com/v1beta'] - API base (override for proxies; posts to `${baseUrl}/models/${model}:generateContent`).
26
26
  * @property {boolean} [exposeErrorBody=false] - Attach the full upstream response to `err.body` on HTTP errors (off by default; `err.message` still carries the API error).
27
- * @property {number} [timeoutMs=600000] - BA-18: request/idle timeout in ms. Bounds a silent or never-answering socket on inactivity so `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`) instead of hanging until the OS TCP timeout (~2h). `0`/`Infinity` disables it (pre-BA-18 behaviour). Overridable per call via `generate(..., { timeoutMs })`.
27
+ * @property {number} [timeoutMs=600000] - BA-18: request/idle timeout in ms. Bounds a silent or never-answering socket on inactivity so `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`, `context.bound: 'idle'`) instead of hanging until the OS TCP timeout (~2h). `0`/`Infinity` disables it (pre-BA-18 behaviour). Overridable per call via `generate(..., { timeoutMs })`.
28
+ * @property {number} [deadlineMs=0] - BA-19: TOTAL call-duration deadline in ms, beside `timeoutMs`. The idle bound resets on any socket activity, so a response that trickles a byte forever never trips it and hangs for hours. This is an absolute, non-resetting ceiling; on trip, `generate()` rejects with a TERMINAL `TimeoutError` (`code: 'EDEADLINE'`, `context.bound: 'deadline'`, `retryable: false`). DISABLED by default; `0`/`Infinity` disable. Overridable per call via `generate(..., { deadlineMs })`.
28
29
  */
29
30
 
30
31
  /**
@@ -43,13 +44,15 @@ class GeminiProvider {
43
44
  this.exposeErrorBody = options.exposeErrorBody === true;
44
45
  // BA-18: request/idle timeout (ms). Resolved at call time (default 600000; 0/Infinity disable).
45
46
  this.timeoutMs = options.timeoutMs;
47
+ // BA-19: total call-duration deadline (ms). Resolved at call time (default 0 = disabled).
48
+ this.deadlineMs = options.deadlineMs;
46
49
  }
47
50
 
48
51
  /**
49
52
  * Generate a response from the Gemini API.
50
53
  * @param {Message[]} messages - Conversation messages (OpenAI format, auto-converted).
51
54
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
52
- * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens, timeoutMs — a per-call override of the constructor's `timeoutMs`; see BA-18).
55
+ * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens, timeoutMs — a per-call override of the constructor's `timeoutMs`, see BA-18; deadlineMs — a per-call override of the constructor's `deadlineMs`, see BA-19).
53
56
  * @returns {Promise<GenerateResult>}
54
57
  * @throws {Error} `[GeminiProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
55
58
  */
@@ -112,8 +115,9 @@ class GeminiProvider {
112
115
  // BA-10: graceful degrade if a model rejects a non-default `temperature` (Gemini nests it under
113
116
  // generationConfig). Keyed off the API error text, so dormant on models that accept temperature.
114
117
  const timeoutMs = resolveTimeoutMs(this.timeoutMs, options.timeoutMs);
118
+ const deadlineMs = resolveTimeoutMs(this.deadlineMs, options.deadlineMs, 0, 'deadlineMs');
115
119
  const { data, temperatureDropped } = await requestWithTemperatureFallback({
116
- request: () => this._request(`/models/${this.model}:generateContent`, body, timeoutMs),
120
+ request: () => this._request(`/models/${this.model}:generateContent`, body, timeoutMs, deadlineMs),
117
121
  hadTemperature: () => body.generationConfig?.temperature != null,
118
122
  stripTemperature: () => { if (body.generationConfig) delete body.generationConfig.temperature; },
119
123
  warnOnce: () => this._warnTemperatureDropped(),
@@ -180,9 +184,10 @@ class GeminiProvider {
180
184
  * @param {string} path
181
185
  * @param {Record<string, any>} body
182
186
  * @param {number} [timeoutMs=0] - Idle-socket timeout (ms); 0 disables. See BA-18 / provider-http.
187
+ * @param {number} [deadlineMs=0] - Total call-duration deadline (ms); 0 disables. See BA-19 / provider-http.
183
188
  * @returns {Promise<any>}
184
189
  */
185
- _request(path, body, timeoutMs = 0) {
190
+ _request(path, body, timeoutMs = 0, deadlineMs = 0) {
186
191
  return new Promise((resolve, reject) => {
187
192
  const url = new URL(this.baseUrl + path);
188
193
  const transport = url.protocol === 'https:' ? https : http;
@@ -219,7 +224,7 @@ class GeminiProvider {
219
224
  }
220
225
  });
221
226
  });
222
- applyRequestTimeout(req, timeoutMs, 'GeminiProvider');
227
+ applyRequestBounds(req, { timeoutMs, deadlineMs }, 'GeminiProvider');
223
228
  req.on('error', reject);
224
229
  req.write(payload);
225
230
  req.end();
@@ -8,18 +8,31 @@
8
8
  */
9
9
  export const DEFAULT_TIMEOUT_MS: 600000;
10
10
  /**
11
- * Resolve the effective timeout in ms. A per-call value overrides the instance default, but `null`
12
- * and `undefined` BOTH mean "inherit" — so a per-call `null` never shadows an instance-level
13
- * disable (finding-2). The explicit opt-out idiom is `0` or `Infinity` → returns 0 (no bound, the
14
- * pre-BA-18 behaviour). A NaN / negative / otherwise non-finite value is treated as a caller
15
- * MISTAKE and falls back to {@link DEFAULT_TIMEOUT_MS}: it must never silently disable the safety
16
- * bound, which would round optimistically back toward the ~2h hang BA-18 exists to prevent
17
- * (finding-3; the disable-edge bug class).
11
+ * Resolve an effective timeout-shaped ms bound. A per-call value overrides the instance default, but
12
+ * `null` and `undefined` BOTH mean "inherit" — so a per-call `null` never shadows an instance-level
13
+ * disable. The explicit opt-out idiom is `0` or `Infinity` → returns 0 (no bound). When the knob is
14
+ * UNSET on both, returns `defaultMs` (the idle bound's 10 min, or 0/disabled for the deadline).
15
+ *
16
+ * The disable-edge (finding-3, and the BA-4/5/6 optimistic-rounding family) is the load-bearing part:
17
+ * a config MISTAKE — a NaN / negative / non-numeric value the caller EXPLICITLY set (e.g.
18
+ * `Number(process.env.X)` on an unset var, or a string `'30s'`) — must never SILENTLY remove a bound
19
+ * the caller evidently tried to set. Two cases, decided by whether the knob has a safe default:
20
+ * - `defaultMs > 0` (the BA-18 idle bound): fail SAFE to that real default (a garbage idle value
21
+ * keeps the 10-min safety net — byte-identical to the shipped BA-18 behaviour).
22
+ * - `defaultMs === 0` (the BA-19 deadline, disabled-by-design): there is NO safe bound to fall back
23
+ * to, so silently returning 0 would reintroduce the very hang the deadline exists to prevent
24
+ * (BA-19 review finding 1). Fail LOUD instead — throw a {@link ValidationError} so the config
25
+ * mistake surfaces immediately, rather than running unbounded for hours.
26
+ * NOTE: an UNSET deadline (null/undefined) is legitimate and still returns 0 — only an explicitly-set
27
+ * garbage value throws.
18
28
  * @param {number|undefined|null} instanceTimeout
19
29
  * @param {number|undefined|null} [callTimeout]
30
+ * @param {number} [defaultMs=DEFAULT_TIMEOUT_MS] - value returned when the knob is unset (or garbage, if >0)
31
+ * @param {string} [name='timeoutMs'] - knob name for the throw message
20
32
  * @returns {number} a finite positive ms bound, or 0 to disable
33
+ * @throws {ValidationError} when the value is explicitly set but garbage AND `defaultMs` is 0 (no safe fallback)
21
34
  */
22
- export function resolveTimeoutMs(instanceTimeout: number | undefined | null, callTimeout?: number | undefined | null): number;
35
+ export function resolveTimeoutMs(instanceTimeout: number | undefined | null, callTimeout?: number | undefined | null, defaultMs?: number, name?: string): number;
23
36
  /**
24
37
  * Bound an in-flight ClientRequest on socket INACTIVITY. On timeout, destroy the request with a
25
38
  * retryable {@link TimeoutError} (`code: 'ETIMEDOUT'`) — `DEFAULT_RETRY_ON` classifies that as
@@ -32,3 +45,36 @@ export function resolveTimeoutMs(instanceTimeout: number | undefined | null, cal
32
45
  * @param {string} providerName - for the error message (e.g. 'AnthropicProvider')
33
46
  */
34
47
  export function applyRequestTimeout(req: import("http").ClientRequest, timeoutMs: number, providerName: string): void;
48
+ /**
49
+ * BA-19 — bound an in-flight ClientRequest on TOTAL call duration, beside {@link applyRequestTimeout}.
50
+ * The idle bound (`req.setTimeout`) resets on ANY socket activity, so a response that trickles a byte
51
+ * forever (a "zombie stream") never trips it — bytes keep arriving while the response never completes,
52
+ * and the call hangs until the OS TCP timeout (~4.5h observed). This adds an absolute, non-resetting
53
+ * wall-clock ceiling: a plain `setTimeout` that destroys the request whether or not the socket is
54
+ * active. On trip, the request is destroyed with a TERMINAL {@link TimeoutError} — distinct
55
+ * `code: 'EDEADLINE'` and `context.bound: 'deadline'` (so a consumer routing governance stops vs
56
+ * transport casualties can tell which timer fired), and `retryable: false` because a deadline is a
57
+ * HARD ceiling the caller set to STOP: auto-retrying would re-spend up to another full `deadlineMs`
58
+ * of tokens/budget. Disabled-by-design (a deliberately long single call is legitimate); a
59
+ * `deadlineMs` of 0 is a no-op. When both bounds are armed and `timeoutMs < deadlineMs`, a silent
60
+ * socket trips the idle bound first; only a still-active-but-never-completing stream reaches the
61
+ * deadline. The timer is unref'd (never keeps the event loop alive) and cleared when the request
62
+ * closes (no dangling handle, no late destroy of a settled request).
63
+ * @param {import('http').ClientRequest} req
64
+ * @param {number} deadlineMs - resolved bound; 0 disables
65
+ * @param {string} providerName - for the error message (e.g. 'AnthropicProvider')
66
+ */
67
+ export function applyRequestDeadline(req: import("http").ClientRequest, deadlineMs: number, providerName: string): void;
68
+ /**
69
+ * Wire ALL request bounds onto a ClientRequest in one call — the single seam each provider's
70
+ * `_request` uses, so the idle (BA-18) + deadline (BA-19) wiring is not copy-pasted at four call
71
+ * sites (BA-19 review finding 2). A future third bound is added HERE once, not at every provider.
72
+ * Each individual bound is a no-op when its ms value is 0/absent, so an unset knob costs nothing.
73
+ * @param {import('http').ClientRequest} req
74
+ * @param {{ timeoutMs?: number, deadlineMs?: number }} bounds - resolved ms bounds; 0/absent disables each
75
+ * @param {string} providerName - for error messages (e.g. 'AnthropicProvider')
76
+ */
77
+ export function applyRequestBounds(req: import("http").ClientRequest, bounds: {
78
+ timeoutMs?: number;
79
+ deadlineMs?: number;
80
+ }, providerName: string): void;
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { TimeoutError } = require('./errors');
3
+ const { TimeoutError, ValidationError } = require('./errors');
4
4
 
5
5
  /**
6
6
  * BA-18 — shared request-timeout helper for the http(s)-based providers (Anthropic, OpenAI,
@@ -18,24 +18,41 @@ const { TimeoutError } = require('./errors');
18
18
  const DEFAULT_TIMEOUT_MS = 600000;
19
19
 
20
20
  /**
21
- * Resolve the effective timeout in ms. A per-call value overrides the instance default, but `null`
22
- * and `undefined` BOTH mean "inherit" — so a per-call `null` never shadows an instance-level
23
- * disable (finding-2). The explicit opt-out idiom is `0` or `Infinity` → returns 0 (no bound, the
24
- * pre-BA-18 behaviour). A NaN / negative / otherwise non-finite value is treated as a caller
25
- * MISTAKE and falls back to {@link DEFAULT_TIMEOUT_MS}: it must never silently disable the safety
26
- * bound, which would round optimistically back toward the ~2h hang BA-18 exists to prevent
27
- * (finding-3; the disable-edge bug class).
21
+ * Resolve an effective timeout-shaped ms bound. A per-call value overrides the instance default, but
22
+ * `null` and `undefined` BOTH mean "inherit" — so a per-call `null` never shadows an instance-level
23
+ * disable. The explicit opt-out idiom is `0` or `Infinity` → returns 0 (no bound). When the knob is
24
+ * UNSET on both, returns `defaultMs` (the idle bound's 10 min, or 0/disabled for the deadline).
25
+ *
26
+ * The disable-edge (finding-3, and the BA-4/5/6 optimistic-rounding family) is the load-bearing part:
27
+ * a config MISTAKE — a NaN / negative / non-numeric value the caller EXPLICITLY set (e.g.
28
+ * `Number(process.env.X)` on an unset var, or a string `'30s'`) — must never SILENTLY remove a bound
29
+ * the caller evidently tried to set. Two cases, decided by whether the knob has a safe default:
30
+ * - `defaultMs > 0` (the BA-18 idle bound): fail SAFE to that real default (a garbage idle value
31
+ * keeps the 10-min safety net — byte-identical to the shipped BA-18 behaviour).
32
+ * - `defaultMs === 0` (the BA-19 deadline, disabled-by-design): there is NO safe bound to fall back
33
+ * to, so silently returning 0 would reintroduce the very hang the deadline exists to prevent
34
+ * (BA-19 review finding 1). Fail LOUD instead — throw a {@link ValidationError} so the config
35
+ * mistake surfaces immediately, rather than running unbounded for hours.
36
+ * NOTE: an UNSET deadline (null/undefined) is legitimate and still returns 0 — only an explicitly-set
37
+ * garbage value throws.
28
38
  * @param {number|undefined|null} instanceTimeout
29
39
  * @param {number|undefined|null} [callTimeout]
40
+ * @param {number} [defaultMs=DEFAULT_TIMEOUT_MS] - value returned when the knob is unset (or garbage, if >0)
41
+ * @param {string} [name='timeoutMs'] - knob name for the throw message
30
42
  * @returns {number} a finite positive ms bound, or 0 to disable
43
+ * @throws {ValidationError} when the value is explicitly set but garbage AND `defaultMs` is 0 (no safe fallback)
31
44
  */
32
- function resolveTimeoutMs(instanceTimeout, callTimeout) {
45
+ function resolveTimeoutMs(instanceTimeout, callTimeout, defaultMs = DEFAULT_TIMEOUT_MS, name = 'timeoutMs') {
33
46
  const raw = callTimeout != null ? callTimeout : instanceTimeout; // null/undefined per-call → inherit
34
- if (raw == null) return DEFAULT_TIMEOUT_MS; // absent on both → finite default
47
+ if (raw == null) return defaultMs; // absent on both → the knob's own default (0 = disabled)
35
48
  const n = Number(raw);
36
49
  if (n === 0 || n === Infinity) return 0; // the explicit opt-out idiom → no bound
37
- if (!Number.isFinite(n) || n < 0) return DEFAULT_TIMEOUT_MS; // NaN / negative / garbage → SAFE default, never a silent disable
38
- return n; // finite positive bound
50
+ if (Number.isFinite(n) && n > 0) return n; // finite positive bound
51
+ // Explicitly set but garbage. Never SILENTLY disable a bound the caller tried to set.
52
+ if (defaultMs > 0) return defaultMs; // a real default exists → fail safe to it (BA-18)
53
+ throw new ValidationError(
54
+ `[provider-http] invalid ${name}: ${String(raw).slice(0, 40)} — expected a positive number, 0, or Infinity`
55
+ );
39
56
  }
40
57
 
41
58
  /**
@@ -52,8 +69,58 @@ function resolveTimeoutMs(instanceTimeout, callTimeout) {
52
69
  function applyRequestTimeout(req, timeoutMs, providerName) {
53
70
  if (!(timeoutMs > 0)) return;
54
71
  req.setTimeout(timeoutMs, () => {
55
- req.destroy(new TimeoutError(`[${providerName}] request timed out after ${timeoutMs}ms of socket inactivity`));
72
+ // `context.bound: 'idle'` mirrors the deadline trip's discriminator (BA-19) so a consumer can
73
+ // switch on one uniform field to tell which timer spoke, not just on the `code`.
74
+ req.destroy(new TimeoutError(
75
+ `[${providerName}] request timed out after ${timeoutMs}ms of socket inactivity`,
76
+ { context: { bound: 'idle' } }
77
+ ));
56
78
  });
57
79
  }
58
80
 
59
- module.exports = { DEFAULT_TIMEOUT_MS, resolveTimeoutMs, applyRequestTimeout };
81
+ /**
82
+ * BA-19 — bound an in-flight ClientRequest on TOTAL call duration, beside {@link applyRequestTimeout}.
83
+ * The idle bound (`req.setTimeout`) resets on ANY socket activity, so a response that trickles a byte
84
+ * forever (a "zombie stream") never trips it — bytes keep arriving while the response never completes,
85
+ * and the call hangs until the OS TCP timeout (~4.5h observed). This adds an absolute, non-resetting
86
+ * wall-clock ceiling: a plain `setTimeout` that destroys the request whether or not the socket is
87
+ * active. On trip, the request is destroyed with a TERMINAL {@link TimeoutError} — distinct
88
+ * `code: 'EDEADLINE'` and `context.bound: 'deadline'` (so a consumer routing governance stops vs
89
+ * transport casualties can tell which timer fired), and `retryable: false` because a deadline is a
90
+ * HARD ceiling the caller set to STOP: auto-retrying would re-spend up to another full `deadlineMs`
91
+ * of tokens/budget. Disabled-by-design (a deliberately long single call is legitimate); a
92
+ * `deadlineMs` of 0 is a no-op. When both bounds are armed and `timeoutMs < deadlineMs`, a silent
93
+ * socket trips the idle bound first; only a still-active-but-never-completing stream reaches the
94
+ * deadline. The timer is unref'd (never keeps the event loop alive) and cleared when the request
95
+ * closes (no dangling handle, no late destroy of a settled request).
96
+ * @param {import('http').ClientRequest} req
97
+ * @param {number} deadlineMs - resolved bound; 0 disables
98
+ * @param {string} providerName - for the error message (e.g. 'AnthropicProvider')
99
+ */
100
+ function applyRequestDeadline(req, deadlineMs, providerName) {
101
+ if (!(deadlineMs > 0)) return;
102
+ const timer = setTimeout(() => {
103
+ req.destroy(new TimeoutError(
104
+ `[${providerName}] request exceeded its total deadline of ${deadlineMs}ms`,
105
+ { code: 'EDEADLINE', retryable: false, context: { bound: 'deadline' } }
106
+ ));
107
+ }, deadlineMs);
108
+ if (timer.unref) timer.unref();
109
+ req.once('close', () => clearTimeout(timer));
110
+ }
111
+
112
+ /**
113
+ * Wire ALL request bounds onto a ClientRequest in one call — the single seam each provider's
114
+ * `_request` uses, so the idle (BA-18) + deadline (BA-19) wiring is not copy-pasted at four call
115
+ * sites (BA-19 review finding 2). A future third bound is added HERE once, not at every provider.
116
+ * Each individual bound is a no-op when its ms value is 0/absent, so an unset knob costs nothing.
117
+ * @param {import('http').ClientRequest} req
118
+ * @param {{ timeoutMs?: number, deadlineMs?: number }} bounds - resolved ms bounds; 0/absent disables each
119
+ * @param {string} providerName - for error messages (e.g. 'AnthropicProvider')
120
+ */
121
+ function applyRequestBounds(req, bounds, providerName) {
122
+ applyRequestTimeout(req, (bounds && bounds.timeoutMs) || 0, providerName);
123
+ applyRequestDeadline(req, (bounds && bounds.deadlineMs) || 0, providerName);
124
+ }
125
+
126
+ module.exports = { DEFAULT_TIMEOUT_MS, resolveTimeoutMs, applyRequestTimeout, applyRequestDeadline, applyRequestBounds };
@@ -6,9 +6,13 @@ export type OllamaOptions = {
6
6
  url?: string | undefined;
7
7
  exposeErrorBody?: boolean | undefined;
8
8
  /**
9
- * - BA-18: request/idle timeout in ms. Bounds a silent or never-answering socket on inactivity so `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`) instead of hanging until the OS TCP timeout. `0`/`Infinity` disables it. Overridable per call via `generate(..., { timeoutMs })`. (A local Ollama that is loading a large model cold can be slow to first byte — raise this or disable it for very large local models.)
9
+ * - BA-18: request/idle timeout in ms. Bounds a silent or never-answering socket on inactivity so `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`, `context.bound: 'idle'`) instead of hanging until the OS TCP timeout. `0`/`Infinity` disables it. Overridable per call via `generate(..., { timeoutMs })`. (A local Ollama that is loading a large model cold can be slow to first byte — raise this or disable it for very large local models.)
10
10
  */
11
11
  timeoutMs?: number | undefined;
12
+ /**
13
+ * - BA-19: TOTAL call-duration deadline in ms, beside `timeoutMs`. The idle bound resets on any socket activity, so a response that trickles a byte forever never trips it and hangs for hours. This is an absolute, non-resetting ceiling; on trip, `generate()` rejects with a TERMINAL `TimeoutError` (`code: 'EDEADLINE'`, `context.bound: 'deadline'`, `retryable: false`). DISABLED by default; `0`/`Infinity` disable. Overridable per call via `generate(..., { deadlineMs })`. (A cold large-model load is a legitimate long single call — leave this disabled or set it generously for such models.)
14
+ */
15
+ deadlineMs?: number | undefined;
12
16
  };
13
17
  /** @typedef {import('../types').Message} Message */
14
18
  /** @typedef {import('../types').ToolDef} ToolDef */
@@ -18,7 +22,8 @@ export type OllamaOptions = {
18
22
  * @property {string} [model='llama3.2']
19
23
  * @property {string} [url='http://localhost:11434']
20
24
  * @property {boolean} [exposeErrorBody=false]
21
- * @property {number} [timeoutMs=600000] - BA-18: request/idle timeout in ms. Bounds a silent or never-answering socket on inactivity so `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`) instead of hanging until the OS TCP timeout. `0`/`Infinity` disables it. Overridable per call via `generate(..., { timeoutMs })`. (A local Ollama that is loading a large model cold can be slow to first byte — raise this or disable it for very large local models.)
25
+ * @property {number} [timeoutMs=600000] - BA-18: request/idle timeout in ms. Bounds a silent or never-answering socket on inactivity so `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`, `context.bound: 'idle'`) instead of hanging until the OS TCP timeout. `0`/`Infinity` disables it. Overridable per call via `generate(..., { timeoutMs })`. (A local Ollama that is loading a large model cold can be slow to first byte — raise this or disable it for very large local models.)
26
+ * @property {number} [deadlineMs=0] - BA-19: TOTAL call-duration deadline in ms, beside `timeoutMs`. The idle bound resets on any socket activity, so a response that trickles a byte forever never trips it and hangs for hours. This is an absolute, non-resetting ceiling; on trip, `generate()` rejects with a TERMINAL `TimeoutError` (`code: 'EDEADLINE'`, `context.bound: 'deadline'`, `retryable: false`). DISABLED by default; `0`/`Infinity` disable. Overridable per call via `generate(..., { deadlineMs })`. (A cold large-model load is a legitimate long single call — leave this disabled or set it generously for such models.)
22
27
  */
23
28
  export class OllamaProvider {
24
29
  /**
@@ -29,11 +34,12 @@ export class OllamaProvider {
29
34
  url: string;
30
35
  exposeErrorBody: boolean;
31
36
  timeoutMs: number | undefined;
37
+ deadlineMs: number | undefined;
32
38
  /**
33
39
  * Generate a response from a local Ollama instance.
34
40
  * @param {Message[]} messages - Conversation messages.
35
41
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
36
- * @param {Record<string, any>} [options={}] - Options (`temperature`, `maxTokens`, `timeoutMs` — a per-call override of the constructor's `timeoutMs`; see BA-18).
42
+ * @param {Record<string, any>} [options={}] - Options (`temperature`, `maxTokens`, `timeoutMs` — a per-call override of the constructor's `timeoutMs`, see BA-18; `deadlineMs` — a per-call override of the constructor's `deadlineMs`, see BA-19).
37
43
  * @returns {Promise<GenerateResult>}
38
44
  * @throws {Error} `[OllamaProvider] ...` — on HTTP errors or invalid JSON response.
39
45
  */
@@ -45,7 +51,8 @@ export class OllamaProvider {
45
51
  * @param {string} path
46
52
  * @param {Record<string, any>} body
47
53
  * @param {number} [timeoutMs=0] - Idle-socket timeout (ms); 0 disables. See BA-18 / provider-http.
54
+ * @param {number} [deadlineMs=0] - Total call-duration deadline (ms); 0 disables. See BA-19 / provider-http.
48
55
  * @returns {Promise<any>}
49
56
  */
50
- _request(path: string, body: Record<string, any>, timeoutMs?: number): Promise<any>;
57
+ _request(path: string, body: Record<string, any>, timeoutMs?: number, deadlineMs?: number): Promise<any>;
51
58
  }
@@ -4,7 +4,7 @@ const http = require('http');
4
4
  const { ProviderError } = require('./errors');
5
5
  const { requestWithTemperatureFallback } = require('./provider-temperature');
6
6
  const { normalizeStopReason } = require('./provider-stop-reason');
7
- const { resolveTimeoutMs, applyRequestTimeout } = require('./provider-http');
7
+ const { resolveTimeoutMs, applyRequestBounds } = require('./provider-http');
8
8
 
9
9
  /** @typedef {import('../types').Message} Message */
10
10
  /** @typedef {import('../types').ToolDef} ToolDef */
@@ -15,7 +15,8 @@ const { resolveTimeoutMs, applyRequestTimeout } = require('./provider-http');
15
15
  * @property {string} [model='llama3.2']
16
16
  * @property {string} [url='http://localhost:11434']
17
17
  * @property {boolean} [exposeErrorBody=false]
18
- * @property {number} [timeoutMs=600000] - BA-18: request/idle timeout in ms. Bounds a silent or never-answering socket on inactivity so `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`) instead of hanging until the OS TCP timeout. `0`/`Infinity` disables it. Overridable per call via `generate(..., { timeoutMs })`. (A local Ollama that is loading a large model cold can be slow to first byte — raise this or disable it for very large local models.)
18
+ * @property {number} [timeoutMs=600000] - BA-18: request/idle timeout in ms. Bounds a silent or never-answering socket on inactivity so `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`, `context.bound: 'idle'`) instead of hanging until the OS TCP timeout. `0`/`Infinity` disables it. Overridable per call via `generate(..., { timeoutMs })`. (A local Ollama that is loading a large model cold can be slow to first byte — raise this or disable it for very large local models.)
19
+ * @property {number} [deadlineMs=0] - BA-19: TOTAL call-duration deadline in ms, beside `timeoutMs`. The idle bound resets on any socket activity, so a response that trickles a byte forever never trips it and hangs for hours. This is an absolute, non-resetting ceiling; on trip, `generate()` rejects with a TERMINAL `TimeoutError` (`code: 'EDEADLINE'`, `context.bound: 'deadline'`, `retryable: false`). DISABLED by default; `0`/`Infinity` disable. Overridable per call via `generate(..., { deadlineMs })`. (A cold large-model load is a legitimate long single call — leave this disabled or set it generously for such models.)
19
20
  */
20
21
 
21
22
  class OllamaProvider {
@@ -29,13 +30,15 @@ class OllamaProvider {
29
30
  this.exposeErrorBody = options.exposeErrorBody === true;
30
31
  // BA-18: request/idle timeout (ms). Resolved at call time (default 600000; 0/Infinity disable).
31
32
  this.timeoutMs = options.timeoutMs;
33
+ // BA-19: total call-duration deadline (ms). Resolved at call time (default 0 = disabled).
34
+ this.deadlineMs = options.deadlineMs;
32
35
  }
33
36
 
34
37
  /**
35
38
  * Generate a response from a local Ollama instance.
36
39
  * @param {Message[]} messages - Conversation messages.
37
40
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
38
- * @param {Record<string, any>} [options={}] - Options (`temperature`, `maxTokens`, `timeoutMs` — a per-call override of the constructor's `timeoutMs`; see BA-18).
41
+ * @param {Record<string, any>} [options={}] - Options (`temperature`, `maxTokens`, `timeoutMs` — a per-call override of the constructor's `timeoutMs`, see BA-18; `deadlineMs` — a per-call override of the constructor's `deadlineMs`, see BA-19).
39
42
  * @returns {Promise<GenerateResult>}
40
43
  * @throws {Error} `[OllamaProvider] ...` — on HTTP errors or invalid JSON response.
41
44
  */
@@ -69,8 +72,9 @@ class OllamaProvider {
69
72
  // BA-10: graceful degrade if a model rejects a non-default `temperature` (Ollama nests it under
70
73
  // `options`). Keyed off the API error text, so dormant on models that accept temperature.
71
74
  const timeoutMs = resolveTimeoutMs(this.timeoutMs, options.timeoutMs);
75
+ const deadlineMs = resolveTimeoutMs(this.deadlineMs, options.deadlineMs, 0, 'deadlineMs');
72
76
  const { data, temperatureDropped } = await requestWithTemperatureFallback({
73
- request: () => this._request('/api/chat', body, timeoutMs),
77
+ request: () => this._request('/api/chat', body, timeoutMs, deadlineMs),
74
78
  hadTemperature: () => body.options?.temperature != null,
75
79
  stripTemperature: () => { if (body.options) delete body.options.temperature; },
76
80
  warnOnce: () => this._warnTemperatureDropped(),
@@ -116,9 +120,10 @@ class OllamaProvider {
116
120
  * @param {string} path
117
121
  * @param {Record<string, any>} body
118
122
  * @param {number} [timeoutMs=0] - Idle-socket timeout (ms); 0 disables. See BA-18 / provider-http.
123
+ * @param {number} [deadlineMs=0] - Total call-duration deadline (ms); 0 disables. See BA-19 / provider-http.
119
124
  * @returns {Promise<any>}
120
125
  */
121
- _request(path, body, timeoutMs = 0) {
126
+ _request(path, body, timeoutMs = 0, deadlineMs = 0) {
122
127
  return new Promise((resolve, reject) => {
123
128
  const url = new URL(this.url + path);
124
129
  const payload = JSON.stringify(body);
@@ -147,7 +152,7 @@ class OllamaProvider {
147
152
  }
148
153
  });
149
154
  });
150
- applyRequestTimeout(req, timeoutMs, 'OllamaProvider');
155
+ applyRequestBounds(req, { timeoutMs, deadlineMs }, 'OllamaProvider');
151
156
  req.on('error', reject);
152
157
  req.write(payload);
153
158
  req.end();
@@ -17,10 +17,19 @@ export type OpenAIOptions = {
17
17
  /**
18
18
  * - BA-18: request/idle timeout in ms. Bounds a silent or
19
19
  * never-answering socket on inactivity so `generate()` rejects with a retryable `TimeoutError`
20
- * (`code: 'ETIMEDOUT'`) instead of hanging until the OS TCP timeout (~2h). `0`/`Infinity`
21
- * disables it (pre-BA-18 behaviour). Overridable per call via `generate(..., { timeoutMs })`.
20
+ * (`code: 'ETIMEDOUT'`, `context.bound: 'idle'`) instead of hanging until the OS TCP timeout (~2h).
21
+ * `0`/`Infinity` disables it (pre-BA-18 behaviour). Overridable per call via `generate(..., { timeoutMs })`.
22
22
  */
23
23
  timeoutMs?: number | undefined;
24
+ /**
25
+ * - BA-19: TOTAL call-duration deadline in ms, beside `timeoutMs`.
26
+ * The idle bound resets on any socket activity, so a response that trickles a byte forever never
27
+ * trips it and hangs for hours. This is an absolute, non-resetting ceiling; on trip, `generate()`
28
+ * rejects with a TERMINAL `TimeoutError` (`code: 'EDEADLINE'`, `context.bound: 'deadline'`,
29
+ * `retryable: false`). DISABLED by default; `0`/`Infinity` disable. Overridable per call via
30
+ * `generate(..., { deadlineMs })`.
31
+ */
32
+ deadlineMs?: number | undefined;
24
33
  };
25
34
  /**
26
35
  * @typedef {object} OpenAIOptions
@@ -34,8 +43,14 @@ export type OpenAIOptions = {
34
43
  * debugging only.
35
44
  * @property {number} [timeoutMs=600000] - BA-18: request/idle timeout in ms. Bounds a silent or
36
45
  * never-answering socket on inactivity so `generate()` rejects with a retryable `TimeoutError`
37
- * (`code: 'ETIMEDOUT'`) instead of hanging until the OS TCP timeout (~2h). `0`/`Infinity`
38
- * disables it (pre-BA-18 behaviour). Overridable per call via `generate(..., { timeoutMs })`.
46
+ * (`code: 'ETIMEDOUT'`, `context.bound: 'idle'`) instead of hanging until the OS TCP timeout (~2h).
47
+ * `0`/`Infinity` disables it (pre-BA-18 behaviour). Overridable per call via `generate(..., { timeoutMs })`.
48
+ * @property {number} [deadlineMs=0] - BA-19: TOTAL call-duration deadline in ms, beside `timeoutMs`.
49
+ * The idle bound resets on any socket activity, so a response that trickles a byte forever never
50
+ * trips it and hangs for hours. This is an absolute, non-resetting ceiling; on trip, `generate()`
51
+ * rejects with a TERMINAL `TimeoutError` (`code: 'EDEADLINE'`, `context.bound: 'deadline'`,
52
+ * `retryable: false`). DISABLED by default; `0`/`Infinity` disable. Overridable per call via
53
+ * `generate(..., { deadlineMs })`.
39
54
  */
40
55
  export class OpenAIProvider {
41
56
  /**
@@ -47,11 +62,12 @@ export class OpenAIProvider {
47
62
  baseUrl: string;
48
63
  exposeErrorBody: boolean;
49
64
  timeoutMs: number | undefined;
65
+ deadlineMs: number | undefined;
50
66
  /**
51
67
  * Generate a response from the OpenAI API.
52
68
  * @param {Message[]} messages - Conversation messages.
53
69
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
54
- * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens, timeoutMs — a per-call override of the constructor's `timeoutMs`; see BA-18).
70
+ * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens, timeoutMs — a per-call override of the constructor's `timeoutMs`, see BA-18; deadlineMs — a per-call override of the constructor's `deadlineMs`, see BA-19).
55
71
  * @returns {Promise<GenerateResult>}
56
72
  * @throws {Error} `[OpenAIProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
57
73
  */
@@ -73,8 +89,9 @@ export class OpenAIProvider {
73
89
  * @param {string} path
74
90
  * @param {Record<string, any>} body
75
91
  * @param {number} [timeoutMs=0] - Idle-socket timeout (ms); 0 disables. See BA-18 / provider-http.
92
+ * @param {number} [deadlineMs=0] - Total call-duration deadline (ms); 0 disables. See BA-19 / provider-http.
76
93
  * @returns {Promise<any>}
77
94
  */
78
- _request(path: string, body: Record<string, any>, timeoutMs?: number): Promise<any>;
95
+ _request(path: string, body: Record<string, any>, timeoutMs?: number, deadlineMs?: number): Promise<any>;
79
96
  _warnedInsecure: boolean | undefined;
80
97
  }
@@ -5,7 +5,7 @@ const http = require('http');
5
5
  const { ProviderError } = require('./errors');
6
6
  const { requestWithTemperatureFallback } = require('./provider-temperature');
7
7
  const { normalizeStopReason } = require('./provider-stop-reason');
8
- const { resolveTimeoutMs, applyRequestTimeout } = require('./provider-http');
8
+ const { resolveTimeoutMs, applyRequestBounds } = require('./provider-http');
9
9
 
10
10
  /** @typedef {import('../types').Message} Message */
11
11
  /** @typedef {import('../types').ToolDef} ToolDef */
@@ -30,8 +30,14 @@ function isLoopbackHost(hostname) {
30
30
  * debugging only.
31
31
  * @property {number} [timeoutMs=600000] - BA-18: request/idle timeout in ms. Bounds a silent or
32
32
  * never-answering socket on inactivity so `generate()` rejects with a retryable `TimeoutError`
33
- * (`code: 'ETIMEDOUT'`) instead of hanging until the OS TCP timeout (~2h). `0`/`Infinity`
34
- * disables it (pre-BA-18 behaviour). Overridable per call via `generate(..., { timeoutMs })`.
33
+ * (`code: 'ETIMEDOUT'`, `context.bound: 'idle'`) instead of hanging until the OS TCP timeout (~2h).
34
+ * `0`/`Infinity` disables it (pre-BA-18 behaviour). Overridable per call via `generate(..., { timeoutMs })`.
35
+ * @property {number} [deadlineMs=0] - BA-19: TOTAL call-duration deadline in ms, beside `timeoutMs`.
36
+ * The idle bound resets on any socket activity, so a response that trickles a byte forever never
37
+ * trips it and hangs for hours. This is an absolute, non-resetting ceiling; on trip, `generate()`
38
+ * rejects with a TERMINAL `TimeoutError` (`code: 'EDEADLINE'`, `context.bound: 'deadline'`,
39
+ * `retryable: false`). DISABLED by default; `0`/`Infinity` disable. Overridable per call via
40
+ * `generate(..., { deadlineMs })`.
35
41
  */
36
42
 
37
43
  class OpenAIProvider {
@@ -45,13 +51,15 @@ class OpenAIProvider {
45
51
  this.exposeErrorBody = options.exposeErrorBody === true;
46
52
  // BA-18: request/idle timeout (ms). Resolved at call time (default 600000; 0/Infinity disable).
47
53
  this.timeoutMs = options.timeoutMs;
54
+ // BA-19: total call-duration deadline (ms). Resolved at call time (default 0 = disabled).
55
+ this.deadlineMs = options.deadlineMs;
48
56
  }
49
57
 
50
58
  /**
51
59
  * Generate a response from the OpenAI API.
52
60
  * @param {Message[]} messages - Conversation messages.
53
61
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
54
- * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens, timeoutMs — a per-call override of the constructor's `timeoutMs`; see BA-18).
62
+ * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens, timeoutMs — a per-call override of the constructor's `timeoutMs`, see BA-18; deadlineMs — a per-call override of the constructor's `deadlineMs`, see BA-19).
55
63
  * @returns {Promise<GenerateResult>}
56
64
  * @throws {Error} `[OpenAIProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
57
65
  */
@@ -73,8 +81,9 @@ class OpenAIProvider {
73
81
  // BA-10: newer models (o1/gpt-5-class) reject a non-default `temperature` with a 400 — drop it and
74
82
  // retry once. `temperatureDropped` flows back so an upstream receipt can report the effective value.
75
83
  const timeoutMs = resolveTimeoutMs(this.timeoutMs, options.timeoutMs);
84
+ const deadlineMs = resolveTimeoutMs(this.deadlineMs, options.deadlineMs, 0, 'deadlineMs');
76
85
  const { data, temperatureDropped } = await requestWithTemperatureFallback({
77
- request: () => this._request('/chat/completions', body, timeoutMs),
86
+ request: () => this._request('/chat/completions', body, timeoutMs, deadlineMs),
78
87
  hadTemperature: () => body.temperature != null,
79
88
  stripTemperature: () => { delete body.temperature; },
80
89
  warnOnce: () => this._warnTemperatureDropped(),
@@ -133,9 +142,10 @@ class OpenAIProvider {
133
142
  * @param {string} path
134
143
  * @param {Record<string, any>} body
135
144
  * @param {number} [timeoutMs=0] - Idle-socket timeout (ms); 0 disables. See BA-18 / provider-http.
145
+ * @param {number} [deadlineMs=0] - Total call-duration deadline (ms); 0 disables. See BA-19 / provider-http.
136
146
  * @returns {Promise<any>}
137
147
  */
138
- _request(path, body, timeoutMs = 0) {
148
+ _request(path, body, timeoutMs = 0, deadlineMs = 0) {
139
149
  return new Promise((resolve, reject) => {
140
150
  const url = new URL(this.baseUrl + path);
141
151
  const transport = url.protocol === 'https:' ? https : http;
@@ -177,7 +187,7 @@ class OpenAIProvider {
177
187
  }
178
188
  });
179
189
  });
180
- applyRequestTimeout(req, timeoutMs, 'OpenAIProvider');
190
+ applyRequestBounds(req, { timeoutMs, deadlineMs }, 'OpenAIProvider');
181
191
  req.on('error', reject);
182
192
  req.write(payload);
183
193
  req.end();