@jxburros/ai-nugget 0.6.0 → 0.7.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.
Files changed (81) hide show
  1. package/README.md +31 -2
  2. package/UPGRADING.md +24 -0
  3. package/dist/adapters/engines/anthropic.js +19 -1
  4. package/dist/adapters/engines/google.js +8 -1
  5. package/dist/adapters/engines/ollama.js +6 -0
  6. package/dist/adapters/engines/openaiChat.d.ts +6 -0
  7. package/dist/adapters/engines/openaiChat.js +51 -1
  8. package/dist/agent/loop.d.ts +3 -1
  9. package/dist/agent/loop.js +1 -0
  10. package/dist/cjs/adapters/engines/anthropic.js +18 -0
  11. package/dist/cjs/adapters/engines/google.js +7 -0
  12. package/dist/cjs/adapters/engines/ollama.js +6 -0
  13. package/dist/cjs/adapters/engines/openaiChat.js +52 -1
  14. package/dist/cjs/agent/loop.js +1 -0
  15. package/dist/cjs/handler.js +27 -2
  16. package/dist/cjs/index.js +1 -0
  17. package/dist/cjs/reasoning.js +159 -0
  18. package/dist/cjs/util.js +14 -0
  19. package/dist/handler.d.ts +6 -0
  20. package/dist/handler.js +27 -2
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.js +1 -0
  23. package/dist/reasoning.d.ts +45 -0
  24. package/dist/reasoning.js +154 -0
  25. package/dist/types.d.ts +16 -0
  26. package/dist/util.d.ts +7 -0
  27. package/dist/util.js +13 -0
  28. package/nugget/VERSION.txt +2 -2
  29. package/nugget/dist/adapters/engines/anthropic.js +19 -1
  30. package/nugget/dist/adapters/engines/anthropic.js.map +1 -1
  31. package/nugget/dist/adapters/engines/google.js +8 -1
  32. package/nugget/dist/adapters/engines/google.js.map +1 -1
  33. package/nugget/dist/adapters/engines/ollama.js +6 -0
  34. package/nugget/dist/adapters/engines/ollama.js.map +1 -1
  35. package/nugget/dist/adapters/engines/openaiChat.d.ts +6 -0
  36. package/nugget/dist/adapters/engines/openaiChat.d.ts.map +1 -1
  37. package/nugget/dist/adapters/engines/openaiChat.js +51 -1
  38. package/nugget/dist/adapters/engines/openaiChat.js.map +1 -1
  39. package/nugget/dist/agent/loop.d.ts +3 -1
  40. package/nugget/dist/agent/loop.d.ts.map +1 -1
  41. package/nugget/dist/agent/loop.js +1 -0
  42. package/nugget/dist/agent/loop.js.map +1 -1
  43. package/nugget/dist/cjs/adapters/engines/anthropic.js +18 -0
  44. package/nugget/dist/cjs/adapters/engines/google.js +7 -0
  45. package/nugget/dist/cjs/adapters/engines/ollama.js +6 -0
  46. package/nugget/dist/cjs/adapters/engines/openaiChat.js +52 -1
  47. package/nugget/dist/cjs/agent/loop.js +1 -0
  48. package/nugget/dist/cjs/handler.js +27 -2
  49. package/nugget/dist/cjs/index.js +1 -0
  50. package/nugget/dist/cjs/reasoning.js +159 -0
  51. package/nugget/dist/cjs/util.js +14 -0
  52. package/nugget/dist/handler.d.ts +6 -0
  53. package/nugget/dist/handler.d.ts.map +1 -1
  54. package/nugget/dist/handler.js +27 -2
  55. package/nugget/dist/handler.js.map +1 -1
  56. package/nugget/dist/index.d.ts +1 -0
  57. package/nugget/dist/index.d.ts.map +1 -1
  58. package/nugget/dist/index.js +1 -0
  59. package/nugget/dist/index.js.map +1 -1
  60. package/nugget/dist/reasoning.d.ts +45 -0
  61. package/nugget/dist/reasoning.d.ts.map +1 -0
  62. package/nugget/dist/reasoning.js +154 -0
  63. package/nugget/dist/reasoning.js.map +1 -0
  64. package/nugget/dist/types.d.ts +16 -0
  65. package/nugget/dist/types.d.ts.map +1 -1
  66. package/nugget/dist/types.js.map +1 -1
  67. package/nugget/dist/util.d.ts +7 -0
  68. package/nugget/dist/util.d.ts.map +1 -1
  69. package/nugget/dist/util.js +13 -0
  70. package/nugget/dist/util.js.map +1 -1
  71. package/nugget/src/adapters/engines/anthropic.ts +17 -1
  72. package/nugget/src/adapters/engines/google.ts +8 -1
  73. package/nugget/src/adapters/engines/ollama.ts +5 -0
  74. package/nugget/src/adapters/engines/openaiChat.ts +46 -1
  75. package/nugget/src/agent/loop.ts +4 -1
  76. package/nugget/src/handler.ts +29 -2
  77. package/nugget/src/index.ts +1 -0
  78. package/nugget/src/reasoning.ts +174 -0
  79. package/nugget/src/types.ts +17 -0
  80. package/nugget/src/util.ts +14 -0
  81. package/package.json +1 -1
@@ -2,7 +2,7 @@ import { AIError } from '../../errors.js';
2
2
  import { estimatedUsage } from '../../tokens.js';
3
3
  import { fetchJson, postResponse, sseLines } from '../../transport.js';
4
4
  import type { ChatMessage, ChatRequest, ChatResult, ModelInfo, ProviderAdapter, ResolvedConnection, StreamEvent, ToolCall } from '../../types.js';
5
- import { applyProviderOptions, asNumber, asRecord, asString, joinUrl, textFromMessages } from '../../util.js';
5
+ import { applyProviderOptions, REASONING_BUDGET_TOKENS, asNumber, asRecord, asString, joinUrl, textFromMessages } from '../../util.js';
6
6
  import { DEFAULT_TIMEOUT_MS, randomId, safeParse, streamAnomaly, streamError, streamTimeout } from './base.js';
7
7
 
8
8
  export class GoogleAdapter implements ProviderAdapter {
@@ -178,6 +178,13 @@ function body(req: ChatRequest): Record<string, unknown> {
178
178
  if (typeof req.toolChoice === 'object') config.allowedFunctionNames = [req.toolChoice.name];
179
179
  payload.toolConfig = { functionCallingConfig: config };
180
180
  }
181
+ if (req.reasoningEffort !== undefined) {
182
+ // First-class reasoning effort → Gemini's thinking budget. 0 disables
183
+ // thinking on models that allow it; others treat it as a minimum.
184
+ (payload.generationConfig as Record<string, unknown>).thinkingConfig = {
185
+ thinkingBudget: REASONING_BUDGET_TOKENS[req.reasoningEffort],
186
+ };
187
+ }
181
188
  // providerOptions reaches Google-native fields: top-level `safetySettings`,
182
189
  // `cachedContent`, and `generationConfig` extras (`thinkingConfig`,
183
190
  // `responseModalities`, …) merged one level deep into generationConfig.
@@ -165,6 +165,11 @@ function body(req: ChatRequest): Record<string, unknown> {
165
165
  format: req.responseFormat?.type === 'json' ? (req.responseFormat.schema ?? 'json') : undefined,
166
166
  tools: req.tools?.map((tool) => ({ type: 'function', function: { name: tool.name, description: tool.description, parameters: tool.parameters } })),
167
167
  };
168
+ // First-class reasoning effort → Ollama's `think` flag (reasoning arrives in
169
+ // `message.thinking`, which this engine already routes to `reasoning` events).
170
+ // Ollama has no graded effort for most models, so anything but `'none'` is
171
+ // `true`. Only sent when asked for: a model without thinking support rejects it.
172
+ if (req.reasoningEffort !== undefined) base.think = req.reasoningEffort !== 'none';
168
173
  // providerOptions reaches Ollama's real request fields — `options.num_ctx`,
169
174
  // `options.num_keep`, top-level `keep_alive`, `think`, etc. `options` is
170
175
  // merged one level deep so num_ctx joins the samplers above rather than
@@ -34,7 +34,32 @@ export class OpenAIChatAdapter implements ProviderAdapter {
34
34
  const timeout = streamTimeout(conn, req.signal);
35
35
  yield { type: 'start', callId: '', provider: conn.provider, model: req.model };
36
36
  try {
37
- const res = await postResponse(urlFor(conn, this.profile, req), openAiBody(req, this.profile), conn.headers, timeout.signal, conn.provider);
37
+ let res: Response;
38
+ try {
39
+ res = await postResponse(urlFor(conn, this.profile, req), openAiBody(req, this.profile), conn.headers, timeout.signal, conn.provider);
40
+ } catch (error) {
41
+ // OpenAI's `/chat/completions` refuses function tools on a reasoning model
42
+ // unless `reasoning_effort` is `'none'` ("Function tools with reasoning_effort
43
+ // are not supported … use /v1/responses or set reasoning_effort to 'none'").
44
+ // The nugget never sets `reasoning_effort` itself, so the provider default
45
+ // applies and every tool-using turn 400s. Rather than make each app learn
46
+ // this the hard way, retry the one request once with the effort disabled
47
+ // and say so on the stream. Reactive on purpose: sending
48
+ // `reasoning_effort` up front would 400 on non-reasoning models instead.
49
+ // A caller-requested effort is overridden too — the alternative is a hard
50
+ // 400 with no tool call at all — and the context event says so.
51
+ if (!shouldRetryWithoutReasoningEffort(error, req)) throw error;
52
+ yield {
53
+ type: 'context',
54
+ kind: 'reasoning_effort_disabled_for_tools',
55
+ data: {
56
+ reason: 'provider rejected function tools with reasoning_effort; retried with reasoning_effort: none',
57
+ requested: req.providerOptions?.reasoning_effort ?? req.reasoningEffort ?? null,
58
+ },
59
+ };
60
+ const retryReq: ChatRequest = { ...req, providerOptions: { ...req.providerOptions, reasoning_effort: 'none' } };
61
+ res = await postResponse(urlFor(conn, this.profile, retryReq), openAiBody(retryReq, this.profile), conn.headers, timeout.signal, conn.provider);
62
+ }
38
63
  const contentType = res.headers.get('content-type') ?? '';
39
64
  if (!contentType.includes('text/event-stream')) {
40
65
  // Server ignored stream:true (or is a buffered gateway) — recover the whole body.
@@ -161,6 +186,9 @@ function openAiBody(req: ChatRequest, profile: ProviderProfile): Record<string,
161
186
  stream_options: profile.quirks?.supportsUsageInStream ? { include_usage: true } : undefined,
162
187
  };
163
188
  if (req.maxTokens !== undefined) body[profile.quirks?.maxTokensParam ?? 'max_tokens'] = req.maxTokens;
189
+ // First-class reasoning effort → OpenAI's `reasoning_effort`. Only sent when
190
+ // asked for: non-reasoning models reject the parameter outright.
191
+ if (req.reasoningEffort !== undefined) body.reasoning_effort = req.reasoningEffort;
164
192
  // providerOptions carries OpenAI-native fields the nugget doesn't model
165
193
  // (`reasoning_effort`, `parallel_tool_calls`, `seed`, `logprobs`, …). `apiVersion`
166
194
  // is consumed by urlFor for Azure and stripped here so it never hits the body.
@@ -250,3 +278,20 @@ function mapFinish(finish: string | undefined, hasToolCalls: boolean): ChatResul
250
278
  return 'stop';
251
279
  }
252
280
 
281
+
282
+ /**
283
+ * True when a `/chat/completions` 400 is the "function tools with reasoning_effort
284
+ * are not supported" refusal AND the request actually carried tools AND the caller
285
+ * did not already pin `reasoning_effort` (in which case the choice is theirs).
286
+ */
287
+ export function shouldRetryWithoutReasoningEffort(error: unknown, req: ChatRequest): boolean {
288
+ if (!(error instanceof AIError) || error.status !== 400) return false;
289
+ if (!req.tools || req.tools.length === 0) return false;
290
+ // Already `'none'` (from either knob) — the refusal is about something else.
291
+ const effective = req.providerOptions && 'reasoning_effort' in req.providerOptions
292
+ ? req.providerOptions.reasoning_effort
293
+ : req.reasoningEffort;
294
+ if (effective === 'none') return false;
295
+ const text = `${error.raw ?? ''} ${error.message}`.toLowerCase();
296
+ return text.includes('reasoning_effort') && text.includes('tool');
297
+ }
@@ -3,7 +3,7 @@ import { AIError } from '../errors.js';
3
3
  import { extractJson } from '../json.js';
4
4
  import { mergeUsage } from '../tokens.js';
5
5
  import type { AIHandler } from '../handler.js';
6
- import type { AIErrorKind, ChatMessage, Connection, StreamEvent, ToolCall, Usage } from '../types.js';
6
+ import type { AIErrorKind, ChatMessage, Connection, ReasoningEffort, StreamEvent, ToolCall, Usage } from '../types.js';
7
7
  import { asRecord, sleep } from '../util.js';
8
8
  import type { ToolSpec } from './tools.js';
9
9
  import { validateToolArgs } from './tools.js';
@@ -37,6 +37,8 @@ export interface AgentOptions {
37
37
  maxTokens?: number;
38
38
  topP?: number;
39
39
  stopSequences?: string[];
40
+ /** Reasoning effort forwarded to every turn — see {@link ChatRequest.reasoningEffort}. */
41
+ reasoningEffort?: ReasoningEffort;
40
42
  /** Provider-native passthrough forwarded to every turn — see {@link ChatRequest.providerOptions}. */
41
43
  providerOptions?: Record<string, unknown>;
42
44
  budget?: {
@@ -139,6 +141,7 @@ async function* run(opts: AgentOptions, resolveResult: (result: AgentResult) =>
139
141
  maxTokens: opts.maxTokens,
140
142
  topP: opts.topP,
141
143
  stopSequences: opts.stopSequences,
144
+ reasoningEffort: opts.reasoningEffort,
142
145
  providerOptions: opts.providerOptions,
143
146
  signal: agentSignal.signal,
144
147
  metadata: { ...opts.metadata, agentStep: step },
@@ -2,6 +2,7 @@ import { adapterFor } from './adapters/index.js';
2
2
  import { applyAuth, profileFor } from './adapters/profiles.js';
3
3
  import { AIError, fromUnknown } from './errors.js';
4
4
  import { extractJson } from './json.js';
5
+ import { containsReasoningBlock, createReasoningStripper, stripReasoningBlocks } from './reasoning.js';
5
6
  import { allowAllPolicy } from './policy.js';
6
7
  import { SessionRedactor } from './redact.js';
7
8
  import type {
@@ -56,6 +57,12 @@ export interface HandlerOptions {
56
57
  * explicit `policy: allowAllPolicy()` says the same thing and is clearer.
57
58
  */
58
59
  silencePolicyWarning?: boolean;
60
+ /**
61
+ * Strip inline `<think>…</think>`-style reasoning out of `delta` events and `ChatResult.text`,
62
+ * emitting it as `{ type: 'reasoning' }` events instead (default `true`; see `src/reasoning.ts`).
63
+ * Set `false` to receive the raw answer text exactly as the provider sent it.
64
+ */
65
+ stripInlineReasoning?: boolean;
59
66
  /**
60
67
  * Optional cost estimator. Called once per successful call/embedding with the
61
68
  * final usage; its return value (USD) is stored on the telemetry record's
@@ -130,8 +137,11 @@ export class AIHandler {
130
137
  const maxAttempts = this.opts.retry?.maxAttempts ?? 3;
131
138
  let emittedStart = false;
132
139
  let emittedOutput = false;
140
+ const stripInline = this.opts.stripInlineReasoning !== false;
133
141
  for (;;) {
134
142
  attempt += 1;
143
+ // One stripper per attempt: a retried stream starts its tag state over.
144
+ const stripper = stripInline ? createReasoningStripper() : null;
135
145
  try {
136
146
  for await (const event of adapter.stream(resolved, req)) {
137
147
  if (event.type === 'start') {
@@ -142,10 +152,27 @@ export class AIHandler {
142
152
  continue;
143
153
  }
144
154
  if (event.type === 'delta' || event.type === 'tool_call') emittedOutput = true;
155
+ if (event.type === 'delta' && stripper) {
156
+ // Inline reasoning never reaches `delta`; it rides the reasoning channel like a
157
+ // provider-native thinking field would.
158
+ const piece = stripper.push(event.text);
159
+ if (piece.reasoning) yield { type: 'reasoning', text: piece.reasoning };
160
+ if (piece.visible) yield { type: 'delta', text: piece.visible };
161
+ continue;
162
+ }
145
163
  if (event.type === 'done') {
146
- await this.recordSuccess(callId, conn, req, startedAt, attempt, event.result);
164
+ let result = event.result;
165
+ if (stripper) {
166
+ const tail = stripper.end();
167
+ if (tail.reasoning) yield { type: 'reasoning', text: tail.reasoning };
168
+ if (tail.visible) yield { type: 'delta', text: tail.visible };
169
+ if (containsReasoningBlock(result.text)) {
170
+ result = { ...result, text: stripReasoningBlocks(result.text) };
171
+ }
172
+ }
173
+ await this.recordSuccess(callId, conn, req, startedAt, attempt, result);
147
174
  recorded = true;
148
- yield event;
175
+ yield { ...event, result };
149
176
  return;
150
177
  }
151
178
  yield event;
@@ -2,6 +2,7 @@ export * from './types.js';
2
2
  export * from './errors.js';
3
3
  export * from './transport.js';
4
4
  export * from './json.js';
5
+ export * from './reasoning.js';
5
6
  export * from './tokens.js';
6
7
  export * from './keys.js';
7
8
  export * from './connect.js';
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Inline reasoning stripper — keeps a reasoning model's chain of thought out of `delta` events
3
+ * and `ChatResult.text`, routing it to the `reasoning` stream event instead.
4
+ *
5
+ * Reasoning models (deepseek-r1, phi4-reasoning, qwq, …) emit their thinking either in the
6
+ * provider's dedicated field — which every engine already maps to `{ type: 'reasoning' }` — or
7
+ * INLINE in the answer as `<think>…</think>`-style blocks. Inline reasoning routinely quotes the
8
+ * system prompt and tool instructions back verbatim, so an app that streams `delta` straight to
9
+ * a screen, a database or a JSON parser leaks it. Every consumer would otherwise write this
10
+ * state machine, and most would get the split-tag and template-opened cases wrong; the handler
11
+ * runs it once, on every engine, unless `HandlerOptions.stripInlineReasoning` is `false`.
12
+ *
13
+ * Ported from AI Server Studio's `services/reasoningFilter.ts` (2026-09-05), which had to exist
14
+ * because the nugget did not do this.
15
+ *
16
+ * Two subtleties the tests pin:
17
+ * - Tags may be split across chunks, so a trailing partial `<thi` is held back until the next
18
+ * chunk decides whether it opens a tag. An unterminated block is reasoning to the end.
19
+ * - The **orphan close**: a closing tag with no opening one at the head of the stream, because
20
+ * the runtime's chat template emitted `<think>` itself. Handled once per stream, only before
21
+ * any visible text has gone out, and only when the closing tag starts its own line — a
22
+ * `</think>` quoted mid-sentence in a real answer is left alone.
23
+ */
24
+
25
+ const TAG_PAIRS: { open: string; close: string }[] = [
26
+ { open: '<think>', close: '</think>' },
27
+ { open: '<thinking>', close: '</thinking>' },
28
+ { open: '<reasoning>', close: '</reasoning>' },
29
+ { open: '<|begin_of_thought|>', close: '<|end_of_thought|>' },
30
+ ];
31
+
32
+ const LONGEST_TAG = Math.max(...TAG_PAIRS.flatMap((p) => [p.open.length, p.close.length]));
33
+
34
+ export interface ReasoningStripResult {
35
+ /** Text safe to show from this chunk (may be empty). */
36
+ visible: string;
37
+ /** Reasoning text removed from this chunk (may be empty). */
38
+ reasoning: string;
39
+ }
40
+
41
+ export interface ReasoningStripper {
42
+ /** Feed the next raw delta; returns what may be shown and what was reasoning. */
43
+ push(chunk: string): ReasoningStripResult;
44
+ /** End of stream: flushes any held-back prefix that never became a tag. */
45
+ end(): ReasoningStripResult;
46
+ /** True while inside an unterminated reasoning block. */
47
+ readonly inReasoning: boolean;
48
+ }
49
+
50
+ function partialTagSuffixLength(text: string): number {
51
+ const lower = text.toLowerCase();
52
+ const maxLen = Math.min(LONGEST_TAG - 1, lower.length);
53
+ for (let len = maxLen; len >= 1; len -= 1) {
54
+ const tail = lower.slice(lower.length - len);
55
+ if (tail[0] !== '<') continue;
56
+ for (const pair of TAG_PAIRS) {
57
+ if (pair.open.startsWith(tail) || pair.close.startsWith(tail)) return len;
58
+ }
59
+ }
60
+ return 0;
61
+ }
62
+
63
+ function closesOwnLine(text: string, idx: number): boolean {
64
+ let i = idx - 1;
65
+ while (i >= 0 && (text[i] === ' ' || text[i] === '\t' || text[i] === '\r')) i -= 1;
66
+ return i < 0 || text[i] === '\n';
67
+ }
68
+
69
+ export function createReasoningStripper(): ReasoningStripper {
70
+ let pending = '';
71
+ let inReasoning = false;
72
+ let closeTag = '';
73
+ let emittedVisible = false;
74
+ let orphanConsumed = false;
75
+
76
+ function process(input: string, flushAll: boolean): ReasoningStripResult {
77
+ let text = pending + input;
78
+ pending = '';
79
+ let visible = '';
80
+ let reasoning = '';
81
+
82
+ while (text.length > 0) {
83
+ const lower = text.toLowerCase();
84
+ if (inReasoning) {
85
+ const idx = lower.indexOf(closeTag);
86
+ if (idx === -1) {
87
+ const hold = flushAll ? 0 : partialTagSuffixLength(text);
88
+ const consumed = text.length - hold;
89
+ reasoning += text.slice(0, consumed);
90
+ pending = text.slice(consumed);
91
+ text = '';
92
+ if (flushAll) {
93
+ reasoning += pending;
94
+ pending = '';
95
+ }
96
+ break;
97
+ }
98
+ reasoning += text.slice(0, idx);
99
+ text = text.slice(idx + closeTag.length);
100
+ inReasoning = false;
101
+ closeTag = '';
102
+ if (text.startsWith('\n')) text = text.slice(1);
103
+ continue;
104
+ }
105
+ let openIdx = -1;
106
+ let pair: { open: string; close: string } | null = null;
107
+ for (const p of TAG_PAIRS) {
108
+ const i = lower.indexOf(p.open);
109
+ if (i !== -1 && (openIdx === -1 || i < openIdx)) {
110
+ openIdx = i;
111
+ pair = p;
112
+ }
113
+ }
114
+ let orphanCloseIdx = -1;
115
+ let orphanCloseLen = 0;
116
+ if (!emittedVisible && !orphanConsumed) {
117
+ for (const p of TAG_PAIRS) {
118
+ const i = lower.indexOf(p.close);
119
+ if (i !== -1 && (orphanCloseIdx === -1 || i < orphanCloseIdx) && closesOwnLine(text, i)) {
120
+ orphanCloseIdx = i;
121
+ orphanCloseLen = p.close.length;
122
+ }
123
+ }
124
+ }
125
+ if (orphanCloseIdx !== -1 && (openIdx === -1 || orphanCloseIdx < openIdx)) {
126
+ orphanConsumed = true;
127
+ reasoning += text.slice(0, orphanCloseIdx);
128
+ text = text.slice(orphanCloseIdx + orphanCloseLen).replace(/^\n+/, '');
129
+ continue;
130
+ }
131
+ if (openIdx === -1 || !pair) {
132
+ const hold = flushAll ? 0 : partialTagSuffixLength(text);
133
+ visible += text.slice(0, text.length - hold);
134
+ pending = text.slice(text.length - hold);
135
+ text = '';
136
+ break;
137
+ }
138
+ visible += text.slice(0, openIdx);
139
+ text = text.slice(openIdx + pair.open.length);
140
+ inReasoning = true;
141
+ closeTag = pair.close;
142
+ }
143
+ if (visible.trim().length > 0) emittedVisible = true;
144
+ return { visible, reasoning };
145
+ }
146
+
147
+ return {
148
+ push: (chunk) => process(chunk, false),
149
+ end: () => process('', true),
150
+ get inReasoning() {
151
+ return inReasoning;
152
+ },
153
+ };
154
+ }
155
+
156
+ /** Whole-string form: `{ visible, reasoning }` for already-buffered text. */
157
+ export function stripReasoningBlocksDetailed(text: string): ReasoningStripResult {
158
+ if (!text) return { visible: '', reasoning: '' };
159
+ const stripper = createReasoningStripper();
160
+ const a = stripper.push(text);
161
+ const b = stripper.end();
162
+ return { visible: (a.visible + b.visible).trim(), reasoning: a.reasoning + b.reasoning };
163
+ }
164
+
165
+ /** Whole-string form returning only the visible text. */
166
+ export function stripReasoningBlocks(text: string): string {
167
+ return stripReasoningBlocksDetailed(text).visible;
168
+ }
169
+
170
+ /** Cheap pre-check: does `text` carry any inline reasoning tag (open or close)? */
171
+ export function containsReasoningBlock(text: string): boolean {
172
+ const lower = text.toLowerCase();
173
+ return TAG_PAIRS.some((p) => lower.includes(p.open) || lower.includes(p.close));
174
+ }
@@ -33,12 +33,29 @@ export interface ToolSchema {
33
33
  parameters: object;
34
34
  }
35
35
 
36
+ /**
37
+ * How hard a reasoning-capable model should think before answering. The nugget
38
+ * maps one vocabulary onto each provider's own knob (see `docs/providers.md`,
39
+ * "Reasoning effort"): OpenAI `reasoning_effort`, Anthropic `thinking`
40
+ * (`budget_tokens` tiers), Google `generationConfig.thinkingConfig.thinkingBudget`,
41
+ * Ollama `think`. `'none'` disables reasoning where the provider allows it.
42
+ * Unset means the provider's own default — the nugget never sends the knob
43
+ * unasked, because non-reasoning models reject it.
44
+ */
45
+ export type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high';
46
+
36
47
  export interface ChatRequest {
37
48
  model: string;
38
49
  messages: ChatMessage[];
39
50
  temperature?: number;
40
51
  maxTokens?: number;
41
52
  topP?: number;
53
+ /**
54
+ * Reasoning effort for thinking-capable models — see {@link ReasoningEffort}.
55
+ * A provider-native value in `providerOptions` (e.g. `reasoning_effort`,
56
+ * `thinking`, `thinkingConfig`, `think`) wins over this on collision.
57
+ */
58
+ reasoningEffort?: ReasoningEffort;
42
59
  responseFormat?: { type: 'text' } | { type: 'json'; schema?: object };
43
60
  tools?: ToolSchema[];
44
61
  toolChoice?: 'auto' | 'none' | { name: string };
@@ -104,3 +104,17 @@ export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
104
104
  }
105
105
  });
106
106
  }
107
+
108
+ /**
109
+ * Token budgets behind the {@link ReasoningEffort} tiers for providers that
110
+ * express effort as a thinking-token budget (Anthropic `budget_tokens`, Google
111
+ * `thinkingBudget`). `'none'` is 0. Deliberately conservative: they are floors
112
+ * a model may stop under, not ceilings it fills.
113
+ */
114
+ export const REASONING_BUDGET_TOKENS: Record<'none' | 'minimal' | 'low' | 'medium' | 'high', number> = {
115
+ none: 0,
116
+ minimal: 1024,
117
+ low: 2048,
118
+ medium: 8192,
119
+ high: 16384,
120
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxburros/ai-nugget",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Small isomorphic AI model nugget for provider calls, policy, redaction, telemetry, and agent loops.",