Skip to content

Commit 3fb4e70

Browse files
feat(provider/anthropic): support fine-grained tool streaming with eagerInputStreaming (#13078)
<!-- Welcome to contributing to AI SDK! We're excited to see your changes. We suggest you read the following contributing guide we've created before submitting: https://github.com/vercel/ai/blob/main/CONTRIBUTING.md --> ## Background <!-- Why was this change necessary? --> Fine-grained tool streaming is supported with an `eagerInputStreaming` flag ([anthropic doc](https://platform.claude.com/docs/en/agents-and-tools/tool-use/fine-grained-tool-streaming)), the feature is recently taken off beta ## Summary <!-- What did you change? --> - Added eagerInputStreaming provider option support for Anthropic function tools - Only custom (function) tools support this. This is not specified on Anthropic's docs, but Anthropic's API rejects eager_input_streaming on provider-defined tools with an "extra inputs are not permitted" validation error. If you'd like to test it, here is a sample request I sent: ``` curl https://api.anthropic.com/v1/messages \ -H "content-type: application/json" \ -H "x-api-key: $ANTHROPIC_API_KEY" \ -H "anthropic-version: 2023-06-01" \ -d '{ "model": "claude-sonnet-4-5-20250514", "max_tokens": 1024, "tools": [ { "type": "code_execution_20260120", "name": "code_execution", "eager_input_streaming": true } ], "messages": [ { "role": "user", "content": "Write and run a python script that prints hello world" } ], "stream": true }' ``` ## Manual Verification <!-- For features & bugfixes. Please explain how you *manually* verified that the change works end-to-end as expected (excluding automated tests). Remove the section if it's not needed (e.g. for docs). --> - added example script ## Checklist <!-- Do not edit this list. Leave items unchecked that don't apply. If you need to track subtasks, create a new "## Tasks" section Please check if the PR fulfills the following requirements: --> - [x] Tests have been added / updated (for bug fixes / features) - [x] Documentation has been added / updated (for bug fixes / features) - [x] A _patch_ changeset for relevant packages has been added (for bug fixes / features - run `pnpm changeset` in the project root) - [x] I have reviewed this pull request (self-review) ## Future Work <!-- Feel free to mention things not covered by this PR that can be done in future PRs. Remove the section if it's not needed. --> - backport to v5
1 parent ad4cfc2 commit 3fb4e70

File tree

6 files changed

+179
-0
lines changed

6 files changed

+179
-0
lines changed

.changeset/pink-jeans-taste.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@ai-sdk/anthropic': patch
3+
---
4+
5+
feat(anthropic): support eagerInputStreaming option for fine-grained tool streaming
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { createAnthropic } from '@ai-sdk/anthropic';
2+
import { streamText } from 'ai';
3+
import 'dotenv/config';
4+
import { z } from 'zod';
5+
6+
const anthropic = createAnthropic();
7+
8+
// fine grained tool streaming can be enabled with eager_input_streaming
9+
// it is currently supported on custom tools only
10+
// https://platform.claude.com/docs/en/agents-and-tools/tool-use/fine-grained-tool-streaming
11+
12+
const tools = {
13+
write_to_file: {
14+
description:
15+
'Request to write content to a file. ALWAYS provide the COMPLETE file content, without any truncation.',
16+
inputSchema: z.object({
17+
path: z
18+
.string()
19+
.describe(
20+
'The path of the file to write to (relative to the current workspace directory)',
21+
),
22+
content: z.string().describe('The content to write to the file.'),
23+
}),
24+
providerOptions: {
25+
anthropic: {
26+
eagerInputStreaming: true,
27+
},
28+
},
29+
},
30+
} as const;
31+
32+
async function main() {
33+
const result = streamText({
34+
model: anthropic('claude-sonnet-4-6'),
35+
36+
messages: [
37+
{
38+
role: 'user',
39+
content: 'Write a bubble sort implementation in JavaScript to sort.js',
40+
},
41+
],
42+
43+
tools,
44+
toolChoice: 'required',
45+
});
46+
47+
// ── stream events ─────────────────────────────────────────────────────────
48+
let sawToolInputStart = false;
49+
let toolInputDeltaCount = 0;
50+
let toolInputTotalBytes = 0;
51+
let sawToolInputEnd = false;
52+
let reasoningDeltaCount = 0;
53+
54+
// ts() prints a compact timestamp to stderr so we can see real-time ordering
55+
const T0 = Date.now();
56+
const ts = (label: string) =>
57+
process.stderr.write(
58+
`+${((Date.now() - T0) / 1000).toFixed(2)}s ${label}\n`,
59+
);
60+
61+
ts('stream started');
62+
63+
for await (const part of result.fullStream) {
64+
switch (part.type) {
65+
case 'reasoning-start':
66+
ts('[reasoning-start]');
67+
process.stdout.write('\n[reasoning-start]\n');
68+
break;
69+
case 'reasoning-delta':
70+
reasoningDeltaCount++;
71+
if (reasoningDeltaCount === 1 || reasoningDeltaCount % 500 === 0) {
72+
ts(`[reasoning-delta #${reasoningDeltaCount}]`);
73+
}
74+
// process.stdout.write(part.text); // suppress to reduce noise
75+
break;
76+
case 'reasoning-end':
77+
ts(`[reasoning-end] total-reasoning-deltas=${reasoningDeltaCount}`);
78+
process.stdout.write('\n[reasoning-end]\n');
79+
break;
80+
case 'text-start':
81+
ts('[text-start]');
82+
process.stdout.write('\n[text-start]\n');
83+
break;
84+
case 'text-delta':
85+
process.stdout.write(part.text);
86+
break;
87+
case 'text-end':
88+
ts('[text-end]');
89+
process.stdout.write('\n[text-end]\n');
90+
break;
91+
case 'tool-input-start':
92+
sawToolInputStart = true;
93+
ts(`[tool-input-start] tool=${part.toolName}`);
94+
process.stdout.write(
95+
`\n[tool-input-start] id=${part.id} tool=${part.toolName}\n`,
96+
);
97+
break;
98+
case 'tool-input-delta':
99+
toolInputDeltaCount++;
100+
toolInputTotalBytes += part.delta.length;
101+
if (toolInputDeltaCount === 1 || toolInputDeltaCount % 100 === 0) {
102+
ts(`[tool-input-delta #${toolInputDeltaCount}]`);
103+
}
104+
process.stdout.write(part.delta);
105+
break;
106+
case 'tool-input-end':
107+
sawToolInputEnd = true;
108+
ts(`[tool-input-end]`);
109+
process.stdout.write(`\n[tool-input-end] id=${part.id}\n`);
110+
break;
111+
case 'tool-call': {
112+
const preview = JSON.stringify(part.input).slice(0, 80);
113+
ts(`[tool-call] tool=${part.toolName}`);
114+
console.log(
115+
`\n[tool-call] tool=${part.toolName} input(preview)=${preview}…`,
116+
);
117+
break;
118+
}
119+
case 'start-step':
120+
ts('[start-step]');
121+
console.log('\n[start-step]');
122+
break;
123+
case 'finish-step':
124+
ts(`[finish-step] reason=${part.finishReason}`);
125+
console.log(
126+
`[finish-step] finishReason=${part.finishReason} usage=${JSON.stringify(part.usage)}`,
127+
);
128+
break;
129+
case 'finish':
130+
ts(`[finish] reason=${part.finishReason}`);
131+
console.log(
132+
`\n[finish] finishReason=${part.finishReason} usage=${JSON.stringify(part.totalUsage)}`,
133+
);
134+
break;
135+
case 'error':
136+
ts('[error]');
137+
console.error('\n[error]', part.error);
138+
break;
139+
}
140+
}
141+
142+
// ── diagnosis ─────────────────────────────────────────────────────────────
143+
// Eager input streaming streams fewer, larger chunks
144+
// Threshold is heuristic — tune based on observed output.
145+
const avgChunkBytes =
146+
toolInputDeltaCount > 0
147+
? Math.round(toolInputTotalBytes / toolInputDeltaCount)
148+
: 0;
149+
const isEager = toolInputDeltaCount >= 1 && avgChunkBytes >= 12;
150+
151+
console.log('\n=== Summary ===');
152+
console.log('tool-input-start received :', sawToolInputStart);
153+
console.log('tool-input-delta count :', toolInputDeltaCount);
154+
console.log('tool-input-end received :', sawToolInputEnd);
155+
console.log('reasoning-delta count :', reasoningDeltaCount);
156+
console.log(
157+
`\nEager input streaming: ${isEager ? 'detected' : 'not detected'} (${toolInputDeltaCount} deltas, avg ${avgChunkBytes} bytes/chunk)`,
158+
);
159+
}
160+
161+
main().catch(console.error);

packages/anthropic/src/anthropic-messages-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,7 @@ export type AnthropicTool =
352352
description: string | undefined;
353353
input_schema: JSONSchema7;
354354
cache_control: AnthropicCacheControl | undefined;
355+
eager_input_streaming?: boolean;
355356
strict?: boolean;
356357
/**
357358
* When true, this tool is deferred and will only be loaded when

packages/anthropic/src/anthropic-messages-language-model.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2807,6 +2807,9 @@ describe('AnthropicMessagesLanguageModel', () => {
28072807
name: 'calculator',
28082808
description: 'Calculate math',
28092809
inputSchema: { type: 'object', properties: {} },
2810+
providerOptions: {
2811+
anthropic: { eagerInputStreaming: true },
2812+
},
28102813
},
28112814
{
28122815
type: 'provider',
@@ -2826,6 +2829,7 @@ describe('AnthropicMessagesLanguageModel', () => {
28262829
name: 'calculator',
28272830
description: 'Calculate math',
28282831
input_schema: { type: 'object', properties: {} },
2832+
eager_input_streaming: true,
28292833
});
28302834

28312835
expect(requestBody.tools[1]).toEqual({

packages/anthropic/src/anthropic-prepare-tools.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ describe('prepareTools', () => {
4747
name: 'testFunction',
4848
description: 'A test function',
4949
inputSchema: { type: 'object', properties: {} },
50+
providerOptions: {
51+
anthropic: { eagerInputStreaming: true },
52+
},
5053
},
5154
],
5255
toolChoice: undefined,
@@ -58,6 +61,7 @@ describe('prepareTools', () => {
5861
name: 'testFunction',
5962
description: 'A test function',
6063
input_schema: { type: 'object', properties: {} },
64+
eager_input_streaming: true,
6165
},
6266
]);
6367
expect(result.toolChoice).toBeUndefined();

packages/anthropic/src/anthropic-prepare-tools.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export interface AnthropicToolOptions {
1717
allowedCallers?: Array<
1818
'direct' | 'code_execution_20250825' | 'code_execution_20260120'
1919
>;
20+
eagerInputStreaming?: boolean;
2021
}
2122

2223
export async function prepareTools({
@@ -66,6 +67,8 @@ export async function prepareTools({
6667
const anthropicOptions = tool.providerOptions?.anthropic as
6768
| AnthropicToolOptions
6869
| undefined;
70+
// eager_input_streaming is only supported on custom (function) tools
71+
const eagerInputStreaming = anthropicOptions?.eagerInputStreaming;
6972
const deferLoading = anthropicOptions?.deferLoading;
7073
const allowedCallers = anthropicOptions?.allowedCallers;
7174

@@ -74,6 +77,7 @@ export async function prepareTools({
7477
description: tool.description,
7578
input_schema: tool.inputSchema,
7679
cache_control: cacheControl,
80+
...(eagerInputStreaming ? { eager_input_streaming: true } : {}),
7781
...(supportsStructuredOutput === true && tool.strict != null
7882
? { strict: tool.strict }
7983
: {}),

0 commit comments

Comments
 (0)