@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.
- package/README.md +31 -2
- package/UPGRADING.md +24 -0
- package/dist/adapters/engines/anthropic.js +19 -1
- package/dist/adapters/engines/google.js +8 -1
- package/dist/adapters/engines/ollama.js +6 -0
- package/dist/adapters/engines/openaiChat.d.ts +6 -0
- package/dist/adapters/engines/openaiChat.js +51 -1
- package/dist/agent/loop.d.ts +3 -1
- package/dist/agent/loop.js +1 -0
- package/dist/cjs/adapters/engines/anthropic.js +18 -0
- package/dist/cjs/adapters/engines/google.js +7 -0
- package/dist/cjs/adapters/engines/ollama.js +6 -0
- package/dist/cjs/adapters/engines/openaiChat.js +52 -1
- package/dist/cjs/agent/loop.js +1 -0
- package/dist/cjs/handler.js +27 -2
- package/dist/cjs/index.js +1 -0
- package/dist/cjs/reasoning.js +159 -0
- package/dist/cjs/util.js +14 -0
- package/dist/handler.d.ts +6 -0
- package/dist/handler.js +27 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/reasoning.d.ts +45 -0
- package/dist/reasoning.js +154 -0
- package/dist/types.d.ts +16 -0
- package/dist/util.d.ts +7 -0
- package/dist/util.js +13 -0
- package/nugget/VERSION.txt +2 -2
- package/nugget/dist/adapters/engines/anthropic.js +19 -1
- package/nugget/dist/adapters/engines/anthropic.js.map +1 -1
- package/nugget/dist/adapters/engines/google.js +8 -1
- package/nugget/dist/adapters/engines/google.js.map +1 -1
- package/nugget/dist/adapters/engines/ollama.js +6 -0
- package/nugget/dist/adapters/engines/ollama.js.map +1 -1
- package/nugget/dist/adapters/engines/openaiChat.d.ts +6 -0
- package/nugget/dist/adapters/engines/openaiChat.d.ts.map +1 -1
- package/nugget/dist/adapters/engines/openaiChat.js +51 -1
- package/nugget/dist/adapters/engines/openaiChat.js.map +1 -1
- package/nugget/dist/agent/loop.d.ts +3 -1
- package/nugget/dist/agent/loop.d.ts.map +1 -1
- package/nugget/dist/agent/loop.js +1 -0
- package/nugget/dist/agent/loop.js.map +1 -1
- package/nugget/dist/cjs/adapters/engines/anthropic.js +18 -0
- package/nugget/dist/cjs/adapters/engines/google.js +7 -0
- package/nugget/dist/cjs/adapters/engines/ollama.js +6 -0
- package/nugget/dist/cjs/adapters/engines/openaiChat.js +52 -1
- package/nugget/dist/cjs/agent/loop.js +1 -0
- package/nugget/dist/cjs/handler.js +27 -2
- package/nugget/dist/cjs/index.js +1 -0
- package/nugget/dist/cjs/reasoning.js +159 -0
- package/nugget/dist/cjs/util.js +14 -0
- package/nugget/dist/handler.d.ts +6 -0
- package/nugget/dist/handler.d.ts.map +1 -1
- package/nugget/dist/handler.js +27 -2
- package/nugget/dist/handler.js.map +1 -1
- package/nugget/dist/index.d.ts +1 -0
- package/nugget/dist/index.d.ts.map +1 -1
- package/nugget/dist/index.js +1 -0
- package/nugget/dist/index.js.map +1 -1
- package/nugget/dist/reasoning.d.ts +45 -0
- package/nugget/dist/reasoning.d.ts.map +1 -0
- package/nugget/dist/reasoning.js +154 -0
- package/nugget/dist/reasoning.js.map +1 -0
- package/nugget/dist/types.d.ts +16 -0
- package/nugget/dist/types.d.ts.map +1 -1
- package/nugget/dist/types.js.map +1 -1
- package/nugget/dist/util.d.ts +7 -0
- package/nugget/dist/util.d.ts.map +1 -1
- package/nugget/dist/util.js +13 -0
- package/nugget/dist/util.js.map +1 -1
- package/nugget/src/adapters/engines/anthropic.ts +17 -1
- package/nugget/src/adapters/engines/google.ts +8 -1
- package/nugget/src/adapters/engines/ollama.ts +5 -0
- package/nugget/src/adapters/engines/openaiChat.ts +46 -1
- package/nugget/src/agent/loop.ts +4 -1
- package/nugget/src/handler.ts +29 -2
- package/nugget/src/index.ts +1 -0
- package/nugget/src/reasoning.ts +174 -0
- package/nugget/src/types.ts +17 -0
- package/nugget/src/util.ts +14 -0
- 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
|
-
|
|
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
|
+
}
|
package/nugget/src/agent/loop.ts
CHANGED
|
@@ -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 },
|
package/nugget/src/handler.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/nugget/src/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/nugget/src/types.ts
CHANGED
|
@@ -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 };
|
package/nugget/src/util.ts
CHANGED
|
@@ -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
|
+
};
|