@juspay/neurolink 12.12.16 → 12.14.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/CHANGELOG.md +3 -3
- package/dist/browser/neurolink.min.js +381 -381
- package/dist/cli/commands/proxy.js +11 -9
- package/dist/cli/proxy-clients/grok.d.ts +45 -0
- package/dist/cli/proxy-clients/grok.js +342 -0
- package/dist/cli/proxy-clients/registry.d.ts +2 -2
- package/dist/cli/proxy-clients/registry.js +4 -2
- package/dist/constants/proxyModels.d.ts +7 -1
- package/dist/constants/proxyModels.js +7 -1
- package/dist/core/baseProvider.d.ts +10 -0
- package/dist/core/baseProvider.js +21 -0
- package/dist/core/constants.d.ts +14 -1
- package/dist/core/constants.js +18 -1
- package/dist/core/loopEngine.js +150 -7
- package/dist/core/toolExecutionGuards.js +6 -1
- package/dist/providers/amazonBedrock/client.js +10 -0
- package/dist/providers/anthropic/client.d.ts +8 -0
- package/dist/providers/anthropic/client.js +132 -3
- package/dist/providers/anthropic/loopAdapter.js +352 -244
- package/dist/providers/googleAiStudio/client.js +12 -0
- package/dist/providers/googleVertex/client.js +19 -5
- package/dist/proxy/clientAttribution.js +3 -0
- package/dist/proxy/proxyConfig.d.ts +4 -0
- package/dist/proxy/proxyConfig.js +13 -1
- package/dist/types/generate.d.ts +22 -9
- package/dist/types/loopEngine.d.ts +97 -2
- package/dist/types/proxyClient.d.ts +34 -1
- package/dist/types/stream.d.ts +141 -5
- package/dist/utils/parameterValidation.d.ts +30 -0
- package/dist/utils/parameterValidation.js +101 -0
- package/dist/utils/timeout.js +25 -2
- package/package.json +2 -1
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
import { resolveDeferredTool } from "../../tools/toolDiscovery.js";
|
|
25
25
|
import { stringifyAnthropicToolOutput } from "./toolOutput.js";
|
|
26
26
|
import { stringifyFinalResultInput } from "./structuredOutput.js";
|
|
27
|
+
import { composeAbortSignalsScoped, createTimeoutController, } from "../../utils/timeout.js";
|
|
28
|
+
import { NeuroLinkError } from "../../utils/errorHandling.js";
|
|
29
|
+
import { ErrorCategory, ErrorSeverity } from "../../constants/enums.js";
|
|
27
30
|
/** Map Anthropic's stop_reason onto the unified finish reason. */
|
|
28
31
|
function mapAnthropicFinishReason(rawStopReason, hadToolCallsAtCap) {
|
|
29
32
|
switch (rawStopReason) {
|
|
@@ -37,6 +40,298 @@ function mapAnthropicFinishReason(rawStopReason, hadToolCallsAtCap) {
|
|
|
37
40
|
return hadToolCallsAtCap ? "tool-calls" : "stop";
|
|
38
41
|
}
|
|
39
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Read one streamed Anthropic step off the wire.
|
|
45
|
+
*
|
|
46
|
+
* Module-level rather than nested inside `executeStep` because it IS the step:
|
|
47
|
+
* folding an SSE event sequence into content blocks, usage and a stop reason.
|
|
48
|
+
* `executeStep` above it is now only the per-request deadline wiring, which is
|
|
49
|
+
* the part a reader looking for "what bounds this request" needs to find.
|
|
50
|
+
*/
|
|
51
|
+
async function readAnthropicStep({ params, client, channel, stepSignal, requestTimeoutSignal, noteObservedPromptTokens, finalResultToolName, onTerminalResult, requireTerminalEvent, }) {
|
|
52
|
+
// Single assertion, not a double: `messages.create` returns a union of
|
|
53
|
+
// Message and Stream, and Stream<RawMessageStreamEvent> already IS an
|
|
54
|
+
// AsyncIterable of that event, so the two types overlap and the
|
|
55
|
+
// compiler still checks the narrowing.
|
|
56
|
+
const events = (await client.messages.create({ ...params, stream: true },
|
|
57
|
+
// The engine's signal composed with this step's own deadline.
|
|
58
|
+
// runAgenticLoop already derives the engine signal from the caller's
|
|
59
|
+
// abortSignal, so preferring config's would ignore engine-initiated
|
|
60
|
+
// cancellation entirely.
|
|
61
|
+
{ signal: stepSignal }));
|
|
62
|
+
const textByIndex = new Map();
|
|
63
|
+
const toolByIndex = new Map();
|
|
64
|
+
const thinkingByIndex = new Map();
|
|
65
|
+
const redactedByIndex = new Map();
|
|
66
|
+
let text = "";
|
|
67
|
+
let reasoning = "";
|
|
68
|
+
let rawStopReason;
|
|
69
|
+
let inputTokens = 0;
|
|
70
|
+
let outputTokens = 0;
|
|
71
|
+
let cacheReadTokens = 0;
|
|
72
|
+
let cacheWriteTokens = 0;
|
|
73
|
+
// Reported alongside the total, not derivable from it: the two TTL
|
|
74
|
+
// tiers are priced differently, so a caller that reports them (the
|
|
75
|
+
// Claude-on-Vertex turn span does) cannot reconstruct the split from
|
|
76
|
+
// cacheWriteTokens alone.
|
|
77
|
+
let cacheWrite5mTokens = 0;
|
|
78
|
+
let cacheWrite1hTokens = 0;
|
|
79
|
+
let stepOutputTokens = 0;
|
|
80
|
+
// Anthropic closes every complete message with `message_stop`. Its
|
|
81
|
+
// absence is the only signal that the response ended early, because the
|
|
82
|
+
// content blocks that DID arrive are syntactically complete.
|
|
83
|
+
let sawTerminalEvent = false;
|
|
84
|
+
for await (const rawEvent of events) {
|
|
85
|
+
if (stepSignal.aborted) {
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
// Narrowed through the SDK's own discriminated union rather than
|
|
89
|
+
// re-declared: `RawMessageStreamEvent` already describes every event
|
|
90
|
+
// shape, so `event.type` checks below are compiler-checked instead of
|
|
91
|
+
// asserted.
|
|
92
|
+
const event = rawEvent;
|
|
93
|
+
if (event.type === "message_start") {
|
|
94
|
+
const usage = event.message?.usage;
|
|
95
|
+
inputTokens += usage?.input_tokens ?? 0;
|
|
96
|
+
const startOutput = usage?.output_tokens ?? 0;
|
|
97
|
+
outputTokens += startOutput - stepOutputTokens;
|
|
98
|
+
stepOutputTokens = startOutput;
|
|
99
|
+
// Anthropic reports cache reads/writes separately from input_tokens
|
|
100
|
+
// on this same event; without these the stream drops all cache
|
|
101
|
+
// accounting.
|
|
102
|
+
cacheReadTokens += usage?.cache_read_input_tokens ?? 0;
|
|
103
|
+
cacheWriteTokens += usage?.cache_creation_input_tokens ?? 0;
|
|
104
|
+
// BEST EFFORT, and the limit is worth stating. The nested TTL
|
|
105
|
+
// breakdown exists only on `Usage` (this event); `MessageDeltaUsage`
|
|
106
|
+
// carries the cache TOTALS but not the split, so message_start is
|
|
107
|
+
// the only place in the raw event stream it can come from. The
|
|
108
|
+
// pre-migration loop read it off `stream.finalMessage()` — the SDK's
|
|
109
|
+
// ACCUMULATED message — so if the API leaves `cache_creation` null
|
|
110
|
+
// here and fills it only on the assembled message, these two stay
|
|
111
|
+
// zero and the totals above remain correct regardless.
|
|
112
|
+
// Reported as undefined rather than a false zero when absent.
|
|
113
|
+
cacheWrite5mTokens +=
|
|
114
|
+
usage?.cache_creation?.ephemeral_5m_input_tokens ?? 0;
|
|
115
|
+
cacheWrite1hTokens +=
|
|
116
|
+
usage?.cache_creation?.ephemeral_1h_input_tokens ?? 0;
|
|
117
|
+
// The guard calibrates from the FULL prompt size, not input_tokens
|
|
118
|
+
// alone: on a cache hit the uncached remainder is tiny and using it
|
|
119
|
+
// would let the guard drift far under the real cost.
|
|
120
|
+
noteObservedPromptTokens?.((usage?.input_tokens ?? 0) +
|
|
121
|
+
(usage?.cache_read_input_tokens ?? 0) +
|
|
122
|
+
(usage?.cache_creation_input_tokens ?? 0));
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (event.type === "content_block_start") {
|
|
126
|
+
const index = event.index ?? 0;
|
|
127
|
+
// A redacted_thinking block carries its whole payload here and
|
|
128
|
+
// produces no deltas, so if it is not captured on this event it is
|
|
129
|
+
// never seen again. Anthropic validates the thinking chain when
|
|
130
|
+
// extended thinking continues across a tool-use turn, so a missing
|
|
131
|
+
// one fails the NEXT request — and only for accounts where safety
|
|
132
|
+
// redaction actually triggers, which is why it survives testing.
|
|
133
|
+
if (event.content_block?.type === "redacted_thinking") {
|
|
134
|
+
const data = event.content_block.data;
|
|
135
|
+
if (typeof data === "string") {
|
|
136
|
+
redactedByIndex.set(index, data);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (event.content_block?.type === "tool_use") {
|
|
140
|
+
toolByIndex.set(index, {
|
|
141
|
+
id: event.content_block.id ?? "",
|
|
142
|
+
name: event.content_block.name ?? "",
|
|
143
|
+
inputJson: "",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (event.type === "content_block_delta") {
|
|
149
|
+
const index = event.index ?? 0;
|
|
150
|
+
const delta = event.delta;
|
|
151
|
+
if (delta?.type === "text_delta" && delta.text) {
|
|
152
|
+
text += delta.text;
|
|
153
|
+
textByIndex.set(index, (textByIndex.get(index) ?? "") + delta.text);
|
|
154
|
+
channel.push({ content: delta.text });
|
|
155
|
+
}
|
|
156
|
+
else if (delta?.type === "thinking_delta" && delta.thinking) {
|
|
157
|
+
const acc = thinkingByIndex.get(index) ?? {
|
|
158
|
+
text: "",
|
|
159
|
+
signature: "",
|
|
160
|
+
};
|
|
161
|
+
acc.text += delta.thinking;
|
|
162
|
+
thinkingByIndex.set(index, acc);
|
|
163
|
+
reasoning += delta.thinking;
|
|
164
|
+
// Reasoning rides its own field; `content` stays a present string
|
|
165
|
+
// so plain-text consumers are unaffected.
|
|
166
|
+
channel.push({ content: "", reasoning: delta.thinking });
|
|
167
|
+
}
|
|
168
|
+
else if (delta?.type === "signature_delta" && delta.signature) {
|
|
169
|
+
const acc = thinkingByIndex.get(index) ?? {
|
|
170
|
+
text: "",
|
|
171
|
+
signature: "",
|
|
172
|
+
};
|
|
173
|
+
acc.signature += delta.signature;
|
|
174
|
+
thinkingByIndex.set(index, acc);
|
|
175
|
+
}
|
|
176
|
+
else if (delta?.type === "input_json_delta" && delta.partial_json) {
|
|
177
|
+
const pending = toolByIndex.get(index);
|
|
178
|
+
if (pending) {
|
|
179
|
+
pending.inputJson += delta.partial_json;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (event.type === "message_delta") {
|
|
185
|
+
rawStopReason = event.delta?.stop_reason ?? rawStopReason;
|
|
186
|
+
const cumulative = event.usage?.output_tokens ?? stepOutputTokens;
|
|
187
|
+
outputTokens += cumulative - stepOutputTokens;
|
|
188
|
+
stepOutputTokens = cumulative;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (event.type === "message_stop") {
|
|
192
|
+
sawTerminalEvent = true;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// Ordered ahead of the terminal-event check because a deadline that
|
|
196
|
+
// fired IS why the terminal event is missing, and the timer's identity
|
|
197
|
+
// is the more useful of the two answers. The SDK's stream iterator exits
|
|
198
|
+
// without throwing on an aborted read, so nothing else would report it:
|
|
199
|
+
// the step would return the partial content as though the model had
|
|
200
|
+
// simply said less.
|
|
201
|
+
if (requestTimeoutSignal?.aborted) {
|
|
202
|
+
throw requestTimeoutSignal.reason;
|
|
203
|
+
}
|
|
204
|
+
// A response that ended before its terminal event is not a turn. Its
|
|
205
|
+
// tool_use blocks parse perfectly, so returning them here would dispatch
|
|
206
|
+
// tools the model never finished asking for, and the turn would go on to
|
|
207
|
+
// report itself as a normal stop. Not retriable: the request was already
|
|
208
|
+
// answered, and re-sending it would double any side effect the truncated
|
|
209
|
+
// half already caused upstream.
|
|
210
|
+
if (requireTerminalEvent && !sawTerminalEvent && !stepSignal.aborted) {
|
|
211
|
+
throw new NeuroLinkError({
|
|
212
|
+
code: "ANTHROPIC_STREAM_TRUNCATED",
|
|
213
|
+
message: "Anthropic stream ended before its terminal message_stop event; the turn is incomplete and its content blocks were not dispatched.",
|
|
214
|
+
category: ErrorCategory.NETWORK,
|
|
215
|
+
severity: ErrorSeverity.HIGH,
|
|
216
|
+
retriable: false,
|
|
217
|
+
context: { provider: "anthropic", rawStopReason },
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
// JSON.parse happily yields null, an array or a primitive, and an
|
|
221
|
+
// assertion converts none of them. Both `tool_use.input` and
|
|
222
|
+
// AgenticLoopToolCall.args require an object, so anything else becomes
|
|
223
|
+
// {} rather than being passed through as invalid wire content.
|
|
224
|
+
const parseArgs = (json) => {
|
|
225
|
+
if (!json) {
|
|
226
|
+
return {};
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
const parsed = JSON.parse(json);
|
|
230
|
+
return typeof parsed === "object" &&
|
|
231
|
+
parsed !== null &&
|
|
232
|
+
!Array.isArray(parsed)
|
|
233
|
+
? parsed
|
|
234
|
+
: {};
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return {};
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
// Rebuild the assistant turn in wire order so it can be replayed as the
|
|
241
|
+
// assistant message on the next step. Text that arrived before a tool
|
|
242
|
+
// call has to survive here, or the model stops seeing its own reasoning
|
|
243
|
+
// mid-turn.
|
|
244
|
+
const blocks = [];
|
|
245
|
+
const indices = new Set([
|
|
246
|
+
...textByIndex.keys(),
|
|
247
|
+
...toolByIndex.keys(),
|
|
248
|
+
...thinkingByIndex.keys(),
|
|
249
|
+
...redactedByIndex.keys(),
|
|
250
|
+
]);
|
|
251
|
+
for (const index of [...indices].sort((a, b) => a - b)) {
|
|
252
|
+
// Thinking blocks are replayed with their signature. Anthropic
|
|
253
|
+
// validates that signature when extended thinking continues across
|
|
254
|
+
// turns, so dropping the block — or keeping the text without the
|
|
255
|
+
// signature — breaks the next step of a thinking turn.
|
|
256
|
+
const redacted = redactedByIndex.get(index);
|
|
257
|
+
if (redacted) {
|
|
258
|
+
blocks.push({ type: "redacted_thinking", data: redacted });
|
|
259
|
+
}
|
|
260
|
+
const thinking = thinkingByIndex.get(index);
|
|
261
|
+
// Both halves required: Anthropic's thinking block carries a
|
|
262
|
+
// mandatory signature, and replaying one with an empty string is
|
|
263
|
+
// rejected outright. A thinking block that never received a
|
|
264
|
+
// signature_delta is dropped rather than sent unsigned.
|
|
265
|
+
if (thinking?.text && thinking.signature) {
|
|
266
|
+
blocks.push({
|
|
267
|
+
type: "thinking",
|
|
268
|
+
thinking: thinking.text,
|
|
269
|
+
signature: thinking.signature,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
const blockText = textByIndex.get(index);
|
|
273
|
+
if (blockText) {
|
|
274
|
+
blocks.push({
|
|
275
|
+
type: "text",
|
|
276
|
+
text: blockText,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
const pending = toolByIndex.get(index);
|
|
280
|
+
if (pending) {
|
|
281
|
+
blocks.push({
|
|
282
|
+
type: "tool_use",
|
|
283
|
+
id: pending.id,
|
|
284
|
+
name: pending.name,
|
|
285
|
+
input: parseArgs(pending.inputJson),
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const allCalls = [...toolByIndex.values()].map((pending) => ({
|
|
290
|
+
id: pending.id,
|
|
291
|
+
name: pending.name,
|
|
292
|
+
args: parseArgs(pending.inputJson),
|
|
293
|
+
}));
|
|
294
|
+
// A terminal structured-output call ends the turn: its arguments are the
|
|
295
|
+
// answer. Reporting it as text and leaving it out of `toolCalls` is what
|
|
296
|
+
// routes it through the engine's ordinary zero-tool-calls exit, so it is
|
|
297
|
+
// never dispatched, never counted against the breaker, and never shows
|
|
298
|
+
// up as a tool execution.
|
|
299
|
+
const terminal = finalResultToolName
|
|
300
|
+
? [...toolByIndex.values()].find((pending) => pending.name === finalResultToolName)
|
|
301
|
+
: undefined;
|
|
302
|
+
const toolCalls = terminal ? [] : allCalls;
|
|
303
|
+
// The RAW accumulated input_json, never the parsed-then-restringified
|
|
304
|
+
// args. `parseArgs` yields {} for a payload the token cap cut off
|
|
305
|
+
// mid-string, so re-stringifying would turn a truncated answer into
|
|
306
|
+
// "{}" and lose it outright. `stringifyFinalResultInput` canonicalizes
|
|
307
|
+
// when the JSON parses and returns it verbatim when it does not, which
|
|
308
|
+
// is what lets the caller's coercion layer repair a partial payload
|
|
309
|
+
// into a partial object instead of nothing.
|
|
310
|
+
const finalText = terminal
|
|
311
|
+
? stringifyFinalResultInput(terminal.inputJson)
|
|
312
|
+
: text;
|
|
313
|
+
if (terminal) {
|
|
314
|
+
onTerminalResult?.(finalText);
|
|
315
|
+
}
|
|
316
|
+
return {
|
|
317
|
+
text: finalText,
|
|
318
|
+
...(reasoning ? { reasoning } : {}),
|
|
319
|
+
toolCalls,
|
|
320
|
+
usage: {
|
|
321
|
+
inputTokens,
|
|
322
|
+
outputTokens,
|
|
323
|
+
cacheReadTokens,
|
|
324
|
+
cacheWriteTokens,
|
|
325
|
+
// Omitted entirely when the stream never reported a split, so a
|
|
326
|
+
// consumer can tell "no TTL breakdown available" from "zero tokens
|
|
327
|
+
// in that tier".
|
|
328
|
+
...(cacheWrite5mTokens ? { cacheWrite5mTokens } : {}),
|
|
329
|
+
...(cacheWrite1hTokens ? { cacheWrite1hTokens } : {}),
|
|
330
|
+
},
|
|
331
|
+
rawStopReason,
|
|
332
|
+
raw: blocks,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
40
335
|
export function createAnthropicLoopAdapter(config) {
|
|
41
336
|
return {
|
|
42
337
|
providerLabel: "anthropic",
|
|
@@ -79,254 +374,67 @@ export function createAnthropicLoopAdapter(config) {
|
|
|
79
374
|
},
|
|
80
375
|
async executeStep(request, channel, signal) {
|
|
81
376
|
const params = request.raw;
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (event.type === "message_start") {
|
|
119
|
-
const usage = event.message?.usage;
|
|
120
|
-
inputTokens += usage?.input_tokens ?? 0;
|
|
121
|
-
const startOutput = usage?.output_tokens ?? 0;
|
|
122
|
-
outputTokens += startOutput - stepOutputTokens;
|
|
123
|
-
stepOutputTokens = startOutput;
|
|
124
|
-
// Anthropic reports cache reads/writes separately from input_tokens
|
|
125
|
-
// on this same event; without these the stream drops all cache
|
|
126
|
-
// accounting.
|
|
127
|
-
cacheReadTokens += usage?.cache_read_input_tokens ?? 0;
|
|
128
|
-
cacheWriteTokens += usage?.cache_creation_input_tokens ?? 0;
|
|
129
|
-
// BEST EFFORT, and the limit is worth stating. The nested TTL
|
|
130
|
-
// breakdown exists only on `Usage` (this event); `MessageDeltaUsage`
|
|
131
|
-
// carries the cache TOTALS but not the split, so message_start is
|
|
132
|
-
// the only place in the raw event stream it can come from. The
|
|
133
|
-
// pre-migration loop read it off `stream.finalMessage()` — the SDK's
|
|
134
|
-
// ACCUMULATED message — so if the API leaves `cache_creation` null
|
|
135
|
-
// here and fills it only on the assembled message, these two stay
|
|
136
|
-
// zero and the totals above remain correct regardless.
|
|
137
|
-
// Reported as undefined rather than a false zero when absent.
|
|
138
|
-
cacheWrite5mTokens +=
|
|
139
|
-
usage?.cache_creation?.ephemeral_5m_input_tokens ?? 0;
|
|
140
|
-
cacheWrite1hTokens +=
|
|
141
|
-
usage?.cache_creation?.ephemeral_1h_input_tokens ?? 0;
|
|
142
|
-
// The guard calibrates from the FULL prompt size, not input_tokens
|
|
143
|
-
// alone: on a cache hit the uncached remainder is tiny and using it
|
|
144
|
-
// would let the guard drift far under the real cost.
|
|
145
|
-
config.noteObservedPromptTokens?.((usage?.input_tokens ?? 0) +
|
|
146
|
-
(usage?.cache_read_input_tokens ?? 0) +
|
|
147
|
-
(usage?.cache_creation_input_tokens ?? 0));
|
|
148
|
-
continue;
|
|
149
|
-
}
|
|
150
|
-
if (event.type === "content_block_start") {
|
|
151
|
-
const index = event.index ?? 0;
|
|
152
|
-
// A redacted_thinking block carries its whole payload here and
|
|
153
|
-
// produces no deltas, so if it is not captured on this event it is
|
|
154
|
-
// never seen again. Anthropic validates the thinking chain when
|
|
155
|
-
// extended thinking continues across a tool-use turn, so a missing
|
|
156
|
-
// one fails the NEXT request — and only for accounts where safety
|
|
157
|
-
// redaction actually triggers, which is why it survives testing.
|
|
158
|
-
if (event.content_block?.type === "redacted_thinking") {
|
|
159
|
-
const data = event.content_block.data;
|
|
160
|
-
if (typeof data === "string") {
|
|
161
|
-
redactedByIndex.set(index, data);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
if (event.content_block?.type === "tool_use") {
|
|
165
|
-
toolByIndex.set(index, {
|
|
166
|
-
id: event.content_block.id ?? "",
|
|
167
|
-
name: event.content_block.name ?? "",
|
|
168
|
-
inputJson: "",
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
continue;
|
|
172
|
-
}
|
|
173
|
-
if (event.type === "content_block_delta") {
|
|
174
|
-
const index = event.index ?? 0;
|
|
175
|
-
const delta = event.delta;
|
|
176
|
-
if (delta?.type === "text_delta" && delta.text) {
|
|
177
|
-
text += delta.text;
|
|
178
|
-
textByIndex.set(index, (textByIndex.get(index) ?? "") + delta.text);
|
|
179
|
-
channel.push({ content: delta.text });
|
|
180
|
-
}
|
|
181
|
-
else if (delta?.type === "thinking_delta" && delta.thinking) {
|
|
182
|
-
const acc = thinkingByIndex.get(index) ?? {
|
|
183
|
-
text: "",
|
|
184
|
-
signature: "",
|
|
185
|
-
};
|
|
186
|
-
acc.text += delta.thinking;
|
|
187
|
-
thinkingByIndex.set(index, acc);
|
|
188
|
-
reasoning += delta.thinking;
|
|
189
|
-
// Reasoning rides its own field; `content` stays a present string
|
|
190
|
-
// so plain-text consumers are unaffected.
|
|
191
|
-
channel.push({ content: "", reasoning: delta.thinking });
|
|
192
|
-
}
|
|
193
|
-
else if (delta?.type === "signature_delta" && delta.signature) {
|
|
194
|
-
const acc = thinkingByIndex.get(index) ?? {
|
|
195
|
-
text: "",
|
|
196
|
-
signature: "",
|
|
197
|
-
};
|
|
198
|
-
acc.signature += delta.signature;
|
|
199
|
-
thinkingByIndex.set(index, acc);
|
|
200
|
-
}
|
|
201
|
-
else if (delta?.type === "input_json_delta" && delta.partial_json) {
|
|
202
|
-
const pending = toolByIndex.get(index);
|
|
203
|
-
if (pending) {
|
|
204
|
-
pending.inputJson += delta.partial_json;
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
continue;
|
|
208
|
-
}
|
|
209
|
-
if (event.type === "message_delta") {
|
|
210
|
-
rawStopReason = event.delta?.stop_reason ?? rawStopReason;
|
|
211
|
-
const cumulative = event.usage?.output_tokens ?? stepOutputTokens;
|
|
212
|
-
outputTokens += cumulative - stepOutputTokens;
|
|
213
|
-
stepOutputTokens = cumulative;
|
|
214
|
-
}
|
|
377
|
+
// Per-REQUEST deadline, armed fresh for this step and disposed the moment
|
|
378
|
+
// it settles. Being armed per step is what makes it unresettable from a
|
|
379
|
+
// step boundary: the boundary runs between two steps, when no request
|
|
380
|
+
// deadline is running at all.
|
|
381
|
+
//
|
|
382
|
+
// Scoped composition rather than AbortSignal.any: the engine signal lives
|
|
383
|
+
// for the whole turn, and `any` keeps its registration on that signal
|
|
384
|
+
// until the derived one is collected, so composing per step accumulates
|
|
385
|
+
// listeners for the turn's whole length.
|
|
386
|
+
const requestTimeout = config.requestTimeoutMs
|
|
387
|
+
? createTimeoutController(config.requestTimeoutMs, "anthropic", "stream")
|
|
388
|
+
: null;
|
|
389
|
+
const composed = composeAbortSignalsScoped(signal, requestTimeout?.controller.signal);
|
|
390
|
+
try {
|
|
391
|
+
return await readAnthropicStep({
|
|
392
|
+
params,
|
|
393
|
+
client: config.client,
|
|
394
|
+
channel,
|
|
395
|
+
// The engine's signal, not config's: runAgenticLoop already derives
|
|
396
|
+
// it from the caller's abortSignal, so preferring config's would
|
|
397
|
+
// ignore engine-initiated cancellation entirely.
|
|
398
|
+
stepSignal: composed.signal ?? signal,
|
|
399
|
+
requestTimeoutSignal: requestTimeout?.controller.signal,
|
|
400
|
+
...(config.noteObservedPromptTokens
|
|
401
|
+
? { noteObservedPromptTokens: config.noteObservedPromptTokens }
|
|
402
|
+
: {}),
|
|
403
|
+
...(config.finalResultToolName
|
|
404
|
+
? { finalResultToolName: config.finalResultToolName }
|
|
405
|
+
: {}),
|
|
406
|
+
...(config.onTerminalResult
|
|
407
|
+
? { onTerminalResult: config.onTerminalResult }
|
|
408
|
+
: {}),
|
|
409
|
+
...(config.requireTerminalEvent
|
|
410
|
+
? { requireTerminalEvent: config.requireTerminalEvent }
|
|
411
|
+
: {}),
|
|
412
|
+
});
|
|
215
413
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
// {} rather than being passed through as invalid wire content.
|
|
220
|
-
const parseArgs = (json) => {
|
|
221
|
-
if (!json) {
|
|
222
|
-
return {};
|
|
223
|
-
}
|
|
224
|
-
try {
|
|
225
|
-
const parsed = JSON.parse(json);
|
|
226
|
-
return typeof parsed === "object" &&
|
|
227
|
-
parsed !== null &&
|
|
228
|
-
!Array.isArray(parsed)
|
|
229
|
-
? parsed
|
|
230
|
-
: {};
|
|
231
|
-
}
|
|
232
|
-
catch {
|
|
233
|
-
return {};
|
|
234
|
-
}
|
|
235
|
-
};
|
|
236
|
-
// Rebuild the assistant turn in wire order so it can be replayed as the
|
|
237
|
-
// assistant message on the next step. Text that arrived before a tool
|
|
238
|
-
// call has to survive here, or the model stops seeing its own reasoning
|
|
239
|
-
// mid-turn.
|
|
240
|
-
const blocks = [];
|
|
241
|
-
const indices = new Set([
|
|
242
|
-
...textByIndex.keys(),
|
|
243
|
-
...toolByIndex.keys(),
|
|
244
|
-
...thinkingByIndex.keys(),
|
|
245
|
-
...redactedByIndex.keys(),
|
|
246
|
-
]);
|
|
247
|
-
for (const index of [...indices].sort((a, b) => a - b)) {
|
|
248
|
-
// Thinking blocks are replayed with their signature. Anthropic
|
|
249
|
-
// validates that signature when extended thinking continues across
|
|
250
|
-
// turns, so dropping the block — or keeping the text without the
|
|
251
|
-
// signature — breaks the next step of a thinking turn.
|
|
252
|
-
const redacted = redactedByIndex.get(index);
|
|
253
|
-
if (redacted) {
|
|
254
|
-
blocks.push({ type: "redacted_thinking", data: redacted });
|
|
255
|
-
}
|
|
256
|
-
const thinking = thinkingByIndex.get(index);
|
|
257
|
-
// Both halves required: Anthropic's thinking block carries a
|
|
258
|
-
// mandatory signature, and replaying one with an empty string is
|
|
259
|
-
// rejected outright. A thinking block that never received a
|
|
260
|
-
// signature_delta is dropped rather than sent unsigned.
|
|
261
|
-
if (thinking?.text && thinking.signature) {
|
|
262
|
-
blocks.push({
|
|
263
|
-
type: "thinking",
|
|
264
|
-
thinking: thinking.text,
|
|
265
|
-
signature: thinking.signature,
|
|
266
|
-
});
|
|
267
|
-
}
|
|
268
|
-
const blockText = textByIndex.get(index);
|
|
269
|
-
if (blockText) {
|
|
270
|
-
blocks.push({
|
|
271
|
-
type: "text",
|
|
272
|
-
text: blockText,
|
|
273
|
-
});
|
|
274
|
-
}
|
|
275
|
-
const pending = toolByIndex.get(index);
|
|
276
|
-
if (pending) {
|
|
277
|
-
blocks.push({
|
|
278
|
-
type: "tool_use",
|
|
279
|
-
id: pending.id,
|
|
280
|
-
name: pending.name,
|
|
281
|
-
input: parseArgs(pending.inputJson),
|
|
282
|
-
});
|
|
283
|
-
}
|
|
414
|
+
finally {
|
|
415
|
+
composed.dispose();
|
|
416
|
+
requestTimeout?.cleanup();
|
|
284
417
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
//
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
// The RAW accumulated input_json, never the parsed-then-restringified
|
|
300
|
-
// args. `parseArgs` yields {} for a payload the token cap cut off
|
|
301
|
-
// mid-string, so re-stringifying would turn a truncated answer into
|
|
302
|
-
// "{}" and lose it outright. `stringifyFinalResultInput` canonicalizes
|
|
303
|
-
// when the JSON parses and returns it verbatim when it does not, which
|
|
304
|
-
// is what lets the caller's coercion layer repair a partial payload
|
|
305
|
-
// into a partial object instead of nothing.
|
|
306
|
-
const finalText = terminal
|
|
307
|
-
? stringifyFinalResultInput(terminal.inputJson)
|
|
308
|
-
: text;
|
|
309
|
-
if (terminal) {
|
|
310
|
-
config.onTerminalResult?.(finalText);
|
|
418
|
+
},
|
|
419
|
+
appendPlanningNudge(conversation, text) {
|
|
420
|
+
// Merged into the trailing user turn when there is one. The step boundary
|
|
421
|
+
// runs immediately after `buildToolResultMessages`, so history ends on a
|
|
422
|
+
// user tool_result message; opening a second consecutive user message
|
|
423
|
+
// there is a shape the wire format does not require anyone to accept.
|
|
424
|
+
const nudgeBlock = { type: "text", text };
|
|
425
|
+
const last = conversation[conversation.length - 1];
|
|
426
|
+
if (last?.role === "user" && Array.isArray(last.content)) {
|
|
427
|
+
const merged = {
|
|
428
|
+
...last,
|
|
429
|
+
content: [...last.content, nudgeBlock],
|
|
430
|
+
};
|
|
431
|
+
return [...conversation.slice(0, -1), merged];
|
|
311
432
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
toolCalls,
|
|
316
|
-
usage: {
|
|
317
|
-
inputTokens,
|
|
318
|
-
outputTokens,
|
|
319
|
-
cacheReadTokens,
|
|
320
|
-
cacheWriteTokens,
|
|
321
|
-
// Omitted entirely when the stream never reported a split, so a
|
|
322
|
-
// consumer can tell "no TTL breakdown available" from "zero tokens
|
|
323
|
-
// in that tier".
|
|
324
|
-
...(cacheWrite5mTokens ? { cacheWrite5mTokens } : {}),
|
|
325
|
-
...(cacheWrite1hTokens ? { cacheWrite1hTokens } : {}),
|
|
326
|
-
},
|
|
327
|
-
rawStopReason,
|
|
328
|
-
raw: blocks,
|
|
433
|
+
const message = {
|
|
434
|
+
role: "user",
|
|
435
|
+
content: [nudgeBlock],
|
|
329
436
|
};
|
|
437
|
+
return [...conversation, message];
|
|
330
438
|
},
|
|
331
439
|
buildToolResultMessages(conversation, stepResult, toolResults) {
|
|
332
440
|
const assistantMessage = {
|
|
@@ -851,6 +851,13 @@ export class GoogleAIStudioProvider extends BaseProvider {
|
|
|
851
851
|
const { stream: engineStream, resultPromise } = runAgenticLoop(adapter, currentContents, {
|
|
852
852
|
tools: engineTools,
|
|
853
853
|
...(composedSignal ? { abortSignal: composedSignal } : {}),
|
|
854
|
+
// This loop guards no executor of its own, so the engine's
|
|
855
|
+
// per-tool bound is the only thing standing between a wedged
|
|
856
|
+
// tool and a turn that never ends. Honour the caller's value
|
|
857
|
+
// when there is one; the engine defaults otherwise.
|
|
858
|
+
...(options.toolTimeoutMs !== undefined
|
|
859
|
+
? { toolTimeoutMs: options.toolTimeoutMs }
|
|
860
|
+
: {}),
|
|
854
861
|
});
|
|
855
862
|
const pump = (async () => {
|
|
856
863
|
for await (const chunk of engineStream) {
|
|
@@ -1161,6 +1168,11 @@ export class GoogleAIStudioProvider extends BaseProvider {
|
|
|
1161
1168
|
const { stream: engineStream, resultPromise } = runAgenticLoop(adapter, currentContents, {
|
|
1162
1169
|
tools: engineTools,
|
|
1163
1170
|
...(composedSignal ? { abortSignal: composedSignal } : {}),
|
|
1171
|
+
// This loop guards no executor of its own — see the streaming
|
|
1172
|
+
// path above for why the engine's bound has to be reachable.
|
|
1173
|
+
...(options.toolTimeoutMs !== undefined
|
|
1174
|
+
? { toolTimeoutMs: options.toolTimeoutMs }
|
|
1175
|
+
: {}),
|
|
1164
1176
|
});
|
|
1165
1177
|
// Drained, not consumed: nothing streams out of generate(), but an
|
|
1166
1178
|
// undrained channel would stall the engine mid-turn.
|