@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
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# AI Nugget
|
|
2
2
|
|
|
3
|
-
**Version 0.
|
|
3
|
+
**Version 0.7.0** · [What's changed since 0.6.0](#whats-changed-since-060) · [Changelog](./CHANGELOG.md) · [Upgrading](./UPGRADING.md)
|
|
4
4
|
|
|
5
5
|
A small, zero-dependency, isomorphic TypeScript nugget for talking to AI model
|
|
6
6
|
providers through one pipeline:
|
|
@@ -108,12 +108,35 @@ new AIHandler({
|
|
|
108
108
|
- **Seams** — `KeySource` (env/literal/memory/chain + ref parsing), `Redactor`,
|
|
109
109
|
neutral `GovernancePolicy` (`blocklistPolicy`/`allowlistPolicy`/`composePolicies`),
|
|
110
110
|
`TelemetrySink`, and an optional `pricing` hook. `providerOptions` passes
|
|
111
|
-
provider-native fields through without waiting for a library release
|
|
111
|
+
provider-native fields through without waiting for a library release;
|
|
112
|
+
`reasoningEffort` is one word for "how hard should the model think", mapped
|
|
113
|
+
per provider ([docs](./docs/providers.md#reasoning-effort)).
|
|
112
114
|
- **Agent layer** (`@jxburros/ai-nugget/agent`) — `defineTool`, `runAgent()`
|
|
113
115
|
model↔tool loop over the full pipeline, streamed `AgentEvent`s, budgets with
|
|
114
116
|
honest `stopReason`s, an `ApprovalGate` for side-effecting tools, and
|
|
115
117
|
`native`/`promptJson`/`auto` tool modes.
|
|
116
118
|
|
|
119
|
+
## What's changed since 0.6.0
|
|
120
|
+
|
|
121
|
+
**0.7.0 is backward-compatible for ordinary consumers** — the first two changes are additive; the third changes what `delta` carries for reasoning models (see UPGRADING). Full detail in the
|
|
122
|
+
[changelog](./CHANGELOG.md) and [UPGRADING.md](./UPGRADING.md#06x--070).
|
|
123
|
+
|
|
124
|
+
- **`ChatRequest.reasoningEffort`** (`none` / `minimal` / `low` / `medium` /
|
|
125
|
+
`high`) — one vocabulary for reasoning effort, mapped onto OpenAI
|
|
126
|
+
`reasoning_effort`, Anthropic `thinking`, Google `thinkingConfig`, and Ollama
|
|
127
|
+
`think`; forwarded by the agent loop via `AgentOptions.reasoningEffort`.
|
|
128
|
+
Never sent unless you set it. See [Reasoning effort](./docs/providers.md#reasoning-effort).
|
|
129
|
+
- **OpenAI reasoning models can call tools again.** `/chat/completions` refuses
|
|
130
|
+
function tools unless `reasoning_effort` is `'none'`; the `openaiChat` engine
|
|
131
|
+
now retries that one request with it set and emits a
|
|
132
|
+
`reasoning_effort_disabled_for_tools` context event so the change is visible.
|
|
133
|
+
Learned from AI Server Studio's Build 3 shakedown, where every tool-using
|
|
134
|
+
turn on `gpt-5.x`/`gpt-6` 400'd.
|
|
135
|
+
- **Inline `<think>` reasoning never reaches `delta` or `result.text`.** The
|
|
136
|
+
handler strips it on every engine and emits it as `reasoning` events
|
|
137
|
+
(`stripInlineReasoning: false` opts out). Ported from the stripper AI Server
|
|
138
|
+
Studio had to run at every call site. See [Inline reasoning](./docs/providers.md#inline-reasoning-is-stripped-at-the-seam).
|
|
139
|
+
|
|
117
140
|
## What's changed since 0.5.0
|
|
118
141
|
|
|
119
142
|
**0.6.0 is backward-compatible** — every change below is additive. The one thing
|
|
@@ -177,3 +200,9 @@ seam), and not a UI. The governance seam ships neutral: it is where an app *can*
|
|
|
177
200
|
enforce rules, not a place the library imposes its own.
|
|
178
201
|
|
|
179
202
|
<!-- GitHub Pages deployment is configured in .github/workflows/pages.yml. -->
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## Project Stewardship
|
|
207
|
+
|
|
208
|
+
This project is developed by **Jeffrey** through **JX Holdings, LLC**. GitHub: [`jxburros`](https://github.com/jxburros).
|
package/UPGRADING.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Upgrading
|
|
2
2
|
|
|
3
|
+
## 0.6.x → 0.7.0
|
|
4
|
+
|
|
5
|
+
**Backward-compatible for ordinary consumers.** Two additive changes and one
|
|
6
|
+
default-behaviour change worth knowing about:
|
|
7
|
+
|
|
8
|
+
- **Inline reasoning is stripped by default.** A model that writes
|
|
9
|
+
`<think>…</think>` (or `<thinking>`, `<reasoning>`, `<|begin_of_thought|>`)
|
|
10
|
+
into its answer no longer has that text in `delta` events or
|
|
11
|
+
`ChatResult.text`; it arrives as `{ type: 'reasoning', text }` events, the
|
|
12
|
+
same channel a provider-native thinking field already used. If your app
|
|
13
|
+
parsed or displayed those tags itself, either delete that code or pass
|
|
14
|
+
`new AIHandler({ stripInlineReasoning: false })` to keep the raw text.
|
|
15
|
+
|
|
16
|
+
- **`ChatRequest.reasoningEffort`** (and `AgentOptions.reasoningEffort`) — a
|
|
17
|
+
new optional field. Nothing is sent to any provider unless you set it. If you
|
|
18
|
+
already pass `reasoning_effort` / `thinking` / `thinkingConfig` / `think`
|
|
19
|
+
through `providerOptions`, that keeps winning on collision.
|
|
20
|
+
- **A new `context` stream event, `reasoning_effort_disabled_for_tools`** — the
|
|
21
|
+
`openaiChat` engine emits it when it retried a tool-using request with
|
|
22
|
+
`reasoning_effort: 'none'` after OpenAI refused the first attempt. Ignoring
|
|
23
|
+
it keeps the old behaviour minus the 400. If you *want* the hard failure
|
|
24
|
+
instead (to route to `/v1/responses` yourself), set `reasoningEffort: 'none'`
|
|
25
|
+
explicitly on tool-using turns — then no retry happens and any 400 is yours.
|
|
26
|
+
|
|
3
27
|
## 0.5.x → 0.6.0
|
|
4
28
|
|
|
5
29
|
Behavior-compatible for ordinary consumers, with **two things worth checking**.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { AIError } from '../../errors.js';
|
|
2
2
|
import { estimatedUsage } from '../../tokens.js';
|
|
3
3
|
import { fetchJson, postResponse, sseLines } from '../../transport.js';
|
|
4
|
-
import { applyProviderOptions, asNumber, asRecord, asString, joinUrl, textFromMessages } from '../../util.js';
|
|
4
|
+
import { applyProviderOptions, REASONING_BUDGET_TOKENS, asNumber, asRecord, asString, joinUrl, textFromMessages } from '../../util.js';
|
|
5
5
|
import { DEFAULT_TIMEOUT_MS, parseArgs, randomId, safeParse, streamAnomaly, streamError, streamTimeout } from './base.js';
|
|
6
6
|
const JSON_MODE_TOOL = 'json_output';
|
|
7
7
|
export class AnthropicAdapter {
|
|
@@ -210,6 +210,24 @@ function body(req, jsonMode) {
|
|
|
210
210
|
else if (req.toolChoice === 'none')
|
|
211
211
|
base.tool_choice = { type: 'none' };
|
|
212
212
|
}
|
|
213
|
+
if (req.reasoningEffort !== undefined) {
|
|
214
|
+
// First-class reasoning effort → extended thinking. Anthropic requires
|
|
215
|
+
// `budget_tokens < max_tokens` and rejects temperature/top_p alongside
|
|
216
|
+
// thinking, so the budget is fitted under max_tokens (raising max_tokens
|
|
217
|
+
// when the caller's value leaves no room) and the samplers are dropped.
|
|
218
|
+
if (req.reasoningEffort === 'none') {
|
|
219
|
+
base.thinking = { type: 'disabled' };
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
const budget = REASONING_BUDGET_TOKENS[req.reasoningEffort];
|
|
223
|
+
const maxTokens = typeof base.max_tokens === 'number' ? base.max_tokens : 4096;
|
|
224
|
+
if (maxTokens <= budget)
|
|
225
|
+
base.max_tokens = budget + 1024;
|
|
226
|
+
base.thinking = { type: 'enabled', budget_tokens: budget };
|
|
227
|
+
delete base.temperature;
|
|
228
|
+
delete base.top_p;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
213
231
|
// providerOptions carries Anthropic-native fields (`thinking`, `metadata`,
|
|
214
232
|
// top-level `cache_control` extras, `service_tier`, …) without a release.
|
|
215
233
|
return applyProviderOptions(base, req.providerOptions);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { AIError } from '../../errors.js';
|
|
2
2
|
import { estimatedUsage } from '../../tokens.js';
|
|
3
3
|
import { fetchJson, postResponse, sseLines } from '../../transport.js';
|
|
4
|
-
import { applyProviderOptions, asNumber, asRecord, asString, joinUrl, textFromMessages } from '../../util.js';
|
|
4
|
+
import { applyProviderOptions, REASONING_BUDGET_TOKENS, asNumber, asRecord, asString, joinUrl, textFromMessages } from '../../util.js';
|
|
5
5
|
import { DEFAULT_TIMEOUT_MS, randomId, safeParse, streamAnomaly, streamError, streamTimeout } from './base.js';
|
|
6
6
|
export class GoogleAdapter {
|
|
7
7
|
provider;
|
|
@@ -184,6 +184,13 @@ function body(req) {
|
|
|
184
184
|
config.allowedFunctionNames = [req.toolChoice.name];
|
|
185
185
|
payload.toolConfig = { functionCallingConfig: config };
|
|
186
186
|
}
|
|
187
|
+
if (req.reasoningEffort !== undefined) {
|
|
188
|
+
// First-class reasoning effort → Gemini's thinking budget. 0 disables
|
|
189
|
+
// thinking on models that allow it; others treat it as a minimum.
|
|
190
|
+
payload.generationConfig.thinkingConfig = {
|
|
191
|
+
thinkingBudget: REASONING_BUDGET_TOKENS[req.reasoningEffort],
|
|
192
|
+
};
|
|
193
|
+
}
|
|
187
194
|
// providerOptions reaches Google-native fields: top-level `safetySettings`,
|
|
188
195
|
// `cachedContent`, and `generationConfig` extras (`thinkingConfig`,
|
|
189
196
|
// `responseModalities`, …) merged one level deep into generationConfig.
|
|
@@ -172,6 +172,12 @@ function body(req) {
|
|
|
172
172
|
format: req.responseFormat?.type === 'json' ? (req.responseFormat.schema ?? 'json') : undefined,
|
|
173
173
|
tools: req.tools?.map((tool) => ({ type: 'function', function: { name: tool.name, description: tool.description, parameters: tool.parameters } })),
|
|
174
174
|
};
|
|
175
|
+
// First-class reasoning effort → Ollama's `think` flag (reasoning arrives in
|
|
176
|
+
// `message.thinking`, which this engine already routes to `reasoning` events).
|
|
177
|
+
// Ollama has no graded effort for most models, so anything but `'none'` is
|
|
178
|
+
// `true`. Only sent when asked for: a model without thinking support rejects it.
|
|
179
|
+
if (req.reasoningEffort !== undefined)
|
|
180
|
+
base.think = req.reasoningEffort !== 'none';
|
|
175
181
|
// providerOptions reaches Ollama's real request fields — `options.num_ctx`,
|
|
176
182
|
// `options.num_keep`, top-level `keep_alive`, `think`, etc. `options` is
|
|
177
183
|
// merged one level deep so num_ctx joins the samplers above rather than
|
|
@@ -13,4 +13,10 @@ export declare class OpenAIChatAdapter implements ProviderAdapter {
|
|
|
13
13
|
}>;
|
|
14
14
|
embed(conn: ResolvedConnection, req: EmbedRequest): Promise<EmbedResult>;
|
|
15
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* True when a `/chat/completions` 400 is the "function tools with reasoning_effort
|
|
18
|
+
* are not supported" refusal AND the request actually carried tools AND the caller
|
|
19
|
+
* did not already pin `reasoning_effort` (in which case the choice is theirs).
|
|
20
|
+
*/
|
|
21
|
+
export declare function shouldRetryWithoutReasoningEffort(error: unknown, req: ChatRequest): boolean;
|
|
16
22
|
//# sourceMappingURL=openaiChat.d.ts.map
|
|
@@ -34,7 +34,34 @@ export class OpenAIChatAdapter {
|
|
|
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;
|
|
38
|
+
try {
|
|
39
|
+
res = await postResponse(urlFor(conn, this.profile, req), openAiBody(req, this.profile), conn.headers, timeout.signal, conn.provider);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
// OpenAI's `/chat/completions` refuses function tools on a reasoning model
|
|
43
|
+
// unless `reasoning_effort` is `'none'` ("Function tools with reasoning_effort
|
|
44
|
+
// are not supported … use /v1/responses or set reasoning_effort to 'none'").
|
|
45
|
+
// The nugget never sets `reasoning_effort` itself, so the provider default
|
|
46
|
+
// applies and every tool-using turn 400s. Rather than make each app learn
|
|
47
|
+
// this the hard way, retry the one request once with the effort disabled
|
|
48
|
+
// and say so on the stream. Reactive on purpose: sending
|
|
49
|
+
// `reasoning_effort` up front would 400 on non-reasoning models instead.
|
|
50
|
+
// A caller-requested effort is overridden too — the alternative is a hard
|
|
51
|
+
// 400 with no tool call at all — and the context event says so.
|
|
52
|
+
if (!shouldRetryWithoutReasoningEffort(error, req))
|
|
53
|
+
throw error;
|
|
54
|
+
yield {
|
|
55
|
+
type: 'context',
|
|
56
|
+
kind: 'reasoning_effort_disabled_for_tools',
|
|
57
|
+
data: {
|
|
58
|
+
reason: 'provider rejected function tools with reasoning_effort; retried with reasoning_effort: none',
|
|
59
|
+
requested: req.providerOptions?.reasoning_effort ?? req.reasoningEffort ?? null,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
const retryReq = { ...req, providerOptions: { ...req.providerOptions, reasoning_effort: 'none' } };
|
|
63
|
+
res = await postResponse(urlFor(conn, this.profile, retryReq), openAiBody(retryReq, this.profile), conn.headers, timeout.signal, conn.provider);
|
|
64
|
+
}
|
|
38
65
|
const contentType = res.headers.get('content-type') ?? '';
|
|
39
66
|
if (!contentType.includes('text/event-stream')) {
|
|
40
67
|
// Server ignored stream:true (or is a buffered gateway) — recover the whole body.
|
|
@@ -169,6 +196,10 @@ function openAiBody(req, profile) {
|
|
|
169
196
|
};
|
|
170
197
|
if (req.maxTokens !== undefined)
|
|
171
198
|
body[profile.quirks?.maxTokensParam ?? 'max_tokens'] = req.maxTokens;
|
|
199
|
+
// First-class reasoning effort → OpenAI's `reasoning_effort`. Only sent when
|
|
200
|
+
// asked for: non-reasoning models reject the parameter outright.
|
|
201
|
+
if (req.reasoningEffort !== undefined)
|
|
202
|
+
body.reasoning_effort = req.reasoningEffort;
|
|
172
203
|
// providerOptions carries OpenAI-native fields the nugget doesn't model
|
|
173
204
|
// (`reasoning_effort`, `parallel_tool_calls`, `seed`, `logprobs`, …). `apiVersion`
|
|
174
205
|
// is consumed by urlFor for Azure and stripped here so it never hits the body.
|
|
@@ -255,4 +286,23 @@ function mapFinish(finish, hasToolCalls) {
|
|
|
255
286
|
return 'content_filter';
|
|
256
287
|
return 'stop';
|
|
257
288
|
}
|
|
289
|
+
/**
|
|
290
|
+
* True when a `/chat/completions` 400 is the "function tools with reasoning_effort
|
|
291
|
+
* are not supported" refusal AND the request actually carried tools AND the caller
|
|
292
|
+
* did not already pin `reasoning_effort` (in which case the choice is theirs).
|
|
293
|
+
*/
|
|
294
|
+
export function shouldRetryWithoutReasoningEffort(error, req) {
|
|
295
|
+
if (!(error instanceof AIError) || error.status !== 400)
|
|
296
|
+
return false;
|
|
297
|
+
if (!req.tools || req.tools.length === 0)
|
|
298
|
+
return false;
|
|
299
|
+
// Already `'none'` (from either knob) — the refusal is about something else.
|
|
300
|
+
const effective = req.providerOptions && 'reasoning_effort' in req.providerOptions
|
|
301
|
+
? req.providerOptions.reasoning_effort
|
|
302
|
+
: req.reasoningEffort;
|
|
303
|
+
if (effective === 'none')
|
|
304
|
+
return false;
|
|
305
|
+
const text = `${error.raw ?? ''} ${error.message}`.toLowerCase();
|
|
306
|
+
return text.includes('reasoning_effort') && text.includes('tool');
|
|
307
|
+
}
|
|
258
308
|
//# sourceMappingURL=openaiChat.js.map
|
package/dist/agent/loop.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AIHandler } from '../handler.js';
|
|
2
|
-
import type { AIErrorKind, ChatMessage, Connection, StreamEvent, ToolCall, Usage } from '../types.js';
|
|
2
|
+
import type { AIErrorKind, ChatMessage, Connection, ReasoningEffort, StreamEvent, ToolCall, Usage } from '../types.js';
|
|
3
3
|
import type { ToolSpec } from './tools.js';
|
|
4
4
|
export interface AgentOptions {
|
|
5
5
|
handler: AIHandler;
|
|
@@ -30,6 +30,8 @@ export interface AgentOptions {
|
|
|
30
30
|
maxTokens?: number;
|
|
31
31
|
topP?: number;
|
|
32
32
|
stopSequences?: string[];
|
|
33
|
+
/** Reasoning effort forwarded to every turn — see {@link ChatRequest.reasoningEffort}. */
|
|
34
|
+
reasoningEffort?: ReasoningEffort;
|
|
33
35
|
/** Provider-native passthrough forwarded to every turn — see {@link ChatRequest.providerOptions}. */
|
|
34
36
|
providerOptions?: Record<string, unknown>;
|
|
35
37
|
budget?: {
|
package/dist/agent/loop.js
CHANGED
|
@@ -46,6 +46,7 @@ async function* run(opts, resolveResult) {
|
|
|
46
46
|
maxTokens: opts.maxTokens,
|
|
47
47
|
topP: opts.topP,
|
|
48
48
|
stopSequences: opts.stopSequences,
|
|
49
|
+
reasoningEffort: opts.reasoningEffort,
|
|
49
50
|
providerOptions: opts.providerOptions,
|
|
50
51
|
signal: agentSignal.signal,
|
|
51
52
|
metadata: { ...opts.metadata, agentStep: step },
|
|
@@ -214,6 +214,24 @@ function body(req, jsonMode) {
|
|
|
214
214
|
else if (req.toolChoice === 'none')
|
|
215
215
|
base.tool_choice = { type: 'none' };
|
|
216
216
|
}
|
|
217
|
+
if (req.reasoningEffort !== undefined) {
|
|
218
|
+
// First-class reasoning effort → extended thinking. Anthropic requires
|
|
219
|
+
// `budget_tokens < max_tokens` and rejects temperature/top_p alongside
|
|
220
|
+
// thinking, so the budget is fitted under max_tokens (raising max_tokens
|
|
221
|
+
// when the caller's value leaves no room) and the samplers are dropped.
|
|
222
|
+
if (req.reasoningEffort === 'none') {
|
|
223
|
+
base.thinking = { type: 'disabled' };
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
const budget = util_js_1.REASONING_BUDGET_TOKENS[req.reasoningEffort];
|
|
227
|
+
const maxTokens = typeof base.max_tokens === 'number' ? base.max_tokens : 4096;
|
|
228
|
+
if (maxTokens <= budget)
|
|
229
|
+
base.max_tokens = budget + 1024;
|
|
230
|
+
base.thinking = { type: 'enabled', budget_tokens: budget };
|
|
231
|
+
delete base.temperature;
|
|
232
|
+
delete base.top_p;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
217
235
|
// providerOptions carries Anthropic-native fields (`thinking`, `metadata`,
|
|
218
236
|
// top-level `cache_control` extras, `service_tier`, …) without a release.
|
|
219
237
|
return (0, util_js_1.applyProviderOptions)(base, req.providerOptions);
|
|
@@ -188,6 +188,13 @@ function body(req) {
|
|
|
188
188
|
config.allowedFunctionNames = [req.toolChoice.name];
|
|
189
189
|
payload.toolConfig = { functionCallingConfig: config };
|
|
190
190
|
}
|
|
191
|
+
if (req.reasoningEffort !== undefined) {
|
|
192
|
+
// First-class reasoning effort → Gemini's thinking budget. 0 disables
|
|
193
|
+
// thinking on models that allow it; others treat it as a minimum.
|
|
194
|
+
payload.generationConfig.thinkingConfig = {
|
|
195
|
+
thinkingBudget: util_js_1.REASONING_BUDGET_TOKENS[req.reasoningEffort],
|
|
196
|
+
};
|
|
197
|
+
}
|
|
191
198
|
// providerOptions reaches Google-native fields: top-level `safetySettings`,
|
|
192
199
|
// `cachedContent`, and `generationConfig` extras (`thinkingConfig`,
|
|
193
200
|
// `responseModalities`, …) merged one level deep into generationConfig.
|
|
@@ -176,6 +176,12 @@ function body(req) {
|
|
|
176
176
|
format: req.responseFormat?.type === 'json' ? (req.responseFormat.schema ?? 'json') : undefined,
|
|
177
177
|
tools: req.tools?.map((tool) => ({ type: 'function', function: { name: tool.name, description: tool.description, parameters: tool.parameters } })),
|
|
178
178
|
};
|
|
179
|
+
// First-class reasoning effort → Ollama's `think` flag (reasoning arrives in
|
|
180
|
+
// `message.thinking`, which this engine already routes to `reasoning` events).
|
|
181
|
+
// Ollama has no graded effort for most models, so anything but `'none'` is
|
|
182
|
+
// `true`. Only sent when asked for: a model without thinking support rejects it.
|
|
183
|
+
if (req.reasoningEffort !== undefined)
|
|
184
|
+
base.think = req.reasoningEffort !== 'none';
|
|
179
185
|
// providerOptions reaches Ollama's real request fields — `options.num_ctx`,
|
|
180
186
|
// `options.num_keep`, top-level `keep_alive`, `think`, etc. `options` is
|
|
181
187
|
// merged one level deep so num_ctx joins the samplers above rather than
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.OpenAIChatAdapter = void 0;
|
|
4
|
+
exports.shouldRetryWithoutReasoningEffort = shouldRetryWithoutReasoningEffort;
|
|
4
5
|
const errors_js_1 = require("../../errors.js");
|
|
5
6
|
const tokens_js_1 = require("../../tokens.js");
|
|
6
7
|
const transport_js_1 = require("../../transport.js");
|
|
@@ -37,7 +38,34 @@ class OpenAIChatAdapter {
|
|
|
37
38
|
const timeout = (0, base_js_1.streamTimeout)(conn, req.signal);
|
|
38
39
|
yield { type: 'start', callId: '', provider: conn.provider, model: req.model };
|
|
39
40
|
try {
|
|
40
|
-
|
|
41
|
+
let res;
|
|
42
|
+
try {
|
|
43
|
+
res = await (0, transport_js_1.postResponse)(urlFor(conn, this.profile, req), openAiBody(req, this.profile), conn.headers, timeout.signal, conn.provider);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
// OpenAI's `/chat/completions` refuses function tools on a reasoning model
|
|
47
|
+
// unless `reasoning_effort` is `'none'` ("Function tools with reasoning_effort
|
|
48
|
+
// are not supported … use /v1/responses or set reasoning_effort to 'none'").
|
|
49
|
+
// The nugget never sets `reasoning_effort` itself, so the provider default
|
|
50
|
+
// applies and every tool-using turn 400s. Rather than make each app learn
|
|
51
|
+
// this the hard way, retry the one request once with the effort disabled
|
|
52
|
+
// and say so on the stream. Reactive on purpose: sending
|
|
53
|
+
// `reasoning_effort` up front would 400 on non-reasoning models instead.
|
|
54
|
+
// A caller-requested effort is overridden too — the alternative is a hard
|
|
55
|
+
// 400 with no tool call at all — and the context event says so.
|
|
56
|
+
if (!shouldRetryWithoutReasoningEffort(error, req))
|
|
57
|
+
throw error;
|
|
58
|
+
yield {
|
|
59
|
+
type: 'context',
|
|
60
|
+
kind: 'reasoning_effort_disabled_for_tools',
|
|
61
|
+
data: {
|
|
62
|
+
reason: 'provider rejected function tools with reasoning_effort; retried with reasoning_effort: none',
|
|
63
|
+
requested: req.providerOptions?.reasoning_effort ?? req.reasoningEffort ?? null,
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
const retryReq = { ...req, providerOptions: { ...req.providerOptions, reasoning_effort: 'none' } };
|
|
67
|
+
res = await (0, transport_js_1.postResponse)(urlFor(conn, this.profile, retryReq), openAiBody(retryReq, this.profile), conn.headers, timeout.signal, conn.provider);
|
|
68
|
+
}
|
|
41
69
|
const contentType = res.headers.get('content-type') ?? '';
|
|
42
70
|
if (!contentType.includes('text/event-stream')) {
|
|
43
71
|
// Server ignored stream:true (or is a buffered gateway) — recover the whole body.
|
|
@@ -173,6 +201,10 @@ function openAiBody(req, profile) {
|
|
|
173
201
|
};
|
|
174
202
|
if (req.maxTokens !== undefined)
|
|
175
203
|
body[profile.quirks?.maxTokensParam ?? 'max_tokens'] = req.maxTokens;
|
|
204
|
+
// First-class reasoning effort → OpenAI's `reasoning_effort`. Only sent when
|
|
205
|
+
// asked for: non-reasoning models reject the parameter outright.
|
|
206
|
+
if (req.reasoningEffort !== undefined)
|
|
207
|
+
body.reasoning_effort = req.reasoningEffort;
|
|
176
208
|
// providerOptions carries OpenAI-native fields the nugget doesn't model
|
|
177
209
|
// (`reasoning_effort`, `parallel_tool_calls`, `seed`, `logprobs`, …). `apiVersion`
|
|
178
210
|
// is consumed by urlFor for Azure and stripped here so it never hits the body.
|
|
@@ -259,3 +291,22 @@ function mapFinish(finish, hasToolCalls) {
|
|
|
259
291
|
return 'content_filter';
|
|
260
292
|
return 'stop';
|
|
261
293
|
}
|
|
294
|
+
/**
|
|
295
|
+
* True when a `/chat/completions` 400 is the "function tools with reasoning_effort
|
|
296
|
+
* are not supported" refusal AND the request actually carried tools AND the caller
|
|
297
|
+
* did not already pin `reasoning_effort` (in which case the choice is theirs).
|
|
298
|
+
*/
|
|
299
|
+
function shouldRetryWithoutReasoningEffort(error, req) {
|
|
300
|
+
if (!(error instanceof errors_js_1.AIError) || error.status !== 400)
|
|
301
|
+
return false;
|
|
302
|
+
if (!req.tools || req.tools.length === 0)
|
|
303
|
+
return false;
|
|
304
|
+
// Already `'none'` (from either knob) — the refusal is about something else.
|
|
305
|
+
const effective = req.providerOptions && 'reasoning_effort' in req.providerOptions
|
|
306
|
+
? req.providerOptions.reasoning_effort
|
|
307
|
+
: req.reasoningEffort;
|
|
308
|
+
if (effective === 'none')
|
|
309
|
+
return false;
|
|
310
|
+
const text = `${error.raw ?? ''} ${error.message}`.toLowerCase();
|
|
311
|
+
return text.includes('reasoning_effort') && text.includes('tool');
|
|
312
|
+
}
|
package/dist/cjs/agent/loop.js
CHANGED
|
@@ -49,6 +49,7 @@ async function* run(opts, resolveResult) {
|
|
|
49
49
|
maxTokens: opts.maxTokens,
|
|
50
50
|
topP: opts.topP,
|
|
51
51
|
stopSequences: opts.stopSequences,
|
|
52
|
+
reasoningEffort: opts.reasoningEffort,
|
|
52
53
|
providerOptions: opts.providerOptions,
|
|
53
54
|
signal: agentSignal.signal,
|
|
54
55
|
metadata: { ...opts.metadata, agentStep: step },
|
package/dist/cjs/handler.js
CHANGED
|
@@ -6,6 +6,7 @@ const index_js_1 = require("./adapters/index.js");
|
|
|
6
6
|
const profiles_js_1 = require("./adapters/profiles.js");
|
|
7
7
|
const errors_js_1 = require("./errors.js");
|
|
8
8
|
const json_js_1 = require("./json.js");
|
|
9
|
+
const reasoning_js_1 = require("./reasoning.js");
|
|
9
10
|
const policy_js_1 = require("./policy.js");
|
|
10
11
|
const redact_js_1 = require("./redact.js");
|
|
11
12
|
const util_js_1 = require("./util.js");
|
|
@@ -71,8 +72,11 @@ class AIHandler {
|
|
|
71
72
|
const maxAttempts = this.opts.retry?.maxAttempts ?? 3;
|
|
72
73
|
let emittedStart = false;
|
|
73
74
|
let emittedOutput = false;
|
|
75
|
+
const stripInline = this.opts.stripInlineReasoning !== false;
|
|
74
76
|
for (;;) {
|
|
75
77
|
attempt += 1;
|
|
78
|
+
// One stripper per attempt: a retried stream starts its tag state over.
|
|
79
|
+
const stripper = stripInline ? (0, reasoning_js_1.createReasoningStripper)() : null;
|
|
76
80
|
try {
|
|
77
81
|
for await (const event of adapter.stream(resolved, req)) {
|
|
78
82
|
if (event.type === 'start') {
|
|
@@ -84,10 +88,31 @@ class AIHandler {
|
|
|
84
88
|
}
|
|
85
89
|
if (event.type === 'delta' || event.type === 'tool_call')
|
|
86
90
|
emittedOutput = true;
|
|
91
|
+
if (event.type === 'delta' && stripper) {
|
|
92
|
+
// Inline reasoning never reaches `delta`; it rides the reasoning channel like a
|
|
93
|
+
// provider-native thinking field would.
|
|
94
|
+
const piece = stripper.push(event.text);
|
|
95
|
+
if (piece.reasoning)
|
|
96
|
+
yield { type: 'reasoning', text: piece.reasoning };
|
|
97
|
+
if (piece.visible)
|
|
98
|
+
yield { type: 'delta', text: piece.visible };
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
87
101
|
if (event.type === 'done') {
|
|
88
|
-
|
|
102
|
+
let result = event.result;
|
|
103
|
+
if (stripper) {
|
|
104
|
+
const tail = stripper.end();
|
|
105
|
+
if (tail.reasoning)
|
|
106
|
+
yield { type: 'reasoning', text: tail.reasoning };
|
|
107
|
+
if (tail.visible)
|
|
108
|
+
yield { type: 'delta', text: tail.visible };
|
|
109
|
+
if ((0, reasoning_js_1.containsReasoningBlock)(result.text)) {
|
|
110
|
+
result = { ...result, text: (0, reasoning_js_1.stripReasoningBlocks)(result.text) };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
await this.recordSuccess(callId, conn, req, startedAt, attempt, result);
|
|
89
114
|
recorded = true;
|
|
90
|
-
yield event;
|
|
115
|
+
yield { ...event, result };
|
|
91
116
|
return;
|
|
92
117
|
}
|
|
93
118
|
yield event;
|
package/dist/cjs/index.js
CHANGED
|
@@ -18,6 +18,7 @@ __exportStar(require("./types.js"), exports);
|
|
|
18
18
|
__exportStar(require("./errors.js"), exports);
|
|
19
19
|
__exportStar(require("./transport.js"), exports);
|
|
20
20
|
__exportStar(require("./json.js"), exports);
|
|
21
|
+
__exportStar(require("./reasoning.js"), exports);
|
|
21
22
|
__exportStar(require("./tokens.js"), exports);
|
|
22
23
|
__exportStar(require("./keys.js"), exports);
|
|
23
24
|
__exportStar(require("./connect.js"), exports);
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Inline reasoning stripper — keeps a reasoning model's chain of thought out of `delta` events
|
|
4
|
+
* and `ChatResult.text`, routing it to the `reasoning` stream event instead.
|
|
5
|
+
*
|
|
6
|
+
* Reasoning models (deepseek-r1, phi4-reasoning, qwq, …) emit their thinking either in the
|
|
7
|
+
* provider's dedicated field — which every engine already maps to `{ type: 'reasoning' }` — or
|
|
8
|
+
* INLINE in the answer as `<think>…</think>`-style blocks. Inline reasoning routinely quotes the
|
|
9
|
+
* system prompt and tool instructions back verbatim, so an app that streams `delta` straight to
|
|
10
|
+
* a screen, a database or a JSON parser leaks it. Every consumer would otherwise write this
|
|
11
|
+
* state machine, and most would get the split-tag and template-opened cases wrong; the handler
|
|
12
|
+
* runs it once, on every engine, unless `HandlerOptions.stripInlineReasoning` is `false`.
|
|
13
|
+
*
|
|
14
|
+
* Ported from AI Server Studio's `services/reasoningFilter.ts` (2026-09-05), which had to exist
|
|
15
|
+
* because the nugget did not do this.
|
|
16
|
+
*
|
|
17
|
+
* Two subtleties the tests pin:
|
|
18
|
+
* - Tags may be split across chunks, so a trailing partial `<thi` is held back until the next
|
|
19
|
+
* chunk decides whether it opens a tag. An unterminated block is reasoning to the end.
|
|
20
|
+
* - The **orphan close**: a closing tag with no opening one at the head of the stream, because
|
|
21
|
+
* the runtime's chat template emitted `<think>` itself. Handled once per stream, only before
|
|
22
|
+
* any visible text has gone out, and only when the closing tag starts its own line — a
|
|
23
|
+
* `</think>` quoted mid-sentence in a real answer is left alone.
|
|
24
|
+
*/
|
|
25
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
+
exports.createReasoningStripper = createReasoningStripper;
|
|
27
|
+
exports.stripReasoningBlocksDetailed = stripReasoningBlocksDetailed;
|
|
28
|
+
exports.stripReasoningBlocks = stripReasoningBlocks;
|
|
29
|
+
exports.containsReasoningBlock = containsReasoningBlock;
|
|
30
|
+
const TAG_PAIRS = [
|
|
31
|
+
{ open: '<think>', close: '</think>' },
|
|
32
|
+
{ open: '<thinking>', close: '</thinking>' },
|
|
33
|
+
{ open: '<reasoning>', close: '</reasoning>' },
|
|
34
|
+
{ open: '<|begin_of_thought|>', close: '<|end_of_thought|>' },
|
|
35
|
+
];
|
|
36
|
+
const LONGEST_TAG = Math.max(...TAG_PAIRS.flatMap((p) => [p.open.length, p.close.length]));
|
|
37
|
+
function partialTagSuffixLength(text) {
|
|
38
|
+
const lower = text.toLowerCase();
|
|
39
|
+
const maxLen = Math.min(LONGEST_TAG - 1, lower.length);
|
|
40
|
+
for (let len = maxLen; len >= 1; len -= 1) {
|
|
41
|
+
const tail = lower.slice(lower.length - len);
|
|
42
|
+
if (tail[0] !== '<')
|
|
43
|
+
continue;
|
|
44
|
+
for (const pair of TAG_PAIRS) {
|
|
45
|
+
if (pair.open.startsWith(tail) || pair.close.startsWith(tail))
|
|
46
|
+
return len;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
function closesOwnLine(text, idx) {
|
|
52
|
+
let i = idx - 1;
|
|
53
|
+
while (i >= 0 && (text[i] === ' ' || text[i] === '\t' || text[i] === '\r'))
|
|
54
|
+
i -= 1;
|
|
55
|
+
return i < 0 || text[i] === '\n';
|
|
56
|
+
}
|
|
57
|
+
function createReasoningStripper() {
|
|
58
|
+
let pending = '';
|
|
59
|
+
let inReasoning = false;
|
|
60
|
+
let closeTag = '';
|
|
61
|
+
let emittedVisible = false;
|
|
62
|
+
let orphanConsumed = false;
|
|
63
|
+
function process(input, flushAll) {
|
|
64
|
+
let text = pending + input;
|
|
65
|
+
pending = '';
|
|
66
|
+
let visible = '';
|
|
67
|
+
let reasoning = '';
|
|
68
|
+
while (text.length > 0) {
|
|
69
|
+
const lower = text.toLowerCase();
|
|
70
|
+
if (inReasoning) {
|
|
71
|
+
const idx = lower.indexOf(closeTag);
|
|
72
|
+
if (idx === -1) {
|
|
73
|
+
const hold = flushAll ? 0 : partialTagSuffixLength(text);
|
|
74
|
+
const consumed = text.length - hold;
|
|
75
|
+
reasoning += text.slice(0, consumed);
|
|
76
|
+
pending = text.slice(consumed);
|
|
77
|
+
text = '';
|
|
78
|
+
if (flushAll) {
|
|
79
|
+
reasoning += pending;
|
|
80
|
+
pending = '';
|
|
81
|
+
}
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
reasoning += text.slice(0, idx);
|
|
85
|
+
text = text.slice(idx + closeTag.length);
|
|
86
|
+
inReasoning = false;
|
|
87
|
+
closeTag = '';
|
|
88
|
+
if (text.startsWith('\n'))
|
|
89
|
+
text = text.slice(1);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
let openIdx = -1;
|
|
93
|
+
let pair = null;
|
|
94
|
+
for (const p of TAG_PAIRS) {
|
|
95
|
+
const i = lower.indexOf(p.open);
|
|
96
|
+
if (i !== -1 && (openIdx === -1 || i < openIdx)) {
|
|
97
|
+
openIdx = i;
|
|
98
|
+
pair = p;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
let orphanCloseIdx = -1;
|
|
102
|
+
let orphanCloseLen = 0;
|
|
103
|
+
if (!emittedVisible && !orphanConsumed) {
|
|
104
|
+
for (const p of TAG_PAIRS) {
|
|
105
|
+
const i = lower.indexOf(p.close);
|
|
106
|
+
if (i !== -1 && (orphanCloseIdx === -1 || i < orphanCloseIdx) && closesOwnLine(text, i)) {
|
|
107
|
+
orphanCloseIdx = i;
|
|
108
|
+
orphanCloseLen = p.close.length;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (orphanCloseIdx !== -1 && (openIdx === -1 || orphanCloseIdx < openIdx)) {
|
|
113
|
+
orphanConsumed = true;
|
|
114
|
+
reasoning += text.slice(0, orphanCloseIdx);
|
|
115
|
+
text = text.slice(orphanCloseIdx + orphanCloseLen).replace(/^\n+/, '');
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (openIdx === -1 || !pair) {
|
|
119
|
+
const hold = flushAll ? 0 : partialTagSuffixLength(text);
|
|
120
|
+
visible += text.slice(0, text.length - hold);
|
|
121
|
+
pending = text.slice(text.length - hold);
|
|
122
|
+
text = '';
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
visible += text.slice(0, openIdx);
|
|
126
|
+
text = text.slice(openIdx + pair.open.length);
|
|
127
|
+
inReasoning = true;
|
|
128
|
+
closeTag = pair.close;
|
|
129
|
+
}
|
|
130
|
+
if (visible.trim().length > 0)
|
|
131
|
+
emittedVisible = true;
|
|
132
|
+
return { visible, reasoning };
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
push: (chunk) => process(chunk, false),
|
|
136
|
+
end: () => process('', true),
|
|
137
|
+
get inReasoning() {
|
|
138
|
+
return inReasoning;
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/** Whole-string form: `{ visible, reasoning }` for already-buffered text. */
|
|
143
|
+
function stripReasoningBlocksDetailed(text) {
|
|
144
|
+
if (!text)
|
|
145
|
+
return { visible: '', reasoning: '' };
|
|
146
|
+
const stripper = createReasoningStripper();
|
|
147
|
+
const a = stripper.push(text);
|
|
148
|
+
const b = stripper.end();
|
|
149
|
+
return { visible: (a.visible + b.visible).trim(), reasoning: a.reasoning + b.reasoning };
|
|
150
|
+
}
|
|
151
|
+
/** Whole-string form returning only the visible text. */
|
|
152
|
+
function stripReasoningBlocks(text) {
|
|
153
|
+
return stripReasoningBlocksDetailed(text).visible;
|
|
154
|
+
}
|
|
155
|
+
/** Cheap pre-check: does `text` carry any inline reasoning tag (open or close)? */
|
|
156
|
+
function containsReasoningBlock(text) {
|
|
157
|
+
const lower = text.toLowerCase();
|
|
158
|
+
return TAG_PAIRS.some((p) => lower.includes(p.open) || lower.includes(p.close));
|
|
159
|
+
}
|