bare-agent 0.51.0 → 0.53.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.
@@ -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
@@ -1,17 +1,18 @@
1
1
  'use strict';
2
2
 
3
- // Normalized `thinkingMode: 'on' | 'off'` (hamr-approved design: try the known shapes, fail LOUDLY,
4
- // check every reply). Callers say "on"/"off"; this module owns the per-provider/per-model request
5
- // field, the retry ladder, the measured per-model facts, and the post-reply signal readers. The
6
- // provider files stay thin: they ask here for the patch and the reader.
3
+ // Normalized `thinkingMode: 'on' | 'off'` (hamr-approved design: callers don't care about providers —
4
+ // try the known shapes, ALWAYS run the call, and REPORT the outcome as `thinkingStatus`). "Not
5
+ // supported" is an ANSWER, not an error: the call runs without our field and says so. Callers say
6
+ // "on"/"off"; this module owns the per-provider/per-model request field, the retry ladder, the measured
7
+ // per-model facts, and the post-reply signal readers. The provider files stay thin.
7
8
  //
8
9
  // Every fact in MODELS below was MEASURED live (poc/thinking-mode-verify.mjs →
9
10
  // poc/results/thinking-mode-verify.json, 2026-10-11) EXCEPT Ollama, which is shape-known but not
10
11
  // live-verified. A model that is not in the table is 'unverified': the ladder tries the shapes at
11
- // runtime and fails loudly if every one is rejected.
12
+ // runtime; if they all fail with a client validation error, ONE plain request decides (success ⇒ 'unsupported', failure ⇒ that error is thrown).
12
13
  //
13
14
  // The raw `thinking` passthrough options (BA-7) are a DIFFERENT, untouched field: opaque and verbatim.
14
- // `thinkingMode` is the normalized switch; setting both at once is a ValidationError (no silent precedence).
15
+ // `thinkingMode` is the normalized switch; setting both at once is a ValidationError (a caller config mistake, no silent precedence).
15
16
 
16
17
  const { ValidationError } = require('./errors');
17
18
 
@@ -72,8 +73,8 @@ const LADDERS = {
72
73
  };
73
74
 
74
75
  /**
75
- * MEASURED per-model facts. Value `null` ⇒ the mode is known NOT switchable on that model (throws
76
- * before any request is sent). A string ⇒ the first shape that worked (tried first).
76
+ * MEASURED per-model facts. Value `null` ⇒ the mode is known NOT switchable on that model (one plain
77
+ * request is sent with no thinking field; `thinkingStatus:'unsupported'`). A string ⇒ the first shape that worked (tried first).
77
78
  * @type {Record<LadderKind, Record<string, { off: string|null, on: string|null }>>}
78
79
  */
79
80
  const MODELS = {
@@ -108,20 +109,8 @@ const MODELS = {
108
109
  ollama: {},
109
110
  };
110
111
 
111
- // Which 400s are "this shape was rejected" (try the next one) vs an ordinary error (surface it). The
112
- // error text must NAME the field being set; per-provider so an unrelated 400 that merely happens to
113
- // say "thinking" elsewhere in a different vendor's vocabulary can't trigger a retry. No `.*`, no
114
- // unbounded quantifiers (ReDoS-safe on provider-supplied text).
115
- /** @type {Record<LadderKind, RegExp>} */
116
- const FIELD_NAMED = {
117
- anthropic: /\bthinking\b/i,
118
- openai: /\b(reasoning_effort|reasoning)\b/i,
119
- deepseek: /\bthinking\b/i,
120
- gemini: /\b(thinkingBudget|thinkingConfig|thinking_budget|thinking_config|thinking|budget)\b/i,
121
- ollama: /\b(thinking|think)\b/i,
122
- };
123
- // A 400 that names `temperature` is the temperature fallback's business (e.g. Anthropic's "temperature
124
- // may only be set to 1 when thinking is enabled" names BOTH) — never a shape rejection.
112
+ // An error that names `temperature` is the temperature fallback's business (e.g. Anthropic's "temperature
113
+ // may only be set to 1 when thinking is enabled" names BOTH) — never a shape failure; it propagates to that path.
125
114
  const TEMPERATURE_NAMED = /temperature/i;
126
115
 
127
116
  /**
@@ -194,62 +183,82 @@ function resolveThinkingMode({ label, instanceMode, options, instanceThinking =
194
183
  }
195
184
 
196
185
  /**
197
- * @param {LadderKind} kind
186
+ * A client validation error (400, or 422 — FastAPI/pydantic servers use it for extra_forbidden) that is
187
+ * not the temperature fallback's. NO error-text word list decides anything beyond the temperature
188
+ * exclusion: whether the thinking field was the problem is decided by a plain retry, not by reading prose.
198
189
  * @param {any} err
199
190
  * @returns {boolean}
200
191
  */
201
- function isShapeRejection(kind, err) {
202
- const msg = err && typeof err.message === 'string' ? err.message : '';
203
- return !!err && err.status === 400 && FIELD_NAMED[kind].test(msg) && !TEMPERATURE_NAMED.test(msg);
192
+ function isClientValidationError(err) {
193
+ if (!err || (err.status !== 400 && err.status !== 422)) return false;
194
+ return !TEMPERATURE_NAMED.test(typeof err.message === 'string' ? err.message : '');
204
195
  }
205
196
 
206
197
  /**
207
- * Send a request with the `thinkingMode` field applied, walking the shape ladder on a 400 that names
208
- * the field. Order: the instance cache (a shape that already worked for this mode+model), else the
209
- * table's known-good shape, else the ladder's first; then the remaining shapes in ladder order. A
210
- * known-not-switchable model throws BEFORE any request. Every shape rejected ⇒ a loud ValidationError.
211
- * A 400 that does not name the field (or any non-400) is an ordinary error and propagates untouched.
198
+ * Send a request with the `thinkingMode` field applied. Order: the instance cache (a shape that already
199
+ * worked for this mode+model), else the table's known-good shape, else the ladder's first; then the
200
+ * remaining shapes in ladder order (a shape whose apply() returns null is skipped).
201
+ *
202
+ * When a shape request fails with a client validation error (400/422, not temperature-named), the next
203
+ * shape is tried; after the last, ONE plain request (no thinking field) decides: it succeeds ⇒ the
204
+ * setting was the problem (`track.unsupported = true`, result kept); it fails too ⇒ it was a real error
205
+ * and the PLAIN request's error is thrown as-is (it is the one without our field). Any other failure
206
+ * (5xx, 429, timeout, transport, 401/403/404) propagates immediately — no ladder.
207
+ *
208
+ * A model the table marks not switchable gets ONE plain request, `unsupported`. Only the winning shape
209
+ * is cached; "unsupported" is never cached (a runtime unsupported cache poisoned on body-dependent
210
+ * skips and transcript/signature errors), so an unknown model that cannot switch re-pays its refused
211
+ * shape requests on every call.
212
212
  *
213
213
  * @param {object} p
214
214
  * @param {LadderKind} p.kind
215
215
  * @param {'on'|'off'} p.mode
216
216
  * @param {string} p.model
217
- * @param {string} p.label - provider class name
218
217
  * @param {Record<string, any>} p.body - the base body (NOT mutated; each attempt gets a patched copy)
219
218
  * @param {(body: Record<string, any>) => Promise<any>} p.request
220
- * @param {Map<string, string>} p.cache - per provider instance
221
- * @returns {Promise<any>} the reply data of the accepted shape
219
+ * @param {Map<string, string>} p.cache - per provider instance; holds winning shape keys only
220
+ * @param {{ unsupported: boolean }} p.track - written: `unsupported` is set true when the call ran without our field
221
+ * @returns {Promise<any>} the reply data
222
222
  */
223
- async function requestWithThinkingMode({ kind, mode, model, label, body, request, cache }) {
223
+ async function requestWithThinkingMode({ kind, mode, model, body, request, cache, track }) {
224
224
  const ladder = LADDERS[kind][mode];
225
225
  const facts = lookupModel(kind, model);
226
+ const cacheKey = `${mode}|${model}`;
227
+ track.unsupported = false;
226
228
  if (facts && facts[mode] === null) {
227
- throw invalid(`[${label}] thinkingMode '${mode}' is not supported by ${kind} model ${model} (measured: the API rejects every ${mode} shape for it) — no request was sent.`,
228
- { provider: kind, model, mode, tried: [], sent: false });
229
+ track.unsupported = true;
230
+ return request(body);
229
231
  }
230
- const cacheKey = `${mode}|${model}`;
231
232
  const preferredKey = cache.get(cacheKey) || (facts && facts[mode]) || null;
232
233
  const ordered = [...ladder].sort((a, b) => (a.key === preferredKey ? -1 : b.key === preferredKey ? 1 : 0));
233
234
 
234
- /** @type {string[]} */
235
- const tried = [];
236
- let lastMessage = '';
237
235
  for (const s of ordered) {
238
236
  const patched = s.apply(body);
239
- if (patched === null) { tried.push(`${s.id} (not applicable to this request)`); continue; }
237
+ if (patched === null) continue; // shape cannot apply to this body (e.g. max_tokens too small)
240
238
  try {
241
239
  const data = await request(patched);
242
240
  cache.set(cacheKey, s.key);
243
241
  return data;
244
242
  } catch (err) {
245
- if (!isShapeRejection(kind, err)) throw err;
246
- tried.push(s.id);
247
- lastMessage = err.message;
243
+ if (!isClientValidationError(err)) throw err;
248
244
  }
249
245
  }
250
- throw invalid(`[${label}] thinkingMode '${mode}' is not supported by ${kind} model ${model} (tried: ${tried.join('; ')})` +
251
- (lastMessage ? ` — last API error: ${lastMessage}` : ''),
252
- { provider: kind, model, mode, tried, sent: tried.length > 0 });
246
+ // Every shape refused (or none applied): one plain request decides. Success ⇒ the setting was the
247
+ // problem; failure ⇒ a real error, thrown as the plain request's own.
248
+ const data = await request(body);
249
+ track.unsupported = true;
250
+ return data;
251
+ }
252
+
253
+ /**
254
+ * The DeepSeek body for thinking-ON + a forced tool choice: thinking-on is impossible there (the API
255
+ * 400s), so the request goes out with `thinking:{type:'disabled'}` — the only shape that works with a
256
+ * forced tool_choice (measured) — and the caller reports `unsupported`.
257
+ * @param {Record<string, any>} body
258
+ * @returns {Record<string, any>}
259
+ */
260
+ function deepseekDisabledBody(body) {
261
+ return LADDERS.deepseek.off[0].apply(body) || body;
253
262
  }
254
263
 
255
264
  /** @param {any} v @returns {boolean} */
@@ -298,39 +307,82 @@ function readThinkingSignal(kind, data) {
298
307
  }
299
308
  }
300
309
 
310
+ /** @typedef {'applied'|'unsupported'|'ignored'|'unknown'} ThinkingStatus */
311
+
312
+ /** @type {Record<ThinkingStatus, boolean|null>} */
313
+ const APPLIED_OF = { applied: true, ignored: false, unsupported: false, unknown: null };
314
+
301
315
  /**
302
- * The `thinkingApplied` verdict for a reply whose request was accepted. 'on' ⇒ `true` (an adaptive
303
- * model may legitimately choose not to think, so 'on' is never flagged). 'off' ⇒ `true` when the reply
304
- * shows zero thinking, `false` when thinking happened anyway, `null` when the signal is unreadable.
305
- * On `false` it warns ONCE per `owner` (the provider instance) and never throws — the round is paid.
316
+ * Derive the legacy `thinkingApplied` boolean from a status (applied⇒true, ignored/unsupported⇒false, unknown⇒null).
317
+ * @param {ThinkingStatus} status
318
+ * @returns {boolean|null}
319
+ */
320
+ function appliedOf(status) {
321
+ return APPLIED_OF[status];
322
+ }
323
+
324
+ /**
325
+ * Warn ONCE per provider instance when the requested mode did not (fully) take effect.
326
+ * @param {object} p
327
+ * @param {any} p.owner - provider instance (holds the warn-once flag)
328
+ * @param {string} p.label
329
+ * @param {string} p.model
330
+ * @param {'on'|'off'} p.mode
331
+ * @param {ThinkingStatus} p.status
332
+ * @param {string} [p.reason] - 'forced-tool': the request was sent with thinking explicitly disabled
333
+ */
334
+ function warnThinkingStatus({ owner, label, model, mode, status, reason }) {
335
+ if (status !== 'unsupported' && status !== 'ignored') return;
336
+ // One once-flag PER status: an 'unsupported' warn must not silence a later, more serious 'ignored'.
337
+ const flag = status === 'ignored' ? '_warnedThinkingIgnored' : '_warnedThinkingUnsupported';
338
+ if (owner[flag]) return;
339
+ owner[flag] = true;
340
+ const why = status === 'unsupported' && reason === 'forced-tool'
341
+ ? 'thinking can\'t be on with a forced tool choice — the call ran with thinking disabled'
342
+ : status === 'unsupported'
343
+ ? 'this provider/model cannot do the requested mode — the call ran WITHOUT a thinking field (the model\'s default behavior)'
344
+ : 'the switch was accepted but the reply shows thinking happened anyway';
345
+ console.warn(`[${label}] thinkingMode '${mode}' for ${model}: thinkingStatus '${status}' — ${why}. The result is kept. Further '${status}' occurrences from this provider instance are silent.`);
346
+ }
347
+
348
+ /**
349
+ * The outcome of a thinkingMode call: `{thinkingStatus, thinkingApplied}` ready to spread onto a
350
+ * GenerateResult. `unsupported` ⇒ the call ran without our field. Otherwise 'on' ⇒ 'applied' (an
351
+ * adaptive model may legitimately choose not to think, so 'on' is never flagged); 'off' ⇒ 'applied'
352
+ * when the reply shows zero thinking, 'ignored' when thinking happened anyway, 'unknown' when the
353
+ * signal is unreadable. Warns ONCE per `owner` on unsupported/ignored and never throws.
306
354
  * @param {object} p
307
355
  * @param {LadderKind} p.kind
308
356
  * @param {'on'|'off'} p.mode
309
357
  * @param {any} p.data
310
- * @param {any} p.owner - provider instance (holds the warn-once flag)
358
+ * @param {boolean} [p.unsupported]
359
+ * @param {string} [p.reason] - why it is unsupported, when the wording differs (see warnThinkingStatus)
360
+ * @param {any} p.owner
311
361
  * @param {string} p.label
312
362
  * @param {string} p.model
313
- * @returns {boolean|null}
363
+ * @returns {{ thinkingStatus: ThinkingStatus, thinkingApplied: boolean|null }}
314
364
  */
315
- function assessThinkingApplied({ kind, mode, data, owner, label, model }) {
316
- if (mode === 'on') return true;
317
- const signal = readThinkingSignal(kind, data);
318
- if (signal === 'none') return true;
319
- if (signal === 'unknown') return null;
320
- if (!owner._warnedThinkingNotApplied) {
321
- owner._warnedThinkingNotApplied = true;
322
- console.warn(`[${label}] thinkingMode 'off' was sent for ${model} but the reply shows thinking happened — the switch did not take effect (the result is kept; thinkingApplied:false). Further occurrences from this provider instance are silent.`);
365
+ function assessThinking({ kind, mode, data, unsupported, reason, owner, label, model }) {
366
+ /** @type {ThinkingStatus} */
367
+ let status;
368
+ if (unsupported) status = 'unsupported';
369
+ else if (mode === 'on') status = 'applied';
370
+ else {
371
+ const signal = readThinkingSignal(kind, data);
372
+ status = signal === 'none' ? 'applied' : signal === 'thinking' ? 'ignored' : 'unknown';
323
373
  }
324
- return false;
374
+ warnThinkingStatus({ owner, label, model, mode, status, reason });
375
+ return { thinkingStatus: status, thinkingApplied: APPLIED_OF[status] };
325
376
  }
326
377
 
327
378
  /**
328
379
  * What bare-agent knows about switching a model's thinking on/off, with no network. Measured models
329
380
  * answer true/false; a model not in the measured table on a provider with a shape ladder answers
330
- * 'unverified' (the ladder will try the shapes at runtime and fail loudly if all are rejected);
381
+ * '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);
331
382
  * clipipe and unknown providers answer false.
332
383
  * @param {'anthropic'|'openai'|'deepseek'|'gemini'|'ollama'|'clipipe'|string} provider
333
384
  * @param {string} [model]
385
+ * @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).
334
386
  * @returns {{ switchable: boolean|'unverified', off: string|null, on: string|null, note: string }}
335
387
  * @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
336
388
  * @fails returns switchable:false for clipipe and unknown providers (never throws); 'unverified' means not measured, not unsupported; Ollama's shapes are not live-verified.
@@ -338,14 +390,19 @@ function assessThinkingApplied({ kind, mode, data, owner, label, model }) {
338
390
  * @example
339
391
  * thinkingSupport('anthropic', 'claude-opus-5-5'); // { switchable: false, off: null, on: "thinking:{type:'adaptive'}", note: '...' }
340
392
  */
341
- function thinkingSupport(provider, model) {
393
+ function thinkingSupport(provider, model, opts) {
394
+ if (opts !== undefined && (opts === null || typeof opts !== 'object' || Array.isArray(opts))) {
395
+ throw invalid(`thinkingSupport: opts must be an object (got ${opts === null ? 'null' : Array.isArray(opts) ? 'array' : typeof opts})`);
396
+ }
397
+ const baseUrl = opts && opts.baseUrl;
342
398
  if (provider === 'clipipe') {
343
- return { switchable: false, off: null, on: null, note: 'CLIPipe has no thinking-mode mechanism; thinkingMode throws a ValidationError.' };
399
+ return { switchable: false, off: null, on: null, note: 'CLIPipe has no thinking-mode mechanism; thinkingMode runs the call unchanged and reports thinkingStatus \'unsupported\'.' };
344
400
  }
345
- const kind = /** @type {LadderKind} */ (provider);
346
401
  if (typeof provider !== 'string' || !Object.prototype.hasOwnProperty.call(LADDERS, provider)) {
347
402
  return { switchable: false, off: null, on: null, note: `Unknown provider '${String(provider)}'.` };
348
403
  }
404
+ // The request path detects DeepSeek by model id OR baseUrl host (isDeepSeek) and sends DeepSeek's shape — the lookup must too, with the same call.
405
+ const kind = /** @type {LadderKind} */ (provider === 'openai' && isDeepSeek(model, baseUrl) ? 'deepseek' : provider);
349
406
  const idOf = (/** @type {'on'|'off'} */ mode, /** @type {string|null} */ key) =>
350
407
  key === null ? null : (LADDERS[kind][mode].find((s) => s.key === key) || LADDERS[kind][mode][0]).id;
351
408
  const facts = lookupModel(kind, /** @type {string} */ (model));
@@ -356,7 +413,7 @@ function thinkingSupport(provider, model) {
356
413
  on: LADDERS[kind].on[0].id,
357
414
  note: kind === 'ollama'
358
415
  ? 'Shapes known (think:true/false) but NOT live-verified; the reply signal is message.thinking.'
359
- : `Model not in the measured table; the shape ladder is tried at runtime (off: ${LADDERS[kind].off.map((s) => s.id).join(' → ')}; on: ${LADDERS[kind].on.map((s) => s.id).join(' → ')}) and fails loudly if all are rejected.`,
416
+ : `Model not in the measured table; the shape ladder is tried at runtime (off: ${LADDERS[kind].off.map((s) => s.id).join(' → ')}; on: ${LADDERS[kind].on.map((s) => s.id).join(' → ')}) and, if they fail with client validation errors, one plain request decides (OK ⇒ thinkingStatus 'unsupported'; failure ⇒ that error is thrown).`,
360
417
  };
361
418
  }
362
419
  const switchable = facts.off !== null && facts.on !== null;
@@ -366,7 +423,7 @@ function thinkingSupport(provider, model) {
366
423
  on: idOf('on', facts.on),
367
424
  note: switchable
368
425
  ? 'Measured: both modes work.'
369
- : `Measured: ${facts.off === null ? "'off'" : "'on'"} is rejected by the API for this model — requesting it throws before any request is sent.`,
426
+ : `Measured: ${facts.off === null ? "'off'" : "'on'"} is rejected by the API for this model — requesting it sends the call without a thinking field and reports thinkingStatus 'unsupported'.`,
370
427
  };
371
428
  }
372
429
 
@@ -376,9 +433,11 @@ module.exports = {
376
433
  normalizeThinkingMode,
377
434
  resolveThinkingMode,
378
435
  requestWithThinkingMode,
379
- assessThinkingApplied,
436
+ assessThinking,
437
+ appliedOf,
438
+ warnThinkingStatus,
439
+ deepseekDisabledBody,
380
440
  readThinkingSignal,
381
- isShapeRejection,
382
441
  isDeepSeek,
383
442
  lookupModel,
384
443
  };
package/types/index.d.ts CHANGED
@@ -135,12 +135,22 @@ export interface GenerateResult {
135
135
  */
136
136
  temperatureDropped?: boolean;
137
137
  /**
138
- * Present ONLY when `thinkingMode` ('on'|'off') was set on the provider/call. The post-reply check:
139
- * `true` — a shape was accepted and, for 'off', the reply shows zero thinking signal (for 'on' always
140
- * `true` once accepted: an adaptive model may legitimately choose not to think); `false` — 'off' was
141
- * requested but the reply shows thinking happened (the provider warns once; the round is paid and kept,
142
- * never thrown); `null` — cannot tell (the reply's thinking signal is unreadable, e.g. OpenAI without
143
- * `reasoning_tokens`). Surfaced on `Loop.run()`'s return, sticky-false: any round reporting `false` makes the run `false`, else the latest round's value.
138
+ * Present ONLY when `thinkingMode` ('on'|'off') was set on the provider/call. The outcome — "not
139
+ * supported" is an ANSWER, never an error; the call always runs:
140
+ * `'applied'` — a shape was accepted and, for 'off', the reply shows zero thinking (for 'on' always
141
+ * `'applied'` once accepted: an adaptive model may legitimately choose not to think);
142
+ * `'unsupported'` — this provider/model cannot do the requested mode (measured, every shape rejected,
143
+ * DeepSeek 'on' + forced toolChoice, or CLIPipe): the call ran WITHOUT our thinking field (the model's
144
+ * default behavior; DeepSeek forced-tool case sends `thinking:{type:'disabled'}`);
145
+ * `'ignored'` — 'off' was sent and accepted but the reply shows thinking anyway (warns once; round kept);
146
+ * `'unknown'` — accepted but the reply's thinking signal is unreadable (e.g. OpenAI without `reasoning_tokens`).
147
+ * `'ignored'`/`'unsupported'` warn once per provider instance. `Loop.run()` surfaces the WORST status
148
+ * across rounds (ignored > unsupported > unknown > applied).
149
+ */
150
+ thinkingStatus?: 'applied' | 'unsupported' | 'ignored' | 'unknown';
151
+ /**
152
+ * Legacy boolean, DERIVED from `thinkingStatus` (present with it): applied⇒`true`, ignored⇒`false`,
153
+ * unsupported⇒`false`, unknown⇒`null`. Prefer `thinkingStatus`. On `Loop.run()` it is derived from the worst status.
144
154
  */
145
155
  thinkingApplied?: boolean | null;
146
156
  /**