bare-agent 0.50.0 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,191 @@
1
+ export type LadderKind = "anthropic" | "openai" | "deepseek" | "gemini" | "ollama";
2
+ export type Shape = {
3
+ /**
4
+ * - Short stable key (the MODELS table refers to shapes by this).
5
+ */
6
+ key: string;
7
+ /**
8
+ * - Human-readable request-field description (error messages, thinkingSupport).
9
+ */
10
+ id: string;
11
+ /**
12
+ * - Returns a NEW body with
13
+ * the field set (copy-on-write; never mutates `body`), or `null` when the shape cannot apply to this body.
14
+ */
15
+ apply: (body: Record<string, any>) => (Record<string, any> | null);
16
+ };
17
+ export type ThinkingStatus = "applied" | "unsupported" | "ignored" | "unknown";
18
+ /**
19
+ * What bare-agent knows about switching a model's thinking on/off, with no network. Measured models
20
+ * answer true/false; a model not in the measured table on a provider with a shape ladder answers
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);
22
+ * clipipe and unknown providers answer false.
23
+ * @param {'anthropic'|'openai'|'deepseek'|'gemini'|'ollama'|'clipipe'|string} provider
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).
26
+ * @returns {{ switchable: boolean|'unverified', off: string|null, on: string|null, note: string }}
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
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.
29
+ * @category providers
30
+ * @example
31
+ * thinkingSupport('anthropic', 'claude-opus-5-5'); // { switchable: false, off: null, on: "thinking:{type:'adaptive'}", note: '...' }
32
+ */
33
+ export function thinkingSupport(provider: "anthropic" | "openai" | "deepseek" | "gemini" | "ollama" | "clipipe" | string, model?: string, opts?: {
34
+ baseUrl?: string;
35
+ }): {
36
+ switchable: boolean | "unverified";
37
+ off: string | null;
38
+ on: string | null;
39
+ note: string;
40
+ };
41
+ /**
42
+ * Validate a `thinkingMode` value. `undefined`/`null` ⇒ `null` (unset); anything but 'on'/'off' throws.
43
+ * @param {any} value
44
+ * @param {string} label - provider name for the message
45
+ * @returns {'on'|'off'|null}
46
+ */
47
+ export function normalizeThinkingMode(value: any, label: string): "on" | "off" | null;
48
+ /**
49
+ * Resolve the EFFECTIVE mode for one call and reject the raw-`thinking` conflict (no silent precedence).
50
+ * Per-call overrides the instance default; `null` suppresses it.
51
+ * @param {object} p
52
+ * @param {string} p.label
53
+ * @param {any} p.instanceMode
54
+ * @param {Record<string, any>} p.options - per-call options
55
+ * @param {any} [p.instanceThinking] - the instance's raw `thinking` (providers that have one)
56
+ * @param {boolean} [p.hasRawThinking] - whether this provider has a raw `thinking` option at all
57
+ * @returns {'on'|'off'|null}
58
+ */
59
+ export function resolveThinkingMode({ label, instanceMode, options, instanceThinking, hasRawThinking }: {
60
+ label: string;
61
+ instanceMode: any;
62
+ options: Record<string, any>;
63
+ instanceThinking?: any;
64
+ hasRawThinking?: boolean | undefined;
65
+ }): "on" | "off" | null;
66
+ /**
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.
81
+ *
82
+ * @param {object} p
83
+ * @param {LadderKind} p.kind
84
+ * @param {'on'|'off'} p.mode
85
+ * @param {string} p.model
86
+ * @param {Record<string, any>} p.body - the base body (NOT mutated; each attempt gets a patched copy)
87
+ * @param {(body: Record<string, any>) => Promise<any>} p.request
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
91
+ */
92
+ export function requestWithThinkingMode({ kind, mode, model, body, request, cache, track }: {
93
+ kind: LadderKind;
94
+ mode: "on" | "off";
95
+ model: string;
96
+ body: Record<string, any>;
97
+ request: (body: Record<string, any>) => Promise<any>;
98
+ cache: Map<string, string>;
99
+ track: {
100
+ unsupported: boolean;
101
+ };
102
+ }): Promise<any>;
103
+ /**
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.
109
+ * @param {object} p
110
+ * @param {LadderKind} p.kind
111
+ * @param {'on'|'off'} p.mode
112
+ * @param {any} p.data
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
116
+ * @param {string} p.label
117
+ * @param {string} p.model
118
+ * @returns {{ thinkingStatus: ThinkingStatus, thinkingApplied: boolean|null }}
119
+ */
120
+ export function assessThinking({ kind, mode, data, unsupported, reason, owner, label, model }: {
121
+ kind: LadderKind;
122
+ mode: "on" | "off";
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 }: {
150
+ owner: any;
151
+ label: string;
152
+ model: string;
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>;
165
+ /**
166
+ * Read the thinking signal off a raw reply: 'thinking' | 'none' | 'unknown'. "Absent field ⇒ none" is
167
+ * used ONLY where measured: Gemini thoughtsTokenCount (given usageMetadata is present), DeepSeek
168
+ * reasoning_content, Ollama message.thinking. Anthropic counts thinking/redacted_thinking blocks.
169
+ * OpenAI reasoning_tokens absent ⇒ 'unknown'.
170
+ * @param {LadderKind} kind
171
+ * @param {any} data - raw provider reply
172
+ * @returns {'thinking'|'none'|'unknown'}
173
+ */
174
+ export function readThinkingSignal(kind: LadderKind, data: any): "thinking" | "none" | "unknown";
175
+ /**
176
+ * @param {any} model
177
+ * @param {any} baseUrl
178
+ * @returns {boolean}
179
+ */
180
+ export function isDeepSeek(model: any, baseUrl: any): boolean;
181
+ /**
182
+ * Longest-prefix lookup of a model's measured facts. A dated/`-latest` suffix of a listed id counts
183
+ * (`claude-haiku-4-5-20251001`); any other suffix does NOT (so `gpt-5-chat` never inherits `gpt-5`).
184
+ * @param {LadderKind} kind
185
+ * @param {string} model
186
+ * @returns {{ off: string|null, on: string|null }|null}
187
+ */
188
+ export function lookupModel(kind: LadderKind, model: string): {
189
+ off: string | null;
190
+ on: string | null;
191
+ } | null;
@@ -0,0 +1,443 @@
1
+ 'use strict';
2
+
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.
8
+ //
9
+ // Every fact in MODELS below was MEASURED live (poc/thinking-mode-verify.mjs →
10
+ // poc/results/thinking-mode-verify.json, 2026-10-11) EXCEPT Ollama, which is shape-known but not
11
+ // live-verified. A model that is not in the table is 'unverified': the ladder tries the shapes at
12
+ // runtime; if they all fail with a client validation error, ONE plain request decides (success ⇒ 'unsupported', failure ⇒ that error is thrown).
13
+ //
14
+ // The raw `thinking` passthrough options (BA-7) are a DIFFERENT, untouched field: opaque and verbatim.
15
+ // `thinkingMode` is the normalized switch; setting both at once is a ValidationError (a caller config mistake, no silent precedence).
16
+
17
+ const { ValidationError } = require('./errors');
18
+
19
+ /** @typedef {'anthropic'|'openai'|'deepseek'|'gemini'|'ollama'} LadderKind */
20
+
21
+ /**
22
+ * @typedef {object} Shape
23
+ * @property {string} key - Short stable key (the MODELS table refers to shapes by this).
24
+ * @property {string} id - Human-readable request-field description (error messages, thinkingSupport).
25
+ * @property {(body: Record<string, any>) => (Record<string, any>|null)} apply - Returns a NEW body with
26
+ * the field set (copy-on-write; never mutates `body`), or `null` when the shape cannot apply to this body.
27
+ */
28
+
29
+ /** @type {(key: string, id: string, apply: Shape['apply']) => Shape} */
30
+ const shape = (key, id, apply) => ({ key, id, apply });
31
+
32
+ // Anthropic `enabled` needs budget_tokens >= 1024 and max_tokens > budget_tokens (measured: haiku-4-5).
33
+ const ANTHROPIC_BUDGET = 2048;
34
+
35
+ /** @type {Record<LadderKind, { off: Shape[], on: Shape[] }>} */
36
+ const LADDERS = {
37
+ anthropic: {
38
+ // sonnet-5 STILL thinks when the field is omitted, so off must be sent explicitly.
39
+ off: [shape('disabled', "thinking:{type:'disabled'}", (b) => ({ ...b, thinking: { type: 'disabled' } }))],
40
+ on: [
41
+ shape('adaptive', "thinking:{type:'adaptive'}", (b) => ({ ...b, thinking: { type: 'adaptive' } })),
42
+ shape('enabled', "thinking:{type:'enabled',budget_tokens:N}", (b) => {
43
+ const budget = Math.min(ANTHROPIC_BUDGET, (b.max_tokens || 0) - 1);
44
+ if (!(budget >= 1024)) return null; // max_tokens too small to leave room for a legal budget
45
+ return { ...b, thinking: { type: 'enabled', budget_tokens: budget } };
46
+ }),
47
+ ],
48
+ },
49
+ openai: {
50
+ // gpt-5.1+ accept 'none' and 400 on 'minimal'; gpt-5 / gpt-5-mini are the reverse.
51
+ off: [
52
+ shape('none', "reasoning_effort:'none'", (b) => ({ ...b, reasoning_effort: 'none' })),
53
+ shape('minimal', "reasoning_effort:'minimal'", (b) => ({ ...b, reasoning_effort: 'minimal' })),
54
+ ],
55
+ on: [shape('medium', "reasoning_effort:'medium'", (b) => ({ ...b, reasoning_effort: 'medium' }))],
56
+ },
57
+ deepseek: {
58
+ off: [shape('disabled', "thinking:{type:'disabled'}", (b) => ({ ...b, thinking: { type: 'disabled' } }))],
59
+ // DeepSeek thinks by default: "on" is the ABSENCE of a field (the body goes out unchanged).
60
+ on: [shape('omit', '(field omitted — thinks by default)', (b) => ({ ...b }))],
61
+ },
62
+ gemini: {
63
+ off: [shape('budget0', 'generationConfig.thinkingConfig.thinkingBudget:0',
64
+ (b) => ({ ...b, generationConfig: { ...b.generationConfig, thinkingConfig: { thinkingBudget: 0 } } }))],
65
+ on: [shape('budget-1', 'generationConfig.thinkingConfig.thinkingBudget:-1',
66
+ (b) => ({ ...b, generationConfig: { ...b.generationConfig, thinkingConfig: { thinkingBudget: -1 } } }))],
67
+ },
68
+ ollama: {
69
+ // NOT live-verified (no Ollama thinking model was available to measure).
70
+ off: [shape('think-false', 'think:false', (b) => ({ ...b, think: false }))],
71
+ on: [shape('think-true', 'think:true', (b) => ({ ...b, think: true }))],
72
+ },
73
+ };
74
+
75
+ /**
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).
78
+ * @type {Record<LadderKind, Record<string, { off: string|null, on: string|null }>>}
79
+ */
80
+ const MODELS = {
81
+ anthropic: {
82
+ 'claude-sonnet-5': { off: 'disabled', on: 'adaptive' },
83
+ 'claude-haiku-4-5': { off: 'disabled', on: 'enabled' }, // 400s on adaptive
84
+ 'claude-opus-5-5': { off: null, on: 'adaptive' }, // 400s on disabled → cannot be turned off
85
+ },
86
+ openai: {
87
+ 'gpt-5': { off: 'minimal', on: 'medium' },
88
+ 'gpt-5-mini': { off: 'minimal', on: 'medium' },
89
+ 'gpt-5.1': { off: 'none', on: 'medium' },
90
+ 'gpt-5.2': { off: 'none', on: 'medium' },
91
+ 'gpt-5.4': { off: 'none', on: 'medium' },
92
+ 'gpt-5.4-mini': { off: 'none', on: 'medium' },
93
+ 'gpt-5.4-nano': { off: 'none', on: 'medium' },
94
+ 'gpt-5.5': { off: 'none', on: 'medium' },
95
+ },
96
+ deepseek: {
97
+ 'deepseek-flash': { off: 'disabled', on: 'omit' },
98
+ 'deepseek-v4-pro': { off: 'disabled', on: 'omit' },
99
+ },
100
+ gemini: {
101
+ 'gemini-2.5-flash': { off: 'budget0', on: 'budget-1' },
102
+ 'gemini-3-flash-preview': { off: 'budget0', on: 'budget-1' },
103
+ 'gemini-3.1-flash-lite': { off: 'budget0', on: 'budget-1' },
104
+ 'gemini-3.1-flash-lite-preview': { off: 'budget0', on: 'budget-1' },
105
+ 'gemini-3.5-flash': { off: 'budget0', on: 'budget-1' },
106
+ 'gemini-3.1-pro-preview': { off: null, on: 'budget-1' }, // 400 "Budget 0 is invalid"
107
+ 'gemini-3.1-pro-preview-customtools': { off: null, on: 'budget-1' }, // same
108
+ },
109
+ ollama: {},
110
+ };
111
+
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.
114
+ const TEMPERATURE_NAMED = /temperature/i;
115
+
116
+ /**
117
+ * Longest-prefix lookup of a model's measured facts. A dated/`-latest` suffix of a listed id counts
118
+ * (`claude-haiku-4-5-20251001`); any other suffix does NOT (so `gpt-5-chat` never inherits `gpt-5`).
119
+ * @param {LadderKind} kind
120
+ * @param {string} model
121
+ * @returns {{ off: string|null, on: string|null }|null}
122
+ */
123
+ function lookupModel(kind, model) {
124
+ const table = MODELS[kind];
125
+ if (typeof model !== 'string') return null;
126
+ let best = null;
127
+ for (const id of Object.keys(table)) {
128
+ if (model === id || (model.startsWith(id) && /^-(\d{8}|\d{4}-\d{2}-\d{2}|latest)$/.test(model.slice(id.length)))) {
129
+ if (best === null || id.length > best.length) best = id;
130
+ }
131
+ }
132
+ return best === null ? null : table[best];
133
+ }
134
+
135
+ /**
136
+ * @param {any} model
137
+ * @param {any} baseUrl
138
+ * @returns {boolean}
139
+ */
140
+ function isDeepSeek(model, baseUrl) {
141
+ return (typeof model === 'string' && /^deepseek-/i.test(model)) ||
142
+ (typeof baseUrl === 'string' && /deepseek\.com/i.test(baseUrl));
143
+ }
144
+
145
+ /**
146
+ * @param {string} message
147
+ * @param {Record<string, any>} [ctx]
148
+ */
149
+ const invalid = (message, ctx = {}) => new ValidationError(message, { context: { lib: 'bare-agent', ...ctx } });
150
+
151
+ /**
152
+ * Validate a `thinkingMode` value. `undefined`/`null` ⇒ `null` (unset); anything but 'on'/'off' throws.
153
+ * @param {any} value
154
+ * @param {string} label - provider name for the message
155
+ * @returns {'on'|'off'|null}
156
+ */
157
+ function normalizeThinkingMode(value, label) {
158
+ if (value === undefined || value === null) return null;
159
+ if (value === 'on' || value === 'off') return value;
160
+ throw invalid(`[${label}] thinkingMode must be 'on' or 'off' (got ${typeof value === 'string' ? JSON.stringify(value) : `<${typeof value}>`})`, { provider: label });
161
+ }
162
+
163
+ /**
164
+ * Resolve the EFFECTIVE mode for one call and reject the raw-`thinking` conflict (no silent precedence).
165
+ * Per-call overrides the instance default; `null` suppresses it.
166
+ * @param {object} p
167
+ * @param {string} p.label
168
+ * @param {any} p.instanceMode
169
+ * @param {Record<string, any>} p.options - per-call options
170
+ * @param {any} [p.instanceThinking] - the instance's raw `thinking` (providers that have one)
171
+ * @param {boolean} [p.hasRawThinking] - whether this provider has a raw `thinking` option at all
172
+ * @returns {'on'|'off'|null}
173
+ */
174
+ function resolveThinkingMode({ label, instanceMode, options, instanceThinking = null, hasRawThinking = false }) {
175
+ const mode = normalizeThinkingMode(options.thinkingMode !== undefined ? options.thinkingMode : instanceMode, label);
176
+ if (mode && hasRawThinking) {
177
+ const raw = options.thinking !== undefined ? options.thinking : instanceThinking;
178
+ if (raw) {
179
+ throw invalid(`[${label}] both the raw 'thinking' option and thinkingMode '${mode}' are set — they target the same request field. Use one (thinkingMode is the normalized switch; raw 'thinking' is the verbatim passthrough).`, { provider: label });
180
+ }
181
+ }
182
+ return mode;
183
+ }
184
+
185
+ /**
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.
189
+ * @param {any} err
190
+ * @returns {boolean}
191
+ */
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 : '');
195
+ }
196
+
197
+ /**
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
+ *
213
+ * @param {object} p
214
+ * @param {LadderKind} p.kind
215
+ * @param {'on'|'off'} p.mode
216
+ * @param {string} p.model
217
+ * @param {Record<string, any>} p.body - the base body (NOT mutated; each attempt gets a patched copy)
218
+ * @param {(body: Record<string, any>) => Promise<any>} p.request
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
+ */
223
+ async function requestWithThinkingMode({ kind, mode, model, body, request, cache, track }) {
224
+ const ladder = LADDERS[kind][mode];
225
+ const facts = lookupModel(kind, model);
226
+ const cacheKey = `${mode}|${model}`;
227
+ track.unsupported = false;
228
+ if (facts && facts[mode] === null) {
229
+ track.unsupported = true;
230
+ return request(body);
231
+ }
232
+ const preferredKey = cache.get(cacheKey) || (facts && facts[mode]) || null;
233
+ const ordered = [...ladder].sort((a, b) => (a.key === preferredKey ? -1 : b.key === preferredKey ? 1 : 0));
234
+
235
+ for (const s of ordered) {
236
+ const patched = s.apply(body);
237
+ if (patched === null) continue; // shape cannot apply to this body (e.g. max_tokens too small)
238
+ try {
239
+ const data = await request(patched);
240
+ cache.set(cacheKey, s.key);
241
+ return data;
242
+ } catch (err) {
243
+ if (!isClientValidationError(err)) throw err;
244
+ }
245
+ }
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;
262
+ }
263
+
264
+ /** @param {any} v @returns {boolean} */
265
+ const positive = (v) => typeof v === 'number' && v > 0;
266
+
267
+ /**
268
+ * Read the thinking signal off a raw reply: 'thinking' | 'none' | 'unknown'. "Absent field ⇒ none" is
269
+ * used ONLY where measured: Gemini thoughtsTokenCount (given usageMetadata is present), DeepSeek
270
+ * reasoning_content, Ollama message.thinking. Anthropic counts thinking/redacted_thinking blocks.
271
+ * OpenAI reasoning_tokens absent ⇒ 'unknown'.
272
+ * @param {LadderKind} kind
273
+ * @param {any} data - raw provider reply
274
+ * @returns {'thinking'|'none'|'unknown'}
275
+ */
276
+ function readThinkingSignal(kind, data) {
277
+ if (!data || typeof data !== 'object') return 'unknown';
278
+ switch (kind) {
279
+ case 'anthropic': {
280
+ if (!Array.isArray(data.content)) return 'unknown';
281
+ return data.content.some((/** @type {any} */ b) => b && (b.type === 'thinking' || b.type === 'redacted_thinking')) ? 'thinking' : 'none';
282
+ }
283
+ case 'openai': {
284
+ const t = data.usage && data.usage.completion_tokens_details && data.usage.completion_tokens_details.reasoning_tokens;
285
+ if (typeof t !== 'number') return 'unknown';
286
+ return t > 0 ? 'thinking' : 'none';
287
+ }
288
+ case 'deepseek': {
289
+ const m = data.choices && data.choices[0] && data.choices[0].message;
290
+ if (!m || typeof m !== 'object') return 'unknown';
291
+ return typeof m.reasoning_content === 'string' && m.reasoning_content.length > 0 ? 'thinking' : 'none';
292
+ }
293
+ case 'gemini': {
294
+ const u = data.usageMetadata;
295
+ if (!u || typeof u !== 'object') return 'unknown';
296
+ const parts = (data.candidates && data.candidates[0] && data.candidates[0].content && data.candidates[0].content.parts) || [];
297
+ if (positive(u.thoughtsTokenCount) || (Array.isArray(parts) && parts.some((/** @type {any} */ p) => p && p.thought === true))) return 'thinking';
298
+ return 'none';
299
+ }
300
+ case 'ollama': {
301
+ const m = data.message;
302
+ if (!m || typeof m !== 'object') return 'unknown';
303
+ return typeof m.thinking === 'string' && m.thinking.length > 0 ? 'thinking' : 'none';
304
+ }
305
+ default:
306
+ return 'unknown';
307
+ }
308
+ }
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
+
315
+ /**
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.
354
+ * @param {object} p
355
+ * @param {LadderKind} p.kind
356
+ * @param {'on'|'off'} p.mode
357
+ * @param {any} p.data
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
361
+ * @param {string} p.label
362
+ * @param {string} p.model
363
+ * @returns {{ thinkingStatus: ThinkingStatus, thinkingApplied: boolean|null }}
364
+ */
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';
373
+ }
374
+ warnThinkingStatus({ owner, label, model, mode, status, reason });
375
+ return { thinkingStatus: status, thinkingApplied: APPLIED_OF[status] };
376
+ }
377
+
378
+ /**
379
+ * What bare-agent knows about switching a model's thinking on/off, with no network. Measured models
380
+ * answer true/false; a model not in the measured table on a provider with a shape ladder answers
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);
382
+ * clipipe and unknown providers answer false.
383
+ * @param {'anthropic'|'openai'|'deepseek'|'gemini'|'ollama'|'clipipe'|string} provider
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).
386
+ * @returns {{ switchable: boolean|'unverified', off: string|null, on: string|null, note: string }}
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
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.
389
+ * @category providers
390
+ * @example
391
+ * thinkingSupport('anthropic', 'claude-opus-5-5'); // { switchable: false, off: null, on: "thinking:{type:'adaptive'}", note: '...' }
392
+ */
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;
398
+ if (provider === 'clipipe') {
399
+ return { switchable: false, off: null, on: null, note: 'CLIPipe has no thinking-mode mechanism; thinkingMode runs the call unchanged and reports thinkingStatus \'unsupported\'.' };
400
+ }
401
+ if (typeof provider !== 'string' || !Object.prototype.hasOwnProperty.call(LADDERS, provider)) {
402
+ return { switchable: false, off: null, on: null, note: `Unknown provider '${String(provider)}'.` };
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);
406
+ const idOf = (/** @type {'on'|'off'} */ mode, /** @type {string|null} */ key) =>
407
+ key === null ? null : (LADDERS[kind][mode].find((s) => s.key === key) || LADDERS[kind][mode][0]).id;
408
+ const facts = lookupModel(kind, /** @type {string} */ (model));
409
+ if (!facts) {
410
+ return {
411
+ switchable: 'unverified',
412
+ off: LADDERS[kind].off[0].id,
413
+ on: LADDERS[kind].on[0].id,
414
+ note: kind === 'ollama'
415
+ ? 'Shapes known (think:true/false) but NOT live-verified; the reply signal is message.thinking.'
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).`,
417
+ };
418
+ }
419
+ const switchable = facts.off !== null && facts.on !== null;
420
+ return {
421
+ switchable,
422
+ off: idOf('off', facts.off),
423
+ on: idOf('on', facts.on),
424
+ note: switchable
425
+ ? 'Measured: both modes work.'
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'.`,
427
+ };
428
+ }
429
+
430
+ module.exports = {
431
+ thinkingSupport,
432
+ // internal (provider files + tests)
433
+ normalizeThinkingMode,
434
+ resolveThinkingMode,
435
+ requestWithThinkingMode,
436
+ assessThinking,
437
+ appliedOf,
438
+ warnThinkingStatus,
439
+ deepseekDisabledBody,
440
+ readThinkingSignal,
441
+ isDeepSeek,
442
+ lookupModel,
443
+ };
package/types/index.d.ts CHANGED
@@ -134,6 +134,25 @@ export interface GenerateResult {
134
134
  * temperature, not the one requested — callers reporting an effective temperature must honor this.
135
135
  */
136
136
  temperatureDropped?: boolean;
137
+ /**
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.
154
+ */
155
+ thinkingApplied?: boolean | null;
137
156
  /**
138
157
  * BA-27 — present ONLY when the model emitted a tool call whose `function.arguments` was
139
158
  * syntactically-broken JSON (an extra brace, a truncated object). The billed round already