bare-agent 0.49.0 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bareagent.context.md +10 -0
- package/package.json +1 -1
- package/primitives.json +9 -0
- package/src/loop.d.ts +6 -14
- package/src/loop.js +21 -12
- package/src/provider-anthropic.d.ts +11 -1
- package/src/provider-anthropic.js +16 -2
- package/src/provider-clipipe.d.ts +5 -1
- package/src/provider-clipipe.js +10 -2
- package/src/provider-fallback.d.ts +3 -0
- package/src/provider-fallback.js +3 -0
- package/src/provider-gemini.d.ts +11 -1
- package/src/provider-gemini.js +13 -2
- package/src/provider-ollama.d.ts +15 -5
- package/src/provider-ollama.js +15 -4
- package/src/provider-openai.d.ts +19 -9
- package/src/provider-openai.js +23 -7
- package/src/provider-temperature.d.ts +9 -1
- package/src/provider-temperature.js +20 -3
- package/src/provider-toolcalls.d.ts +7 -6
- package/src/provider-toolcalls.js +8 -13
- package/src/providers.d.ts +2 -1
- package/src/providers.js +3 -0
- package/src/thinking-mode.d.ts +142 -0
- package/src/thinking-mode.js +384 -0
- package/types/index.d.ts +16 -4
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
'use strict';
|
|
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.
|
|
7
|
+
//
|
|
8
|
+
// Every fact in MODELS below was MEASURED live (poc/thinking-mode-verify.mjs →
|
|
9
|
+
// poc/results/thinking-mode-verify.json, 2026-10-11) EXCEPT Ollama, which is shape-known but not
|
|
10
|
+
// 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
|
+
//
|
|
13
|
+
// 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
|
+
|
|
16
|
+
const { ValidationError } = require('./errors');
|
|
17
|
+
|
|
18
|
+
/** @typedef {'anthropic'|'openai'|'deepseek'|'gemini'|'ollama'} LadderKind */
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {object} Shape
|
|
22
|
+
* @property {string} key - Short stable key (the MODELS table refers to shapes by this).
|
|
23
|
+
* @property {string} id - Human-readable request-field description (error messages, thinkingSupport).
|
|
24
|
+
* @property {(body: Record<string, any>) => (Record<string, any>|null)} apply - Returns a NEW body with
|
|
25
|
+
* the field set (copy-on-write; never mutates `body`), or `null` when the shape cannot apply to this body.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** @type {(key: string, id: string, apply: Shape['apply']) => Shape} */
|
|
29
|
+
const shape = (key, id, apply) => ({ key, id, apply });
|
|
30
|
+
|
|
31
|
+
// Anthropic `enabled` needs budget_tokens >= 1024 and max_tokens > budget_tokens (measured: haiku-4-5).
|
|
32
|
+
const ANTHROPIC_BUDGET = 2048;
|
|
33
|
+
|
|
34
|
+
/** @type {Record<LadderKind, { off: Shape[], on: Shape[] }>} */
|
|
35
|
+
const LADDERS = {
|
|
36
|
+
anthropic: {
|
|
37
|
+
// sonnet-5 STILL thinks when the field is omitted, so off must be sent explicitly.
|
|
38
|
+
off: [shape('disabled', "thinking:{type:'disabled'}", (b) => ({ ...b, thinking: { type: 'disabled' } }))],
|
|
39
|
+
on: [
|
|
40
|
+
shape('adaptive', "thinking:{type:'adaptive'}", (b) => ({ ...b, thinking: { type: 'adaptive' } })),
|
|
41
|
+
shape('enabled', "thinking:{type:'enabled',budget_tokens:N}", (b) => {
|
|
42
|
+
const budget = Math.min(ANTHROPIC_BUDGET, (b.max_tokens || 0) - 1);
|
|
43
|
+
if (!(budget >= 1024)) return null; // max_tokens too small to leave room for a legal budget
|
|
44
|
+
return { ...b, thinking: { type: 'enabled', budget_tokens: budget } };
|
|
45
|
+
}),
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
openai: {
|
|
49
|
+
// gpt-5.1+ accept 'none' and 400 on 'minimal'; gpt-5 / gpt-5-mini are the reverse.
|
|
50
|
+
off: [
|
|
51
|
+
shape('none', "reasoning_effort:'none'", (b) => ({ ...b, reasoning_effort: 'none' })),
|
|
52
|
+
shape('minimal', "reasoning_effort:'minimal'", (b) => ({ ...b, reasoning_effort: 'minimal' })),
|
|
53
|
+
],
|
|
54
|
+
on: [shape('medium', "reasoning_effort:'medium'", (b) => ({ ...b, reasoning_effort: 'medium' }))],
|
|
55
|
+
},
|
|
56
|
+
deepseek: {
|
|
57
|
+
off: [shape('disabled', "thinking:{type:'disabled'}", (b) => ({ ...b, thinking: { type: 'disabled' } }))],
|
|
58
|
+
// DeepSeek thinks by default: "on" is the ABSENCE of a field (the body goes out unchanged).
|
|
59
|
+
on: [shape('omit', '(field omitted — thinks by default)', (b) => ({ ...b }))],
|
|
60
|
+
},
|
|
61
|
+
gemini: {
|
|
62
|
+
off: [shape('budget0', 'generationConfig.thinkingConfig.thinkingBudget:0',
|
|
63
|
+
(b) => ({ ...b, generationConfig: { ...b.generationConfig, thinkingConfig: { thinkingBudget: 0 } } }))],
|
|
64
|
+
on: [shape('budget-1', 'generationConfig.thinkingConfig.thinkingBudget:-1',
|
|
65
|
+
(b) => ({ ...b, generationConfig: { ...b.generationConfig, thinkingConfig: { thinkingBudget: -1 } } }))],
|
|
66
|
+
},
|
|
67
|
+
ollama: {
|
|
68
|
+
// NOT live-verified (no Ollama thinking model was available to measure).
|
|
69
|
+
off: [shape('think-false', 'think:false', (b) => ({ ...b, think: false }))],
|
|
70
|
+
on: [shape('think-true', 'think:true', (b) => ({ ...b, think: true }))],
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
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).
|
|
77
|
+
* @type {Record<LadderKind, Record<string, { off: string|null, on: string|null }>>}
|
|
78
|
+
*/
|
|
79
|
+
const MODELS = {
|
|
80
|
+
anthropic: {
|
|
81
|
+
'claude-sonnet-5': { off: 'disabled', on: 'adaptive' },
|
|
82
|
+
'claude-haiku-4-5': { off: 'disabled', on: 'enabled' }, // 400s on adaptive
|
|
83
|
+
'claude-opus-5-5': { off: null, on: 'adaptive' }, // 400s on disabled → cannot be turned off
|
|
84
|
+
},
|
|
85
|
+
openai: {
|
|
86
|
+
'gpt-5': { off: 'minimal', on: 'medium' },
|
|
87
|
+
'gpt-5-mini': { off: 'minimal', on: 'medium' },
|
|
88
|
+
'gpt-5.1': { off: 'none', on: 'medium' },
|
|
89
|
+
'gpt-5.2': { off: 'none', on: 'medium' },
|
|
90
|
+
'gpt-5.4': { off: 'none', on: 'medium' },
|
|
91
|
+
'gpt-5.4-mini': { off: 'none', on: 'medium' },
|
|
92
|
+
'gpt-5.4-nano': { off: 'none', on: 'medium' },
|
|
93
|
+
'gpt-5.5': { off: 'none', on: 'medium' },
|
|
94
|
+
},
|
|
95
|
+
deepseek: {
|
|
96
|
+
'deepseek-flash': { off: 'disabled', on: 'omit' },
|
|
97
|
+
'deepseek-v4-pro': { off: 'disabled', on: 'omit' },
|
|
98
|
+
},
|
|
99
|
+
gemini: {
|
|
100
|
+
'gemini-2.5-flash': { off: 'budget0', on: 'budget-1' },
|
|
101
|
+
'gemini-3-flash-preview': { off: 'budget0', on: 'budget-1' },
|
|
102
|
+
'gemini-3.1-flash-lite': { off: 'budget0', on: 'budget-1' },
|
|
103
|
+
'gemini-3.1-flash-lite-preview': { off: 'budget0', on: 'budget-1' },
|
|
104
|
+
'gemini-3.5-flash': { off: 'budget0', on: 'budget-1' },
|
|
105
|
+
'gemini-3.1-pro-preview': { off: null, on: 'budget-1' }, // 400 "Budget 0 is invalid"
|
|
106
|
+
'gemini-3.1-pro-preview-customtools': { off: null, on: 'budget-1' }, // same
|
|
107
|
+
},
|
|
108
|
+
ollama: {},
|
|
109
|
+
};
|
|
110
|
+
|
|
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.
|
|
125
|
+
const TEMPERATURE_NAMED = /temperature/i;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Longest-prefix lookup of a model's measured facts. A dated/`-latest` suffix of a listed id counts
|
|
129
|
+
* (`claude-haiku-4-5-20251001`); any other suffix does NOT (so `gpt-5-chat` never inherits `gpt-5`).
|
|
130
|
+
* @param {LadderKind} kind
|
|
131
|
+
* @param {string} model
|
|
132
|
+
* @returns {{ off: string|null, on: string|null }|null}
|
|
133
|
+
*/
|
|
134
|
+
function lookupModel(kind, model) {
|
|
135
|
+
const table = MODELS[kind];
|
|
136
|
+
if (typeof model !== 'string') return null;
|
|
137
|
+
let best = null;
|
|
138
|
+
for (const id of Object.keys(table)) {
|
|
139
|
+
if (model === id || (model.startsWith(id) && /^-(\d{8}|\d{4}-\d{2}-\d{2}|latest)$/.test(model.slice(id.length)))) {
|
|
140
|
+
if (best === null || id.length > best.length) best = id;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return best === null ? null : table[best];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @param {any} model
|
|
148
|
+
* @param {any} baseUrl
|
|
149
|
+
* @returns {boolean}
|
|
150
|
+
*/
|
|
151
|
+
function isDeepSeek(model, baseUrl) {
|
|
152
|
+
return (typeof model === 'string' && /^deepseek-/i.test(model)) ||
|
|
153
|
+
(typeof baseUrl === 'string' && /deepseek\.com/i.test(baseUrl));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* @param {string} message
|
|
158
|
+
* @param {Record<string, any>} [ctx]
|
|
159
|
+
*/
|
|
160
|
+
const invalid = (message, ctx = {}) => new ValidationError(message, { context: { lib: 'bare-agent', ...ctx } });
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Validate a `thinkingMode` value. `undefined`/`null` ⇒ `null` (unset); anything but 'on'/'off' throws.
|
|
164
|
+
* @param {any} value
|
|
165
|
+
* @param {string} label - provider name for the message
|
|
166
|
+
* @returns {'on'|'off'|null}
|
|
167
|
+
*/
|
|
168
|
+
function normalizeThinkingMode(value, label) {
|
|
169
|
+
if (value === undefined || value === null) return null;
|
|
170
|
+
if (value === 'on' || value === 'off') return value;
|
|
171
|
+
throw invalid(`[${label}] thinkingMode must be 'on' or 'off' (got ${typeof value === 'string' ? JSON.stringify(value) : `<${typeof value}>`})`, { provider: label });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Resolve the EFFECTIVE mode for one call and reject the raw-`thinking` conflict (no silent precedence).
|
|
176
|
+
* Per-call overrides the instance default; `null` suppresses it.
|
|
177
|
+
* @param {object} p
|
|
178
|
+
* @param {string} p.label
|
|
179
|
+
* @param {any} p.instanceMode
|
|
180
|
+
* @param {Record<string, any>} p.options - per-call options
|
|
181
|
+
* @param {any} [p.instanceThinking] - the instance's raw `thinking` (providers that have one)
|
|
182
|
+
* @param {boolean} [p.hasRawThinking] - whether this provider has a raw `thinking` option at all
|
|
183
|
+
* @returns {'on'|'off'|null}
|
|
184
|
+
*/
|
|
185
|
+
function resolveThinkingMode({ label, instanceMode, options, instanceThinking = null, hasRawThinking = false }) {
|
|
186
|
+
const mode = normalizeThinkingMode(options.thinkingMode !== undefined ? options.thinkingMode : instanceMode, label);
|
|
187
|
+
if (mode && hasRawThinking) {
|
|
188
|
+
const raw = options.thinking !== undefined ? options.thinking : instanceThinking;
|
|
189
|
+
if (raw) {
|
|
190
|
+
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 });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return mode;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* @param {LadderKind} kind
|
|
198
|
+
* @param {any} err
|
|
199
|
+
* @returns {boolean}
|
|
200
|
+
*/
|
|
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);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
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.
|
|
212
|
+
*
|
|
213
|
+
* @param {object} p
|
|
214
|
+
* @param {LadderKind} p.kind
|
|
215
|
+
* @param {'on'|'off'} p.mode
|
|
216
|
+
* @param {string} p.model
|
|
217
|
+
* @param {string} p.label - provider class name
|
|
218
|
+
* @param {Record<string, any>} p.body - the base body (NOT mutated; each attempt gets a patched copy)
|
|
219
|
+
* @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
|
|
222
|
+
*/
|
|
223
|
+
async function requestWithThinkingMode({ kind, mode, model, label, body, request, cache }) {
|
|
224
|
+
const ladder = LADDERS[kind][mode];
|
|
225
|
+
const facts = lookupModel(kind, model);
|
|
226
|
+
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
|
+
}
|
|
230
|
+
const cacheKey = `${mode}|${model}`;
|
|
231
|
+
const preferredKey = cache.get(cacheKey) || (facts && facts[mode]) || null;
|
|
232
|
+
const ordered = [...ladder].sort((a, b) => (a.key === preferredKey ? -1 : b.key === preferredKey ? 1 : 0));
|
|
233
|
+
|
|
234
|
+
/** @type {string[]} */
|
|
235
|
+
const tried = [];
|
|
236
|
+
let lastMessage = '';
|
|
237
|
+
for (const s of ordered) {
|
|
238
|
+
const patched = s.apply(body);
|
|
239
|
+
if (patched === null) { tried.push(`${s.id} (not applicable to this request)`); continue; }
|
|
240
|
+
try {
|
|
241
|
+
const data = await request(patched);
|
|
242
|
+
cache.set(cacheKey, s.key);
|
|
243
|
+
return data;
|
|
244
|
+
} catch (err) {
|
|
245
|
+
if (!isShapeRejection(kind, err)) throw err;
|
|
246
|
+
tried.push(s.id);
|
|
247
|
+
lastMessage = err.message;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
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 });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** @param {any} v @returns {boolean} */
|
|
256
|
+
const positive = (v) => typeof v === 'number' && v > 0;
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Read the thinking signal off a raw reply: 'thinking' | 'none' | 'unknown'. "Absent field ⇒ none" is
|
|
260
|
+
* used ONLY where measured: Gemini thoughtsTokenCount (given usageMetadata is present), DeepSeek
|
|
261
|
+
* reasoning_content, Ollama message.thinking. Anthropic counts thinking/redacted_thinking blocks.
|
|
262
|
+
* OpenAI reasoning_tokens absent ⇒ 'unknown'.
|
|
263
|
+
* @param {LadderKind} kind
|
|
264
|
+
* @param {any} data - raw provider reply
|
|
265
|
+
* @returns {'thinking'|'none'|'unknown'}
|
|
266
|
+
*/
|
|
267
|
+
function readThinkingSignal(kind, data) {
|
|
268
|
+
if (!data || typeof data !== 'object') return 'unknown';
|
|
269
|
+
switch (kind) {
|
|
270
|
+
case 'anthropic': {
|
|
271
|
+
if (!Array.isArray(data.content)) return 'unknown';
|
|
272
|
+
return data.content.some((/** @type {any} */ b) => b && (b.type === 'thinking' || b.type === 'redacted_thinking')) ? 'thinking' : 'none';
|
|
273
|
+
}
|
|
274
|
+
case 'openai': {
|
|
275
|
+
const t = data.usage && data.usage.completion_tokens_details && data.usage.completion_tokens_details.reasoning_tokens;
|
|
276
|
+
if (typeof t !== 'number') return 'unknown';
|
|
277
|
+
return t > 0 ? 'thinking' : 'none';
|
|
278
|
+
}
|
|
279
|
+
case 'deepseek': {
|
|
280
|
+
const m = data.choices && data.choices[0] && data.choices[0].message;
|
|
281
|
+
if (!m || typeof m !== 'object') return 'unknown';
|
|
282
|
+
return typeof m.reasoning_content === 'string' && m.reasoning_content.length > 0 ? 'thinking' : 'none';
|
|
283
|
+
}
|
|
284
|
+
case 'gemini': {
|
|
285
|
+
const u = data.usageMetadata;
|
|
286
|
+
if (!u || typeof u !== 'object') return 'unknown';
|
|
287
|
+
const parts = (data.candidates && data.candidates[0] && data.candidates[0].content && data.candidates[0].content.parts) || [];
|
|
288
|
+
if (positive(u.thoughtsTokenCount) || (Array.isArray(parts) && parts.some((/** @type {any} */ p) => p && p.thought === true))) return 'thinking';
|
|
289
|
+
return 'none';
|
|
290
|
+
}
|
|
291
|
+
case 'ollama': {
|
|
292
|
+
const m = data.message;
|
|
293
|
+
if (!m || typeof m !== 'object') return 'unknown';
|
|
294
|
+
return typeof m.thinking === 'string' && m.thinking.length > 0 ? 'thinking' : 'none';
|
|
295
|
+
}
|
|
296
|
+
default:
|
|
297
|
+
return 'unknown';
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
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.
|
|
306
|
+
* @param {object} p
|
|
307
|
+
* @param {LadderKind} p.kind
|
|
308
|
+
* @param {'on'|'off'} p.mode
|
|
309
|
+
* @param {any} p.data
|
|
310
|
+
* @param {any} p.owner - provider instance (holds the warn-once flag)
|
|
311
|
+
* @param {string} p.label
|
|
312
|
+
* @param {string} p.model
|
|
313
|
+
* @returns {boolean|null}
|
|
314
|
+
*/
|
|
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.`);
|
|
323
|
+
}
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* What bare-agent knows about switching a model's thinking on/off, with no network. Measured models
|
|
329
|
+
* 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);
|
|
331
|
+
* clipipe and unknown providers answer false.
|
|
332
|
+
* @param {'anthropic'|'openai'|'deepseek'|'gemini'|'ollama'|'clipipe'|string} provider
|
|
333
|
+
* @param {string} [model]
|
|
334
|
+
* @returns {{ switchable: boolean|'unverified', off: string|null, on: string|null, note: string }}
|
|
335
|
+
* @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
|
+
* @fails returns switchable:false for clipipe and unknown providers (never throws); 'unverified' means not measured, not unsupported; Ollama's shapes are not live-verified.
|
|
337
|
+
* @category providers
|
|
338
|
+
* @example
|
|
339
|
+
* thinkingSupport('anthropic', 'claude-opus-5-5'); // { switchable: false, off: null, on: "thinking:{type:'adaptive'}", note: '...' }
|
|
340
|
+
*/
|
|
341
|
+
function thinkingSupport(provider, model) {
|
|
342
|
+
if (provider === 'clipipe') {
|
|
343
|
+
return { switchable: false, off: null, on: null, note: 'CLIPipe has no thinking-mode mechanism; thinkingMode throws a ValidationError.' };
|
|
344
|
+
}
|
|
345
|
+
const kind = /** @type {LadderKind} */ (provider);
|
|
346
|
+
if (typeof provider !== 'string' || !Object.prototype.hasOwnProperty.call(LADDERS, provider)) {
|
|
347
|
+
return { switchable: false, off: null, on: null, note: `Unknown provider '${String(provider)}'.` };
|
|
348
|
+
}
|
|
349
|
+
const idOf = (/** @type {'on'|'off'} */ mode, /** @type {string|null} */ key) =>
|
|
350
|
+
key === null ? null : (LADDERS[kind][mode].find((s) => s.key === key) || LADDERS[kind][mode][0]).id;
|
|
351
|
+
const facts = lookupModel(kind, /** @type {string} */ (model));
|
|
352
|
+
if (!facts) {
|
|
353
|
+
return {
|
|
354
|
+
switchable: 'unverified',
|
|
355
|
+
off: LADDERS[kind].off[0].id,
|
|
356
|
+
on: LADDERS[kind].on[0].id,
|
|
357
|
+
note: kind === 'ollama'
|
|
358
|
+
? '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.`,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
const switchable = facts.off !== null && facts.on !== null;
|
|
363
|
+
return {
|
|
364
|
+
switchable,
|
|
365
|
+
off: idOf('off', facts.off),
|
|
366
|
+
on: idOf('on', facts.on),
|
|
367
|
+
note: switchable
|
|
368
|
+
? '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.`,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
module.exports = {
|
|
374
|
+
thinkingSupport,
|
|
375
|
+
// internal (provider files + tests)
|
|
376
|
+
normalizeThinkingMode,
|
|
377
|
+
resolveThinkingMode,
|
|
378
|
+
requestWithThinkingMode,
|
|
379
|
+
assessThinkingApplied,
|
|
380
|
+
readThinkingSignal,
|
|
381
|
+
isShapeRejection,
|
|
382
|
+
isDeepSeek,
|
|
383
|
+
lookupModel,
|
|
384
|
+
};
|
package/types/index.d.ts
CHANGED
|
@@ -134,6 +134,15 @@ 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 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.
|
|
144
|
+
*/
|
|
145
|
+
thinkingApplied?: boolean | null;
|
|
137
146
|
/**
|
|
138
147
|
* BA-27 — present ONLY when the model emitted a tool call whose `function.arguments` was
|
|
139
148
|
* syntactically-broken JSON (an extra brace, a truncated object). The billed round already
|
|
@@ -143,12 +152,15 @@ export interface GenerateResult {
|
|
|
143
152
|
* repaired. Absent on a clean round. OpenAI-compatible + Ollama string-arguments only; Anthropic
|
|
144
153
|
* arrives pre-parsed and cannot hit this.
|
|
145
154
|
*
|
|
146
|
-
* `rawArguments
|
|
155
|
+
* `rawArguments` (BA-27 follow-up) is present only when the provider was
|
|
147
156
|
* constructed with `exposeMalformedArgs: true` (default off, mirrors `exposeErrorBody`) AND the
|
|
148
|
-
* raw `function.arguments` was a string: the raw value verbatim,
|
|
149
|
-
*
|
|
157
|
+
* raw `function.arguments` was a string: the FULL raw value verbatim, uncapped (model-generated; may be
|
|
158
|
+
* large or sensitive).
|
|
150
159
|
*/
|
|
151
|
-
malformedToolCall?: { name: string | undefined; error: string; rawArguments?: string;
|
|
160
|
+
malformedToolCall?: { name: string | undefined; error: string; rawArguments?: string;
|
|
161
|
+
/** @deprecated never set since 0.50.0 (rawArguments is now uncapped) */
|
|
162
|
+
rawTruncated?: true;
|
|
163
|
+
};
|
|
152
164
|
/**
|
|
153
165
|
* BA-7 — provider-native content blocks the normalized `{text, toolCalls}` shape cannot express
|
|
154
166
|
* (Anthropic `thinking` / `redacted_thinking`), captured opaquely so the Loop can put them on the
|