@absolutejs/ai 0.3.4 → 0.4.1

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 CHANGED
@@ -6,6 +6,24 @@ This file is generated by `absolute-changelog` from the entries in
6
6
  `changelog/`. Edit an entry, not this file — and add new ones under
7
7
  `changelog/unreleased/`.
8
8
 
9
+ ## 0.4.1 — 2026-10-17
10
+
11
+ ### Fixed
12
+
13
+ - **Preserve provider usage snapshots through stream interruptions, distinguish partial from final usage, and retain tool-input start events without application patches** (`AIUsageUpdateChunk`, `anthropic`, `openai`, `openaiResponses`, `AIChunk`, `StreamAIWithToolsEvent`, `AIToolInputStartChunk`)
14
+
15
+ ## 0.4.0 — 2026-10-16
16
+
17
+ ### Added
18
+
19
+ - **Report a tool call's input while it streams: a `tool_input` chunk, and a `preparing` tools event from streamAIToSSE** (`AIChunk`, `AIToolInputChunk`, `streamAIToSSE`)
20
+ A tool call with a long input, such as a whole file, takes the model a minute or more to write, and until now nothing arrived meanwhile. The Anthropic provider now yields a `tool_input` chunk when a tool call starts and every 2 KB of its input, with the tool name, the characters received and the top-level string fields already complete (a path, a name; at most 200 characters each, never read from inside other values). With structured events, streamAIToSSE turns it into a `tools` event with status `preparing`. Consumers that do not know the chunk can ignore it.
21
+
22
+ ### Fixed
23
+
24
+ - **streamAIToSSE sends a turn's text back as one block, not one per streamed delta, so agent loops stop re-writing their prompt cache** (`streamAIToSSE`)
25
+ streamAIToSSE stored every streamed text delta as its own content block, so an agent loop sent each turn's text back as dozens of blocks. Anthropic looks back only 20 blocks for an earlier cache entry, and a turn with a few sentences of text pushed the previous request's entry out of reach: the next request re-wrote the whole history at the cache-write price. A run of text is now one block, as generateAI and streamAIWithTools already kept it.
26
+
9
27
  ## 0.3.4 — 2026-10-15
10
28
 
11
29
  ### Fixed
package/README.md CHANGED
@@ -560,3 +560,18 @@ are documented provider limits, not a substitute for runtime token budgeting.
560
560
  Regional features are omitted when they are not consistently supported.
561
561
 
562
562
  `@absolutejs/ai/research` separates an injected `SearchProvider` from evidence-only synthesis. It returns sources, findings, searches, limitations and explicit availability, and excludes invented source references/quotes. Quotation checks establish provenance; they are not a substitute for claim entailment or entity review. Hosts can set `maxRetries: 0` on one-shot or stream requests when they own attempt accounting, and independently bound `maxRepairAttempts` and disable context recovery.
563
+
564
+ ### Interrupted streams and usage
565
+
566
+ Provider streams can emit `usage_update` chunks before `done`. Each carries a
567
+ provider-reported cumulative snapshot: replace the previous snapshot for that
568
+ attempt; never sum snapshots. `final: true` means the provider reported complete
569
+ usage. An absent or false `final` means partial evidence, not a final bill.
570
+ Anthropic exposes message-start and message-delta usage; OpenAI Chat exposes its
571
+ usage chunk, and Responses preserves usage even on incomplete responses.
572
+
573
+ Persist snapshots while consuming the stream. Cancellation or transport failure
574
+ can prevent `done`, and no adapter can reconstruct usage the provider never sent.
575
+ `onUsage` still reports only complete usage, once per instrumented stream. Keep
576
+ credit reservations separate from charges and reconcile unknown outcomes in the
577
+ host application. The package does not invent token counts or decide billing policy.
package/changelog.json CHANGED
@@ -2,6 +2,49 @@
2
2
  "contract": 1,
3
3
  "name": "@absolutejs/ai",
4
4
  "releases": [
5
+ {
6
+ "changes": [
7
+ {
8
+ "kind": "fixed",
9
+ "summary": "Preserve provider usage snapshots through stream interruptions, distinguish partial from final usage, and retain tool-input start events without application patches",
10
+ "symbols": [
11
+ "AIUsageUpdateChunk",
12
+ "anthropic",
13
+ "openai",
14
+ "openaiResponses",
15
+ "AIChunk",
16
+ "StreamAIWithToolsEvent",
17
+ "AIToolInputStartChunk"
18
+ ]
19
+ }
20
+ ],
21
+ "date": "2026-10-17",
22
+ "version": "0.4.1"
23
+ },
24
+ {
25
+ "changes": [
26
+ {
27
+ "detail": "streamAIToSSE stored every streamed text delta as its own content block, so an agent loop sent each turn's text back as dozens of blocks. Anthropic looks back only 20 blocks for an earlier cache entry, and a turn with a few sentences of text pushed the previous request's entry out of reach: the next request re-wrote the whole history at the cache-write price. A run of text is now one block, as generateAI and streamAIWithTools already kept it.",
28
+ "kind": "fixed",
29
+ "summary": "streamAIToSSE sends a turn's text back as one block, not one per streamed delta, so agent loops stop re-writing their prompt cache",
30
+ "symbols": [
31
+ "streamAIToSSE"
32
+ ]
33
+ },
34
+ {
35
+ "detail": "A tool call with a long input, such as a whole file, takes the model a minute or more to write, and until now nothing arrived meanwhile. The Anthropic provider now yields a `tool_input` chunk when a tool call starts and every 2 KB of its input, with the tool name, the characters received and the top-level string fields already complete (a path, a name; at most 200 characters each, never read from inside other values). With structured events, streamAIToSSE turns it into a `tools` event with status `preparing`. Consumers that do not know the chunk can ignore it.",
36
+ "kind": "added",
37
+ "summary": "Report a tool call's input while it streams: a `tool_input` chunk, and a `preparing` tools event from streamAIToSSE",
38
+ "symbols": [
39
+ "AIChunk",
40
+ "AIToolInputChunk",
41
+ "streamAIToSSE"
42
+ ]
43
+ }
44
+ ],
45
+ "date": "2026-10-16",
46
+ "version": "0.4.0"
47
+ },
5
48
  {
6
49
  "changes": [
7
50
  {
package/dist/ai/index.js CHANGED
@@ -999,10 +999,11 @@ var maybeFlushThinking = (chunkState) => {
999
999
  };
1000
1000
  var processTextChunk = function* (content, chunkState, renderers, fullResponse, options) {
1001
1001
  maybeFlushThinking(chunkState);
1002
- chunkState.contentBlocks.push({
1003
- content,
1004
- type: "text"
1005
- });
1002
+ const last = chunkState.contentBlocks.at(-1);
1003
+ if (last?.type === "text")
1004
+ last.content += content;
1005
+ else
1006
+ chunkState.contentBlocks.push({ content, type: "text" });
1006
1007
  yield contentEvent(options, renderers, content, fullResponse + content);
1007
1008
  };
1008
1009
  var processImageChunk = function* (chunk, renderers, options) {
@@ -1056,6 +1057,19 @@ var processChunk = function* (chunk, chunkState, renderers, options, fullRespons
1056
1057
  case "tool_use":
1057
1058
  processToolUseChunk(chunk, chunkState);
1058
1059
  break;
1060
+ case "tool_input":
1061
+ if (options.structuredEvents)
1062
+ yield {
1063
+ data: JSON.stringify({
1064
+ fields: chunk.fields,
1065
+ id: chunk.id,
1066
+ name: chunk.name,
1067
+ receivedChars: chunk.receivedChars,
1068
+ status: "preparing"
1069
+ }),
1070
+ event: "tools"
1071
+ };
1072
+ break;
1059
1073
  case "provider_event":
1060
1074
  maybeFlushThinking(chunkState);
1061
1075
  chunkState.contentBlocks.push({
@@ -2060,6 +2074,8 @@ var streamAIWithTools = async function* (options) {
2060
2074
  transcript: chunk.transcript,
2061
2075
  type: "audio"
2062
2076
  };
2077
+ } else if (chunk.type === "tool_input_start") {
2078
+ yield chunk;
2063
2079
  } else if (chunk.type === "tool_use") {
2064
2080
  thinking = flushThinking3(blocks, thinking);
2065
2081
  pending.push({ id: chunk.id, input: chunk.input, name: chunk.name });
@@ -4860,7 +4876,7 @@ async function* tapStream(source, params, providerName) {
4860
4876
  let lastUsage;
4861
4877
  try {
4862
4878
  for await (const chunk of source) {
4863
- if (chunk.type === "done" && chunk.usage) {
4879
+ if ((chunk.type === "done" || chunk.type === "usage_update" && chunk.final) && chunk.usage) {
4864
4880
  lastUsage = chunk.usage;
4865
4881
  }
4866
4882
  yield chunk;
@@ -5473,6 +5489,8 @@ var processCompleted = function* (parsed) {
5473
5489
  const { response } = parsed;
5474
5490
  const usage = extractUsage(response);
5475
5491
  const metadata = extractResponseMetadata(response);
5492
+ if (usage)
5493
+ yield { type: "usage_update", usage, metadata, final: true };
5476
5494
  if (isRecordArray(response.output)) {
5477
5495
  yield* extractCitationsFromOutput(response.output);
5478
5496
  yield* extractImageFromOutput(response.output);
@@ -5511,6 +5529,15 @@ var processSSEEvent = function* (eventType, parsed, pendingCalls, providerName)
5511
5529
  break;
5512
5530
  case "response.output_item.added":
5513
5531
  processOutputItemAdded(parsed, pendingCalls);
5532
+ if (isRecord2(parsed.item) && parsed.item.type === "function_call" && typeof parsed.item.name === "string") {
5533
+ const id = typeof parsed.item.call_id === "string" ? parsed.item.call_id : parsed.item.id;
5534
+ if (typeof id === "string")
5535
+ yield {
5536
+ type: "tool_input_start",
5537
+ id,
5538
+ name: parsed.item.name
5539
+ };
5540
+ }
5514
5541
  break;
5515
5542
  case "response.output_item.done":
5516
5543
  yield* processOutputItemDone(parsed);
@@ -5527,8 +5554,19 @@ var processSSEEvent = function* (eventType, parsed, pendingCalls, providerName)
5527
5554
  case "response.failed":
5528
5555
  case "response.incomplete":
5529
5556
  case "response.error":
5530
- case "error":
5557
+ case "error": {
5558
+ if (isRecord2(parsed.response)) {
5559
+ const usage = extractUsage(parsed.response);
5560
+ if (usage)
5561
+ yield {
5562
+ type: "usage_update",
5563
+ usage,
5564
+ metadata: extractResponseMetadata(parsed.response),
5565
+ final: false
5566
+ };
5567
+ }
5531
5568
  throw responseFailure(eventType, parsed, providerName);
5569
+ }
5532
5570
  }
5533
5571
  };
5534
5572
  var flushSSEBuffer = function* (state) {
@@ -5950,6 +5988,14 @@ var processDelta = function* (delta, pendingToolCalls) {
5950
5988
  }
5951
5989
  if (isRecordArray2(delta.tool_calls)) {
5952
5990
  processToolCallDeltas(delta.tool_calls, pendingToolCalls);
5991
+ for (const call of delta.tool_calls) {
5992
+ if (isRecord3(call.function) && typeof call.function.name === "string" && call.function.name)
5993
+ yield {
5994
+ type: "tool_input_start",
5995
+ id: typeof call.id === "string" ? call.id : String(call.index),
5996
+ name: call.function.name
5997
+ };
5998
+ }
5953
5999
  }
5954
6000
  if (Array.isArray(delta.annotations)) {
5955
6001
  for (const annotation of delta.annotations) {
@@ -6048,7 +6094,12 @@ var processSSELine2 = function* (line, pendingToolCalls, providerName) {
6048
6094
  }
6049
6095
  const usageUpdate = narrowUsageRecord(parsed);
6050
6096
  if (usageUpdate) {
6051
- yield { type: "usage_update", usage: usageUpdate };
6097
+ yield {
6098
+ type: "usage_update",
6099
+ usage: usageUpdate,
6100
+ final: Array.isArray(parsed.choices) && parsed.choices.length === 0,
6101
+ metadata: narrowResponseMetadata(parsed)
6102
+ };
6052
6103
  }
6053
6104
  const metadata = narrowResponseMetadata(parsed);
6054
6105
  if (metadata) {
@@ -6077,10 +6128,9 @@ var processSSELine2 = function* (line, pendingToolCalls, providerName) {
6077
6128
  }
6078
6129
  yield* processChoice(firstChoice, pendingToolCalls);
6079
6130
  };
6080
- var isUsageUpdate = (chunk) => chunk.type === "usage_update";
6081
6131
  var collectYieldableChunks = (line, pendingToolCalls, usageRef, metadataRef, providerName) => {
6082
6132
  const allChunks = Array.from(processSSELine2(line, pendingToolCalls, providerName));
6083
- const usageChunks = allChunks.filter(isUsageUpdate);
6133
+ const usageChunks = allChunks.filter((chunk) => chunk.type === "usage_update");
6084
6134
  const lastUsage = usageChunks.at(NOT_FOUND4);
6085
6135
  if (lastUsage) {
6086
6136
  usageRef.current = lastUsage.usage;
@@ -6097,7 +6147,7 @@ var collectYieldableChunks = (line, pendingToolCalls, usageRef, metadataRef, pro
6097
6147
  }
6098
6148
  };
6099
6149
  }
6100
- return allChunks.filter((chunk) => !isUsageUpdate(chunk) && chunk.type !== "response_metadata");
6150
+ return allChunks.filter((chunk) => chunk.type !== "response_metadata").map((chunk) => chunk.type === "usage_update" ? { ...chunk, metadata: metadataRef.current } : chunk);
6101
6151
  };
6102
6152
  var processSSELines2 = function* (lines, pendingToolCalls, usageRef, metadataRef, providerName) {
6103
6153
  for (const line of lines) {
@@ -6599,7 +6649,6 @@ var API_VERSION = "2023-06-01";
6599
6649
  var DEFAULT_MAX_TOKENS = 32000;
6600
6650
  var EVENT_PREFIX_LENGTH2 = 7;
6601
6651
  var DATA_PREFIX_LENGTH2 = 6;
6602
- var EMPTY_CHUNKS = [];
6603
6652
  var isRecord5 = (val) => typeof val === "object" && val !== null;
6604
6653
  var mapContentBlock2 = (block) => {
6605
6654
  if (block.type === "thinking") {
@@ -6878,14 +6927,79 @@ var getNumber = (obj, key) => {
6878
6927
  }
6879
6928
  return 0;
6880
6929
  };
6930
+ var TOOL_INPUT_PROGRESS_CHARS = 2048;
6931
+ var TOOL_INPUT_FIELD_MAX_CHARS = 200;
6932
+ var readJsonString = (json, start) => {
6933
+ for (let index = start + 1;index < json.length; index += 1) {
6934
+ if (json[index] === "\\")
6935
+ index += 1;
6936
+ else if (json[index] === '"')
6937
+ return { end: index + 1, raw: json.slice(start + 1, index) };
6938
+ }
6939
+ return;
6940
+ };
6941
+ var decodeJsonString = (raw) => {
6942
+ try {
6943
+ const value = JSON.parse(`"${raw}"`);
6944
+ return typeof value === "string" ? value : undefined;
6945
+ } catch {
6946
+ return;
6947
+ }
6948
+ };
6949
+ var completeStringFields = (partialJson) => {
6950
+ const fields = {};
6951
+ let depth = 0;
6952
+ let key;
6953
+ let index = 0;
6954
+ while (index < partialJson.length) {
6955
+ const character = partialJson[index];
6956
+ if (character === '"') {
6957
+ const text = readJsonString(partialJson, index);
6958
+ if (!text)
6959
+ break;
6960
+ if (depth === 1 && key === undefined) {
6961
+ const after = partialJson.slice(text.end).trimStart();
6962
+ if (after.startsWith(":"))
6963
+ key = text.raw;
6964
+ } else if (depth === 1 && key !== undefined) {
6965
+ const value = decodeJsonString(text.raw);
6966
+ if (value !== undefined && value.length <= TOOL_INPUT_FIELD_MAX_CHARS)
6967
+ fields[key] = value;
6968
+ key = undefined;
6969
+ }
6970
+ index = text.end;
6971
+ continue;
6972
+ }
6973
+ if (character === "{" || character === "[")
6974
+ depth += 1;
6975
+ else if (character === "}" || character === "]")
6976
+ depth -= 1;
6977
+ else if (character === "," && depth === 1)
6978
+ key = undefined;
6979
+ index += 1;
6980
+ }
6981
+ return fields;
6982
+ };
6983
+ var toolInputProgress = (state) => {
6984
+ state.toolInputReported = state.toolInputJson.length;
6985
+ return {
6986
+ fields: completeStringFields(state.toolInputJson),
6987
+ id: state.currentToolId,
6988
+ name: state.currentToolName,
6989
+ receivedChars: state.toolInputJson.length,
6990
+ type: "tool_input"
6991
+ };
6992
+ };
6881
6993
  var handleContentBlockStart = (parsed, state) => {
6882
6994
  const block = getRecord(parsed, "content_block");
6883
6995
  if (block && block.type === "tool_use") {
6884
6996
  state.currentToolId = getString(block, "id");
6885
6997
  state.currentToolName = getString(block, "name");
6886
6998
  state.toolInputJson = "";
6999
+ state.toolInputReported = 0;
6887
7000
  state.isThinkingBlock = false;
6888
7001
  state.currentProviderBlock = undefined;
7002
+ return toolInputProgress(state);
6889
7003
  } else if (block && block.type === "thinking") {
6890
7004
  state.isThinkingBlock = true;
6891
7005
  state.thinkingSignature = "";
@@ -6895,6 +7009,7 @@ var handleContentBlockStart = (parsed, state) => {
6895
7009
  state.currentProviderBlock = block && block.type !== "text" ? { ...block } : undefined;
6896
7010
  state.providerBlockInputJson = "";
6897
7011
  }
7012
+ return;
6898
7013
  };
6899
7014
  var handleContentBlockDelta = (parsed, state) => {
6900
7015
  const delta = getRecord(parsed, "delta");
@@ -6929,6 +7044,8 @@ var handleContentBlockDelta = (parsed, state) => {
6929
7044
  state.providerBlockInputJson += getString(delta, "partial_json");
6930
7045
  } else {
6931
7046
  state.toolInputJson += getString(delta, "partial_json");
7047
+ if (state.toolInputJson.length - state.toolInputReported >= TOOL_INPUT_PROGRESS_CHARS)
7048
+ return toolInputProgress(state);
6932
7049
  }
6933
7050
  }
6934
7051
  if (delta.type === "signature_delta") {
@@ -7048,8 +7165,7 @@ var handleError = (parsed, state) => {
7048
7165
  var processEvent = (eventType, parsed, state) => {
7049
7166
  switch (eventType) {
7050
7167
  case "content_block_start": {
7051
- handleContentBlockStart(parsed, state);
7052
- return;
7168
+ return handleContentBlockStart(parsed, state);
7053
7169
  }
7054
7170
  case "content_block_delta": {
7055
7171
  return handleContentBlockDelta(parsed, state);
@@ -7060,11 +7176,21 @@ var processEvent = (eventType, parsed, state) => {
7060
7176
  case "message_delta": {
7061
7177
  handleMessageDelta(parsed, state);
7062
7178
  mergeMetadata(parsed, state);
7063
- return;
7179
+ return state.usage ? {
7180
+ type: "usage_update",
7181
+ usage: state.usage,
7182
+ metadata: state.metadata,
7183
+ final: false
7184
+ } : undefined;
7064
7185
  }
7065
7186
  case "message_start": {
7066
7187
  handleMessageStart(parsed, state);
7067
- return;
7188
+ return state.usage ? {
7189
+ type: "usage_update",
7190
+ usage: state.usage,
7191
+ metadata: state.metadata,
7192
+ final: false
7193
+ } : undefined;
7068
7194
  }
7069
7195
  case "message_stop": {
7070
7196
  mergeMetadata(parsed, state);
@@ -7098,49 +7224,6 @@ var processSingleEvent = (event, state) => {
7098
7224
  }
7099
7225
  return processEvent(eventType, parsed, state);
7100
7226
  };
7101
- var collectChunk = (event, state) => {
7102
- const chunk = processSingleEvent(event, state);
7103
- return chunk ? [chunk] : [];
7104
- };
7105
- var processBufferedEvents = (eventsText, state) => {
7106
- const events = eventsText.split(`
7107
-
7108
- `);
7109
- state.buffer = events.pop() ?? "";
7110
- return events.flatMap((event) => collectChunk(event, state));
7111
- };
7112
- var readNextChunks = async (reader, decoder, state, signal) => {
7113
- if (signal?.aborted) {
7114
- return { chunks: EMPTY_CHUNKS, done: true };
7115
- }
7116
- const { done, value } = await reader.read();
7117
- if (done) {
7118
- return { chunks: EMPTY_CHUNKS, done: true };
7119
- }
7120
- const rawText = state.buffer + decoder.decode(value, { stream: true });
7121
- const chunks = processBufferedEvents(rawText, state);
7122
- return { chunks, done: false };
7123
- };
7124
- var findDoneChunk = (chunks) => chunks.findIndex((c) => c.type === "done");
7125
- var sseStreamLoop = async (reader, decoder, state, signal) => {
7126
- const result = await readNextChunks(reader, decoder, state, signal);
7127
- if (result.done) {
7128
- return { chunks: result.chunks, finished: true };
7129
- }
7130
- const doneIdx = findDoneChunk(result.chunks);
7131
- if (doneIdx >= 0) {
7132
- return { chunks: result.chunks.slice(0, doneIdx + 1), finished: true };
7133
- }
7134
- return { chunks: result.chunks, finished: false };
7135
- };
7136
- async function* streamChunks(reader, decoder, state, signal) {
7137
- let finished = false;
7138
- while (!finished) {
7139
- const result = await sseStreamLoop(reader, decoder, state, signal);
7140
- ({ finished } = result);
7141
- yield* result.chunks;
7142
- }
7143
- }
7144
7227
  async function* parseSSEStream4(body, providerName, signal) {
7145
7228
  const reader = body.getReader();
7146
7229
  const decoder = new TextDecoder;
@@ -7152,13 +7235,40 @@ async function* parseSSEStream4(body, providerName, signal) {
7152
7235
  stopReason: "",
7153
7236
  thinkingSignature: "",
7154
7237
  toolInputJson: "",
7238
+ toolInputReported: 0,
7155
7239
  usage: undefined,
7156
7240
  providerName,
7157
7241
  providerBlockInputJson: ""
7158
7242
  };
7159
7243
  try {
7160
- yield* streamChunks(reader, decoder, state, signal);
7244
+ while (true) {
7245
+ signal?.throwIfAborted();
7246
+ const { done, value } = await reader.read();
7247
+ if (done)
7248
+ return;
7249
+ const events = (state.buffer + decoder.decode(value, { stream: true })).split(`
7250
+
7251
+ `);
7252
+ state.buffer = events.pop() ?? "";
7253
+ for (const event of events) {
7254
+ const chunk = processSingleEvent(event, state);
7255
+ if (!chunk)
7256
+ continue;
7257
+ if (chunk.type === "tool_input" && chunk.receivedChars === 0)
7258
+ yield {
7259
+ type: "tool_input_start",
7260
+ id: chunk.id,
7261
+ name: chunk.name
7262
+ };
7263
+ yield chunk;
7264
+ if (chunk.type === "done")
7265
+ return;
7266
+ }
7267
+ }
7161
7268
  } finally {
7269
+ await reader.cancel().catch(() => {
7270
+ return;
7271
+ });
7162
7272
  reader.releaseLock();
7163
7273
  }
7164
7274
  }
@@ -9062,5 +9172,5 @@ export {
9062
9172
  xai
9063
9173
  };
9064
9174
 
9065
- //# debugId=16913D2F7231C56164756E2164756E21
9175
+ //# debugId=A58174807DA8A73664756E2164756E21
9066
9176
  //# sourceMappingURL=index.js.map