Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pink-jeans-taste.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@ai-sdk/anthropic': patch
---

feat(anthropic): support eagerInputStreaming option for fine-grained tool streaming
161 changes: 161 additions & 0 deletions examples/ai-core/src/stream-text/fine-grained-tool-streaming.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { createAnthropic } from '@ai-sdk/anthropic';
import { streamText } from 'ai';
import 'dotenv/config';
import { z } from 'zod';

const anthropic = createAnthropic();

// fine grained tool streaming can be enabled with eager_input_streaming
// it is currently supported on custom tools only
// https://platform.claude.com/docs/en/agents-and-tools/tool-use/fine-grained-tool-streaming

const tools = {
write_to_file: {
description:
'Request to write content to a file. ALWAYS provide the COMPLETE file content, without any truncation.',
inputSchema: z.object({
path: z
.string()
.describe(
'The path of the file to write to (relative to the current workspace directory)',
),
content: z.string().describe('The content to write to the file.'),
}),
providerOptions: {
anthropic: {
eagerInputStreaming: true,
},
},
},
} as const;

async function main() {
const result = streamText({
model: anthropic('claude-sonnet-4-6'),

messages: [
{
role: 'user',
content: 'Write a bubble sort implementation in JavaScript to sort.js',
},
],

tools,
toolChoice: 'required',
});

// ── stream events ─────────────────────────────────────────────────────────
let sawToolInputStart = false;
let toolInputDeltaCount = 0;
let toolInputTotalBytes = 0;
let sawToolInputEnd = false;
let reasoningDeltaCount = 0;

// ts() prints a compact timestamp to stderr so we can see real-time ordering
const T0 = Date.now();
const ts = (label: string) =>
process.stderr.write(
`+${((Date.now() - T0) / 1000).toFixed(2)}s ${label}\n`,
);

ts('stream started');

for await (const part of result.fullStream) {
switch (part.type) {
case 'reasoning-start':
ts('[reasoning-start]');
process.stdout.write('\n[reasoning-start]\n');
break;
case 'reasoning-delta':
reasoningDeltaCount++;
if (reasoningDeltaCount === 1 || reasoningDeltaCount % 500 === 0) {
ts(`[reasoning-delta #${reasoningDeltaCount}]`);
}
// process.stdout.write(part.text); // suppress to reduce noise
break;
case 'reasoning-end':
ts(`[reasoning-end] total-reasoning-deltas=${reasoningDeltaCount}`);
process.stdout.write('\n[reasoning-end]\n');
break;
case 'text-start':
ts('[text-start]');
process.stdout.write('\n[text-start]\n');
break;
case 'text-delta':
process.stdout.write(part.text);
break;
case 'text-end':
ts('[text-end]');
process.stdout.write('\n[text-end]\n');
break;
case 'tool-input-start':
sawToolInputStart = true;
ts(`[tool-input-start] tool=${part.toolName}`);
process.stdout.write(
`\n[tool-input-start] id=${part.id} tool=${part.toolName}\n`,
);
break;
case 'tool-input-delta':
toolInputDeltaCount++;
toolInputTotalBytes += part.delta.length;
if (toolInputDeltaCount === 1 || toolInputDeltaCount % 100 === 0) {
ts(`[tool-input-delta #${toolInputDeltaCount}]`);
}
process.stdout.write(part.delta);
break;
case 'tool-input-end':
sawToolInputEnd = true;
ts(`[tool-input-end]`);
process.stdout.write(`\n[tool-input-end] id=${part.id}\n`);
break;
case 'tool-call': {
const preview = JSON.stringify(part.input).slice(0, 80);
ts(`[tool-call] tool=${part.toolName}`);
console.log(
`\n[tool-call] tool=${part.toolName} input(preview)=${preview}…`,
);
break;
}
case 'start-step':
ts('[start-step]');
console.log('\n[start-step]');
break;
case 'finish-step':
ts(`[finish-step] reason=${part.finishReason}`);
console.log(
`[finish-step] finishReason=${part.finishReason} usage=${JSON.stringify(part.usage)}`,
);
break;
case 'finish':
ts(`[finish] reason=${part.finishReason}`);
console.log(
`\n[finish] finishReason=${part.finishReason} usage=${JSON.stringify(part.totalUsage)}`,
);
break;
case 'error':
ts('[error]');
console.error('\n[error]', part.error);
break;
}
}

// ── diagnosis ─────────────────────────────────────────────────────────────
// Eager input streaming streams fewer, larger chunks
// Threshold is heuristic — tune based on observed output.
const avgChunkBytes =
toolInputDeltaCount > 0
? Math.round(toolInputTotalBytes / toolInputDeltaCount)
: 0;
const isEager = toolInputDeltaCount >= 1 && avgChunkBytes >= 12;

console.log('\n=== Summary ===');
console.log('tool-input-start received :', sawToolInputStart);
console.log('tool-input-delta count :', toolInputDeltaCount);
console.log('tool-input-end received :', sawToolInputEnd);
console.log('reasoning-delta count :', reasoningDeltaCount);
console.log(
`\nEager input streaming: ${isEager ? 'detected' : 'not detected'} (${toolInputDeltaCount} deltas, avg ${avgChunkBytes} bytes/chunk)`,
);
}

main().catch(console.error);
1 change: 1 addition & 0 deletions packages/anthropic/src/anthropic-messages-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ export type AnthropicTool =
description: string | undefined;
input_schema: JSONSchema7;
cache_control: AnthropicCacheControl | undefined;
eager_input_streaming?: boolean;
}
| {
type: 'code_execution_20250522';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2030,6 +2030,9 @@ describe('AnthropicMessagesLanguageModel', () => {
name: 'calculator',
description: 'Calculate math',
inputSchema: { type: 'object', properties: {} },
providerOptions: {
anthropic: { eagerInputStreaming: true },
},
},
{
type: 'provider-defined',
Expand All @@ -2049,6 +2052,7 @@ describe('AnthropicMessagesLanguageModel', () => {
name: 'calculator',
description: 'Calculate math',
input_schema: { type: 'object', properties: {} },
eager_input_streaming: true,
});

expect(requestBody.tools[1]).toEqual({
Expand Down
4 changes: 4 additions & 0 deletions packages/anthropic/src/anthropic-prepare-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ describe('prepareTools', () => {
name: 'testFunction',
description: 'A test function',
inputSchema: { type: 'object', properties: {} },
providerOptions: {
anthropic: { eagerInputStreaming: true },
},
},
],
});
Expand All @@ -45,6 +48,7 @@ describe('prepareTools', () => {
name: 'testFunction',
description: 'A test function',
input_schema: { type: 'object', properties: {} },
eager_input_streaming: true,
},
]);
expect(result.toolChoice).toBeUndefined();
Expand Down
12 changes: 12 additions & 0 deletions packages/anthropic/src/anthropic-prepare-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import { webSearch_20250305ArgsSchema } from './tool/web-search_20250305';
import { webFetch_20250910ArgsSchema } from './tool/web-fetch-20250910';
import { validateTypes } from '@ai-sdk/provider-utils';

export interface AnthropicToolOptions {
eagerInputStreaming?: boolean;
}

export async function prepareTools({
tools,
toolChoice,
Expand Down Expand Up @@ -47,11 +51,19 @@ export async function prepareTools({
canCache: true,
});

// Read Anthropic-specific provider options
const anthropicOptions = tool.providerOptions?.anthropic as
| AnthropicToolOptions
| undefined;
// eager_input_streaming is only supported on custom (function) tools
const eagerInputStreaming = anthropicOptions?.eagerInputStreaming;

anthropicTools.push({
name: tool.name,
description: tool.description,
input_schema: tool.inputSchema,
cache_control: cacheControl,
...(eagerInputStreaming ? { eager_input_streaming: true } : {}),
});
break;
}
Expand Down