Skip to content

Commit 74c18ee

Browse files
committed
feat(core): Instrument tool calls for Anthropic AI
1 parent b672f40 commit 74c18ee

File tree

4 files changed

+136
-15
lines changed

4 files changed

+136
-15
lines changed

packages/core/src/utils/anthropic-ai/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ export const ANTHROPIC_AI_INSTRUMENTED_METHODS = [
99
'models.get',
1010
'completions.create',
1111
'models.retrieve',
12+
'beta.messages.create',
1213
] as const;

packages/core/src/utils/anthropic-ai/index.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
ANTHROPIC_AI_RESPONSE_TIMESTAMP_ATTRIBUTE,
99
GEN_AI_OPERATION_NAME_ATTRIBUTE,
1010
GEN_AI_PROMPT_ATTRIBUTE,
11+
GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE,
1112
GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE,
1213
GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE,
1314
GEN_AI_REQUEST_MESSAGES_ATTRIBUTE,
@@ -19,6 +20,7 @@ import {
1920
GEN_AI_RESPONSE_ID_ATTRIBUTE,
2021
GEN_AI_RESPONSE_MODEL_ATTRIBUTE,
2122
GEN_AI_RESPONSE_TEXT_ATTRIBUTE,
23+
GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE,
2224
GEN_AI_SYSTEM_ATTRIBUTE,
2325
} from '../ai/gen-ai-attributes';
2426
import { buildMethodPath, getFinalOperationName, getSpanOperation, setTokenUsageAttributes } from '../ai/utils';
@@ -31,6 +33,7 @@ import type {
3133
AnthropicAiOptions,
3234
AnthropicAiResponse,
3335
AnthropicAiStreamingEvent,
36+
ContentBlock,
3437
} from './types';
3538
import { shouldInstrument } from './utils';
3639

@@ -46,6 +49,9 @@ function extractRequestAttributes(args: unknown[], methodPath: string): Record<s
4649

4750
if (args.length > 0 && typeof args[0] === 'object' && args[0] !== null) {
4851
const params = args[0] as Record<string, unknown>;
52+
if (params.tools && Array.isArray(params.tools)) {
53+
attributes[GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE] = JSON.stringify(params.tools);
54+
}
4955

5056
attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] = params.model ?? 'unknown';
5157
if ('temperature' in params) attributes[GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE] = params.temperature;
@@ -96,10 +102,20 @@ function addResponseAttributes(span: Span, response: AnthropicAiResponse, record
96102
if (Array.isArray(response.content)) {
97103
span.setAttributes({
98104
[GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: response.content
99-
.map((item: { text: string | undefined }) => item.text)
105+
.map((item: ContentBlock) => item.text)
100106
.filter((text): text is string => text !== undefined)
101107
.join(''),
102108
});
109+
110+
const toolCalls: Array<ContentBlock> = [];
111+
112+
for (const item of response.content) {
113+
if (item.type === 'tool_use' || item.type === 'server_tool_use') {
114+
toolCalls.push(item);
115+
}
116+
}
117+
118+
span.setAttributes({ [GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]: JSON.stringify(toolCalls) });
103119
}
104120
}
105121
// Completions.create

packages/core/src/utils/anthropic-ai/streaming.ts

Lines changed: 92 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
GEN_AI_RESPONSE_MODEL_ATTRIBUTE,
88
GEN_AI_RESPONSE_STREAMING_ATTRIBUTE,
99
GEN_AI_RESPONSE_TEXT_ATTRIBUTE,
10+
GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE,
1011
} from '../ai/gen-ai-attributes';
1112
import { setTokenUsageAttributes } from '../ai/utils';
1213
import type { AnthropicAiStreamingEvent } from './types';
@@ -32,6 +33,17 @@ interface StreamingState {
3233
cacheCreationInputTokens: number | undefined;
3334
/** Number of cache read input tokens used. */
3435
cacheReadInputTokens: number | undefined;
36+
/** Accumulated tool calls (finalized) */
37+
toolCalls: Array<Record<string, unknown>>;
38+
/** In-progress tool call blocks keyed by index */
39+
activeToolBlocks: Record<
40+
number,
41+
{
42+
id?: string;
43+
name?: string;
44+
inputJsonParts: string[];
45+
}
46+
>;
3547
}
3648

3749
/**
@@ -43,12 +55,7 @@ interface StreamingState {
4355
* @returns Whether an error occurred
4456
*/
4557

46-
function isErrorEvent(
47-
event: AnthropicAiStreamingEvent,
48-
state: StreamingState,
49-
recordOutputs: boolean,
50-
span: Span,
51-
): boolean {
58+
function isErrorEvent(event: AnthropicAiStreamingEvent, span: Span): boolean {
5259
if ('type' in event && typeof event.type === 'string') {
5360
// If the event is an error, set the span status and capture the error
5461
// These error events are not rejected by the API by default, but are sent as metadata of the response
@@ -69,11 +76,6 @@ function isErrorEvent(
6976
});
7077
return true;
7178
}
72-
73-
if (recordOutputs && event.type === 'content_block_delta') {
74-
const text = event.delta?.text;
75-
if (text) state.responseTexts.push(text);
76-
}
7779
}
7880
return false;
7981
}
@@ -110,6 +112,66 @@ function handleMessageMetadata(event: AnthropicAiStreamingEvent, state: Streamin
110112
}
111113
}
112114

115+
/**
116+
* Handle start of a content block (e.g., tool_use)
117+
*/
118+
function handleContentBlockStart(event: AnthropicAiStreamingEvent, state: StreamingState): void {
119+
if (event.type !== 'content_block_start' || typeof event.index !== 'number' || !event.content_block) return;
120+
if (event.content_block.type === 'tool_use') {
121+
state.activeToolBlocks[event.index] = {
122+
id: event.content_block.id,
123+
name: event.content_block.name,
124+
inputJsonParts: [],
125+
};
126+
}
127+
}
128+
129+
/**
130+
* Handle deltas of a content block, including input_json_delta for tool_use
131+
*/
132+
function handleContentBlockDelta(
133+
event: AnthropicAiStreamingEvent,
134+
state: StreamingState,
135+
recordOutputs: boolean,
136+
): void {
137+
if (event.type !== 'content_block_delta' || typeof event.index !== 'number' || !event.delta) return;
138+
139+
if ('partial_json' in event.delta && typeof event.delta.partial_json === 'string') {
140+
const active = state.activeToolBlocks[event.index];
141+
if (active) {
142+
active.inputJsonParts.push(event.delta.partial_json);
143+
}
144+
}
145+
146+
if (recordOutputs && event.delta.text) {
147+
state.responseTexts.push(event.delta.text);
148+
}
149+
}
150+
151+
/**
152+
* Handle stop of a content block; finalize tool_use entries
153+
*/
154+
function handleContentBlockStop(event: AnthropicAiStreamingEvent, state: StreamingState): void {
155+
if (event.type !== 'content_block_stop' || typeof event.index !== 'number') return;
156+
157+
const active = state.activeToolBlocks[event.index];
158+
if (!active) return;
159+
160+
const raw = active.inputJsonParts.join('');
161+
const parsedInput = raw ? JSON.parse(raw) : {};
162+
163+
state.toolCalls.push({
164+
type: 'tool_use',
165+
id: active.id,
166+
name: active.name,
167+
input: parsedInput,
168+
});
169+
170+
// Avoid deleting a dynamic key; rebuild the map without this index
171+
const remainingEntries = Object.entries(state.activeToolBlocks).filter(([key]) => key !== String(event.index));
172+
state.activeToolBlocks = Object.fromEntries(remainingEntries);
173+
}
174+
113175
/**
114176
* Processes an event
115177
* @param event - The event to process
@@ -128,10 +190,19 @@ function processEvent(
128190
return;
129191
}
130192

131-
const isError = isErrorEvent(event, state, recordOutputs, span);
193+
const isError = isErrorEvent(event, span);
132194
if (isError) return;
133195

134196
handleMessageMetadata(event, state);
197+
198+
// Tool call events are sent via 3 separate events:
199+
// - content_block_start (start of the tool call)
200+
// - content_block_delta (delta aka input of the tool call)
201+
// - content_block_stop (end of the tool call)
202+
// We need to handle them all to capture the full tool call.
203+
handleContentBlockStart(event, state);
204+
handleContentBlockDelta(event, state, recordOutputs);
205+
handleContentBlockStop(event, state);
135206
}
136207

137208
/**
@@ -153,6 +224,8 @@ export async function* instrumentStream(
153224
completionTokens: undefined,
154225
cacheCreationInputTokens: undefined,
155226
cacheReadInputTokens: undefined,
227+
toolCalls: [],
228+
activeToolBlocks: {},
156229
};
157230

158231
try {
@@ -197,6 +270,13 @@ export async function* instrumentStream(
197270
});
198271
}
199272

273+
// Set tool calls if any were captured
274+
if (recordOutputs && state.toolCalls.length > 0) {
275+
span.setAttributes({
276+
[GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]: JSON.stringify(state.toolCalls),
277+
});
278+
}
279+
200280
span.end();
201281
}
202282
}

packages/core/src/utils/anthropic-ai/types.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,25 @@ export type Message = {
1616
content: string | unknown[];
1717
};
1818

19+
export type ContentBlock = {
20+
type: 'tool_use' | 'server_tool_use' | string;
21+
text?: string;
22+
/** Tool name when type is tool_use */
23+
name?: string;
24+
/** Tool invocation id when type is tool_use */
25+
id?: string;
26+
input?: Record<string, unknown>;
27+
tool_use_id?: string;
28+
};
29+
1930
export type AnthropicAiResponse = {
2031
[key: string]: unknown; // Allow for additional unknown properties
2132
id: string;
2233
model: string;
2334
created?: number;
2435
created_at?: number; // Available for Models.retrieve
2536
messages?: Array<Message>;
26-
content?: string; // Available for Messages.create
37+
content?: string | Array<ContentBlock>; // Available for Messages.create
2738
completion?: string; // Available for Completions.create
2839
input_tokens?: number; // Available for Models.countTokens
2940
usage?: {
@@ -87,7 +98,14 @@ export type AnthropicAiMessage = {
8798
* Streaming event type for Anthropic AI
8899
*/
89100
export type AnthropicAiStreamingEvent = {
90-
type: 'message_delta' | 'content_block_start' | 'content_block_delta' | 'content_block_stop' | 'error';
101+
type:
102+
| 'message_start'
103+
| 'message_delta'
104+
| 'message_stop'
105+
| 'content_block_start'
106+
| 'content_block_delta'
107+
| 'content_block_stop'
108+
| 'error';
91109
error?: {
92110
type: string;
93111
message: string;
@@ -96,9 +114,15 @@ export type AnthropicAiStreamingEvent = {
96114
delta?: {
97115
type: unknown;
98116
text?: string;
117+
/** Present for fine-grained tool streaming */
118+
partial_json?: string;
119+
stop_reason?: string;
120+
stop_sequence?: number;
99121
};
100122
usage?: {
101123
output_tokens: number; // Final total output tokens; emitted on the last `message_delta` event
102124
};
103125
message?: AnthropicAiMessage;
126+
/** Present for fine-grained tool streaming */
127+
content_block?: ContentBlock;
104128
};

0 commit comments

Comments
 (0)