bare-agent 0.48.0 → 0.49.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.
@@ -448,7 +448,7 @@ const { createShellTools } = require('bare-agent/tools');
448
448
  const gate = new Gate({
449
449
  budget: { maxCostUsd: 0.50 },
450
450
  limits: { maxTurns: 20 },
451
- fs: { readScope: ['/tmp', '~/Projects'], deny: ['/etc'] },
451
+ fs: { readScope: ['/srv/agent', '~/Projects'], deny: ['/etc'] }, // list REAL paths (bareguard >=0.19.2 throws on a symlinked scope root; on macOS /tmp is one — use fs.realpathSync(os.tmpdir()))
452
452
  bash: { allow: ['ls', 'cat', 'grep', 'ps', 'df'] }, // argv[0] allowlist
453
453
  audit: { path: './audit.jsonl' },
454
454
  humanChannel: async (event) => ({ decision: 'deny' }), // wire to your UI
@@ -505,6 +505,14 @@ The blocks now ride the transcript on `Message.providerBlocks` (`{provider, mode
505
505
 
506
506
  `AnthropicProvider({ thinking })` is a separate, opt-in knob, forwarded to `body.thinking` verbatim (e.g. `{type:'adaptive', display:'summarized'}` to surface the reasoning; the default `display` is `'omitted'`). **It does not "turn thinking on"** — it's already on. Use it to pin the mode or reach `display`/`effort`. It is passed through unvalidated on purpose: `budget_tokens` was removed from the API and now **400s** on sonnet-5/Opus 4.7+, so a library that reshaped this parameter would need a release every time Anthropic moved.
507
507
 
508
+ `OpenAIProvider({ thinking })` is the OpenAI-compatible counterpart: same verbatim/unvalidated forwarding to `body.thinking`, overridable per call (`loop.run(msgs, tools, { thinking })` / `generate(..., { thinking })`), and per-call `null` suppresses the constructor default. Needed for DeepSeek, which runs thinking mode by default and 400s "Thinking mode does not support this tool_choice" on a forced `toolChoice`:
509
+
510
+ ```js
511
+ new OpenAIProvider({ baseUrl: 'https://api.deepseek.com', model: 'deepseek-flash', legacyMaxTokens: true, thinking: { type: 'disabled' } })
512
+ ```
513
+
514
+ > **Fallback caveat:** per-call options (incl. `thinking`) are forwarded to EVERY provider in a `FallbackProvider` chain. For a mixed-vendor chain set `thinking` on each provider's constructor, not per-call.
515
+
508
516
  > **Honesty note, and we mean it.** This is a **protocol/data-loss fix, not a capability fix.** A head-to-head with thinking fully preserved vs. stock bare-agent produced **indistinguishable** outcomes. Do not adopt 0.27 expecting better reasoning — you will not get it, and we have the measurement.
509
517
 
510
518
  Legacy `wrapTool` / `wrapTools` are retained as deprecation shims (one-shot console warning, removal in 1.0). Migration: replace `wrapTools(tools)` at `loop.run()` with `filterTools(tools)` once upfront + `onLlmResult` / `onToolResult` on `new Loop({...})` to pick up LLM-cost recording and `_ctx` threading.
@@ -1427,7 +1435,7 @@ Mobile tools follow the observe-act pattern: action tools auto-return a fresh sn
1427
1435
 
1428
1436
  **Zero baked-in allowlist.** The library ships the primitives; gating is bareguard's job via the standard `wireGate(gate)` wiring.
1429
1437
 
1430
- > **Path canonicalization + symlink refusal, together.** bareguard's own resolved-path (symlink) containment check is planned for an upcoming release, not yet published; until then the gate matches paths lexically, so a symlinked parent dir is NOT contained even when the link sits inside an allowed scope. bare-agent canonicalizes the path (`~` + resolve, via `resolveToolPath`) before the gate check and opens that same path — this closes a *different* gap (the gate judging a `~`/relative path in a different form than the one the tool opens), not the lexical-match gap above. `noFollowSymlinks` refuses a symlinked final path *component* at open time. Together they narrow but do not close the check-then-open window, and neither covers hardlinks.
1438
+ > **Path canonicalization + symlink refusal, together.** bareguard >=0.19.0 already realpath-resolves the checked path and every scope root on each call, so a symlink under an allowed scope that resolves outside it is denied (`fs.<scope>.symlinkEscape`; dangling → `danglingSymlink`). bareguard 0.19.2 additionally requires the scope ROOTS themselves not be, or sit under, a symlink: `new Gate` throws otherwise, and a root swapped for a symlink later denies `fs.readScope.symlinkRoot` / `fs.writeScope.symlinkRoot` — list real paths (e.g. `fs.realpathSync(os.tmpdir())`; on macOS `/tmp` and `os.tmpdir()` are symlinks). The checked path must ALSO pass a lexical check (`within(path, scopeRoot)`) before the resolved one, so its lexical form must lie under a realpath'd scope root: with a symlinked `$HOME`, `~/x` expands (via `resolveToolPath`, which does not realpath) to `<symlink>/x` and is DENIED against a realpath'd home scope — fail-closed, a usability gap not a leak; pass the real path. bare-agent canonicalizes the path (`~` + resolve, via `resolveToolPath`) before the gate check and opens that same path — this closes a *different* gap (the gate judging a `~`/relative path in a different form than the one the tool opens), not the symlink-containment gap, which is bareguard's job. `noFollowSymlinks` refuses a symlinked final path *component* at open time. Together they narrow but do not close the check-then-open window, and neither covers hardlinks.
1431
1439
  >
1432
1440
  > **Opt-in symlink refusal:** `createShellTools({ noFollowSymlinks: true })` makes `shell_read`, `shell_write`, `shell_edit`, and `shell_grep` refuse to open a path whose **final** path component is a symlink — a file, a dir, or dangling — instead of silently following it. Refusal is a **thrown** error with `err.code === 'ELOOP'` (feeds the Loop's `maxIdenticalToolErrors` guard like any other tool error), and for `shell_write` the link's target is never created or touched. Scope is the final component **only** — a symlinked *parent* directory is not covered (per the canonicalization note above), and neither is the `cwd` option of `shell_run`/`shell_exec` — it only gets `~` expansion, never this flag or `resolveToolPath`'s full canonicalization, so a symlinked `cwd` is always followed. `shell_grep` refuses only a symlinked **root** path loudly, via the same atomic open every other tool uses (open-then-fstat, not a look-then-open `lstat`); a file that turns into a symlink partway through the walk is skipped silently, the same as any other read error. The check is atomic (`O_NOFOLLOW`) on linux/macOS; on Windows, which has no `O_NOFOLLOW`-equivalent open flag, it falls back to a non-atomic `lstat`-then-open check — a symlink planted in that gap slips through. A directory listing (`shell_read` on a dir, `shell_grep`'s root when it's a dir) additionally rechecks the path's `dev`+`ino` against the open handle right after listing — Node has no `fdopendir`, so the listing itself still goes by path, and this narrows (does not close) the window where a directory swapped in between the open and the listing would report a different directory's names; a swap-and-swap-back inside that narrow window still passes undetected. Default `false` — omitting the option is byte-identical to the pre-existing behavior.
1433
1441
  >
@@ -1451,7 +1459,7 @@ const gate = new Gate({
1451
1459
  // Hard-deny shell_exec for this agent. tools.denylist short-circuits before content checks.
1452
1460
  tools: { denylist: ['shell_exec'] },
1453
1461
  // fs scope for shell_read / shell_grep.
1454
- fs: { readScope: ['/home/', '/tmp/'] },
1462
+ fs: { readScope: ['/home/', '/srv/agent/'] }, // real paths only — see the symlink note above
1455
1463
  audit: { path: './shell-audit.jsonl' },
1456
1464
  humanChannel: async (event) => ({ decision: 'deny' }),
1457
1465
  });
@@ -31,17 +31,20 @@ const { createShellTools } = require('bare-agent/tools');
31
31
  // floor), a shell file action denies by default when its scope is unset (fs.readScope.unset /
32
32
  // fs.writeScope.unset) — leaving writeScope out here would silently deny every shell_write /
33
33
  // shell_edit call, not leave the primitive dead like on older bareguard.
34
- // readScope is deliberately WIDER than writeScope here — read is fine anywhere under /tmp,
34
+ // Scope roots are realpath'd: bareguard >=0.19.2 throws at `new Gate` on a root that is or
35
+ // contains a symlink (macOS /tmp and os.tmpdir() are symlinks), so list real paths.
36
+ // readScope is deliberately WIDER than writeScope here — read is fine anywhere under the tmp root,
35
37
  // but writes are narrowed to one demo subdir, to show the two scopes are independent (a
36
38
  // folder listed only in readScope is NOT writable, and vice versa). The demo dir is created
37
39
  // below since fs.writeScope doesn't need the path to pre-exist, but the example's own
38
40
  // shell_write call does.
39
- const writeDir = path.join(os.tmpdir(), 'bare-agent-demo');
41
+ const TMP = fs.realpathSync(os.tmpdir());
42
+ const writeDir = path.join(TMP, 'bare-agent-demo');
40
43
  fs.mkdirSync(writeDir, { recursive: true });
41
44
  const gate = new Gate({
42
45
  budget: { maxCostUsd: 0.10 }, // hard USD cap
43
46
  limits: { maxTurns: 20 }, // safety net on think/act cycles
44
- fs: { readScope: ['/tmp'], writeScope: [writeDir] }, // read: anywhere under /tmp; write: one narrower demo dir
47
+ fs: { readScope: [TMP], writeScope: [writeDir] }, // read: anywhere under the tmp root; write: one narrower demo dir
45
48
  bash: { allow: ['ls', 'cat', 'echo', 'pwd'] }, // argv[0] allowlist for shell_run
46
49
  audit: { path: './bareagent-audit.jsonl' },
47
50
  // Required by bareguard: any ask/halt event flows through here.
@@ -87,7 +90,7 @@ const loop = new Loop({
87
90
 
88
91
  // 4. Run. Pass the tools as-is — gating is via policy/onToolResult, not by wrapping execute().
89
92
  const result = await loop.run(
90
- [{ role: 'user', content: 'List the contents of /tmp using shell_run with argv ["ls", "/tmp"].' }],
93
+ [{ role: 'user', content: `List the contents of ${TMP} using shell_run with argv ["ls", "${TMP}"].` }],
91
94
  tools,
92
95
  );
93
96
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.48.0",
3
+ "version": "0.49.0",
4
4
  "files": [
5
5
  "index.js",
6
6
  "index.d.ts",
@@ -107,7 +107,7 @@
107
107
  },
108
108
  "devDependencies": {
109
109
  "@types/node": "^22.19.19",
110
- "bareguard": ">=0.19.0 <1.0.0",
110
+ "bareguard": ">=0.19.2 <1.0.0",
111
111
  "litectx": "^0.26.0",
112
112
  "typescript": "^5.7.0"
113
113
  }
@@ -24,6 +24,8 @@ export type AnthropicOptions = {
24
24
  cacheMessages?: boolean | undefined;
25
25
  /**
26
26
  * - Opt-in extended thinking (BA-7), forwarded to `body.thinking` VERBATIM and unvalidated — e.g. `{ type: 'adaptive' }`, or `{ type: 'adaptive', display: 'summarized' }` to surface the reasoning (the default `display` is `'omitted'`). Deliberately opaque: this parameter has already broken once (`budget_tokens` was removed and now 400s on `claude-sonnet-5` / Opus 4.7+), and a library that reshapes it would need a release every time the API moves. Overridable per call via `generate(..., { thinking })`; pass `null` there to suppress an instance default.
27
+ * WARNING: per-call options (incl. `thinking`) are forwarded to EVERY provider in a `FallbackProvider` chain — for a mixed-vendor
28
+ * chain set `thinking` on each provider's CONSTRUCTOR instead of per-call.
27
29
  *
28
30
  * **MEASURED CAVEAT — this option does not "turn thinking on".** On `claude-sonnet-5` adaptive thinking is ALREADY the default: sending this changed the observed thinking rate not at all (2/10 rounds with it vs 3/10 without — `poc/ba7-adaptive-default.mjs`). Its real use is pinning the mode and reaching `display`/`effort`. The change that mattered is that thinking blocks are now PRESERVED and replayed (see `Message.providerBlocks`), which happens whether or not you ever set this.
29
31
  */
@@ -52,6 +54,8 @@ export type AnthropicOptions = {
52
54
  * @property {boolean} [cacheSystem=false] - Opt-in prompt caching: send the system prompt with a `cache_control` breakpoint so Anthropic caches it. Anthropic does NOT auto-cache, so without this its cache tiers are always 0. Overridable per call via `generate(..., { cacheSystem })`. NOTE: on its own this rarely helps a tool loop — Anthropic's minimum cacheable prefix is 1024–4096 tokens (model-dependent) and a typical system persona is a few hundred, so it silently never caches. The transcript is where a tool loop's tokens actually live — see `cacheMessages`.
53
55
  * @property {boolean} [cacheMessages=false] - Opt-in TRANSCRIPT caching (BA-1): roll a `cache_control` breakpoint onto the last content block of the last message, so Anthropic caches the whole conversation prefix and the loop stops re-buying it at full price every round. In a tool loop the transcript IS the tool results (file bodies from `shell_read`) and it always ENDS on one, which `_toAnthropicMessage` rebuilds from scratch — so no caller-side seam (`assemble` included) can reach it, and this has to live in the provider. Measured on `claude-sonnet-5` with a ~15k-token tool-result transcript (`poc/ba1-message-caching.mjs`): steady state **$0.0753 → $0.0110 per round, 6.8x cheaper**; round 1 pays a 1.25x cache WRITE once. Off by default — it changes the wire format, so adopters opt in. Overridable per call via `generate(..., { cacheMessages })`. **Interaction:** a destructive `trim`/stash fold that rewrites the transcript PREFIX invalidates the cache (the prefix is the cache key), so a fold must keep the head stable or you re-pay the write premium every round for nothing.
54
56
  * @property {any} [thinking] - Opt-in extended thinking (BA-7), forwarded to `body.thinking` VERBATIM and unvalidated — e.g. `{ type: 'adaptive' }`, or `{ type: 'adaptive', display: 'summarized' }` to surface the reasoning (the default `display` is `'omitted'`). Deliberately opaque: this parameter has already broken once (`budget_tokens` was removed and now 400s on `claude-sonnet-5` / Opus 4.7+), and a library that reshapes it would need a release every time the API moves. Overridable per call via `generate(..., { thinking })`; pass `null` there to suppress an instance default.
57
+ * WARNING: per-call options (incl. `thinking`) are forwarded to EVERY provider in a `FallbackProvider` chain — for a mixed-vendor
58
+ * chain set `thinking` on each provider's CONSTRUCTOR instead of per-call.
55
59
  *
56
60
  * **MEASURED CAVEAT — this option does not "turn thinking on".** On `claude-sonnet-5` adaptive thinking is ALREADY the default: sending this changed the observed thinking rate not at all (2/10 rounds with it vs 3/10 without — `poc/ba7-adaptive-default.mjs`). Its real use is pinning the mode and reaching `display`/`effort`. The change that mattered is that thinking blocks are now PRESERVED and replayed (see `Message.providerBlocks`), which happens whether or not you ever set this.
57
61
  * @property {boolean} [exposeErrorBody=false] - Attach the full upstream response to `err.body` on HTTP errors (off by default to avoid leaking unexpected fields through error logs; `err.message` still carries the API error).
@@ -30,6 +30,8 @@ function isLoopbackHost(hostname) {
30
30
  * @property {boolean} [cacheSystem=false] - Opt-in prompt caching: send the system prompt with a `cache_control` breakpoint so Anthropic caches it. Anthropic does NOT auto-cache, so without this its cache tiers are always 0. Overridable per call via `generate(..., { cacheSystem })`. NOTE: on its own this rarely helps a tool loop — Anthropic's minimum cacheable prefix is 1024–4096 tokens (model-dependent) and a typical system persona is a few hundred, so it silently never caches. The transcript is where a tool loop's tokens actually live — see `cacheMessages`.
31
31
  * @property {boolean} [cacheMessages=false] - Opt-in TRANSCRIPT caching (BA-1): roll a `cache_control` breakpoint onto the last content block of the last message, so Anthropic caches the whole conversation prefix and the loop stops re-buying it at full price every round. In a tool loop the transcript IS the tool results (file bodies from `shell_read`) and it always ENDS on one, which `_toAnthropicMessage` rebuilds from scratch — so no caller-side seam (`assemble` included) can reach it, and this has to live in the provider. Measured on `claude-sonnet-5` with a ~15k-token tool-result transcript (`poc/ba1-message-caching.mjs`): steady state **$0.0753 → $0.0110 per round, 6.8x cheaper**; round 1 pays a 1.25x cache WRITE once. Off by default — it changes the wire format, so adopters opt in. Overridable per call via `generate(..., { cacheMessages })`. **Interaction:** a destructive `trim`/stash fold that rewrites the transcript PREFIX invalidates the cache (the prefix is the cache key), so a fold must keep the head stable or you re-pay the write premium every round for nothing.
32
32
  * @property {any} [thinking] - Opt-in extended thinking (BA-7), forwarded to `body.thinking` VERBATIM and unvalidated — e.g. `{ type: 'adaptive' }`, or `{ type: 'adaptive', display: 'summarized' }` to surface the reasoning (the default `display` is `'omitted'`). Deliberately opaque: this parameter has already broken once (`budget_tokens` was removed and now 400s on `claude-sonnet-5` / Opus 4.7+), and a library that reshapes it would need a release every time the API moves. Overridable per call via `generate(..., { thinking })`; pass `null` there to suppress an instance default.
33
+ * WARNING: per-call options (incl. `thinking`) are forwarded to EVERY provider in a `FallbackProvider` chain — for a mixed-vendor
34
+ * chain set `thinking` on each provider's CONSTRUCTOR instead of per-call.
33
35
  *
34
36
  * **MEASURED CAVEAT — this option does not "turn thinking on".** On `claude-sonnet-5` adaptive thinking is ALREADY the default: sending this changed the observed thinking rate not at all (2/10 rounds with it vs 3/10 without — `poc/ba7-adaptive-default.mjs`). Its real use is pinning the mode and reaching `display`/`effort`. The change that mattered is that thinking blocks are now PRESERVED and replayed (see `Message.providerBlocks`), which happens whether or not you ever set this.
35
37
  * @property {boolean} [exposeErrorBody=false] - Attach the full upstream response to `err.body` on HTTP errors (off by default to avoid leaking unexpected fields through error logs; `err.message` still carries the API error).
@@ -21,6 +21,11 @@ export type FallbackOptions = {
21
21
  * @property {(error: any, index: number) => boolean} [shouldFallback] - Return false to stop.
22
22
  * @property {(error: any, fromIndex: number, toIndex: number) => void} [onFallback] - Callback.
23
23
  */
24
+ /**
25
+ * NOTE: per-call `generate()` options (incl. `thinking`, `toolChoice`) are forwarded UNCHANGED to EVERY provider in the chain. For a
26
+ * mixed-vendor chain (e.g. OpenAI-compat DeepSeek + Anthropic) set `thinking` on each provider's CONSTRUCTOR instead of per-call,
27
+ * or a value shaped for one vendor lands in another's `body.thinking` on fallback.
28
+ */
24
29
  export class FallbackProvider {
25
30
  /**
26
31
  * Provider that tries multiple providers in order.
@@ -11,6 +11,11 @@
11
11
  * @property {(error: any, fromIndex: number, toIndex: number) => void} [onFallback] - Callback.
12
12
  */
13
13
 
14
+ /**
15
+ * NOTE: per-call `generate()` options (incl. `thinking`, `toolChoice`) are forwarded UNCHANGED to EVERY provider in the chain. For a
16
+ * mixed-vendor chain (e.g. OpenAI-compat DeepSeek + Anthropic) set `thinking` on each provider's CONSTRUCTOR instead of per-call,
17
+ * or a value shaped for one vendor lands in another's `body.thinking` on fallback.
18
+ */
14
19
  class FallbackProvider {
15
20
  /**
16
21
  * Provider that tries multiple providers in order.
@@ -46,6 +46,19 @@ export type OpenAIOptions = {
46
46
  * unexpected string into logs by default" reasoning applies. See `src/provider-toolcalls.js`.
47
47
  */
48
48
  exposeMalformedArgs?: boolean | undefined;
49
+ /**
50
+ * - Opt-in `thinking` request field (fwdloop DeepSeek ask — extends BA-7(b)'s Anthropic option), forwarded to `body.thinking`
51
+ * VERBATIM and unvalidated — e.g. `{ type: 'disabled' }`. Generalises the Anthropic provider's option to
52
+ * OpenAI-compatible endpoints. Motivating case: DeepSeek's OpenAI-compatible API (`baseUrl`
53
+ * `https://api.deepseek.com`, model `deepseek-flash`) runs thinking mode by default and 400s "Thinking mode
54
+ * does not support this tool_choice" on a named or `'required'` `toolChoice`; `{ type: 'disabled' }` makes the
55
+ * forced tool call return 200. Deliberately opaque (no model sniffing, no free-form extra-body field) — a
56
+ * library that reshapes it would need a release every time a vendor moves. Unset ⇒ request body unchanged.
57
+ * Overridable per call via `generate(..., { thinking })`; pass `null` there to suppress an instance default.
58
+ * WARNING: per-call options (incl. `thinking`) are forwarded to EVERY provider in a `FallbackProvider` chain — for a mixed-vendor
59
+ * chain set `thinking` on each provider's CONSTRUCTOR instead of per-call.
60
+ */
61
+ thinking?: any;
49
62
  };
50
63
  /**
51
64
  * @typedef {object} OpenAIOptions
@@ -77,6 +90,16 @@ export type OpenAIOptions = {
77
90
  * when longer) to `malformedToolCall.rawArguments`. Off by default, independent of `exposeErrorBody` —
78
91
  * the raw blob is model-generated, not an upstream error body, but the same "don't leak an unbounded
79
92
  * unexpected string into logs by default" reasoning applies. See `src/provider-toolcalls.js`.
93
+ * @property {any} [thinking] - Opt-in `thinking` request field (fwdloop DeepSeek ask — extends BA-7(b)'s Anthropic option), forwarded to `body.thinking`
94
+ * VERBATIM and unvalidated — e.g. `{ type: 'disabled' }`. Generalises the Anthropic provider's option to
95
+ * OpenAI-compatible endpoints. Motivating case: DeepSeek's OpenAI-compatible API (`baseUrl`
96
+ * `https://api.deepseek.com`, model `deepseek-flash`) runs thinking mode by default and 400s "Thinking mode
97
+ * does not support this tool_choice" on a named or `'required'` `toolChoice`; `{ type: 'disabled' }` makes the
98
+ * forced tool call return 200. Deliberately opaque (no model sniffing, no free-form extra-body field) — a
99
+ * library that reshapes it would need a release every time a vendor moves. Unset ⇒ request body unchanged.
100
+ * Overridable per call via `generate(..., { thinking })`; pass `null` there to suppress an instance default.
101
+ * WARNING: per-call options (incl. `thinking`) are forwarded to EVERY provider in a `FallbackProvider` chain — for a mixed-vendor
102
+ * chain set `thinking` on each provider's CONSTRUCTOR instead of per-call.
80
103
  */
81
104
  export class OpenAIProvider {
82
105
  /**
@@ -95,11 +118,12 @@ export class OpenAIProvider {
95
118
  deadlineMs: number | undefined;
96
119
  legacyMaxTokens: boolean;
97
120
  exposeMalformedArgs: boolean;
121
+ thinking: any;
98
122
  /**
99
123
  * Generate a response from the OpenAI API.
100
124
  * @param {Message[]} messages - Conversation messages.
101
125
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
102
- * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens, timeoutMs — a per-call override of the constructor's `timeoutMs`, see BA-18; deadlineMs — a per-call override of the constructor's `deadlineMs`, see BA-19; toolChoice — `'auto'` | `'required'` | `{ name }`, forwarded as OpenAI `tool_choice`, applied only when `tools` are present).
126
+ * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens, timeoutMs — a per-call override of the constructor's `timeoutMs`, see BA-18; deadlineMs — a per-call override of the constructor's `deadlineMs`, see BA-19; toolChoice — `'auto'` | `'required'` | `{ name }`, forwarded as OpenAI `tool_choice`, applied only when `tools` are present; thinking — per-call override of the constructor's `thinking`, `null` suppresses an instance default, see BA-7 (b)).
103
127
  * @returns {Promise<GenerateResult>}
104
128
  * @throws {Error} `[OpenAIProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
105
129
  */
@@ -77,6 +77,16 @@ function isLoopbackHost(hostname) {
77
77
  * when longer) to `malformedToolCall.rawArguments`. Off by default, independent of `exposeErrorBody` —
78
78
  * the raw blob is model-generated, not an upstream error body, but the same "don't leak an unbounded
79
79
  * unexpected string into logs by default" reasoning applies. See `src/provider-toolcalls.js`.
80
+ * @property {any} [thinking] - Opt-in `thinking` request field (fwdloop DeepSeek ask — extends BA-7(b)'s Anthropic option), forwarded to `body.thinking`
81
+ * VERBATIM and unvalidated — e.g. `{ type: 'disabled' }`. Generalises the Anthropic provider's option to
82
+ * OpenAI-compatible endpoints. Motivating case: DeepSeek's OpenAI-compatible API (`baseUrl`
83
+ * `https://api.deepseek.com`, model `deepseek-flash`) runs thinking mode by default and 400s "Thinking mode
84
+ * does not support this tool_choice" on a named or `'required'` `toolChoice`; `{ type: 'disabled' }` makes the
85
+ * forced tool call return 200. Deliberately opaque (no model sniffing, no free-form extra-body field) — a
86
+ * library that reshapes it would need a release every time a vendor moves. Unset ⇒ request body unchanged.
87
+ * Overridable per call via `generate(..., { thinking })`; pass `null` there to suppress an instance default.
88
+ * WARNING: per-call options (incl. `thinking`) are forwarded to EVERY provider in a `FallbackProvider` chain — for a mixed-vendor
89
+ * chain set `thinking` on each provider's CONSTRUCTOR instead of per-call.
80
90
  */
81
91
 
82
92
  class OpenAIProvider {
@@ -100,13 +110,16 @@ class OpenAIProvider {
100
110
  this.legacyMaxTokens = options.legacyMaxTokens === true;
101
111
  // BA-27 follow-up: expose the raw malformed tool-call arguments string. Off by default.
102
112
  this.exposeMalformedArgs = options.exposeMalformedArgs === true;
113
+ // BA-7 (b) / fwdloop: forwarded to `body.thinking` VERBATIM — unvalidated, un-reshaped (mirrors the
114
+ // Anthropic provider). Null/unset ⇒ no `thinking` key, byte-identical to the pre-option body.
115
+ this.thinking = options.thinking != null ? options.thinking : null;
103
116
  }
104
117
 
105
118
  /**
106
119
  * Generate a response from the OpenAI API.
107
120
  * @param {Message[]} messages - Conversation messages.
108
121
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
109
- * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens, timeoutMs — a per-call override of the constructor's `timeoutMs`, see BA-18; deadlineMs — a per-call override of the constructor's `deadlineMs`, see BA-19; toolChoice — `'auto'` | `'required'` | `{ name }`, forwarded as OpenAI `tool_choice`, applied only when `tools` are present).
122
+ * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens, timeoutMs — a per-call override of the constructor's `timeoutMs`, see BA-18; deadlineMs — a per-call override of the constructor's `deadlineMs`, see BA-19; toolChoice — `'auto'` | `'required'` | `{ name }`, forwarded as OpenAI `tool_choice`, applied only when `tools` are present; thinking — per-call override of the constructor's `thinking`, `null` suppresses an instance default, see BA-7 (b)).
110
123
  * @returns {Promise<GenerateResult>}
111
124
  * @throws {Error} `[OpenAIProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
112
125
  */
@@ -121,6 +134,11 @@ class OpenAIProvider {
121
134
  ...(options.temperature != null && { temperature: options.temperature }),
122
135
  ...(options.maxTokens && { [maxTokensKey]: options.maxTokens }),
123
136
  };
137
+ // BA-7 (b) / fwdloop: DeepSeek-style compat endpoints default to thinking mode and 400 on a forced
138
+ // tool_choice; the caller pins it (e.g. `{type:'disabled'}`). Set on `body` before the temperature
139
+ // fallback so the retry (which only deletes `temperature`) keeps it. Per-call `null` suppresses.
140
+ const thinking = options.thinking !== undefined ? options.thinking : this.thinking;
141
+ if (thinking) body.thinking = thinking;
124
142
  // Ask 4 (fwdloop): validate the toolChoice SHAPE unconditionally so an invalid value ALWAYS throws
125
143
  // (a silently-dropped force is the exact confusion this surfaces) — even when tools happen to be
126
144
  // empty. Attach it only when tools are present: OpenAI 400s on a tool_choice with no tools, so a