bare-agent 0.34.0 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bareagent.context.md +60 -2
- package/index.d.ts +9 -1
- package/index.js +11 -1
- package/package.json +1 -1
- package/src/bareguard-adapter.d.ts +70 -0
- package/src/bareguard-adapter.js +131 -1
- package/src/judge-calibration.d.ts +169 -0
- package/src/judge-calibration.js +188 -0
- package/src/judge.d.ts +200 -0
- package/src/judge.js +226 -0
- package/src/provider-anthropic.d.ts +11 -4
- package/src/provider-anthropic.js +11 -6
- package/src/provider-gemini.d.ts +11 -4
- package/src/provider-gemini.js +11 -6
- package/src/provider-http.d.ts +54 -8
- package/src/provider-http.js +81 -14
- package/src/provider-ollama.d.ts +11 -4
- package/src/provider-ollama.js +11 -6
- package/src/provider-openai.d.ts +23 -6
- package/src/provider-openai.js +17 -7
|
@@ -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
|
|
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,
|
|
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
|
|
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
|
-
|
|
328
|
+
applyRequestBounds(req, { timeoutMs, deadlineMs }, 'AnthropicProvider');
|
|
324
329
|
req.on('error', reject);
|
|
325
330
|
req.write(payload);
|
|
326
331
|
req.end();
|
package/src/provider-gemini.d.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/provider-gemini.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
227
|
+
applyRequestBounds(req, { timeoutMs, deadlineMs }, 'GeminiProvider');
|
|
223
228
|
req.on('error', reject);
|
|
224
229
|
req.write(payload);
|
|
225
230
|
req.end();
|
package/src/provider-http.d.ts
CHANGED
|
@@ -8,18 +8,31 @@
|
|
|
8
8
|
*/
|
|
9
9
|
export const DEFAULT_TIMEOUT_MS: 600000;
|
|
10
10
|
/**
|
|
11
|
-
* Resolve
|
|
12
|
-
* and `undefined` BOTH mean "inherit" — so a per-call `null` never shadows an instance-level
|
|
13
|
-
* disable
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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;
|
package/src/provider-http.js
CHANGED
|
@@ -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
|
|
22
|
-
* and `undefined` BOTH mean "inherit" — so a per-call `null` never shadows an instance-level
|
|
23
|
-
* disable
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
|
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 (
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|
package/src/provider-ollama.d.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/provider-ollama.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
155
|
+
applyRequestBounds(req, { timeoutMs, deadlineMs }, 'OllamaProvider');
|
|
151
156
|
req.on('error', reject);
|
|
152
157
|
req.write(payload);
|
|
153
158
|
req.end();
|