@jxburros/ai-nugget 0.6.0 → 0.7.3

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 (82) hide show
  1. package/README.md +92 -3
  2. package/UPGRADING.md +43 -1
  3. package/dist/adapters/engines/anthropic.js +98 -10
  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 +4 -1
  10. package/dist/cjs/adapters/engines/anthropic.js +96 -8
  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 +4 -1
  15. package/dist/cjs/handler.js +28 -3
  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 +28 -3
  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 +25 -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.d.ts.map +1 -1
  30. package/nugget/dist/adapters/engines/anthropic.js +98 -10
  31. package/nugget/dist/adapters/engines/anthropic.js.map +1 -1
  32. package/nugget/dist/adapters/engines/google.js +8 -1
  33. package/nugget/dist/adapters/engines/google.js.map +1 -1
  34. package/nugget/dist/adapters/engines/ollama.js +6 -0
  35. package/nugget/dist/adapters/engines/ollama.js.map +1 -1
  36. package/nugget/dist/adapters/engines/openaiChat.d.ts +6 -0
  37. package/nugget/dist/adapters/engines/openaiChat.d.ts.map +1 -1
  38. package/nugget/dist/adapters/engines/openaiChat.js +51 -1
  39. package/nugget/dist/adapters/engines/openaiChat.js.map +1 -1
  40. package/nugget/dist/agent/loop.d.ts +3 -1
  41. package/nugget/dist/agent/loop.d.ts.map +1 -1
  42. package/nugget/dist/agent/loop.js +4 -1
  43. package/nugget/dist/agent/loop.js.map +1 -1
  44. package/nugget/dist/cjs/adapters/engines/anthropic.js +96 -8
  45. package/nugget/dist/cjs/adapters/engines/google.js +7 -0
  46. package/nugget/dist/cjs/adapters/engines/ollama.js +6 -0
  47. package/nugget/dist/cjs/adapters/engines/openaiChat.js +52 -1
  48. package/nugget/dist/cjs/agent/loop.js +4 -1
  49. package/nugget/dist/cjs/handler.js +28 -3
  50. package/nugget/dist/cjs/index.js +1 -0
  51. package/nugget/dist/cjs/reasoning.js +159 -0
  52. package/nugget/dist/cjs/util.js +14 -0
  53. package/nugget/dist/handler.d.ts +6 -0
  54. package/nugget/dist/handler.d.ts.map +1 -1
  55. package/nugget/dist/handler.js +28 -3
  56. package/nugget/dist/handler.js.map +1 -1
  57. package/nugget/dist/index.d.ts +1 -0
  58. package/nugget/dist/index.d.ts.map +1 -1
  59. package/nugget/dist/index.js +1 -0
  60. package/nugget/dist/index.js.map +1 -1
  61. package/nugget/dist/reasoning.d.ts +45 -0
  62. package/nugget/dist/reasoning.d.ts.map +1 -0
  63. package/nugget/dist/reasoning.js +154 -0
  64. package/nugget/dist/reasoning.js.map +1 -0
  65. package/nugget/dist/types.d.ts +25 -0
  66. package/nugget/dist/types.d.ts.map +1 -1
  67. package/nugget/dist/types.js.map +1 -1
  68. package/nugget/dist/util.d.ts +7 -0
  69. package/nugget/dist/util.d.ts.map +1 -1
  70. package/nugget/dist/util.js +13 -0
  71. package/nugget/dist/util.js.map +1 -1
  72. package/nugget/src/adapters/engines/anthropic.ts +87 -10
  73. package/nugget/src/adapters/engines/google.ts +8 -1
  74. package/nugget/src/adapters/engines/ollama.ts +5 -0
  75. package/nugget/src/adapters/engines/openaiChat.ts +46 -1
  76. package/nugget/src/agent/loop.ts +7 -2
  77. package/nugget/src/handler.ts +30 -3
  78. package/nugget/src/index.ts +1 -0
  79. package/nugget/src/reasoning.ts +174 -0
  80. package/nugget/src/types.ts +27 -0
  81. package/nugget/src/util.ts +14 -0
  82. package/package.json +12 -5
@@ -1,8 +1,8 @@
1
- import { AIError } from '../../errors.js';
1
+ import { AIError, classify } 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, parseArgs, randomId, safeParse, streamAnomaly, streamError, streamTimeout } from './base.js';
7
7
 
8
8
  const JSON_MODE_TOOL = 'json_output';
@@ -25,7 +25,8 @@ export class AnthropicAdapter implements ProviderAdapter {
25
25
 
26
26
  async *stream(conn: ResolvedConnection, req: ChatRequest): AsyncIterable<StreamEvent> {
27
27
  const started = Date.now();
28
- const jsonMode = req.responseFormat?.type === 'json' && !req.tools?.length;
28
+ const nativeJson = modernModel(req.model);
29
+ const jsonMode = req.responseFormat?.type === 'json' && !nativeJson && !req.tools?.length;
29
30
  let firstTokenMs: number | null = null;
30
31
  let text = '';
31
32
  let inputTokens: number | undefined;
@@ -33,6 +34,7 @@ export class AnthropicAdapter implements ProviderAdapter {
33
34
  let stopReason: string | undefined;
34
35
  let sawTerminal = false;
35
36
  const emittedTools: ToolCall[] = [];
37
+ const responseBlocks = new Map<number, Record<string, unknown>>();
36
38
  // Partial tool_use blocks keyed by content-block index (input_json_delta accumulation).
37
39
  const blocks = new Map<number, { id: string; name: string; raw: string }>();
38
40
  const timeout = streamTimeout(conn, req.signal);
@@ -40,19 +42,27 @@ export class AnthropicAdapter implements ProviderAdapter {
40
42
  // Anthropic JSON mode is implemented as a forced `json_output` tool, which
41
43
  // cannot coexist with the caller's own tools — same silent-downgrade shape
42
44
  // as Gemini, so it gets the same signal rather than quietly dropping.
43
- if (req.responseFormat?.type === 'json' && req.tools?.length) {
45
+ if (req.responseFormat?.type === 'json' && !nativeJson && req.tools?.length) {
44
46
  yield {
45
47
  type: 'context',
46
48
  kind: 'json_mode_downgraded',
47
49
  data: { reason: 'Anthropic JSON mode uses a forced tool and cannot be combined with caller tools; the request was sent without JSON mode', provider: conn.provider },
48
50
  };
49
51
  }
52
+ if (req.responseFormat?.type === 'json' && nativeJson && !req.responseFormat.schema) {
53
+ yield { type: 'context', kind: 'json_mode_downgraded', data: { provider: conn.provider, reason: 'Claude native JSON outputs require a schema; using a JSON instruction without constrained decoding.' } };
54
+ }
50
55
  try {
51
56
  const res = await postResponse(`${conn.baseUrl}/v1/messages`, body(req, jsonMode), conn.headers, timeout.signal, conn.provider);
52
57
  const contentType = res.headers.get('content-type') ?? '';
53
58
  if (!contentType.includes('text/event-stream')) {
54
59
  const data = await res.json() as unknown;
55
60
  const parsed = parseResponse(data);
61
+ const content = asRecord(data)?.content;
62
+ if (Array.isArray(content)) content.forEach((block, i) => {
63
+ const record = asRecord(block);
64
+ if (record) responseBlocks.set(i, record);
65
+ });
56
66
  inputTokens = parsed.inputTokens;
57
67
  outputTokens = parsed.outputTokens;
58
68
  stopReason = parsed.stopReason;
@@ -74,19 +84,30 @@ export class AnthropicAdapter implements ProviderAdapter {
74
84
  const record = asRecord(safeParse(line));
75
85
  if (!record) continue;
76
86
  const type = asString(record.type);
77
- if (type === 'message_start') {
87
+ if (type === 'error') {
88
+ const error = asRecord(record.error);
89
+ const statuses: Record<string, number> = { overloaded_error: 529, rate_limit_error: 429, authentication_error: 401, permission_error: 403, invalid_request_error: 400, not_found_error: 404, api_error: 500, billing_error: 402, timeout_error: 504, request_too_large: 413 };
90
+ throw classify(statuses[asString(error?.type) ?? ''] ?? 500, JSON.stringify(record), conn.provider);
91
+ } else if (type === 'message_start') {
78
92
  const usage = asRecord(asRecord(record.message)?.usage);
79
93
  inputTokens = asNumber(usage?.input_tokens) ?? inputTokens;
80
94
  outputTokens = asNumber(usage?.output_tokens) ?? outputTokens;
81
95
  } else if (type === 'content_block_start') {
82
96
  const index = asNumber(record.index) ?? 0;
83
97
  const block = asRecord(record.content_block);
98
+ if (block) responseBlocks.set(index, { ...block });
84
99
  if (block?.type === 'tool_use') {
85
100
  blocks.set(index, { id: asString(block.id) ?? randomId(), name: asString(block.name) ?? 'unknown', raw: '' });
86
101
  }
87
102
  } else if (type === 'content_block_delta') {
88
103
  const index = asNumber(record.index) ?? 0;
89
104
  const delta = asRecord(record.delta);
105
+ const block = responseBlocks.get(index);
106
+ if (block && delta) {
107
+ const field = delta.type === 'text_delta' ? 'text' : delta.type === 'thinking_delta' ? 'thinking' : delta.type === 'signature_delta' ? 'signature' : undefined;
108
+ if (field) block[field] = String(block[field] ?? '') + String(delta[field] ?? '');
109
+ if (delta.type === 'citations_delta') block.citations = [...(Array.isArray(block.citations) ? block.citations : []), delta.citation];
110
+ }
90
111
  if (delta?.type === 'text_delta') {
91
112
  const piece = asString(delta.text) ?? '';
92
113
  if (piece) {
@@ -107,6 +128,8 @@ export class AnthropicAdapter implements ProviderAdapter {
107
128
  const partial = blocks.get(index);
108
129
  if (partial) {
109
130
  const call: ToolCall = { id: partial.id, name: partial.name, raw: partial.raw, arguments: parseArgs(partial.raw) };
131
+ const block = responseBlocks.get(index);
132
+ if (block) block.input = call.arguments;
110
133
  blocks.delete(index);
111
134
  if (jsonMode && call.name === JSON_MODE_TOOL) {
112
135
  text = jsonTextFrom([call], text);
@@ -132,6 +155,7 @@ export class AnthropicAdapter implements ProviderAdapter {
132
155
  const hasTools = emittedTools.length > 0;
133
156
  yield { type: 'done', result: {
134
157
  text,
158
+ providerState: { provider: conn.provider, model: req.model, content: [...responseBlocks.entries()].sort(([a], [b]) => a - b).map(([, block]) => block) },
135
159
  toolCalls: hasTools ? emittedTools : undefined,
136
160
  finishReason: mapStop(stopReason, hasTools),
137
161
  usage: inputTokens !== undefined || outputTokens !== undefined ? { inputTokens, outputTokens, estimated: false } : estimatedUsage(textFromMessages(req.messages), text),
@@ -175,9 +199,15 @@ function body(req: ChatRequest, jsonMode: boolean): Record<string, unknown> {
175
199
  top_p: req.topP,
176
200
  stop_sequences: req.stopSequences,
177
201
  system,
178
- messages: toAnthropicMessages(req.messages.filter((m) => m.role !== 'system')),
202
+ messages: toAnthropicMessages(req.messages.filter((m) => m.role !== 'system'), req.model),
179
203
  stream: true,
180
204
  };
205
+ if (req.responseFormat?.type === 'json' && modernModel(req.model) && req.responseFormat.schema) {
206
+ base.output_config = { format: { type: 'json_schema', schema: schemaFor(req) } };
207
+ }
208
+ if (req.responseFormat?.type === 'json' && modernModel(req.model) && !req.responseFormat.schema) {
209
+ base.system = [system, 'Return only valid JSON, without markdown fences or a preamble.'].filter(Boolean).join('\n\n');
210
+ }
181
211
  if (jsonMode) {
182
212
  // Forced-tool JSON mode: Anthropic has no response_format, so we make the
183
213
  // model answer through a single required tool whose input is the JSON we want.
@@ -189,17 +219,48 @@ function body(req: ChatRequest, jsonMode: boolean): Record<string, unknown> {
189
219
  else if (req.toolChoice === 'auto') base.tool_choice = { type: 'auto' };
190
220
  else if (req.toolChoice === 'none') base.tool_choice = { type: 'none' };
191
221
  }
222
+ if (req.reasoningEffort !== undefined) {
223
+ // First-class reasoning effort → extended thinking. Anthropic requires
224
+ // `budget_tokens < max_tokens` and rejects temperature/top_p alongside
225
+ // thinking, so the budget is fitted under max_tokens (raising max_tokens
226
+ // when the caller's value leaves no room) and the samplers are dropped.
227
+ if (modernModel(req.model)) {
228
+ const none = req.reasoningEffort === 'none';
229
+ if (none && alwaysThinking(req.model)) throw new AIError('This Claude model cannot disable thinking; choose a reasoning effort instead.', { kind: 'invalid_request', provider: 'anthropic' });
230
+ base.thinking = { type: none ? (/^claude-sonnet-5-5(?:-|$)/.test(req.model) ? 'between_tools' : 'disabled') : 'adaptive' };
231
+ base.output_config = { ...asRecord(base.output_config), effort: none ? 'low' : req.reasoningEffort === 'minimal' ? 'low' : req.reasoningEffort };
232
+ } else if (req.reasoningEffort === 'none') {
233
+ base.thinking = { type: 'disabled' };
234
+ } else {
235
+ const budget = REASONING_BUDGET_TOKENS[req.reasoningEffort];
236
+ const maxTokens = typeof base.max_tokens === 'number' ? base.max_tokens : 4096;
237
+ if (maxTokens <= budget) base.max_tokens = budget + 1024;
238
+ base.thinking = { type: 'enabled', budget_tokens: budget };
239
+ delete base.temperature;
240
+ delete base.top_p;
241
+ }
242
+ }
192
243
  // providerOptions carries Anthropic-native fields (`thinking`, `metadata`,
193
244
  // top-level `cache_control` extras, `service_tier`, …) without a release.
194
- return applyProviderOptions(base, req.providerOptions);
245
+ const result = applyProviderOptions(base, req.providerOptions, ['output_config']);
246
+ if (modernModel(req.model)) {
247
+ delete result.temperature;
248
+ delete result.top_p;
249
+ delete result.top_k;
250
+ }
251
+ const choice = asRecord(result.tool_choice);
252
+ if (noForcedTools(req.model) && (choice?.type === 'tool' || choice?.type === 'any')) {
253
+ throw new AIError('This Claude model does not support forced tool choice; use auto or structured outputs.', { kind: 'invalid_request', provider: 'anthropic' });
254
+ }
255
+ return result;
195
256
  }
196
257
 
197
- function toAnthropicMessages(messages: ChatMessage[]): Record<string, unknown>[] {
258
+ function toAnthropicMessages(messages: ChatMessage[], model: string): Record<string, unknown>[] {
198
259
  const out: Record<string, unknown>[] = [];
199
260
  for (let index = 0; index < messages.length; index += 1) {
200
261
  const message = messages[index]!;
201
262
  if (message.role !== 'tool') {
202
- out.push(toAnthropicMessage(message));
263
+ out.push(toAnthropicMessage(message, model));
203
264
  continue;
204
265
  }
205
266
  const content: unknown[] = [];
@@ -214,7 +275,10 @@ function toAnthropicMessages(messages: ChatMessage[]): Record<string, unknown>[]
214
275
  return out;
215
276
  }
216
277
 
217
- function toAnthropicMessage(m: ChatMessage): Record<string, unknown> {
278
+ function toAnthropicMessage(m: ChatMessage, model: string): Record<string, unknown> {
279
+ if (m.role === 'assistant' && m.providerState?.provider === 'anthropic' && m.providerState.model === model) {
280
+ return { role: 'assistant', content: m.providerState.content };
281
+ }
218
282
  // Tool-role messages are handled by toAnthropicMessages' batching loop and
219
283
  // never reach here, so no tool branch is needed.
220
284
 
@@ -279,7 +343,20 @@ function jsonTextFrom(toolCalls: ToolCall[], fallback: string): string {
279
343
 
280
344
  function mapStop(stop: string | undefined, hasTools: boolean): ChatResult['finishReason'] {
281
345
  if (stop === 'tool_use' && hasTools) return 'tool';
346
+ if (stop === 'refusal') return 'content_filter';
282
347
  if (stop === 'max_tokens') return 'length';
283
348
  return 'stop';
284
349
  }
285
350
 
351
+
352
+ /** Models released after Opus 4.6 use adaptive effort and reject sampling knobs.
353
+ * Unknown/custom IDs retain legacy behavior rather than guessing capabilities. */
354
+ function modernModel(model: string): boolean {
355
+ return /^claude-(?:opus-(?:4-[78]|5(?:-5)?)|sonnet-5(?:-5)?|haiku-5-5|(?:fable|mythos)-5(?:-1)?|mythos-preview)(?:-\d{8}|-latest)?$/.test(model);
356
+ }
357
+ function alwaysThinking(model: string): boolean {
358
+ return /^claude-(?:fable-|mythos-|opus-5-5(?:-|$))/.test(model);
359
+ }
360
+ function noForcedTools(model: string): boolean {
361
+ return /^claude-(?:(?:opus|sonnet)-5-5(?:-|$)|(?:fable|mythos)-5-1(?:-|$))/.test(model);
362
+ }
@@ -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, ProviderState, 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?: {
@@ -125,6 +127,7 @@ async function* run(opts: AgentOptions, resolveResult: (result: AgentResult) =>
125
127
 
126
128
  const calls: ToolCall[] = [];
127
129
  let stepText = '';
130
+ let providerState: ProviderState | undefined;
128
131
  let streamFailure: AIError | undefined;
129
132
  // In promptJson mode a tool directive must not leak into the visible
130
133
  // stream, so directive-looking text is withheld from `delta` events (still
@@ -139,6 +142,7 @@ async function* run(opts: AgentOptions, resolveResult: (result: AgentResult) =>
139
142
  maxTokens: opts.maxTokens,
140
143
  topP: opts.topP,
141
144
  stopSequences: opts.stopSequences,
145
+ reasoningEffort: opts.reasoningEffort,
142
146
  providerOptions: opts.providerOptions,
143
147
  signal: agentSignal.signal,
144
148
  metadata: { ...opts.metadata, agentStep: step },
@@ -154,6 +158,7 @@ async function* run(opts: AgentOptions, resolveResult: (result: AgentResult) =>
154
158
  if (event.type === 'tool_call') calls.push(event.call);
155
159
  if (event.type === 'error') streamFailure = event.error;
156
160
  if (event.type === 'done') {
161
+ providerState = event.result.providerState;
157
162
  usage = usage ? mergeUsage(usage, event.result.usage) : event.result.usage;
158
163
  if (toolMode === 'promptJson' && calls.length === 0) calls.push(...callsFromPromptJson(stepText));
159
164
  }
@@ -173,7 +178,7 @@ async function* run(opts: AgentOptions, resolveResult: (result: AgentResult) =>
173
178
  finalText = stepText;
174
179
  messages.push(toolMode === 'promptJson'
175
180
  ? { role: 'assistant', content: stepText }
176
- : { role: 'assistant', content: stepText, toolCalls: calls.length ? calls : undefined });
181
+ : { role: 'assistant', content: stepText, providerState, toolCalls: calls.length ? calls : undefined });
177
182
  if (calls.length === 0) return yield* yieldDone('finished');
178
183
 
179
184
  for (const call of calls) {
@@ -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') {
@@ -141,11 +151,28 @@ export class AIHandler {
141
151
  }
142
152
  continue;
143
153
  }
144
- if (event.type === 'delta' || event.type === 'tool_call') emittedOutput = true;
154
+ if (event.type === 'delta' || event.type === 'tool_call' || event.type === 'reasoning') 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
+ }
@@ -11,9 +11,18 @@ export interface ContentPart {
11
11
  mimeType?: string;
12
12
  }
13
13
 
14
+ /** Opaque provider response content, for replay within the same model/tool workflow.
15
+ * Keep it unmodified; never include it in telemetry or display it as answer text. */
16
+ export interface ProviderState {
17
+ provider: string;
18
+ model: string;
19
+ content: Record<string, unknown>[];
20
+ }
21
+
14
22
  export interface ChatMessage {
15
23
  role: Role;
16
24
  content: string | ContentPart[];
25
+ providerState?: ProviderState;
17
26
  toolCalls?: ToolCall[]; // role: 'assistant'
18
27
  toolCallId?: string; // role: 'tool' — answers a specific call
19
28
  name?: string; // role: 'tool' — the tool's name (Gemini keys
@@ -33,12 +42,29 @@ export interface ToolSchema {
33
42
  parameters: object;
34
43
  }
35
44
 
45
+ /**
46
+ * How hard a reasoning-capable model should think before answering. The nugget
47
+ * maps one vocabulary onto each provider's own knob (see `docs/providers.md`,
48
+ * "Reasoning effort"): OpenAI `reasoning_effort`, Anthropic `thinking`
49
+ * (`budget_tokens` tiers), Google `generationConfig.thinkingConfig.thinkingBudget`,
50
+ * Ollama `think`. `'none'` disables reasoning where the provider allows it.
51
+ * Unset means the provider's own default — the nugget never sends the knob
52
+ * unasked, because non-reasoning models reject it.
53
+ */
54
+ export type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high';
55
+
36
56
  export interface ChatRequest {
37
57
  model: string;
38
58
  messages: ChatMessage[];
39
59
  temperature?: number;
40
60
  maxTokens?: number;
41
61
  topP?: number;
62
+ /**
63
+ * Reasoning effort for thinking-capable models — see {@link ReasoningEffort}.
64
+ * A provider-native value in `providerOptions` (e.g. `reasoning_effort`,
65
+ * `thinking`, `thinkingConfig`, `think`) wins over this on collision.
66
+ */
67
+ reasoningEffort?: ReasoningEffort;
42
68
  responseFormat?: { type: 'text' } | { type: 'json'; schema?: object };
43
69
  tools?: ToolSchema[];
44
70
  toolChoice?: 'auto' | 'none' | { name: string };
@@ -109,6 +135,7 @@ export interface Usage {
109
135
 
110
136
  export interface ChatResult {
111
137
  text: string;
138
+ providerState?: ProviderState;
112
139
  toolCalls?: ToolCall[];
113
140
  finishReason: 'stop' | 'length' | 'tool' | 'content_filter' | 'error' | 'canceled';
114
141
  usage: Usage;