@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.
- package/README.md +92 -3
- package/UPGRADING.md +43 -1
- package/dist/adapters/engines/anthropic.js +98 -10
- 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 +4 -1
- package/dist/cjs/adapters/engines/anthropic.js +96 -8
- 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 +4 -1
- package/dist/cjs/handler.js +28 -3
- 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 +28 -3
- 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 +25 -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.d.ts.map +1 -1
- package/nugget/dist/adapters/engines/anthropic.js +98 -10
- 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 +4 -1
- package/nugget/dist/agent/loop.js.map +1 -1
- package/nugget/dist/cjs/adapters/engines/anthropic.js +96 -8
- 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 +4 -1
- package/nugget/dist/cjs/handler.js +28 -3
- 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 +28 -3
- 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 +25 -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 +87 -10
- 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 +7 -2
- package/nugget/src/handler.ts +30 -3
- package/nugget/src/index.ts +1 -0
- package/nugget/src/reasoning.ts +174 -0
- package/nugget/src/types.ts +27 -0
- package/nugget/src/util.ts +14 -0
- 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
|
|
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 === '
|
|
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
|
-
|
|
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
|
-
|
|
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, 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) {
|
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') {
|
|
@@ -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
|
-
|
|
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
|
@@ -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;
|