Skip to content

Commit a5540b0

Browse files
authored
feat(streaming|anthropic): implement Anthropic provider tool call handling in streaming responses (#712)
1 parent d8d009d commit a5540b0

File tree

4 files changed

+230
-0
lines changed

4 files changed

+230
-0
lines changed

src/Providers/Anthropic/Handlers/Stream.php

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use Prism\Prism\Streaming\EventID;
1717
use Prism\Prism\Streaming\Events\CitationEvent;
1818
use Prism\Prism\Streaming\Events\ErrorEvent;
19+
use Prism\Prism\Streaming\Events\ProviderToolEvent;
1920
use Prism\Prism\Streaming\Events\StreamEndEvent;
2021
use Prism\Prism\Streaming\Events\StreamEvent;
2122
use Prism\Prism\Streaming\Events\StreamStartEvent;
@@ -152,6 +153,7 @@ protected function handleContentBlockStart(array $event): ?StreamEvent
152153
),
153154
'thinking' => $this->handleThinkingStart(),
154155
'tool_use' => $this->handleToolUseStart($contentBlock),
156+
'server_tool_use' => $this->handleProviderToolUseStart($contentBlock),
155157
default => null,
156158
};
157159
}
@@ -170,6 +172,7 @@ protected function handleContentBlockDelta(array $event): ?StreamEvent
170172
['thinking', 'thinking_delta'] => $this->handleThinkingDelta($delta),
171173
['thinking', 'signature_delta'] => $this->handleSignatureDelta($delta),
172174
['tool_use', 'input_json_delta'] => $this->handleToolInputDelta($delta),
175+
['server_tool_use', 'input_json_delta'] => $this->handleProviderToolInputDelta($delta),
173176
default => null,
174177
};
175178
}
@@ -191,6 +194,7 @@ protected function handleContentBlockStop(array $event): ?StreamEvent
191194
reasoningId: $this->state->reasoningId()
192195
),
193196
'tool_use' => $this->handleToolUseComplete(),
197+
'server_tool_use' => $this->handleProviderToolUseComplete(),
194198
default => null,
195199
};
196200

@@ -493,6 +497,72 @@ protected function handleToolCalls(Request $request, int $depth): Generator
493497
}
494498
}
495499

500+
/**
501+
* @param array<string, mixed> $contentBlock
502+
*/
503+
protected function handleProviderToolUseStart(array $contentBlock): ProviderToolEvent
504+
{
505+
if ($this->state->currentBlockIndex() !== null) {
506+
$this->state->addProviderToolCall($this->state->currentBlockIndex(), [
507+
'id' => $contentBlock['id'] ?? EventID::generate(),
508+
'name' => $contentBlock['name'] ?? 'unknown',
509+
'input' => '',
510+
]);
511+
}
512+
513+
return new ProviderToolEvent(
514+
id: EventID::generate(),
515+
timestamp: time(),
516+
toolType: $contentBlock['name'] ?? 'unknown',
517+
status: 'started',
518+
itemId: $contentBlock['id'],
519+
data: $contentBlock['input'] ?? '',
520+
);
521+
}
522+
523+
/**
524+
* @param array<string, mixed> $delta
525+
*/
526+
protected function handleProviderToolInputDelta(array $delta): null
527+
{
528+
$partialJson = $delta['partial_json'] ?? '';
529+
530+
if ($this->state->currentBlockIndex() !== null && isset($this->state->providerToolCalls()[$this->state->currentBlockIndex()])) {
531+
$this->state->appendProviderToolCallInput($this->state->currentBlockIndex(), $partialJson);
532+
}
533+
534+
return null;
535+
}
536+
537+
protected function handleProviderToolUseComplete(): ?ProviderToolEvent
538+
{
539+
if ($this->state->currentBlockIndex() === null || ! isset($this->state->providerToolCalls()[$this->state->currentBlockIndex()])) {
540+
return null;
541+
}
542+
543+
$providerToolCall = $this->state->providerToolCalls()[$this->state->currentBlockIndex()];
544+
545+
// Parse the JSON input
546+
$input = $providerToolCall['input'];
547+
if (is_string($input) && json_validate($input)) {
548+
$input = json_decode($input, true);
549+
} elseif (is_string($input) && $input !== '') {
550+
// If it's not valid JSON but not empty, wrap in array
551+
$input = ['input' => $input];
552+
} else {
553+
$input = [];
554+
}
555+
556+
return new ProviderToolEvent(
557+
id: EventID::generate(),
558+
timestamp: time(),
559+
toolType: $providerToolCall['name'],
560+
status: 'completed',
561+
itemId: $providerToolCall['id'],
562+
data: $input,
563+
);
564+
}
565+
496566
/**
497567
* @return array<string, mixed>|null
498568
*/

src/Providers/Anthropic/ValueObjects/AnthropicStreamState.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ class AnthropicStreamState extends StreamState
1010
{
1111
protected string $currentThinkingSignature = '';
1212

13+
/** @var array<int, array<string, mixed>> */
14+
protected array $providerToolCalls = [];
15+
1316
public function appendThinkingSignature(string $signature): self
1417
{
1518
$this->currentThinkingSignature .= $signature;
@@ -22,10 +25,40 @@ public function currentThinkingSignature(): string
2225
return $this->currentThinkingSignature;
2326
}
2427

28+
/**
29+
* @param array<string, mixed> $providerToolCall
30+
*/
31+
public function addProviderToolCall(int $index, array $providerToolCall): self
32+
{
33+
$this->providerToolCalls[$index] = $providerToolCall;
34+
35+
return $this;
36+
}
37+
38+
/**
39+
* @return array<int, array<string, mixed>>
40+
*/
41+
public function providerToolCalls(): array
42+
{
43+
return $this->providerToolCalls;
44+
}
45+
46+
public function appendProviderToolCallInput(int $index, string $input): self
47+
{
48+
if (! isset($this->providerToolCalls[$index])) {
49+
$this->providerToolCalls[$index] = ['input' => ''];
50+
}
51+
52+
$this->providerToolCalls[$index]['input'] .= $input;
53+
54+
return $this;
55+
}
56+
2557
public function reset(): self
2658
{
2759
parent::reset();
2860
$this->currentThinkingSignature = '';
61+
$this->providerToolCalls = [];
2962

3063
return $this;
3164
}
@@ -34,6 +67,7 @@ public function resetTextState(): self
3467
{
3568
parent::resetTextState();
3669
$this->currentThinkingSignature = '';
70+
$this->providerToolCalls = [];
3771

3872
return $this;
3973
}

tests/Providers/Anthropic/StreamTest.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use Prism\Prism\Facades\Prism;
1717
use Prism\Prism\Facades\Tool;
1818
use Prism\Prism\Streaming\Events\CitationEvent;
19+
use Prism\Prism\Streaming\Events\ProviderToolEvent;
1920
use Prism\Prism\Streaming\Events\StreamEndEvent;
2021
use Prism\Prism\Streaming\Events\StreamEvent;
2122
use Prism\Prism\Streaming\Events\TextDeltaEvent;
@@ -454,6 +455,45 @@
454455
});
455456
});
456457

458+
describe('provider tool calls', function (): void {
459+
it('can handle provider tool calls in streaming responses', function (): void {
460+
FixtureResponse::fakeStreamResponses('v1/messages', 'anthropic/stream-with-web-search-citations');
461+
462+
$response = Prism::text()
463+
->using(Provider::Anthropic, 'claude-3-7-sonnet-20250219')
464+
->withPrompt('What is the weather like in London UK today?')
465+
->withProviderTools([new ProviderTool(type: 'web_search_20250305', name: 'web_search')])
466+
->asStream();
467+
468+
$providerToolEvents = [];
469+
470+
foreach ($response as $event) {
471+
if ($event instanceof ProviderToolEvent) {
472+
$providerToolEvents[] = $event;
473+
}
474+
}
475+
476+
// Verify that provider tool call events were emitted
477+
expect($providerToolEvents)->not->toBeEmpty();
478+
479+
// Verify the structure of the first provider tool call event
480+
$firstEvent = $providerToolEvents[0];
481+
expect($firstEvent)->toBeInstanceOf(ProviderToolEvent::class);
482+
expect($firstEvent->toolType)->toBe('web_search');
483+
expect($firstEvent->status)->toBe('started');
484+
expect($firstEvent->itemId)->not->toBeEmpty();
485+
expect($firstEvent->data)->toBeArray();
486+
487+
// Verify the structure of the second provider tool call event
488+
$secondEvent = $providerToolEvents[1];
489+
expect($secondEvent)->toBeInstanceOf(ProviderToolEvent::class);
490+
expect($secondEvent->toolType)->toBe('web_search');
491+
expect($secondEvent->status)->toBe('completed');
492+
expect($secondEvent->itemId)->not->toBeEmpty();
493+
expect($secondEvent->data)->toBeArray();
494+
});
495+
});
496+
457497
describe('exception handling', function (): void {
458498
it('throws a PrismRateLimitedException with a 429 response code', function (): void {
459499
Http::fake([

tests/Unit/Providers/Anthropic/AnthropicStreamStateTest.php

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,11 +113,13 @@
113113
$state->appendThinkingSignature('sig-1');
114114
$state->appendText('Hello');
115115
$state->appendThinkingSignature('-sig-2');
116+
$state->addProviderToolCall(0, ['id' => 'tool-123']);
116117
$state->markStreamStarted();
117118

118119
expect($state->messageId())->toBe('msg-456')
119120
->and($state->model())->toBe('claude-3-5-sonnet')
120121
->and($state->currentThinkingSignature())->toBe('sig-1-sig-2')
122+
->and($state->providerToolCalls())->toBe([0 => ['id' => 'tool-123']])
121123
->and($state->currentText())->toBe('Hello')
122124
->and($state->hasStreamStarted())->toBeTrue();
123125
});
@@ -140,6 +142,8 @@
140142
$state->appendThinkingSignature('signature-value');
141143
$state->withBlockContext(2, 'text');
142144
$state->addToolCall(0, ['id' => 'tool-1', 'name' => 'search']);
145+
$state->addProviderToolCall(0, ['id' => 'tool-1']);
146+
$state->addProviderToolCall(1, ['id' => 'tool-2']);
143147
$state->addCitation($citation);
144148
$state->withUsage($usage);
145149
$state->withFinishReason(FinishReason::Stop);
@@ -155,6 +159,7 @@
155159
->and($state->currentText())->toBe('response text')
156160
->and($state->currentThinking())->toBe('thought process')
157161
->and($state->currentThinkingSignature())->toBe('signature-value')
162+
->and($state->providerToolCalls())->toBe([0 => ['id' => 'tool-1'], 1 => ['id' => 'tool-2']])
158163
->and($state->currentBlockIndex())->toBe(2)
159164
->and($state->currentBlockType())->toBe('text')
160165
->and($state->toolCalls())->toBe([0 => ['id' => 'tool-1', 'name' => 'search']])
@@ -287,3 +292,84 @@
287292

288293
expect($state->currentThinkingSignature())->toBe($expected);
289294
});
295+
296+
it('constructs with empty provider tool calls', function (): void {
297+
$state = new AnthropicStreamState;
298+
299+
expect($state->providerToolCalls())->toBe([]);
300+
});
301+
302+
it('reset clears provider tool calls', function (): void {
303+
$state = new AnthropicStreamState;
304+
$state->addProviderToolCall(0, ['id' => 'tool-1']);
305+
$state->addProviderToolCall(1, ['id' => 'tool-2']);
306+
307+
$state->reset();
308+
309+
expect($state->providerToolCalls())->toBe([]);
310+
});
311+
312+
it('addProviderToolCall adds tool call at specified block index', function (): void {
313+
$state = new AnthropicStreamState;
314+
315+
$state->addProviderToolCall(0, ['id' => 'tool-1']);
316+
$state->addProviderToolCall(2, ['id' => 'tool-3']);
317+
318+
expect($state->providerToolCalls())->toBe([
319+
0 => ['id' => 'tool-1'],
320+
2 => ['id' => 'tool-3'],
321+
]);
322+
});
323+
324+
it('addProviderToolCall returns self for fluent chaining', function (): void {
325+
$state = new AnthropicStreamState;
326+
327+
$result = $state->addProviderToolCall(0, ['id' => 'tool-1']);
328+
329+
expect($result)->toBe($state);
330+
});
331+
332+
it('appendProviderToolCallInput appends input to existing tool call', function (): void {
333+
$state = new AnthropicStreamState;
334+
335+
$state->addProviderToolCall(0, ['input' => 'initial-']);
336+
$state->appendProviderToolCallInput(0, 'more-');
337+
$state->appendProviderToolCallInput(0, 'data');
338+
339+
expect($state->providerToolCalls())->toBe([
340+
0 => ['input' => 'initial-more-data'],
341+
]);
342+
});
343+
344+
it('appendProviderToolCallInput initializes tool call if not present', function (): void {
345+
$state = new AnthropicStreamState;
346+
347+
$state->appendProviderToolCallInput(1, 'input-data');
348+
349+
expect($state->providerToolCalls())->toBe([
350+
1 => ['input' => 'input-data'],
351+
]);
352+
});
353+
354+
it('appendProviderToolCallInput returns self for fluent chaining', function (): void {
355+
$state = new AnthropicStreamState;
356+
357+
$result = $state->appendProviderToolCallInput(0, 'data');
358+
359+
expect($result)->toBe($state);
360+
});
361+
362+
it('handles multiple provider tool calls correctly', function (): void {
363+
$state = new AnthropicStreamState;
364+
365+
$state->addProviderToolCall(0, ['id' => 'tool-1', 'input' => 'start-']);
366+
$state->appendProviderToolCallInput(0, 'middle-');
367+
$state->appendProviderToolCallInput(0, 'end');
368+
369+
$state->addProviderToolCall(2, ['id' => 'tool-3', 'input' => 'only-input']);
370+
371+
expect($state->providerToolCalls())->toBe([
372+
0 => ['id' => 'tool-1', 'input' => 'start-middle-end'],
373+
2 => ['id' => 'tool-3', 'input' => 'only-input'],
374+
]);
375+
});

0 commit comments

Comments
 (0)