bare-agent 0.50.0 → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bareagent.context.md +8 -0
- package/package.json +1 -1
- package/primitives.json +9 -0
- package/src/loop.d.ts +6 -2
- package/src/loop.js +31 -12
- package/src/provider-anthropic.d.ts +13 -4
- package/src/provider-anthropic.js +27 -8
- package/src/provider-clipipe.d.ts +14 -1
- package/src/provider-clipipe.js +21 -1
- package/src/provider-fallback.d.ts +3 -0
- package/src/provider-fallback.js +3 -0
- package/src/provider-gemini.d.ts +13 -4
- package/src/provider-gemini.js +24 -8
- package/src/provider-ollama.d.ts +13 -4
- package/src/provider-ollama.js +24 -8
- package/src/provider-openai.d.ts +15 -4
- package/src/provider-openai.js +32 -8
- package/src/provider-temperature.d.ts +11 -3
- package/src/provider-temperature.js +24 -5
- package/src/providers.d.ts +2 -1
- package/src/providers.js +3 -0
- package/src/thinking-mode.d.ts +191 -0
- package/src/thinking-mode.js +443 -0
- package/types/index.d.ts +19 -0
package/src/provider-gemini.js
CHANGED
|
@@ -7,6 +7,7 @@ const { requestWithTemperatureFallback } = require('./provider-temperature');
|
|
|
7
7
|
const { normalizeStopReason } = require('./provider-stop-reason');
|
|
8
8
|
const { resolveTimeoutMs, applyRequestBounds, guardResponseSettles } = require('./provider-http');
|
|
9
9
|
const { hasUsageSignal } = require('./provider-usage');
|
|
10
|
+
const { normalizeThinkingMode, resolveThinkingMode, requestWithThinkingMode, assessThinking } = require('./thinking-mode');
|
|
10
11
|
|
|
11
12
|
// BA-24: raw Gemini usageMetadata fields. Any present (even 0) ⇒ a usage signal; none ⇒ null.
|
|
12
13
|
const GEMINI_USAGE_KEYS = ['promptTokenCount', 'candidatesTokenCount', 'thoughtsTokenCount', 'cachedContentTokenCount'];
|
|
@@ -27,6 +28,8 @@ function isLoopbackHost(hostname) {
|
|
|
27
28
|
* @property {string} [apiKey] - Google AI Studio (Gemini) API key.
|
|
28
29
|
* @property {string} [model='gemini-2.5-flash'] - Model ID.
|
|
29
30
|
* @property {string} [baseUrl='https://generativelanguage.googleapis.com/v1beta'] - API base (override for proxies; posts to `${baseUrl}/models/${model}:generateContent`).
|
|
31
|
+
* @property {'on'|'off'|null} [thinkingMode] - Normalized thinking switch: `'on'` or `'off'`, and bare-agent sends the right `generationConfig.thinkingConfig.thinkingBudget` request field for the provider/model, tries the next known shape when a shape request fails with a 400/422 (not the temperature path), and the call ALWAYS RUNS: if every shape fails that way, ONE plain request without a thinking field decides — plain OK ⇒ `thinkingStatus:'unsupported'`, plain fails ⇒ that plain request's error is thrown as-is; other failures propagate after one request; a model measured as not switchable is sent plain directly. Only the winning shape is cached per instance+model ('unsupported' is never cached); warns once per status. Every reply is checked: `GenerateResult.thinkingStatus` is `'applied'`/`'unsupported'`/`'ignored'`/`'unknown'` (`'ignored'` = 'off' was accepted but the reply shows thinking anyway — warns once, result kept) and the legacy `thinkingApplied` is derived (`true`/`false`/`null`). Unset ⇒ request body byte-identical to before. Overridable per call via `generate(..., { thinkingMode })`; `null` suppresses an instance default. Any value but 'on'/'off' ⇒ `ValidationError`. A NEW field: it does not change the raw `thinking` option; setting both (after per-call override) ⇒ `ValidationError`. See `src/thinking-mode.js` / `thinkingSupport()`.
|
|
32
|
+
* Per-call `thinkingMode` reaches EVERY provider in a `FallbackProvider` chain — correct for a normalized field (each provider maps it to its own shape; one that cannot — CLIPipe, a non-switchable model — just runs without it and reports `thinkingStatus:'unsupported'`, so the first member answers and the chain does not fall through).
|
|
30
33
|
* @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).
|
|
31
34
|
* @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 })`.
|
|
32
35
|
* @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 })`.
|
|
@@ -52,6 +55,10 @@ class GeminiProvider {
|
|
|
52
55
|
this.model = options.model || 'gemini-2.5-flash';
|
|
53
56
|
this.baseUrl = options.baseUrl || 'https://generativelanguage.googleapis.com/v1beta';
|
|
54
57
|
this.exposeErrorBody = options.exposeErrorBody === true;
|
|
58
|
+
// Normalized on/off switch (src/thinking-mode.js). Gemini has no raw `thinking` option to conflict with.
|
|
59
|
+
this.thinkingMode = normalizeThinkingMode(options.thinkingMode, 'GeminiProvider');
|
|
60
|
+
/** @type {Map<string, string>} shape that worked per mode+model (no re-probing) */
|
|
61
|
+
this._thinkingShapes = new Map();
|
|
55
62
|
// BA-18: request/idle timeout (ms). Resolved at call time (default 600000; 0/Infinity disable).
|
|
56
63
|
this.timeoutMs = options.timeoutMs;
|
|
57
64
|
// BA-19: total call-duration deadline (ms). Resolved at call time (default 0 = disabled).
|
|
@@ -62,7 +69,7 @@ class GeminiProvider {
|
|
|
62
69
|
* Generate a response from the Gemini API.
|
|
63
70
|
* @param {Message[]} messages - Conversation messages (OpenAI format, auto-converted).
|
|
64
71
|
* @param {ToolDef[]} [tools=[]] - Tool definitions.
|
|
65
|
-
* @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).
|
|
72
|
+
* @param {Record<string, any>} [options={}] - Options (thinkingMode — per-call override of the constructor's, `null` suppresses; 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).
|
|
66
73
|
* @returns {Promise<GenerateResult>}
|
|
67
74
|
* @throws {Error} `[GeminiProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
|
|
68
75
|
*/
|
|
@@ -124,13 +131,18 @@ class GeminiProvider {
|
|
|
124
131
|
|
|
125
132
|
// BA-10: graceful degrade if a model rejects a non-default `temperature` (Gemini nests it under
|
|
126
133
|
// generationConfig). Keyed off the API error text, so dormant on models that accept temperature.
|
|
134
|
+
const thinkingMode = resolveThinkingMode({ label: 'GeminiProvider', instanceMode: this.thinkingMode, options });
|
|
135
|
+
/** @type {{ unsupported: boolean }} */
|
|
136
|
+
const thinkingTrack = { unsupported: false };
|
|
127
137
|
const timeoutMs = resolveTimeoutMs(this.timeoutMs, options.timeoutMs);
|
|
128
138
|
const deadlineMs = resolveTimeoutMs(this.deadlineMs, options.deadlineMs, 0, 'deadlineMs');
|
|
129
139
|
const { data, temperatureDropped } = await requestWithTemperatureFallback({
|
|
130
|
-
request: () =>
|
|
140
|
+
request: () => thinkingMode
|
|
141
|
+
? requestWithThinkingMode({ kind: 'gemini', mode: thinkingMode, model: this.model, body, cache: this._thinkingShapes, track: thinkingTrack, request: (b) => this._request(`/models/${this.model}:generateContent`, b, timeoutMs, deadlineMs) })
|
|
142
|
+
: this._request(`/models/${this.model}:generateContent`, body, timeoutMs, deadlineMs),
|
|
131
143
|
hadTemperature: () => body.generationConfig?.temperature != null,
|
|
132
144
|
stripTemperature: () => { if (body.generationConfig) delete body.generationConfig.temperature; },
|
|
133
|
-
warnOnce: () => this._warnTemperatureDropped(),
|
|
145
|
+
warnOnce: (reason) => this._warnTemperatureDropped(reason),
|
|
134
146
|
});
|
|
135
147
|
|
|
136
148
|
let text = '';
|
|
@@ -161,14 +173,18 @@ class GeminiProvider {
|
|
|
161
173
|
stopReason: normalizeStopReason(data.candidates?.[0]?.finishReason, 'gemini', { hasToolCalls: toolCalls.length > 0 }),
|
|
162
174
|
usage: this._normalizeUsage(data.usageMetadata),
|
|
163
175
|
...(temperatureDropped && { temperatureDropped: true }),
|
|
176
|
+
...(thinkingMode && assessThinking({ kind: 'gemini', mode: thinkingMode, data, unsupported: thinkingTrack.unsupported, owner: this, label: 'GeminiProvider', model: this.model })),
|
|
164
177
|
};
|
|
165
178
|
}
|
|
166
179
|
|
|
167
|
-
/** One-time warning that this model rejected `temperature` and the request was retried without it (BA-10). */
|
|
168
|
-
_warnTemperatureDropped() {
|
|
169
|
-
|
|
170
|
-
this
|
|
171
|
-
|
|
180
|
+
/** One-time warning (per reason) that this model rejected `temperature` and the request was retried without it (BA-10). */
|
|
181
|
+
_warnTemperatureDropped(reason = 'unsupported') {
|
|
182
|
+
const flag = reason === 'thinking' ? '_warnedTempThinking' : '_warnedTempDropped';
|
|
183
|
+
if (this[flag]) return;
|
|
184
|
+
this[flag] = true;
|
|
185
|
+
console.warn(reason === 'thinking'
|
|
186
|
+
? `[GeminiProvider] '${this.model}': 'temperature' must be 1 while thinking is on — retrying without it. Further drops from this provider instance are silent.`
|
|
187
|
+
: `[GeminiProvider] '${this.model}' rejected a non-default 'temperature' (unsupported/deprecated) — retrying without it. Further drops from this provider instance are silent.`);
|
|
172
188
|
}
|
|
173
189
|
|
|
174
190
|
/**
|
package/src/provider-ollama.d.ts
CHANGED
|
@@ -4,6 +4,11 @@ export type GenerateResult = import("../types").GenerateResult;
|
|
|
4
4
|
export type OllamaOptions = {
|
|
5
5
|
model?: string | undefined;
|
|
6
6
|
url?: string | undefined;
|
|
7
|
+
/**
|
|
8
|
+
* - Normalized thinking switch: `'on'` or `'off'`, and bare-agent sends the right `think` (`/api/chat`; NOT live-verified against a real Ollama thinking model — reply signal `message.thinking`) request field for the provider/model, tries the next known shape when a shape request fails with a 400/422 (not the temperature path), and the call ALWAYS RUNS: if every shape fails that way, ONE plain request without a thinking field decides — plain OK ⇒ `thinkingStatus:'unsupported'`, plain fails ⇒ that plain request's error is thrown as-is; other failures propagate after one request; a model measured as not switchable is sent plain directly. Only the winning shape is cached per instance+model ('unsupported' is never cached); warns once per status. Every reply is checked: `GenerateResult.thinkingStatus` is `'applied'`/`'unsupported'`/`'ignored'`/`'unknown'` (`'ignored'` = 'off' was accepted but the reply shows thinking anyway — warns once, result kept) and the legacy `thinkingApplied` is derived (`true`/`false`/`null`). Unset ⇒ request body byte-identical to before. Overridable per call via `generate(..., { thinkingMode })`; `null` suppresses an instance default. Any value but 'on'/'off' ⇒ `ValidationError`. A NEW field: it does not change the raw `thinking` option; setting both (after per-call override) ⇒ `ValidationError`. See `src/thinking-mode.js` / `thinkingSupport()`.
|
|
9
|
+
* Per-call `thinkingMode` reaches EVERY provider in a `FallbackProvider` chain — correct for a normalized field (each provider maps it to its own shape; one that cannot — CLIPipe, a non-switchable model — just runs without it and reports `thinkingStatus:'unsupported'`, so the first member answers and the chain does not fall through).
|
|
10
|
+
*/
|
|
11
|
+
thinkingMode?: "on" | "off" | null | undefined;
|
|
7
12
|
exposeErrorBody?: boolean | undefined;
|
|
8
13
|
/**
|
|
9
14
|
* - 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.)
|
|
@@ -28,6 +33,8 @@ export type OllamaOptions = {
|
|
|
28
33
|
* @typedef {object} OllamaOptions
|
|
29
34
|
* @property {string} [model='llama3.2']
|
|
30
35
|
* @property {string} [url='http://localhost:11434']
|
|
36
|
+
* @property {'on'|'off'|null} [thinkingMode] - Normalized thinking switch: `'on'` or `'off'`, and bare-agent sends the right `think` (`/api/chat`; NOT live-verified against a real Ollama thinking model — reply signal `message.thinking`) request field for the provider/model, tries the next known shape when a shape request fails with a 400/422 (not the temperature path), and the call ALWAYS RUNS: if every shape fails that way, ONE plain request without a thinking field decides — plain OK ⇒ `thinkingStatus:'unsupported'`, plain fails ⇒ that plain request's error is thrown as-is; other failures propagate after one request; a model measured as not switchable is sent plain directly. Only the winning shape is cached per instance+model ('unsupported' is never cached); warns once per status. Every reply is checked: `GenerateResult.thinkingStatus` is `'applied'`/`'unsupported'`/`'ignored'`/`'unknown'` (`'ignored'` = 'off' was accepted but the reply shows thinking anyway — warns once, result kept) and the legacy `thinkingApplied` is derived (`true`/`false`/`null`). Unset ⇒ request body byte-identical to before. Overridable per call via `generate(..., { thinkingMode })`; `null` suppresses an instance default. Any value but 'on'/'off' ⇒ `ValidationError`. A NEW field: it does not change the raw `thinking` option; setting both (after per-call override) ⇒ `ValidationError`. See `src/thinking-mode.js` / `thinkingSupport()`.
|
|
37
|
+
* Per-call `thinkingMode` reaches EVERY provider in a `FallbackProvider` chain — correct for a normalized field (each provider maps it to its own shape; one that cannot — CLIPipe, a non-switchable model — just runs without it and reports `thinkingStatus:'unsupported'`, so the first member answers and the chain does not fall through).
|
|
31
38
|
* @property {boolean} [exposeErrorBody=false]
|
|
32
39
|
* @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.)
|
|
33
40
|
* @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.)
|
|
@@ -48,6 +55,9 @@ export class OllamaProvider {
|
|
|
48
55
|
model: string;
|
|
49
56
|
url: string;
|
|
50
57
|
exposeErrorBody: boolean;
|
|
58
|
+
thinkingMode: "on" | "off" | null;
|
|
59
|
+
/** @type {Map<string, string>} shape that worked per mode+model (no re-probing) */
|
|
60
|
+
_thinkingShapes: Map<string, string>;
|
|
51
61
|
timeoutMs: number | undefined;
|
|
52
62
|
deadlineMs: number | undefined;
|
|
53
63
|
exposeMalformedArgs: boolean;
|
|
@@ -55,14 +65,13 @@ export class OllamaProvider {
|
|
|
55
65
|
* Generate a response from a local Ollama instance.
|
|
56
66
|
* @param {Message[]} messages - Conversation messages.
|
|
57
67
|
* @param {ToolDef[]} [tools=[]] - Tool definitions.
|
|
58
|
-
* @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).
|
|
68
|
+
* @param {Record<string, any>} [options={}] - Options (`thinkingMode` — per-call override of the constructor's, `null` suppresses; `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).
|
|
59
69
|
* @returns {Promise<GenerateResult>}
|
|
60
70
|
* @throws {Error} `[OllamaProvider] ...` — on HTTP errors or invalid JSON response.
|
|
61
71
|
*/
|
|
62
72
|
generate(messages: Message[], tools?: ToolDef[], options?: Record<string, any>): Promise<GenerateResult>;
|
|
63
|
-
/** One-time warning that this model rejected `temperature` and the request was retried without it (BA-10). */
|
|
64
|
-
_warnTemperatureDropped(): void;
|
|
65
|
-
_warnedTempDropped: boolean | undefined;
|
|
73
|
+
/** One-time warning (per reason) that this model rejected `temperature` and the request was retried without it (BA-10). */
|
|
74
|
+
_warnTemperatureDropped(reason?: string): void;
|
|
66
75
|
/**
|
|
67
76
|
* @param {string} path
|
|
68
77
|
* @param {Record<string, any>} body
|
package/src/provider-ollama.js
CHANGED
|
@@ -7,6 +7,7 @@ const { normalizeStopReason } = require('./provider-stop-reason');
|
|
|
7
7
|
const { resolveTimeoutMs, applyRequestBounds, guardResponseSettles } = require('./provider-http');
|
|
8
8
|
const { hasUsageSignal } = require('./provider-usage');
|
|
9
9
|
const { parseToolCalls } = require('./provider-toolcalls');
|
|
10
|
+
const { normalizeThinkingMode, resolveThinkingMode, requestWithThinkingMode, assessThinking } = require('./thinking-mode');
|
|
10
11
|
|
|
11
12
|
// BA-24: raw Ollama usage fields. Any present (even 0) ⇒ a usage signal; none ⇒ null (unpriceable).
|
|
12
13
|
const OLLAMA_USAGE_KEYS = ['prompt_eval_count', 'eval_count'];
|
|
@@ -19,6 +20,8 @@ const OLLAMA_USAGE_KEYS = ['prompt_eval_count', 'eval_count'];
|
|
|
19
20
|
* @typedef {object} OllamaOptions
|
|
20
21
|
* @property {string} [model='llama3.2']
|
|
21
22
|
* @property {string} [url='http://localhost:11434']
|
|
23
|
+
* @property {'on'|'off'|null} [thinkingMode] - Normalized thinking switch: `'on'` or `'off'`, and bare-agent sends the right `think` (`/api/chat`; NOT live-verified against a real Ollama thinking model — reply signal `message.thinking`) request field for the provider/model, tries the next known shape when a shape request fails with a 400/422 (not the temperature path), and the call ALWAYS RUNS: if every shape fails that way, ONE plain request without a thinking field decides — plain OK ⇒ `thinkingStatus:'unsupported'`, plain fails ⇒ that plain request's error is thrown as-is; other failures propagate after one request; a model measured as not switchable is sent plain directly. Only the winning shape is cached per instance+model ('unsupported' is never cached); warns once per status. Every reply is checked: `GenerateResult.thinkingStatus` is `'applied'`/`'unsupported'`/`'ignored'`/`'unknown'` (`'ignored'` = 'off' was accepted but the reply shows thinking anyway — warns once, result kept) and the legacy `thinkingApplied` is derived (`true`/`false`/`null`). Unset ⇒ request body byte-identical to before. Overridable per call via `generate(..., { thinkingMode })`; `null` suppresses an instance default. Any value but 'on'/'off' ⇒ `ValidationError`. A NEW field: it does not change the raw `thinking` option; setting both (after per-call override) ⇒ `ValidationError`. See `src/thinking-mode.js` / `thinkingSupport()`.
|
|
24
|
+
* Per-call `thinkingMode` reaches EVERY provider in a `FallbackProvider` chain — correct for a normalized field (each provider maps it to its own shape; one that cannot — CLIPipe, a non-switchable model — just runs without it and reports `thinkingStatus:'unsupported'`, so the first member answers and the chain does not fall through).
|
|
22
25
|
* @property {boolean} [exposeErrorBody=false]
|
|
23
26
|
* @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.)
|
|
24
27
|
* @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.)
|
|
@@ -41,6 +44,10 @@ class OllamaProvider {
|
|
|
41
44
|
this.url = options.url || 'http://localhost:11434';
|
|
42
45
|
// See OpenAIProvider: attach full upstream body to err.body only on opt-in.
|
|
43
46
|
this.exposeErrorBody = options.exposeErrorBody === true;
|
|
47
|
+
// Normalized on/off switch (src/thinking-mode.js). Ollama has no raw `thinking` option to conflict with.
|
|
48
|
+
this.thinkingMode = normalizeThinkingMode(options.thinkingMode, 'OllamaProvider');
|
|
49
|
+
/** @type {Map<string, string>} shape that worked per mode+model (no re-probing) */
|
|
50
|
+
this._thinkingShapes = new Map();
|
|
44
51
|
// BA-18: request/idle timeout (ms). Resolved at call time (default 600000; 0/Infinity disable).
|
|
45
52
|
this.timeoutMs = options.timeoutMs;
|
|
46
53
|
// BA-19: total call-duration deadline (ms). Resolved at call time (default 0 = disabled).
|
|
@@ -53,7 +60,7 @@ class OllamaProvider {
|
|
|
53
60
|
* Generate a response from a local Ollama instance.
|
|
54
61
|
* @param {Message[]} messages - Conversation messages.
|
|
55
62
|
* @param {ToolDef[]} [tools=[]] - Tool definitions.
|
|
56
|
-
* @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).
|
|
63
|
+
* @param {Record<string, any>} [options={}] - Options (`thinkingMode` — per-call override of the constructor's, `null` suppresses; `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).
|
|
57
64
|
* @returns {Promise<GenerateResult>}
|
|
58
65
|
* @throws {Error} `[OllamaProvider] ...` — on HTTP errors or invalid JSON response.
|
|
59
66
|
*/
|
|
@@ -86,13 +93,18 @@ class OllamaProvider {
|
|
|
86
93
|
|
|
87
94
|
// BA-10: graceful degrade if a model rejects a non-default `temperature` (Ollama nests it under
|
|
88
95
|
// `options`). Keyed off the API error text, so dormant on models that accept temperature.
|
|
96
|
+
const thinkingMode = resolveThinkingMode({ label: 'OllamaProvider', instanceMode: this.thinkingMode, options });
|
|
97
|
+
/** @type {{ unsupported: boolean }} */
|
|
98
|
+
const thinkingTrack = { unsupported: false };
|
|
89
99
|
const timeoutMs = resolveTimeoutMs(this.timeoutMs, options.timeoutMs);
|
|
90
100
|
const deadlineMs = resolveTimeoutMs(this.deadlineMs, options.deadlineMs, 0, 'deadlineMs');
|
|
91
101
|
const { data, temperatureDropped } = await requestWithTemperatureFallback({
|
|
92
|
-
request: () =>
|
|
102
|
+
request: () => thinkingMode
|
|
103
|
+
? requestWithThinkingMode({ kind: 'ollama', mode: thinkingMode, model: this.model, body, cache: this._thinkingShapes, track: thinkingTrack, request: (b) => this._request('/api/chat', b, timeoutMs, deadlineMs) })
|
|
104
|
+
: this._request('/api/chat', body, timeoutMs, deadlineMs),
|
|
93
105
|
hadTemperature: () => body.options?.temperature != null,
|
|
94
106
|
stripTemperature: () => { if (body.options) delete body.options.temperature; },
|
|
95
|
-
warnOnce: () => this._warnTemperatureDropped(),
|
|
107
|
+
warnOnce: (reason) => this._warnTemperatureDropped(reason),
|
|
96
108
|
});
|
|
97
109
|
const msg = data.message || {};
|
|
98
110
|
|
|
@@ -126,14 +138,18 @@ class OllamaProvider {
|
|
|
126
138
|
? { inputTokens: data.prompt_eval_count || 0, outputTokens: data.eval_count || 0 }
|
|
127
139
|
: null,
|
|
128
140
|
...(temperatureDropped && { temperatureDropped: true }),
|
|
141
|
+
...(thinkingMode && assessThinking({ kind: 'ollama', mode: thinkingMode, data, unsupported: thinkingTrack.unsupported, owner: this, label: 'OllamaProvider', model: this.model })),
|
|
129
142
|
};
|
|
130
143
|
}
|
|
131
144
|
|
|
132
|
-
/** One-time warning that this model rejected `temperature` and the request was retried without it (BA-10). */
|
|
133
|
-
_warnTemperatureDropped() {
|
|
134
|
-
|
|
135
|
-
this
|
|
136
|
-
|
|
145
|
+
/** One-time warning (per reason) that this model rejected `temperature` and the request was retried without it (BA-10). */
|
|
146
|
+
_warnTemperatureDropped(reason = 'unsupported') {
|
|
147
|
+
const flag = reason === 'thinking' ? '_warnedTempThinking' : '_warnedTempDropped';
|
|
148
|
+
if (this[flag]) return;
|
|
149
|
+
this[flag] = true;
|
|
150
|
+
console.warn(reason === 'thinking'
|
|
151
|
+
? `[OllamaProvider] '${this.model}': 'temperature' must be 1 while thinking is on — retrying without it. Further drops from this provider instance are silent.`
|
|
152
|
+
: `[OllamaProvider] '${this.model}' rejected a non-default 'temperature' (unsupported/deprecated) — retrying without it. Further drops from this provider instance are silent.`);
|
|
137
153
|
}
|
|
138
154
|
|
|
139
155
|
/**
|
package/src/provider-openai.d.ts
CHANGED
|
@@ -45,6 +45,12 @@ export type OpenAIOptions = {
|
|
|
45
45
|
* or sensitive, so it only reaches logs/audit rows if the caller opts in. See `src/provider-toolcalls.js`.
|
|
46
46
|
*/
|
|
47
47
|
exposeMalformedArgs?: boolean | undefined;
|
|
48
|
+
/**
|
|
49
|
+
* - Normalized thinking switch: `'on'` or `'off'`, and bare-agent sends the right `reasoning_effort` (OpenAI) / `thinking` (DeepSeek — selected by model id `deepseek-*` or a `deepseek.com` baseUrl) request field for the provider/model, tries the next known shape when a shape request fails with a 400/422 (not the temperature path), and the call ALWAYS RUNS: if every shape fails that way, ONE plain request without a thinking field decides — plain OK ⇒ `thinkingStatus:'unsupported'`, plain fails ⇒ that plain request's error is thrown as-is; other failures propagate after one request; a model measured as not switchable is sent plain directly. Only the winning shape is cached per instance+model ('unsupported' is never cached); warns once per status. Every reply is checked: `GenerateResult.thinkingStatus` is `'applied'`/`'unsupported'`/`'ignored'`/`'unknown'` (`'ignored'` = 'off' was accepted but the reply shows thinking anyway — warns once, result kept) and the legacy `thinkingApplied` is derived (`true`/`false`/`null`). Unset ⇒ request body byte-identical to before. Overridable per call via `generate(..., { thinkingMode })`; `null` suppresses an instance default. Any value but 'on'/'off' ⇒ `ValidationError`. A NEW field: it does not change the raw `thinking` option; setting both (after per-call override) ⇒ `ValidationError`. See `src/thinking-mode.js` / `thinkingSupport()`.
|
|
50
|
+
* Per-call `thinkingMode` reaches EVERY provider in a `FallbackProvider` chain — correct for a normalized field (each provider maps it to its own shape; one that cannot — CLIPipe, a non-switchable model — just runs without it and reports `thinkingStatus:'unsupported'`, so the first member answers and the chain does not fall through).
|
|
51
|
+
* DeepSeek: `'on'` + a forced `toolChoice` (`'required'`/`{name}`) cannot work (the API 400s thinking mode on a forced tool_choice) ⇒ the call is sent with `thinking:{type:'disabled'}` (the only shape that works there) and reports `thinkingStatus:'unsupported'`.
|
|
52
|
+
*/
|
|
53
|
+
thinkingMode?: "on" | "off" | null | undefined;
|
|
48
54
|
/**
|
|
49
55
|
* - Opt-in `thinking` request field (fwdloop DeepSeek ask — extends BA-7(b)'s Anthropic option), forwarded to `body.thinking`
|
|
50
56
|
* VERBATIM and unvalidated — e.g. `{ type: 'disabled' }`. Generalises the Anthropic provider's option to
|
|
@@ -88,6 +94,9 @@ export type OpenAIOptions = {
|
|
|
88
94
|
* JSON fails to parse, also attach the FULL raw string (verbatim, uncapped) to `malformedToolCall.rawArguments`.
|
|
89
95
|
* Off by default, independent of `exposeErrorBody` — the raw blob is model-generated and may be large
|
|
90
96
|
* or sensitive, so it only reaches logs/audit rows if the caller opts in. See `src/provider-toolcalls.js`.
|
|
97
|
+
* @property {'on'|'off'|null} [thinkingMode] - Normalized thinking switch: `'on'` or `'off'`, and bare-agent sends the right `reasoning_effort` (OpenAI) / `thinking` (DeepSeek — selected by model id `deepseek-*` or a `deepseek.com` baseUrl) request field for the provider/model, tries the next known shape when a shape request fails with a 400/422 (not the temperature path), and the call ALWAYS RUNS: if every shape fails that way, ONE plain request without a thinking field decides — plain OK ⇒ `thinkingStatus:'unsupported'`, plain fails ⇒ that plain request's error is thrown as-is; other failures propagate after one request; a model measured as not switchable is sent plain directly. Only the winning shape is cached per instance+model ('unsupported' is never cached); warns once per status. Every reply is checked: `GenerateResult.thinkingStatus` is `'applied'`/`'unsupported'`/`'ignored'`/`'unknown'` (`'ignored'` = 'off' was accepted but the reply shows thinking anyway — warns once, result kept) and the legacy `thinkingApplied` is derived (`true`/`false`/`null`). Unset ⇒ request body byte-identical to before. Overridable per call via `generate(..., { thinkingMode })`; `null` suppresses an instance default. Any value but 'on'/'off' ⇒ `ValidationError`. A NEW field: it does not change the raw `thinking` option; setting both (after per-call override) ⇒ `ValidationError`. See `src/thinking-mode.js` / `thinkingSupport()`.
|
|
98
|
+
* Per-call `thinkingMode` reaches EVERY provider in a `FallbackProvider` chain — correct for a normalized field (each provider maps it to its own shape; one that cannot — CLIPipe, a non-switchable model — just runs without it and reports `thinkingStatus:'unsupported'`, so the first member answers and the chain does not fall through).
|
|
99
|
+
* DeepSeek: `'on'` + a forced `toolChoice` (`'required'`/`{name}`) cannot work (the API 400s thinking mode on a forced tool_choice) ⇒ the call is sent with `thinking:{type:'disabled'}` (the only shape that works there) and reports `thinkingStatus:'unsupported'`.
|
|
91
100
|
* @property {any} [thinking] - Opt-in `thinking` request field (fwdloop DeepSeek ask — extends BA-7(b)'s Anthropic option), forwarded to `body.thinking`
|
|
92
101
|
* VERBATIM and unvalidated — e.g. `{ type: 'disabled' }`. Generalises the Anthropic provider's option to
|
|
93
102
|
* OpenAI-compatible endpoints. Motivating case: DeepSeek's OpenAI-compatible API (`baseUrl`
|
|
@@ -117,18 +126,20 @@ export class OpenAIProvider {
|
|
|
117
126
|
legacyMaxTokens: boolean;
|
|
118
127
|
exposeMalformedArgs: boolean;
|
|
119
128
|
thinking: any;
|
|
129
|
+
thinkingMode: "on" | "off" | null;
|
|
130
|
+
/** @type {Map<string, string>} shape that worked per mode+model (no re-probing) */
|
|
131
|
+
_thinkingShapes: Map<string, string>;
|
|
120
132
|
/**
|
|
121
133
|
* Generate a response from the OpenAI API.
|
|
122
134
|
* @param {Message[]} messages - Conversation messages.
|
|
123
135
|
* @param {ToolDef[]} [tools=[]] - Tool definitions.
|
|
124
|
-
* @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; toolChoice — `'auto'` | `'required'` | `{ name }`, forwarded as OpenAI `tool_choice`, applied only when `tools` are present; thinking — per-call override of the constructor's `thinking`, `null` suppresses an instance default, see BA-7 (b)).
|
|
136
|
+
* @param {Record<string, any>} [options={}] - Options (thinkingMode — per-call override of the constructor's, `null` suppresses; 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; toolChoice — `'auto'` | `'required'` | `{ name }`, forwarded as OpenAI `tool_choice`, applied only when `tools` are present; thinking — per-call override of the constructor's `thinking`, `null` suppresses an instance default, see BA-7 (b)).
|
|
125
137
|
* @returns {Promise<GenerateResult>}
|
|
126
138
|
* @throws {Error} `[OpenAIProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
|
|
127
139
|
*/
|
|
128
140
|
generate(messages: Message[], tools?: ToolDef[], options?: Record<string, any>): Promise<GenerateResult>;
|
|
129
|
-
/** One-time warning that this model rejected `temperature` and the request was retried without it (BA-10). */
|
|
130
|
-
_warnTemperatureDropped(): void;
|
|
131
|
-
_warnedTempDropped: boolean | undefined;
|
|
141
|
+
/** One-time warning (per reason) that this model rejected `temperature` and the request was retried without it (BA-10). */
|
|
142
|
+
_warnTemperatureDropped(reason?: string): void;
|
|
132
143
|
/**
|
|
133
144
|
* Normalize OpenAI usage to the neutral {@link Usage} shape. OpenAI auto-caches prompt prefixes
|
|
134
145
|
* (>=1024 tokens) and reports the cached portion in `prompt_tokens_details.cached_tokens` —
|
package/src/provider-openai.js
CHANGED
|
@@ -8,6 +8,7 @@ const { normalizeStopReason } = require('./provider-stop-reason');
|
|
|
8
8
|
const { resolveTimeoutMs, applyRequestBounds, guardResponseSettles } = require('./provider-http');
|
|
9
9
|
const { hasUsageSignal } = require('./provider-usage');
|
|
10
10
|
const { parseToolCalls } = require('./provider-toolcalls');
|
|
11
|
+
const { normalizeThinkingMode, resolveThinkingMode, requestWithThinkingMode, assessThinking, deepseekDisabledBody, isDeepSeek } = require('./thinking-mode');
|
|
11
12
|
|
|
12
13
|
// BA-24: raw OpenAI usage fields. Any present (even 0) ⇒ a usage signal; none ⇒ null (unpriceable).
|
|
13
14
|
const OPENAI_USAGE_KEYS = ['prompt_tokens', 'completion_tokens', 'prompt_tokens_details'];
|
|
@@ -76,6 +77,9 @@ function isLoopbackHost(hostname) {
|
|
|
76
77
|
* JSON fails to parse, also attach the FULL raw string (verbatim, uncapped) to `malformedToolCall.rawArguments`.
|
|
77
78
|
* Off by default, independent of `exposeErrorBody` — the raw blob is model-generated and may be large
|
|
78
79
|
* or sensitive, so it only reaches logs/audit rows if the caller opts in. See `src/provider-toolcalls.js`.
|
|
80
|
+
* @property {'on'|'off'|null} [thinkingMode] - Normalized thinking switch: `'on'` or `'off'`, and bare-agent sends the right `reasoning_effort` (OpenAI) / `thinking` (DeepSeek — selected by model id `deepseek-*` or a `deepseek.com` baseUrl) request field for the provider/model, tries the next known shape when a shape request fails with a 400/422 (not the temperature path), and the call ALWAYS RUNS: if every shape fails that way, ONE plain request without a thinking field decides — plain OK ⇒ `thinkingStatus:'unsupported'`, plain fails ⇒ that plain request's error is thrown as-is; other failures propagate after one request; a model measured as not switchable is sent plain directly. Only the winning shape is cached per instance+model ('unsupported' is never cached); warns once per status. Every reply is checked: `GenerateResult.thinkingStatus` is `'applied'`/`'unsupported'`/`'ignored'`/`'unknown'` (`'ignored'` = 'off' was accepted but the reply shows thinking anyway — warns once, result kept) and the legacy `thinkingApplied` is derived (`true`/`false`/`null`). Unset ⇒ request body byte-identical to before. Overridable per call via `generate(..., { thinkingMode })`; `null` suppresses an instance default. Any value but 'on'/'off' ⇒ `ValidationError`. A NEW field: it does not change the raw `thinking` option; setting both (after per-call override) ⇒ `ValidationError`. See `src/thinking-mode.js` / `thinkingSupport()`.
|
|
81
|
+
* Per-call `thinkingMode` reaches EVERY provider in a `FallbackProvider` chain — correct for a normalized field (each provider maps it to its own shape; one that cannot — CLIPipe, a non-switchable model — just runs without it and reports `thinkingStatus:'unsupported'`, so the first member answers and the chain does not fall through).
|
|
82
|
+
* DeepSeek: `'on'` + a forced `toolChoice` (`'required'`/`{name}`) cannot work (the API 400s thinking mode on a forced tool_choice) ⇒ the call is sent with `thinking:{type:'disabled'}` (the only shape that works there) and reports `thinkingStatus:'unsupported'`.
|
|
79
83
|
* @property {any} [thinking] - Opt-in `thinking` request field (fwdloop DeepSeek ask — extends BA-7(b)'s Anthropic option), forwarded to `body.thinking`
|
|
80
84
|
* VERBATIM and unvalidated — e.g. `{ type: 'disabled' }`. Generalises the Anthropic provider's option to
|
|
81
85
|
* OpenAI-compatible endpoints. Motivating case: DeepSeek's OpenAI-compatible API (`baseUrl`
|
|
@@ -112,13 +116,18 @@ class OpenAIProvider {
|
|
|
112
116
|
// BA-7 (b) / fwdloop: forwarded to `body.thinking` VERBATIM — unvalidated, un-reshaped (mirrors the
|
|
113
117
|
// Anthropic provider). Null/unset ⇒ no `thinking` key, byte-identical to the pre-option body.
|
|
114
118
|
this.thinking = options.thinking != null ? options.thinking : null;
|
|
119
|
+
// Normalized on/off switch (src/thinking-mode.js); conflict with raw `thinking` is a ValidationError.
|
|
120
|
+
this.thinkingMode = normalizeThinkingMode(options.thinkingMode, 'OpenAIProvider');
|
|
121
|
+
resolveThinkingMode({ label: 'OpenAIProvider', instanceMode: this.thinkingMode, options: {}, instanceThinking: this.thinking, hasRawThinking: true });
|
|
122
|
+
/** @type {Map<string, string>} shape that worked per mode+model (no re-probing) */
|
|
123
|
+
this._thinkingShapes = new Map();
|
|
115
124
|
}
|
|
116
125
|
|
|
117
126
|
/**
|
|
118
127
|
* Generate a response from the OpenAI API.
|
|
119
128
|
* @param {Message[]} messages - Conversation messages.
|
|
120
129
|
* @param {ToolDef[]} [tools=[]] - Tool definitions.
|
|
121
|
-
* @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; toolChoice — `'auto'` | `'required'` | `{ name }`, forwarded as OpenAI `tool_choice`, applied only when `tools` are present; thinking — per-call override of the constructor's `thinking`, `null` suppresses an instance default, see BA-7 (b)).
|
|
130
|
+
* @param {Record<string, any>} [options={}] - Options (thinkingMode — per-call override of the constructor's, `null` suppresses; 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; toolChoice — `'auto'` | `'required'` | `{ name }`, forwarded as OpenAI `tool_choice`, applied only when `tools` are present; thinking — per-call override of the constructor's `thinking`, `null` suppresses an instance default, see BA-7 (b)).
|
|
122
131
|
* @returns {Promise<GenerateResult>}
|
|
123
132
|
* @throws {Error} `[OpenAIProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
|
|
124
133
|
*/
|
|
@@ -143,6 +152,13 @@ class OpenAIProvider {
|
|
|
143
152
|
// empty. Attach it only when tools are present: OpenAI 400s on a tool_choice with no tools, so a
|
|
144
153
|
// valid choice with nothing to force is dropped (documented), while absent ⇒ the API default 'auto'.
|
|
145
154
|
const toolChoice = toOpenAIToolChoice(options.toolChoice);
|
|
155
|
+
const thinkingMode = resolveThinkingMode({ label: 'OpenAIProvider', instanceMode: this.thinkingMode, options, instanceThinking: this.thinking, hasRawThinking: true });
|
|
156
|
+
/** @type {{ unsupported: boolean, reason?: string }} */
|
|
157
|
+
const thinkingTrack = { unsupported: false };
|
|
158
|
+
const thinkingKind = thinkingMode ? (isDeepSeek(this.model, this.baseUrl) ? 'deepseek' : 'openai') : null;
|
|
159
|
+
// DeepSeek 'on' + a forced tool choice is impossible (the API 400s thinking mode on it): the call still
|
|
160
|
+
// runs, with the only shape that works there (thinking:{type:'disabled'}), and reports 'unsupported'.
|
|
161
|
+
const forcedDeepseekOn = thinkingKind === 'deepseek' && thinkingMode === 'on' && tools.length > 0 && toolChoice != null && toolChoice !== 'auto';
|
|
146
162
|
if (tools.length > 0) {
|
|
147
163
|
body.tools = tools.map(t => ({
|
|
148
164
|
type: 'function',
|
|
@@ -156,10 +172,14 @@ class OpenAIProvider {
|
|
|
156
172
|
const timeoutMs = resolveTimeoutMs(this.timeoutMs, options.timeoutMs);
|
|
157
173
|
const deadlineMs = resolveTimeoutMs(this.deadlineMs, options.deadlineMs, 0, 'deadlineMs');
|
|
158
174
|
const { data, temperatureDropped } = await requestWithTemperatureFallback({
|
|
159
|
-
request: () =>
|
|
175
|
+
request: () => forcedDeepseekOn
|
|
176
|
+
? (thinkingTrack.unsupported = true, thinkingTrack.reason = 'forced-tool', this._request('/chat/completions', deepseekDisabledBody(body), timeoutMs, deadlineMs))
|
|
177
|
+
: thinkingKind
|
|
178
|
+
? requestWithThinkingMode({ kind: /** @type {'openai'|'deepseek'} */ (thinkingKind), mode: /** @type {'on'|'off'} */ (thinkingMode), model: this.model, body, cache: this._thinkingShapes, track: thinkingTrack, request: (b) => this._request('/chat/completions', b, timeoutMs, deadlineMs) })
|
|
179
|
+
: this._request('/chat/completions', body, timeoutMs, deadlineMs),
|
|
160
180
|
hadTemperature: () => body.temperature != null,
|
|
161
181
|
stripTemperature: () => { delete body.temperature; },
|
|
162
|
-
warnOnce: () => this._warnTemperatureDropped(),
|
|
182
|
+
warnOnce: (reason) => this._warnTemperatureDropped(reason),
|
|
163
183
|
});
|
|
164
184
|
// BA-27: a successful 200 whose body carries no `choices` (some OpenAI-compat servers return a
|
|
165
185
|
// 4xx-shaped error object with HTTP 200) reached `data.choices[0]` as a bare TypeError with no
|
|
@@ -199,14 +219,18 @@ class OpenAIProvider {
|
|
|
199
219
|
stopReason: normalizeStopReason(choice?.finish_reason, 'openai', { hasToolCalls: toolCalls.length > 0 }),
|
|
200
220
|
usage: this._normalizeUsage(data.usage),
|
|
201
221
|
...(temperatureDropped && { temperatureDropped: true }),
|
|
222
|
+
...(thinkingKind && assessThinking({ kind: /** @type {'openai'|'deepseek'} */ (thinkingKind), mode: /** @type {'on'|'off'} */ (thinkingMode), data, unsupported: thinkingTrack.unsupported, reason: thinkingTrack.reason, owner: this, label: 'OpenAIProvider', model: this.model })),
|
|
202
223
|
};
|
|
203
224
|
}
|
|
204
225
|
|
|
205
|
-
/** One-time warning that this model rejected `temperature` and the request was retried without it (BA-10). */
|
|
206
|
-
_warnTemperatureDropped() {
|
|
207
|
-
|
|
208
|
-
this
|
|
209
|
-
|
|
226
|
+
/** One-time warning (per reason) that this model rejected `temperature` and the request was retried without it (BA-10). */
|
|
227
|
+
_warnTemperatureDropped(reason = 'unsupported') {
|
|
228
|
+
const flag = reason === 'thinking' ? '_warnedTempThinking' : '_warnedTempDropped';
|
|
229
|
+
if (this[flag]) return;
|
|
230
|
+
this[flag] = true;
|
|
231
|
+
console.warn(reason === 'thinking'
|
|
232
|
+
? `[OpenAIProvider] '${this.model}': 'temperature' must be 1 while thinking is on — retrying without it. Further drops from this provider instance are silent.`
|
|
233
|
+
: `[OpenAIProvider] '${this.model}' rejected a non-default 'temperature' (unsupported/deprecated) — retrying without it. Further drops from this provider instance are silent.`);
|
|
210
234
|
}
|
|
211
235
|
|
|
212
236
|
/**
|
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
* @returns {boolean}
|
|
5
5
|
*/
|
|
6
6
|
export function isTemperatureUnsupported(err: any): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* Is this a 400 saying `temperature` conflicts with enabled thinking (names both)?
|
|
9
|
+
* @param {any} err
|
|
10
|
+
* @returns {boolean}
|
|
11
|
+
*/
|
|
12
|
+
export function isTemperatureThinkingConflict(err: any): boolean;
|
|
7
13
|
/**
|
|
8
14
|
* Issue a provider request; if it 400s because `temperature` is unsupported AND a temperature was
|
|
9
15
|
* actually sent, strip it and retry ONCE. Returns whether the temperature was dropped so the caller
|
|
@@ -13,14 +19,16 @@ export function isTemperatureUnsupported(err: any): boolean;
|
|
|
13
19
|
* @param {() => Promise<any>} opts.request - issues the API call (rejects `ProviderError` on 4xx).
|
|
14
20
|
* @param {() => boolean} opts.hadTemperature - was a temperature actually in the request body?
|
|
15
21
|
* @param {() => void} opts.stripTemperature - mutate the request body to remove the temperature.
|
|
16
|
-
* @param {
|
|
22
|
+
* @param {boolean} [opts.thinkingOn] - a thinkingMode 'on' shape is being sent: ALSO treat a 400 naming both temperature and thinking as droppable (thinking wins). Default off ⇒ behavior unchanged.
|
|
23
|
+
* @param {(reason: 'unsupported'|'thinking') => void} [opts.warnOnce] - emit the one-time degrade warning for that reason (caller dedupes per instance AND reason).
|
|
17
24
|
* @returns {Promise<{ data: any, temperatureDropped: boolean }>}
|
|
18
25
|
*/
|
|
19
|
-
export function requestWithTemperatureFallback({ request, hadTemperature, stripTemperature, warnOnce }: {
|
|
26
|
+
export function requestWithTemperatureFallback({ request, hadTemperature, stripTemperature, thinkingOn, warnOnce }: {
|
|
20
27
|
request: () => Promise<any>;
|
|
21
28
|
hadTemperature: () => boolean;
|
|
22
29
|
stripTemperature: () => void;
|
|
23
|
-
|
|
30
|
+
thinkingOn?: boolean | undefined;
|
|
31
|
+
warnOnce?: ((reason: "unsupported" | "thinking") => void) | undefined;
|
|
24
32
|
}): Promise<{
|
|
25
33
|
data: any;
|
|
26
34
|
temperatureDropped: boolean;
|
|
@@ -32,6 +32,22 @@ function isTemperatureUnsupported(err) {
|
|
|
32
32
|
return !!err && err.status === 400 && TEMP_NAMED.test(msg) && TEMP_UNSUPPORTED.test(msg);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// Anthropic with thinking ENABLED rejects any non-default temperature ("`temperature` may only be set to 1
|
|
36
|
+
// when thinking is enabled" — live-confirmed 2026-10-11 on haiku-4-5; sonnet-5 appends "or in adaptive mode"). Thinking-on wins: the
|
|
37
|
+
// caller asked for thinking explicitly, so the temperature is what gets dropped. Narrow: a 400 that names
|
|
38
|
+
// BOTH words (no `.*`, so ReDoS-safe); only consulted when the caller passes `thinkingOn`.
|
|
39
|
+
const THINKING_NAMED = /thinking/i;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Is this a 400 saying `temperature` conflicts with enabled thinking (names both)?
|
|
43
|
+
* @param {any} err
|
|
44
|
+
* @returns {boolean}
|
|
45
|
+
*/
|
|
46
|
+
function isTemperatureThinkingConflict(err) {
|
|
47
|
+
const msg = err && typeof err.message === 'string' ? err.message : '';
|
|
48
|
+
return !!err && err.status === 400 && TEMP_NAMED.test(msg) && THINKING_NAMED.test(msg);
|
|
49
|
+
}
|
|
50
|
+
|
|
35
51
|
/**
|
|
36
52
|
* Issue a provider request; if it 400s because `temperature` is unsupported AND a temperature was
|
|
37
53
|
* actually sent, strip it and retry ONCE. Returns whether the temperature was dropped so the caller
|
|
@@ -41,20 +57,23 @@ function isTemperatureUnsupported(err) {
|
|
|
41
57
|
* @param {() => Promise<any>} opts.request - issues the API call (rejects `ProviderError` on 4xx).
|
|
42
58
|
* @param {() => boolean} opts.hadTemperature - was a temperature actually in the request body?
|
|
43
59
|
* @param {() => void} opts.stripTemperature - mutate the request body to remove the temperature.
|
|
44
|
-
* @param {
|
|
60
|
+
* @param {boolean} [opts.thinkingOn] - a thinkingMode 'on' shape is being sent: ALSO treat a 400 naming both temperature and thinking as droppable (thinking wins). Default off ⇒ behavior unchanged.
|
|
61
|
+
* @param {(reason: 'unsupported'|'thinking') => void} [opts.warnOnce] - emit the one-time degrade warning for that reason (caller dedupes per instance AND reason).
|
|
45
62
|
* @returns {Promise<{ data: any, temperatureDropped: boolean }>}
|
|
46
63
|
*/
|
|
47
|
-
async function requestWithTemperatureFallback({ request, hadTemperature, stripTemperature, warnOnce }) {
|
|
64
|
+
async function requestWithTemperatureFallback({ request, hadTemperature, stripTemperature, thinkingOn, warnOnce }) {
|
|
48
65
|
try {
|
|
49
66
|
return { data: await request(), temperatureDropped: false };
|
|
50
67
|
} catch (err) {
|
|
51
|
-
|
|
68
|
+
// 'unsupported' wins when both match (the model refuses temperature outright); else the thinking conflict.
|
|
69
|
+
const reason = isTemperatureUnsupported(err) ? 'unsupported' : (thinkingOn === true && isTemperatureThinkingConflict(err)) ? 'thinking' : null;
|
|
70
|
+
if (reason && hadTemperature()) {
|
|
52
71
|
stripTemperature();
|
|
53
|
-
if (warnOnce) warnOnce();
|
|
72
|
+
if (warnOnce) warnOnce(reason);
|
|
54
73
|
return { data: await request(), temperatureDropped: true };
|
|
55
74
|
}
|
|
56
75
|
throw err;
|
|
57
76
|
}
|
|
58
77
|
}
|
|
59
78
|
|
|
60
|
-
module.exports = { isTemperatureUnsupported, requestWithTemperatureFallback };
|
|
79
|
+
module.exports = { isTemperatureUnsupported, isTemperatureThinkingConflict, requestWithTemperatureFallback };
|
package/src/providers.d.ts
CHANGED
|
@@ -5,4 +5,5 @@ import { OllamaProvider } from "./provider-ollama";
|
|
|
5
5
|
import { CLIPipeProvider } from "./provider-clipipe";
|
|
6
6
|
import { FallbackProvider } from "./provider-fallback";
|
|
7
7
|
import { JevProvider } from "./provider-jev";
|
|
8
|
-
|
|
8
|
+
import { thinkingSupport } from "./thinking-mode";
|
|
9
|
+
export { OpenAIProvider as OpenAI, AnthropicProvider as Anthropic, GeminiProvider as Gemini, OllamaProvider as Ollama, CLIPipeProvider as CLIPipe, FallbackProvider as Fallback, JevProvider as Jev, OpenAIProvider, AnthropicProvider, GeminiProvider, OllamaProvider, CLIPipeProvider, FallbackProvider, JevProvider, thinkingSupport };
|
package/src/providers.js
CHANGED
|
@@ -7,6 +7,7 @@ const { OllamaProvider } = require('./provider-ollama');
|
|
|
7
7
|
const { CLIPipeProvider } = require('./provider-clipipe');
|
|
8
8
|
const { FallbackProvider } = require('./provider-fallback');
|
|
9
9
|
const { JevProvider } = require('./provider-jev');
|
|
10
|
+
const { thinkingSupport } = require('./thinking-mode');
|
|
10
11
|
|
|
11
12
|
module.exports = {
|
|
12
13
|
// Short names (canonical — used throughout docs and the integration guide)
|
|
@@ -26,4 +27,6 @@ module.exports = {
|
|
|
26
27
|
CLIPipeProvider,
|
|
27
28
|
FallbackProvider,
|
|
28
29
|
JevProvider,
|
|
30
|
+
// Pure lookup (no network): what bare-agent knows about switching a model's thinking on/off.
|
|
31
|
+
thinkingSupport,
|
|
29
32
|
};
|