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.
@@ -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: () => this._request(`/models/${this.model}:generateContent`, body, timeoutMs, deadlineMs),
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
- if (this._warnedTempDropped) return;
170
- this._warnedTempDropped = true;
171
- console.warn(`[GeminiProvider] '${this.model}' rejected a non-default 'temperature' (unsupported/deprecated) — retrying without it. Further drops from this provider instance are silent.`);
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
  /**
@@ -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
@@ -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: () => this._request('/api/chat', body, timeoutMs, deadlineMs),
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
- if (this._warnedTempDropped) return;
135
- this._warnedTempDropped = true;
136
- console.warn(`[OllamaProvider] '${this.model}' rejected a non-default 'temperature' (unsupported/deprecated) — retrying without it. Further drops from this provider instance are silent.`);
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
  /**
@@ -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` —
@@ -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: () => this._request('/chat/completions', body, timeoutMs, deadlineMs),
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
- if (this._warnedTempDropped) return;
208
- this._warnedTempDropped = true;
209
- console.warn(`[OpenAIProvider] '${this.model}' rejected a non-default 'temperature' (unsupported/deprecated) — retrying without it. Further drops from this provider instance are silent.`);
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 {() => void} [opts.warnOnce] - emit the one-time degrade warning (caller dedupes per instance).
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
- warnOnce?: (() => void) | undefined;
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 {() => void} [opts.warnOnce] - emit the one-time degrade warning (caller dedupes per instance).
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
- if (isTemperatureUnsupported(err) && hadTemperature()) {
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 };
@@ -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
- 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 };
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
  };