bare-agent 0.51.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,7 +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, assessThinkingApplied } = require('./thinking-mode');
10
+ const { normalizeThinkingMode, resolveThinkingMode, requestWithThinkingMode, assessThinking } = require('./thinking-mode');
11
11
 
12
12
  // BA-24: raw Gemini usageMetadata fields. Any present (even 0) ⇒ a usage signal; none ⇒ null.
13
13
  const GEMINI_USAGE_KEYS = ['promptTokenCount', 'candidatesTokenCount', 'thoughtsTokenCount', 'cachedContentTokenCount'];
@@ -28,8 +28,8 @@ function isLoopbackHost(hostname) {
28
28
  * @property {string} [apiKey] - Google AI Studio (Gemini) API key.
29
29
  * @property {string} [model='gemini-2.5-flash'] - Model ID.
30
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, retries the known alternative shapes if the API rejects the field by name, and THROWS a `ValidationError` (naming what it tried) if every shape is rejected — or before any request when the model is measured as not switchable. Every reply is checked: `GenerateResult.thinkingApplied` is `true`/`false`/`null` (`false` = 'off' was sent but the reply shows thinking happened — warns once per instance, result kept). 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 — throws `ValidationError`, which the chain treats like any failure and falls through).
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).
33
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).
34
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 })`.
35
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 })`.
@@ -132,15 +132,17 @@ class GeminiProvider {
132
132
  // BA-10: graceful degrade if a model rejects a non-default `temperature` (Gemini nests it under
133
133
  // generationConfig). Keyed off the API error text, so dormant on models that accept temperature.
134
134
  const thinkingMode = resolveThinkingMode({ label: 'GeminiProvider', instanceMode: this.thinkingMode, options });
135
+ /** @type {{ unsupported: boolean }} */
136
+ const thinkingTrack = { unsupported: false };
135
137
  const timeoutMs = resolveTimeoutMs(this.timeoutMs, options.timeoutMs);
136
138
  const deadlineMs = resolveTimeoutMs(this.deadlineMs, options.deadlineMs, 0, 'deadlineMs');
137
139
  const { data, temperatureDropped } = await requestWithTemperatureFallback({
138
140
  request: () => thinkingMode
139
- ? requestWithThinkingMode({ kind: 'gemini', mode: thinkingMode, model: this.model, label: 'GeminiProvider', body, cache: this._thinkingShapes, request: (b) => this._request(`/models/${this.model}:generateContent`, b, timeoutMs, deadlineMs) })
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) })
140
142
  : this._request(`/models/${this.model}:generateContent`, body, timeoutMs, deadlineMs),
141
143
  hadTemperature: () => body.generationConfig?.temperature != null,
142
144
  stripTemperature: () => { if (body.generationConfig) delete body.generationConfig.temperature; },
143
- warnOnce: () => this._warnTemperatureDropped(),
145
+ warnOnce: (reason) => this._warnTemperatureDropped(reason),
144
146
  });
145
147
 
146
148
  let text = '';
@@ -171,15 +173,18 @@ class GeminiProvider {
171
173
  stopReason: normalizeStopReason(data.candidates?.[0]?.finishReason, 'gemini', { hasToolCalls: toolCalls.length > 0 }),
172
174
  usage: this._normalizeUsage(data.usageMetadata),
173
175
  ...(temperatureDropped && { temperatureDropped: true }),
174
- ...(thinkingMode && { thinkingApplied: assessThinkingApplied({ kind: 'gemini', mode: thinkingMode, data, owner: this, label: 'GeminiProvider', model: this.model }) }),
176
+ ...(thinkingMode && assessThinking({ kind: 'gemini', mode: thinkingMode, data, unsupported: thinkingTrack.unsupported, owner: this, label: 'GeminiProvider', model: this.model })),
175
177
  };
176
178
  }
177
179
 
178
- /** One-time warning that this model rejected `temperature` and the request was retried without it (BA-10). */
179
- _warnTemperatureDropped() {
180
- if (this._warnedTempDropped) return;
181
- this._warnedTempDropped = true;
182
- 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.`);
183
188
  }
184
189
 
185
190
  /**
@@ -5,8 +5,8 @@ export type OllamaOptions = {
5
5
  model?: string | undefined;
6
6
  url?: string | undefined;
7
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, retries the known alternative shapes if the API rejects the field by name, and THROWS a `ValidationError` (naming what it tried) if every shape is rejected — or before any request when the model is measured as not switchable. Every reply is checked: `GenerateResult.thinkingApplied` is `true`/`false`/`null` (`false` = 'off' was sent but the reply shows thinking happened — warns once per instance, result kept). 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 — throws `ValidationError`, which the chain treats like any failure and falls through).
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
10
  */
11
11
  thinkingMode?: "on" | "off" | null | undefined;
12
12
  exposeErrorBody?: boolean | undefined;
@@ -33,8 +33,8 @@ export type OllamaOptions = {
33
33
  * @typedef {object} OllamaOptions
34
34
  * @property {string} [model='llama3.2']
35
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, retries the known alternative shapes if the API rejects the field by name, and THROWS a `ValidationError` (naming what it tried) if every shape is rejected — or before any request when the model is measured as not switchable. Every reply is checked: `GenerateResult.thinkingApplied` is `true`/`false`/`null` (`false` = 'off' was sent but the reply shows thinking happened — warns once per instance, result kept). 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 — throws `ValidationError`, which the chain treats like any failure and falls through).
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).
38
38
  * @property {boolean} [exposeErrorBody=false]
39
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.)
40
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.)
@@ -70,9 +70,8 @@ export class OllamaProvider {
70
70
  * @throws {Error} `[OllamaProvider] ...` — on HTTP errors or invalid JSON response.
71
71
  */
72
72
  generate(messages: Message[], tools?: ToolDef[], options?: Record<string, any>): Promise<GenerateResult>;
73
- /** One-time warning that this model rejected `temperature` and the request was retried without it (BA-10). */
74
- _warnTemperatureDropped(): void;
75
- _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;
76
75
  /**
77
76
  * @param {string} path
78
77
  * @param {Record<string, any>} body
@@ -7,7 +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, assessThinkingApplied } = require('./thinking-mode');
10
+ const { normalizeThinkingMode, resolveThinkingMode, requestWithThinkingMode, assessThinking } = require('./thinking-mode');
11
11
 
12
12
  // BA-24: raw Ollama usage fields. Any present (even 0) ⇒ a usage signal; none ⇒ null (unpriceable).
13
13
  const OLLAMA_USAGE_KEYS = ['prompt_eval_count', 'eval_count'];
@@ -20,8 +20,8 @@ const OLLAMA_USAGE_KEYS = ['prompt_eval_count', 'eval_count'];
20
20
  * @typedef {object} OllamaOptions
21
21
  * @property {string} [model='llama3.2']
22
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, retries the known alternative shapes if the API rejects the field by name, and THROWS a `ValidationError` (naming what it tried) if every shape is rejected — or before any request when the model is measured as not switchable. Every reply is checked: `GenerateResult.thinkingApplied` is `true`/`false`/`null` (`false` = 'off' was sent but the reply shows thinking happened — warns once per instance, result kept). 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 — throws `ValidationError`, which the chain treats like any failure and falls through).
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).
25
25
  * @property {boolean} [exposeErrorBody=false]
26
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.)
27
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.)
@@ -94,15 +94,17 @@ class OllamaProvider {
94
94
  // BA-10: graceful degrade if a model rejects a non-default `temperature` (Ollama nests it under
95
95
  // `options`). Keyed off the API error text, so dormant on models that accept temperature.
96
96
  const thinkingMode = resolveThinkingMode({ label: 'OllamaProvider', instanceMode: this.thinkingMode, options });
97
+ /** @type {{ unsupported: boolean }} */
98
+ const thinkingTrack = { unsupported: false };
97
99
  const timeoutMs = resolveTimeoutMs(this.timeoutMs, options.timeoutMs);
98
100
  const deadlineMs = resolveTimeoutMs(this.deadlineMs, options.deadlineMs, 0, 'deadlineMs');
99
101
  const { data, temperatureDropped } = await requestWithTemperatureFallback({
100
102
  request: () => thinkingMode
101
- ? requestWithThinkingMode({ kind: 'ollama', mode: thinkingMode, model: this.model, label: 'OllamaProvider', body, cache: this._thinkingShapes, request: (b) => this._request('/api/chat', b, timeoutMs, deadlineMs) })
103
+ ? requestWithThinkingMode({ kind: 'ollama', mode: thinkingMode, model: this.model, body, cache: this._thinkingShapes, track: thinkingTrack, request: (b) => this._request('/api/chat', b, timeoutMs, deadlineMs) })
102
104
  : this._request('/api/chat', body, timeoutMs, deadlineMs),
103
105
  hadTemperature: () => body.options?.temperature != null,
104
106
  stripTemperature: () => { if (body.options) delete body.options.temperature; },
105
- warnOnce: () => this._warnTemperatureDropped(),
107
+ warnOnce: (reason) => this._warnTemperatureDropped(reason),
106
108
  });
107
109
  const msg = data.message || {};
108
110
 
@@ -136,15 +138,18 @@ class OllamaProvider {
136
138
  ? { inputTokens: data.prompt_eval_count || 0, outputTokens: data.eval_count || 0 }
137
139
  : null,
138
140
  ...(temperatureDropped && { temperatureDropped: true }),
139
- ...(thinkingMode && { thinkingApplied: assessThinkingApplied({ kind: 'ollama', mode: thinkingMode, data, owner: this, label: 'OllamaProvider', model: this.model }) }),
141
+ ...(thinkingMode && assessThinking({ kind: 'ollama', mode: thinkingMode, data, unsupported: thinkingTrack.unsupported, owner: this, label: 'OllamaProvider', model: this.model })),
140
142
  };
141
143
  }
142
144
 
143
- /** One-time warning that this model rejected `temperature` and the request was retried without it (BA-10). */
144
- _warnTemperatureDropped() {
145
- if (this._warnedTempDropped) return;
146
- this._warnedTempDropped = true;
147
- 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.`);
148
153
  }
149
154
 
150
155
  /**
@@ -46,9 +46,9 @@ export type OpenAIOptions = {
46
46
  */
47
47
  exposeMalformedArgs?: boolean | undefined;
48
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, retries the known alternative shapes if the API rejects the field by name, and THROWS a `ValidationError` (naming what it tried) if every shape is rejected — or before any request when the model is measured as not switchable. Every reply is checked: `GenerateResult.thinkingApplied` is `true`/`false`/`null` (`false` = 'off' was sent but the reply shows thinking happened — warns once per instance, result kept). 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 — throws `ValidationError`, which the chain treats like any failure and falls through).
51
- * DeepSeek: `'on'` + a forced `toolChoice` (`'required'`/`{name}`) ⇒ `ValidationError` before sending (the API 400s thinking mode on a forced tool_choice).
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
52
  */
53
53
  thinkingMode?: "on" | "off" | null | undefined;
54
54
  /**
@@ -94,9 +94,9 @@ export type OpenAIOptions = {
94
94
  * JSON fails to parse, also attach the FULL raw string (verbatim, uncapped) to `malformedToolCall.rawArguments`.
95
95
  * Off by default, independent of `exposeErrorBody` — the raw blob is model-generated and may be large
96
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, retries the known alternative shapes if the API rejects the field by name, and THROWS a `ValidationError` (naming what it tried) if every shape is rejected — or before any request when the model is measured as not switchable. Every reply is checked: `GenerateResult.thinkingApplied` is `true`/`false`/`null` (`false` = 'off' was sent but the reply shows thinking happened — warns once per instance, result kept). 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 — throws `ValidationError`, which the chain treats like any failure and falls through).
99
- * DeepSeek: `'on'` + a forced `toolChoice` (`'required'`/`{name}`) ⇒ `ValidationError` before sending (the API 400s thinking mode on a forced tool_choice).
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'`.
100
100
  * @property {any} [thinking] - Opt-in `thinking` request field (fwdloop DeepSeek ask — extends BA-7(b)'s Anthropic option), forwarded to `body.thinking`
101
101
  * VERBATIM and unvalidated — e.g. `{ type: 'disabled' }`. Generalises the Anthropic provider's option to
102
102
  * OpenAI-compatible endpoints. Motivating case: DeepSeek's OpenAI-compatible API (`baseUrl`
@@ -138,9 +138,8 @@ export class OpenAIProvider {
138
138
  * @throws {Error} `[OpenAIProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
139
139
  */
140
140
  generate(messages: Message[], tools?: ToolDef[], options?: Record<string, any>): Promise<GenerateResult>;
141
- /** One-time warning that this model rejected `temperature` and the request was retried without it (BA-10). */
142
- _warnTemperatureDropped(): void;
143
- _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;
144
143
  /**
145
144
  * Normalize OpenAI usage to the neutral {@link Usage} shape. OpenAI auto-caches prompt prefixes
146
145
  * (>=1024 tokens) and reports the cached portion in `prompt_tokens_details.cached_tokens` —
@@ -2,13 +2,13 @@
2
2
 
3
3
  const https = require('https');
4
4
  const http = require('http');
5
- const { ProviderError, ValidationError } = require('./errors');
5
+ const { ProviderError } = require('./errors');
6
6
  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
10
  const { parseToolCalls } = require('./provider-toolcalls');
11
- const { normalizeThinkingMode, resolveThinkingMode, requestWithThinkingMode, assessThinkingApplied, isDeepSeek } = require('./thinking-mode');
11
+ const { normalizeThinkingMode, resolveThinkingMode, requestWithThinkingMode, assessThinking, deepseekDisabledBody, isDeepSeek } = require('./thinking-mode');
12
12
 
13
13
  // BA-24: raw OpenAI usage fields. Any present (even 0) ⇒ a usage signal; none ⇒ null (unpriceable).
14
14
  const OPENAI_USAGE_KEYS = ['prompt_tokens', 'completion_tokens', 'prompt_tokens_details'];
@@ -77,9 +77,9 @@ function isLoopbackHost(hostname) {
77
77
  * JSON fails to parse, also attach the FULL raw string (verbatim, uncapped) to `malformedToolCall.rawArguments`.
78
78
  * Off by default, independent of `exposeErrorBody` — the raw blob is model-generated and may be large
79
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, retries the known alternative shapes if the API rejects the field by name, and THROWS a `ValidationError` (naming what it tried) if every shape is rejected — or before any request when the model is measured as not switchable. Every reply is checked: `GenerateResult.thinkingApplied` is `true`/`false`/`null` (`false` = 'off' was sent but the reply shows thinking happened — warns once per instance, result kept). 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 — throws `ValidationError`, which the chain treats like any failure and falls through).
82
- * DeepSeek: `'on'` + a forced `toolChoice` (`'required'`/`{name}`) ⇒ `ValidationError` before sending (the API 400s thinking mode on a forced tool_choice).
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'`.
83
83
  * @property {any} [thinking] - Opt-in `thinking` request field (fwdloop DeepSeek ask — extends BA-7(b)'s Anthropic option), forwarded to `body.thinking`
84
84
  * VERBATIM and unvalidated — e.g. `{ type: 'disabled' }`. Generalises the Anthropic provider's option to
85
85
  * OpenAI-compatible endpoints. Motivating case: DeepSeek's OpenAI-compatible API (`baseUrl`
@@ -153,10 +153,12 @@ class OpenAIProvider {
153
153
  // valid choice with nothing to force is dropped (documented), while absent ⇒ the API default 'auto'.
154
154
  const toolChoice = toOpenAIToolChoice(options.toolChoice);
155
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 };
156
158
  const thinkingKind = thinkingMode ? (isDeepSeek(this.model, this.baseUrl) ? 'deepseek' : 'openai') : null;
157
- if (thinkingKind === 'deepseek' && thinkingMode === 'on' && tools.length > 0 && toolChoice != null && toolChoice !== 'auto') {
158
- throw new ValidationError("[OpenAIProvider] thinkingMode 'on' cannot be combined with a forced toolChoice on DeepSeek (the API 400s: thinking mode does not support a forced tool_choice) — use thinkingMode 'off' or toolChoice 'auto'.", { context: { lib: 'bare-agent', provider: 'deepseek', model: this.model } });
159
- }
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';
160
162
  if (tools.length > 0) {
161
163
  body.tools = tools.map(t => ({
162
164
  type: 'function',
@@ -170,12 +172,14 @@ class OpenAIProvider {
170
172
  const timeoutMs = resolveTimeoutMs(this.timeoutMs, options.timeoutMs);
171
173
  const deadlineMs = resolveTimeoutMs(this.deadlineMs, options.deadlineMs, 0, 'deadlineMs');
172
174
  const { data, temperatureDropped } = await requestWithTemperatureFallback({
173
- request: () => thinkingKind
174
- ? requestWithThinkingMode({ kind: /** @type {'openai'|'deepseek'} */ (thinkingKind), mode: /** @type {'on'|'off'} */ (thinkingMode), model: this.model, label: 'OpenAIProvider', body, cache: this._thinkingShapes, request: (b) => this._request('/chat/completions', b, 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) })
175
179
  : this._request('/chat/completions', body, timeoutMs, deadlineMs),
176
180
  hadTemperature: () => body.temperature != null,
177
181
  stripTemperature: () => { delete body.temperature; },
178
- warnOnce: () => this._warnTemperatureDropped(),
182
+ warnOnce: (reason) => this._warnTemperatureDropped(reason),
179
183
  });
180
184
  // BA-27: a successful 200 whose body carries no `choices` (some OpenAI-compat servers return a
181
185
  // 4xx-shaped error object with HTTP 200) reached `data.choices[0]` as a bare TypeError with no
@@ -215,15 +219,18 @@ class OpenAIProvider {
215
219
  stopReason: normalizeStopReason(choice?.finish_reason, 'openai', { hasToolCalls: toolCalls.length > 0 }),
216
220
  usage: this._normalizeUsage(data.usage),
217
221
  ...(temperatureDropped && { temperatureDropped: true }),
218
- ...(thinkingKind && { thinkingApplied: assessThinkingApplied({ kind: /** @type {'openai'|'deepseek'} */ (thinkingKind), mode: /** @type {'on'|'off'} */ (thinkingMode), data, owner: this, label: 'OpenAIProvider', model: this.model }) }),
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 })),
219
223
  };
220
224
  }
221
225
 
222
- /** One-time warning that this model rejected `temperature` and the request was retried without it (BA-10). */
223
- _warnTemperatureDropped() {
224
- if (this._warnedTempDropped) return;
225
- this._warnedTempDropped = true;
226
- 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.`);
227
234
  }
228
235
 
229
236
  /**
@@ -20,7 +20,7 @@ export function isTemperatureThinkingConflict(err: any): boolean;
20
20
  * @param {() => boolean} opts.hadTemperature - was a temperature actually in the request body?
21
21
  * @param {() => void} opts.stripTemperature - mutate the request body to remove the temperature.
22
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 {() => void} [opts.warnOnce] - emit the one-time degrade warning (caller dedupes per instance).
23
+ * @param {(reason: 'unsupported'|'thinking') => void} [opts.warnOnce] - emit the one-time degrade warning for that reason (caller dedupes per instance AND reason).
24
24
  * @returns {Promise<{ data: any, temperatureDropped: boolean }>}
25
25
  */
26
26
  export function requestWithTemperatureFallback({ request, hadTemperature, stripTemperature, thinkingOn, warnOnce }: {
@@ -28,7 +28,7 @@ export function requestWithTemperatureFallback({ request, hadTemperature, stripT
28
28
  hadTemperature: () => boolean;
29
29
  stripTemperature: () => void;
30
30
  thinkingOn?: boolean | undefined;
31
- warnOnce?: (() => void) | undefined;
31
+ warnOnce?: ((reason: "unsupported" | "thinking") => void) | undefined;
32
32
  }): Promise<{
33
33
  data: any;
34
34
  temperatureDropped: boolean;
@@ -58,16 +58,18 @@ function isTemperatureThinkingConflict(err) {
58
58
  * @param {() => boolean} opts.hadTemperature - was a temperature actually in the request body?
59
59
  * @param {() => void} opts.stripTemperature - mutate the request body to remove the temperature.
60
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 {() => void} [opts.warnOnce] - emit the one-time degrade warning (caller dedupes per instance).
61
+ * @param {(reason: 'unsupported'|'thinking') => void} [opts.warnOnce] - emit the one-time degrade warning for that reason (caller dedupes per instance AND reason).
62
62
  * @returns {Promise<{ data: any, temperatureDropped: boolean }>}
63
63
  */
64
64
  async function requestWithTemperatureFallback({ request, hadTemperature, stripTemperature, thinkingOn, warnOnce }) {
65
65
  try {
66
66
  return { data: await request(), temperatureDropped: false };
67
67
  } catch (err) {
68
- if ((isTemperatureUnsupported(err) || (thinkingOn === true && isTemperatureThinkingConflict(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()) {
69
71
  stripTemperature();
70
- if (warnOnce) warnOnce();
72
+ if (warnOnce) warnOnce(reason);
71
73
  return { data: await request(), temperatureDropped: true };
72
74
  }
73
75
  throw err;
@@ -14,13 +14,15 @@ export type Shape = {
14
14
  */
15
15
  apply: (body: Record<string, any>) => (Record<string, any> | null);
16
16
  };
17
+ export type ThinkingStatus = "applied" | "unsupported" | "ignored" | "unknown";
17
18
  /**
18
19
  * What bare-agent knows about switching a model's thinking on/off, with no network. Measured models
19
20
  * answer true/false; a model not in the measured table on a provider with a shape ladder answers
20
- * 'unverified' (the ladder will try the shapes at runtime and fail loudly if all are rejected);
21
+ * 'unverified' (the ladder will try the shapes at runtime; if they fail with client validation errors a plain request decides: OK ⇒ thinkingStatus 'unsupported', failure ⇒ that error is thrown);
21
22
  * clipipe and unknown providers answer false.
22
23
  * @param {'anthropic'|'openai'|'deepseek'|'gemini'|'ollama'|'clipipe'|string} provider
23
24
  * @param {string} [model]
25
+ * @param {{ baseUrl?: string }} [opts] - `baseUrl` only matters for provider 'openai': an OpenAI-wire endpoint on a DeepSeek host answers with DeepSeek's shapes, exactly as the request path does (same shared `isDeepSeek`). Omitted ⇒ model-id detection only. A non-object (incl. `null`) throws ValidationError (type only).
24
26
  * @returns {{ switchable: boolean|'unverified', off: string|null, on: string|null, note: string }}
25
27
  * @when you need to know, before building a request, whether a provider/model can have thinking turned on or off and which request field does it — pure lookup, no network
26
28
  * @fails returns switchable:false for clipipe and unknown providers (never throws); 'unverified' means not measured, not unsupported; Ollama's shapes are not live-verified.
@@ -28,7 +30,9 @@ export type Shape = {
28
30
  * @example
29
31
  * thinkingSupport('anthropic', 'claude-opus-5-5'); // { switchable: false, off: null, on: "thinking:{type:'adaptive'}", note: '...' }
30
32
  */
31
- export function thinkingSupport(provider: "anthropic" | "openai" | "deepseek" | "gemini" | "ollama" | "clipipe" | string, model?: string): {
33
+ export function thinkingSupport(provider: "anthropic" | "openai" | "deepseek" | "gemini" | "ollama" | "clipipe" | string, model?: string, opts?: {
34
+ baseUrl?: string;
35
+ }): {
32
36
  switchable: boolean | "unverified";
33
37
  off: string | null;
34
38
  on: string | null;
@@ -60,53 +64,104 @@ export function resolveThinkingMode({ label, instanceMode, options, instanceThin
60
64
  hasRawThinking?: boolean | undefined;
61
65
  }): "on" | "off" | null;
62
66
  /**
63
- * Send a request with the `thinkingMode` field applied, walking the shape ladder on a 400 that names
64
- * the field. Order: the instance cache (a shape that already worked for this mode+model), else the
65
- * table's known-good shape, else the ladder's first; then the remaining shapes in ladder order. A
66
- * known-not-switchable model throws BEFORE any request. Every shape rejected ⇒ a loud ValidationError.
67
- * A 400 that does not name the field (or any non-400) is an ordinary error and propagates untouched.
67
+ * Send a request with the `thinkingMode` field applied. Order: the instance cache (a shape that already
68
+ * worked for this mode+model), else the table's known-good shape, else the ladder's first; then the
69
+ * remaining shapes in ladder order (a shape whose apply() returns null is skipped).
70
+ *
71
+ * When a shape request fails with a client validation error (400/422, not temperature-named), the next
72
+ * shape is tried; after the last, ONE plain request (no thinking field) decides: it succeeds ⇒ the
73
+ * setting was the problem (`track.unsupported = true`, result kept); it fails too ⇒ it was a real error
74
+ * and the PLAIN request's error is thrown as-is (it is the one without our field). Any other failure
75
+ * (5xx, 429, timeout, transport, 401/403/404) propagates immediately — no ladder.
76
+ *
77
+ * A model the table marks not switchable gets ONE plain request, `unsupported`. Only the winning shape
78
+ * is cached; "unsupported" is never cached (a runtime unsupported cache poisoned on body-dependent
79
+ * skips and transcript/signature errors), so an unknown model that cannot switch re-pays its refused
80
+ * shape requests on every call.
68
81
  *
69
82
  * @param {object} p
70
83
  * @param {LadderKind} p.kind
71
84
  * @param {'on'|'off'} p.mode
72
85
  * @param {string} p.model
73
- * @param {string} p.label - provider class name
74
86
  * @param {Record<string, any>} p.body - the base body (NOT mutated; each attempt gets a patched copy)
75
87
  * @param {(body: Record<string, any>) => Promise<any>} p.request
76
- * @param {Map<string, string>} p.cache - per provider instance
77
- * @returns {Promise<any>} the reply data of the accepted shape
88
+ * @param {Map<string, string>} p.cache - per provider instance; holds winning shape keys only
89
+ * @param {{ unsupported: boolean }} p.track - written: `unsupported` is set true when the call ran without our field
90
+ * @returns {Promise<any>} the reply data
78
91
  */
79
- export function requestWithThinkingMode({ kind, mode, model, label, body, request, cache }: {
92
+ export function requestWithThinkingMode({ kind, mode, model, body, request, cache, track }: {
80
93
  kind: LadderKind;
81
94
  mode: "on" | "off";
82
95
  model: string;
83
- label: string;
84
96
  body: Record<string, any>;
85
97
  request: (body: Record<string, any>) => Promise<any>;
86
98
  cache: Map<string, string>;
99
+ track: {
100
+ unsupported: boolean;
101
+ };
87
102
  }): Promise<any>;
88
103
  /**
89
- * The `thinkingApplied` verdict for a reply whose request was accepted. 'on' ⇒ `true` (an adaptive
90
- * model may legitimately choose not to think, so 'on' is never flagged). 'off' ⇒ `true` when the reply
91
- * shows zero thinking, `false` when thinking happened anyway, `null` when the signal is unreadable.
92
- * On `false` it warns ONCE per `owner` (the provider instance) and never throws — the round is paid.
104
+ * The outcome of a thinkingMode call: `{thinkingStatus, thinkingApplied}` ready to spread onto a
105
+ * GenerateResult. `unsupported` ⇒ the call ran without our field. Otherwise 'on' ⇒ 'applied' (an
106
+ * adaptive model may legitimately choose not to think, so 'on' is never flagged); 'off' ⇒ 'applied'
107
+ * when the reply shows zero thinking, 'ignored' when thinking happened anyway, 'unknown' when the
108
+ * signal is unreadable. Warns ONCE per `owner` on unsupported/ignored and never throws.
93
109
  * @param {object} p
94
110
  * @param {LadderKind} p.kind
95
111
  * @param {'on'|'off'} p.mode
96
112
  * @param {any} p.data
97
- * @param {any} p.owner - provider instance (holds the warn-once flag)
113
+ * @param {boolean} [p.unsupported]
114
+ * @param {string} [p.reason] - why it is unsupported, when the wording differs (see warnThinkingStatus)
115
+ * @param {any} p.owner
98
116
  * @param {string} p.label
99
117
  * @param {string} p.model
100
- * @returns {boolean|null}
118
+ * @returns {{ thinkingStatus: ThinkingStatus, thinkingApplied: boolean|null }}
101
119
  */
102
- export function assessThinkingApplied({ kind, mode, data, owner, label, model }: {
120
+ export function assessThinking({ kind, mode, data, unsupported, reason, owner, label, model }: {
103
121
  kind: LadderKind;
104
122
  mode: "on" | "off";
105
123
  data: any;
124
+ unsupported?: boolean | undefined;
125
+ reason?: string | undefined;
126
+ owner: any;
127
+ label: string;
128
+ model: string;
129
+ }): {
130
+ thinkingStatus: ThinkingStatus;
131
+ thinkingApplied: boolean | null;
132
+ };
133
+ /**
134
+ * Derive the legacy `thinkingApplied` boolean from a status (applied⇒true, ignored/unsupported⇒false, unknown⇒null).
135
+ * @param {ThinkingStatus} status
136
+ * @returns {boolean|null}
137
+ */
138
+ export function appliedOf(status: ThinkingStatus): boolean | null;
139
+ /**
140
+ * Warn ONCE per provider instance when the requested mode did not (fully) take effect.
141
+ * @param {object} p
142
+ * @param {any} p.owner - provider instance (holds the warn-once flag)
143
+ * @param {string} p.label
144
+ * @param {string} p.model
145
+ * @param {'on'|'off'} p.mode
146
+ * @param {ThinkingStatus} p.status
147
+ * @param {string} [p.reason] - 'forced-tool': the request was sent with thinking explicitly disabled
148
+ */
149
+ export function warnThinkingStatus({ owner, label, model, mode, status, reason }: {
106
150
  owner: any;
107
151
  label: string;
108
152
  model: string;
109
- }): boolean | null;
153
+ mode: "on" | "off";
154
+ status: ThinkingStatus;
155
+ reason?: string | undefined;
156
+ }): void;
157
+ /**
158
+ * The DeepSeek body for thinking-ON + a forced tool choice: thinking-on is impossible there (the API
159
+ * 400s), so the request goes out with `thinking:{type:'disabled'}` — the only shape that works with a
160
+ * forced tool_choice (measured) — and the caller reports `unsupported`.
161
+ * @param {Record<string, any>} body
162
+ * @returns {Record<string, any>}
163
+ */
164
+ export function deepseekDisabledBody(body: Record<string, any>): Record<string, any>;
110
165
  /**
111
166
  * Read the thinking signal off a raw reply: 'thinking' | 'none' | 'unknown'. "Absent field ⇒ none" is
112
167
  * used ONLY where measured: Gemini thoughtsTokenCount (given usageMetadata is present), DeepSeek
@@ -117,12 +172,6 @@ export function assessThinkingApplied({ kind, mode, data, owner, label, model }:
117
172
  * @returns {'thinking'|'none'|'unknown'}
118
173
  */
119
174
  export function readThinkingSignal(kind: LadderKind, data: any): "thinking" | "none" | "unknown";
120
- /**
121
- * @param {LadderKind} kind
122
- * @param {any} err
123
- * @returns {boolean}
124
- */
125
- export function isShapeRejection(kind: LadderKind, err: any): boolean;
126
175
  /**
127
176
  * @param {any} model
128
177
  * @param {any} baseUrl