Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
6 changes: 6 additions & 0 deletions .changeset/tall-scissors-help.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@ai-sdk/anthropic': patch
'ai': patch
---

feat(anthropic): add programmatic tool calling
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { anthropic, AnthropicMessageMetadata } from '@ai-sdk/anthropic';
import { generateText, stepCountIs, tool } from 'ai';
import { z } from 'zod';
import { run } from '../lib/run';

run(async () => {
const result = await generateText({
model: anthropic('claude-sonnet-4-5'),
prompt:
'Query sales data for West, East, and Central regions, ' +
'then tell me which region had the highest revenue',
stopWhen: stepCountIs(10),
tools: {
code_execution: anthropic.tools.codeExecution_20250825(),

queryDatabase: tool({
description: 'Execute a SQL query against the sales database',
inputSchema: z.object({
sql: z.string().describe('SQL query to execute'),
}),
execute: async () => {
return [
{ region: 'West', revenue: 45000 },
{ region: 'East', revenue: 38000 },
{ region: 'Central', revenue: 52000 },
];
},
providerOptions: {
anthropic: {
allowedCallers: ['code_execution_20250825'],
},
},
}),
},
prepareStep: ({ steps }) => {
if (steps.length === 0) {
return undefined;
}

const lastStep = steps[steps.length - 1];
const containerId = (
lastStep.providerMetadata?.anthropic as
| AnthropicMessageMetadata
| undefined
)?.container?.id;

if (!containerId) {
return undefined;
}

return {
providerOptions: {
anthropic: {
container: { id: containerId },
},
},
};
},
});

console.log('Text:', result.text);
console.log('Steps:', result.steps.length);
});
54 changes: 9 additions & 45 deletions packages/ai/src/generate-text/generate-text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2635,6 +2635,7 @@ describe('generateText', () => {
expect(result.content).toMatchInlineSnapshot(`
[
{
"dynamic": true,
"input": {
"value": "value",
},
Expand All @@ -2646,7 +2647,7 @@ describe('generateText', () => {
"type": "tool-call",
},
{
"dynamic": undefined,
"dynamic": true,
"input": {
"value": "value",
},
Expand All @@ -2657,6 +2658,7 @@ describe('generateText', () => {
"type": "tool-result",
},
{
"dynamic": true,
"input": {
"value": "value",
},
Expand All @@ -2668,7 +2670,7 @@ describe('generateText', () => {
"type": "tool-call",
},
{
"dynamic": undefined,
"dynamic": true,
"error": "ERROR",
"input": {
"value": "value",
Expand All @@ -2683,50 +2685,11 @@ describe('generateText', () => {
});

it('should include provider-executed tool calls in staticToolCalls', async () => {
expect(result.staticToolCalls).toMatchInlineSnapshot(`
[
{
"input": {
"value": "value",
},
"providerExecuted": true,
"providerMetadata": undefined,
"title": undefined,
"toolCallId": "call-1",
"toolName": "web_search",
"type": "tool-call",
},
{
"input": {
"value": "value",
},
"providerExecuted": true,
"providerMetadata": undefined,
"title": undefined,
"toolCallId": "call-2",
"toolName": "web_search",
"type": "tool-call",
},
]
`);
expect(result.staticToolCalls).toMatchInlineSnapshot(`[]`);
});

it('should include provider-executed results in staticToolResults (errors excluded)', async () => {
expect(result.staticToolResults).toMatchInlineSnapshot(`
[
{
"dynamic": undefined,
"input": {
"value": "value",
},
"output": "{ "value": "result1" }",
"providerExecuted": true,
"toolCallId": "call-1",
"toolName": "web_search",
"type": "tool-result",
},
]
`);
expect(result.staticToolResults).toMatchInlineSnapshot(`[]`);
});

it('should only execute a single step', async () => {
Expand Down Expand Up @@ -3286,6 +3249,7 @@ describe('generateText', () => {
expect(result.content).toMatchInlineSnapshot(`
[
{
"dynamic": true,
"input": {
"value": "test",
},
Expand All @@ -3297,7 +3261,7 @@ describe('generateText', () => {
"type": "tool-call",
},
{
"dynamic": undefined,
"dynamic": true,
"input": {
"value": "test",
},
Expand All @@ -3316,7 +3280,7 @@ describe('generateText', () => {
expect(result.toolResults).toMatchInlineSnapshot(`
[
{
"dynamic": undefined,
"dynamic": true,
"input": {
"value": "test",
},
Expand Down
28 changes: 19 additions & 9 deletions packages/ai/src/generate-text/generate-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,13 +473,21 @@ A function that attempts to repair a tool call that failed to parse.
}),
tracer,
fn: async span => {
// Merge base providerOptions with step-specific providerOptions
const stepProviderOptions = prepareStepResult?.providerOptions
? {
...providerOptions,
...prepareStepResult.providerOptions,
}
: providerOptions;

const result = await stepModel.doGenerate({
...callSettings,
tools: stepTools,
toolChoice: stepToolChoice,
responseFormat: await output?.responseFormat,
prompt: promptMessages,
providerOptions,
providerOptions: stepProviderOptions,
abortSignal,
headers: headersWithUserAgent,
});
Expand Down Expand Up @@ -978,32 +986,34 @@ function asContent<TOOLS extends ToolSet>({
case 'tool-result': {
const toolCall = toolCalls.find(
toolCall => toolCall.toolCallId === part.toolCallId,
)!;
);

if (toolCall == null) {
throw new Error(`Tool call ${part.toolCallId} not found.`);
}
// For provider-executed tool results (like code_execution_tool_result),
// the tool call might be from a previous step (programmatic tool calling).
// In this case, we use the information from the tool result itself.
const input = toolCall?.input;
const dynamic = part.dynamic ?? toolCall?.dynamic;

if (part.isError) {
return {
type: 'tool-error' as const,
toolCallId: part.toolCallId,
toolName: part.toolName as keyof TOOLS & string,
input: toolCall.input,
input,
error: part.result,
providerExecuted: true,
dynamic: toolCall.dynamic,
dynamic,
} as TypedToolError<TOOLS>;
}

return {
type: 'tool-result' as const,
toolCallId: part.toolCallId,
toolName: part.toolName as keyof TOOLS & string,
input: toolCall.input,
input,
output: part.result,
providerExecuted: true,
dynamic: toolCall.dynamic,
dynamic,
} as TypedToolResult<TOOLS>;
}
}
Expand Down
31 changes: 31 additions & 0 deletions packages/ai/src/generate-text/parse-tool-call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,37 @@ async function doParseToolCall<TOOLS extends ToolSet>({
});
}

// For provider-executed tools, skip schema validation since the provider
// already executed the tool. Just parse the JSON input.
// This is needed for cases like programmatic tool calling where the
// provider returns a different input format than the tool's schema.
// We treat these as dynamic since we don't have type guarantees on the input.
if (toolCall.providerExecuted) {
const parseResult =
toolCall.input.trim() === ''
? { success: true as const, value: {} }
: await safeParseJSON({ text: toolCall.input });

if (parseResult.success === false) {
throw new InvalidToolInputError({
toolName,
toolInput: toolCall.input,
cause: parseResult.error,
});
}

return {
type: 'tool-call',
toolCallId: toolCall.toolCallId,
toolName: toolCall.toolName,
input: parseResult.value,
providerExecuted: true,
providerMetadata: toolCall.providerMetadata,
dynamic: true,
title: tool.title,
};
}

const schema = asSchema(tool.inputSchema);

// when the tool call has no arguments, we try passing an empty object to the schema
Expand Down
8 changes: 7 additions & 1 deletion packages/ai/src/generate-text/prepare-step.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { ModelMessage, SystemModelMessage, Tool } from '@ai-sdk/provider-utils';
import {
ModelMessage,
ProviderOptions,
SystemModelMessage,
Tool,
} from '@ai-sdk/provider-utils';
import { LanguageModel, ToolChoice } from '../types/language-model';
import { StepResult } from './step-result';

Expand Down Expand Up @@ -31,5 +36,6 @@ export type PrepareStepResult<
activeTools?: Array<keyof NoInfer<TOOLS>>;
system?: string | SystemModelMessage | Array<SystemModelMessage>;
messages?: Array<ModelMessage>;
providerOptions?: ProviderOptions;
}
| undefined;
4 changes: 4 additions & 0 deletions packages/ai/src/generate-text/stream-text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8757,6 +8757,7 @@ describe('streamText', () => {
expect(await result.content).toMatchInlineSnapshot(`
[
{
"dynamic": true,
"input": {
"value": "value",
},
Expand All @@ -8779,6 +8780,7 @@ describe('streamText', () => {
"type": "tool-result",
},
{
"dynamic": true,
"input": {
"value": "value",
},
Expand Down Expand Up @@ -8834,6 +8836,7 @@ describe('streamText', () => {
"type": "tool-input-end",
},
{
"dynamic": true,
"input": {
"value": "value",
},
Expand All @@ -8856,6 +8859,7 @@ describe('streamText', () => {
"type": "tool-result",
},
{
"dynamic": true,
"input": {
"value": "value",
},
Expand Down
10 changes: 9 additions & 1 deletion packages/ai/src/generate-text/stream-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1208,6 +1208,14 @@ class DefaultStreamTextResult<TOOLS extends ToolSet, OUTPUT extends Output>
activeTools: prepareStepResult?.activeTools ?? activeTools,
});

// Merge base providerOptions with step-specific providerOptions
const stepProviderOptions = prepareStepResult?.providerOptions
? {
...providerOptions,
...prepareStepResult.providerOptions,
}
: providerOptions;

const {
result: { stream, response, request },
doStreamSpan,
Expand Down Expand Up @@ -1266,7 +1274,7 @@ class DefaultStreamTextResult<TOOLS extends ToolSet, OUTPUT extends Output>
toolChoice: stepToolChoice,
responseFormat: await output?.responseFormat,
prompt: promptMessages,
providerOptions,
providerOptions: stepProviderOptions,
abortSignal,
headers,
includeRawChunks,
Expand Down
Loading
Loading