From 1bf9cad9632766575e19bd9bf42e2e645d23860a Mon Sep 17 00:00:00 2001 From: "vinit.ka" Date: Sat, 3 Jan 2026 01:39:49 +0530 Subject: [PATCH] feat: client executed tools --- docs/core-concepts/tools-function-calling.md | 69 +++++++++++++++ src/Concerns/CallsTools.php | 73 ++++++++++++++-- src/Exceptions/PrismException.php | 7 ++ src/Providers/Anthropic/Handlers/Stream.php | 10 ++- .../Anthropic/Handlers/Structured.php | 8 +- src/Providers/Anthropic/Handlers/Text.php | 5 +- src/Providers/DeepSeek/Handlers/Stream.php | 10 ++- src/Providers/DeepSeek/Handlers/Text.php | 6 +- src/Providers/Gemini/Handlers/Stream.php | 11 ++- src/Providers/Gemini/Handlers/Structured.php | 6 +- src/Providers/Gemini/Handlers/Text.php | 6 +- src/Providers/Groq/Handlers/Stream.php | 10 ++- src/Providers/Groq/Handlers/Text.php | 4 +- src/Providers/Mistral/Handlers/Stream.php | 10 ++- src/Providers/Mistral/Handlers/Text.php | 4 +- src/Providers/Ollama/Handlers/Stream.php | 10 ++- src/Providers/Ollama/Handlers/Text.php | 16 +++- src/Providers/OpenAI/Handlers/Stream.php | 10 ++- src/Providers/OpenAI/Handlers/Structured.php | 4 +- src/Providers/OpenAI/Handlers/Text.php | 4 +- src/Providers/OpenRouter/Handlers/Stream.php | 10 ++- src/Providers/OpenRouter/Handlers/Text.php | 6 +- src/Providers/XAI/Handlers/Stream.php | 10 ++- src/Providers/XAI/Handlers/Text.php | 5 +- src/Tool.php | 24 +++++- tests/Concerns/CallsToolsConcurrentTest.php | 21 +++-- tests/Concerns/CallsToolsTest.php | 83 ++++++++++++++++++- .../stream-with-client-executed-tool-1.sse | 31 +++++++ ...tructured-with-client-executed-tool-1.json | 2 + .../text-with-client-executed-tool-1.json | 2 + .../anthropic/text-with-mixed-tools-1.json | 1 + .../stream-with-client-executed-tool-1.sse | 9 ++ .../text-with-client-executed-tool-1.json | 2 + .../stream-with-client-executed-tool-1.json | 3 + ...tructured-with-client-executed-tool-1.json | 40 +++++++++ .../text-with-client-executed-tool-1.json | 40 +++++++++ .../stream-with-client-executed-tool-1.sse | 9 ++ .../text-with-client-executed-tool-1.json | 2 + .../stream-with-client-executed-tool-1.sse | 6 ++ .../text-with-client-executed-tool-1.json | 32 +++++++ .../stream-with-client-executed-tool-1.sse | 3 + .../text-with-client-executed-tool-1.json | 2 + .../stream-with-client-executed-tool-1.json | 22 +++++ ...tructured-with-client-executed-tool-1.json | 31 +++++++ .../text-with-client-executed-tool-1.json | 31 +++++++ .../stream-with-client-executed-tool-1.sse | 9 ++ .../text-with-client-executed-tool-1.json | 2 + .../stream-with-client-executed-tool-1.json | 9 ++ .../xai/text-with-client-executed-tool-1.json | 34 ++++++++ .../Providers/Anthropic/AnthropicTextTest.php | 61 ++++++++++++++ tests/Providers/Anthropic/StreamTest.php | 34 ++++++++ .../Anthropic/StructuredWithToolsTest.php | 30 +++++++ tests/Providers/DeepSeek/StreamTest.php | 34 ++++++++ tests/Providers/DeepSeek/TextTest.php | 22 +++++ tests/Providers/Gemini/GeminiStreamTest.php | 34 ++++++++ tests/Providers/Gemini/GeminiTextTest.php | 23 +++++ .../Gemini/StructuredWithToolsTest.php | 29 +++++++ tests/Providers/Groq/GroqTextTest.php | 21 +++++ tests/Providers/Groq/StreamTest.php | 34 ++++++++ tests/Providers/Mistral/MistralTextTest.php | 20 +++++ tests/Providers/Mistral/StreamTest.php | 34 ++++++++ tests/Providers/Ollama/StreamTest.php | 34 ++++++++ tests/Providers/Ollama/TextTest.php | 23 +++++ tests/Providers/OpenAI/StreamTest.php | 35 ++++++++ .../OpenAI/StructuredWithToolsTest.php | 29 +++++++ tests/Providers/OpenAI/TextTest.php | 22 +++++ tests/Providers/OpenRouter/StreamTest.php | 34 ++++++++ tests/Providers/OpenRouter/TextTest.php | 22 +++++ tests/Providers/XAI/StreamTest.php | 34 ++++++++ tests/Providers/XAI/XAITextTest.php | 22 +++++ tests/ToolTest.php | 12 +++ 71 files changed, 1360 insertions(+), 47 deletions(-) create mode 100644 tests/Fixtures/anthropic/stream-with-client-executed-tool-1.sse create mode 100644 tests/Fixtures/anthropic/structured-with-client-executed-tool-1.json create mode 100644 tests/Fixtures/anthropic/text-with-client-executed-tool-1.json create mode 100644 tests/Fixtures/anthropic/text-with-mixed-tools-1.json create mode 100644 tests/Fixtures/deepseek/stream-with-client-executed-tool-1.sse create mode 100644 tests/Fixtures/deepseek/text-with-client-executed-tool-1.json create mode 100644 tests/Fixtures/gemini/stream-with-client-executed-tool-1.json create mode 100644 tests/Fixtures/gemini/structured-with-client-executed-tool-1.json create mode 100644 tests/Fixtures/gemini/text-with-client-executed-tool-1.json create mode 100644 tests/Fixtures/groq/stream-with-client-executed-tool-1.sse create mode 100644 tests/Fixtures/groq/text-with-client-executed-tool-1.json create mode 100644 tests/Fixtures/mistral/stream-with-client-executed-tool-1.sse create mode 100644 tests/Fixtures/mistral/text-with-client-executed-tool-1.json create mode 100644 tests/Fixtures/ollama/stream-with-client-executed-tool-1.sse create mode 100644 tests/Fixtures/ollama/text-with-client-executed-tool-1.json create mode 100644 tests/Fixtures/openai/stream-with-client-executed-tool-1.json create mode 100644 tests/Fixtures/openai/structured-with-client-executed-tool-1.json create mode 100644 tests/Fixtures/openai/text-with-client-executed-tool-1.json create mode 100644 tests/Fixtures/openrouter/stream-with-client-executed-tool-1.sse create mode 100644 tests/Fixtures/openrouter/text-with-client-executed-tool-1.json create mode 100644 tests/Fixtures/xai/stream-with-client-executed-tool-1.json create mode 100644 tests/Fixtures/xai/text-with-client-executed-tool-1.json diff --git a/docs/core-concepts/tools-function-calling.md b/docs/core-concepts/tools-function-calling.md index 0fdf9dff1..fb5eb7356 100644 --- a/docs/core-concepts/tools-function-calling.md +++ b/docs/core-concepts/tools-function-calling.md @@ -388,6 +388,75 @@ use Prism\Prism\Facades\Tool; $tool = Tool::make(CurrentWeatherTool::class); ``` +## Client-Executed Tools + +Sometimes you need tools that are executed by the client (e.g., frontend application) rather than on the server. Client-executed tools are defined without a handler function. + +### Explicit Declaration (Recommended) + +Use the `clientExecuted()` method to explicitly mark a tool as client-executed: + +```php +use Prism\Prism\Facades\Tool; + +$clientTool = Tool::as('browser_action') + ->for('Perform an action in the user\'s browser') + ->withStringParameter('action', 'The action to perform') + ->clientExecuted(); +``` + +This makes your intent clear and self-documenting. + +### Implicit Declaration + +You can also create a client-executed tool by simply omitting the `using()` call: + +```php +use Prism\Prism\Facades\Tool; + +$clientTool = Tool::as('browser_action') + ->for('Perform an action in the user\'s browser') + ->withStringParameter('action', 'The action to perform'); + // No using() call - tool is implicitly client-executed +``` + +When the AI calls a client-executed tool, Prism will: +1. Stop execution and return control to your application +2. Set the response's `finishReason` to `FinishReason::ToolCalls` +3. Include the tool calls in the response for your client to execute + +### Handling Client-Executed Tools + +```php +use Prism\Prism\Facades\Prism; +use Prism\Prism\Enums\FinishReason; + +$response = Prism::text() + ->using('anthropic', 'claude-3-5-sonnet-latest') + ->withTools([$clientTool]) + ->withMaxSteps(3) + ->withPrompt('Click the submit button') + ->asText(); + +``` + +### Streaming with Client-Executed Tools + +When streaming, client-executed tools emit a `ToolCallEvent` but no `ToolResultEvent`: + +```php + +$response = Prism::text() + ->using('anthropic', 'claude-3-5-sonnet-latest') + ->withTools([$clientTool]) + ->withMaxSteps(3) + ->withPrompt('Click the submit button') + ->asStream(); +``` + +> [!NOTE] +> Client-executed tools are useful for scenarios like browser automation, UI interactions, or any operation that must run on the user's device rather than the server. + ## Tool Choice Options You can control how the AI uses tools with the `withToolChoice` method: diff --git a/src/Concerns/CallsTools.php b/src/Concerns/CallsTools.php index 9ad211a82..18d744032 100644 --- a/src/Concerns/CallsTools.php +++ b/src/Concerns/CallsTools.php @@ -8,10 +8,15 @@ use Illuminate\Support\Facades\Concurrency; use Illuminate\Support\ItemNotFoundException; use Illuminate\Support\MultipleItemsFoundException; +use JsonException; +use Prism\Prism\Enums\FinishReason; use Prism\Prism\Exceptions\PrismException; use Prism\Prism\Streaming\EventID; use Prism\Prism\Streaming\Events\ArtifactEvent; +use Prism\Prism\Streaming\Events\StepFinishEvent; +use Prism\Prism\Streaming\Events\StreamEndEvent; use Prism\Prism\Streaming\Events\ToolResultEvent; +use Prism\Prism\Streaming\StreamState; use Prism\Prism\Tool; use Prism\Prism\ValueObjects\ToolCall; use Prism\Prism\ValueObjects\ToolOutput; @@ -25,13 +30,15 @@ trait CallsTools * @param Tool[] $tools * @param ToolCall[] $toolCalls * @return ToolResult[] + * + * @throws PrismException|JsonException */ - protected function callTools(array $tools, array $toolCalls): array + protected function callTools(array $tools, array $toolCalls, bool &$hasPendingToolCalls): array { $toolResults = []; // Consume generator to execute all tools and collect results - foreach ($this->callToolsAndYieldEvents($tools, $toolCalls, EventID::generate(), $toolResults) as $event) { + foreach ($this->callToolsAndYieldEvents($tools, $toolCalls, EventID::generate(), $toolResults, $hasPendingToolCalls) as $event) { // Events are discarded for non-streaming handlers } @@ -46,13 +53,15 @@ protected function callTools(array $tools, array $toolCalls): array * @param ToolResult[] $toolResults Results are collected into this array by reference * @return Generator */ - protected function callToolsAndYieldEvents(array $tools, array $toolCalls, string $messageId, array &$toolResults): Generator + protected function callToolsAndYieldEvents(array $tools, array $toolCalls, string $messageId, array &$toolResults, bool &$hasPendingToolCalls): Generator { - $groupedToolCalls = $this->groupToolCallsByConcurrency($tools, $toolCalls); + $serverToolCalls = $this->filterServerExecutedToolCalls($tools, $toolCalls, $hasPendingToolCalls); + + $groupedToolCalls = $this->groupToolCallsByConcurrency($tools, $serverToolCalls); $executionResults = $this->executeToolsWithConcurrency($tools, $groupedToolCalls, $messageId); - foreach (array_keys($toolCalls) as $index) { + foreach (collect($executionResults)->keys()->sort() as $index) { $result = $executionResults[$index]; $toolResults[] = $result['toolResult']; @@ -64,8 +73,39 @@ protected function callToolsAndYieldEvents(array $tools, array $toolCalls, strin } /** + * Filter out client-executed tool calls, setting the pending flag if any are found. + * * @param Tool[] $tools * @param ToolCall[] $toolCalls + * @return array Server-executed tool calls with original indices preserved + */ + protected function filterServerExecutedToolCalls(array $tools, array $toolCalls, bool &$hasPendingToolCalls): array + { + $serverToolCalls = []; + + foreach ($toolCalls as $index => $toolCall) { + try { + $tool = $this->resolveTool($toolCall->name, $tools); + + if ($tool->isClientExecuted()) { + $hasPendingToolCalls = true; + + continue; + } + + $serverToolCalls[$index] = $toolCall; + } catch (PrismException) { + // Unknown tool - keep it so error handling works in executeToolCall + $serverToolCalls[$index] = $toolCall; + } + } + + return $serverToolCalls; + } + + /** + * @param Tool[] $tools + * @param array $toolCalls * @return array{concurrent: array, sequential: array} */ protected function groupToolCallsByConcurrency(array $tools, array $toolCalls): array @@ -197,8 +237,31 @@ protected function executeToolCall(array $tools, ToolCall $toolCall, string $mes } } + /** + * Yield stream completion events when client-executed tools are pending. + * + * @return Generator + */ + protected function yieldToolCallsFinishEvents(StreamState $state): Generator + { + yield new StepFinishEvent( + id: EventID::generate(), + timestamp: time() + ); + + yield new StreamEndEvent( + id: EventID::generate(), + timestamp: time(), + finishReason: FinishReason::ToolCalls, + usage: $state->usage(), + citations: $state->citations(), + ); + } + /** * @param Tool[] $tools + * + * @throws PrismException */ protected function resolveTool(string $name, array $tools): Tool { diff --git a/src/Exceptions/PrismException.php b/src/Exceptions/PrismException.php index 9c9f852d8..e98707ead 100644 --- a/src/Exceptions/PrismException.php +++ b/src/Exceptions/PrismException.php @@ -94,4 +94,11 @@ public static function unsupportedProviderAction(string $method, string $provide $provider, )); } + + public static function toolHandlerNotDefined(string $toolName): self + { + return new self( + sprintf('Tool (%s) has no handler defined', $toolName) + ); + } } diff --git a/src/Providers/Anthropic/Handlers/Stream.php b/src/Providers/Anthropic/Handlers/Stream.php index 54fb9457c..bf3c348c0 100644 --- a/src/Providers/Anthropic/Handlers/Stream.php +++ b/src/Providers/Anthropic/Handlers/Stream.php @@ -499,7 +499,15 @@ protected function handleToolCalls(Request $request, int $depth): Generator // Execute tools and emit results $toolResults = []; - yield from $this->callToolsAndYieldEvents($request->tools(), $toolCalls, $this->state->messageId(), $toolResults); + $hasPendingToolCalls = false; + yield from $this->callToolsAndYieldEvents($request->tools(), $toolCalls, $this->state->messageId(), $toolResults, $hasPendingToolCalls); + + if ($hasPendingToolCalls) { + $this->state->markStepFinished(); + yield from $this->yieldToolCallsFinishEvents($this->state); + + return; + } // Add messages to request for next turn if ($toolResults !== []) { diff --git a/src/Providers/Anthropic/Handlers/Structured.php b/src/Providers/Anthropic/Handlers/Structured.php index be917b430..3ecac4792 100644 --- a/src/Providers/Anthropic/Handlers/Structured.php +++ b/src/Providers/Anthropic/Handlers/Structured.php @@ -150,7 +150,8 @@ protected function handleToolCalls(array $toolCalls, Response $tempResponse): Re protected function executeCustomToolsAndFinalize(array $toolCalls, Response $tempResponse): Response { $customToolCalls = $this->filterCustomToolCalls($toolCalls); - $toolResults = $this->callTools($this->request->tools(), $customToolCalls); + $hasPendingToolCalls = false; + $toolResults = $this->callTools($this->request->tools(), $customToolCalls, $hasPendingToolCalls); $this->addStep($toolCalls, $tempResponse, $toolResults); return $this->responseBuilder->toResponse(); @@ -162,7 +163,8 @@ protected function executeCustomToolsAndFinalize(array $toolCalls, Response $tem protected function executeCustomToolsAndContinue(array $toolCalls, Response $tempResponse): Response { $customToolCalls = $this->filterCustomToolCalls($toolCalls); - $toolResults = $this->callTools($this->request->tools(), $customToolCalls); + $hasPendingToolCalls = false; + $toolResults = $this->callTools($this->request->tools(), $customToolCalls, $hasPendingToolCalls); $message = new ToolResultMessage($toolResults); if ($toolResultCacheType = $this->request->providerOptions('tool_result_cache_type')) { @@ -173,7 +175,7 @@ protected function executeCustomToolsAndContinue(array $toolCalls, Response $tem $this->request->resetToolChoice(); $this->addStep($toolCalls, $tempResponse, $toolResults); - if ($this->canContinue()) { + if (! $hasPendingToolCalls && $this->canContinue()) { return $this->handle(); } diff --git a/src/Providers/Anthropic/Handlers/Text.php b/src/Providers/Anthropic/Handlers/Text.php index bc5b31780..ff8e2899f 100644 --- a/src/Providers/Anthropic/Handlers/Text.php +++ b/src/Providers/Anthropic/Handlers/Text.php @@ -101,7 +101,8 @@ public static function buildHttpRequestPayload(PrismRequest $request): array protected function handleToolCalls(): Response { - $toolResults = $this->callTools($this->request->tools(), $this->tempResponse->toolCalls); + $hasPendingToolCalls = false; + $toolResults = $this->callTools($this->request->tools(), $this->tempResponse->toolCalls, $hasPendingToolCalls); $message = new ToolResultMessage($toolResults); // Apply tool result caching if configured @@ -114,7 +115,7 @@ protected function handleToolCalls(): Response $this->addStep($toolResults); - if ($this->responseBuilder->steps->count() < $this->request->maxSteps()) { + if (! $hasPendingToolCalls && $this->responseBuilder->steps->count() < $this->request->maxSteps()) { return $this->handle(); } diff --git a/src/Providers/DeepSeek/Handlers/Stream.php b/src/Providers/DeepSeek/Handlers/Stream.php index 1ef4edb6a..e17e6cb8b 100644 --- a/src/Providers/DeepSeek/Handlers/Stream.php +++ b/src/Providers/DeepSeek/Handlers/Stream.php @@ -381,7 +381,15 @@ protected function handleToolCalls(Request $request, string $text, array $toolCa } $toolResults = []; - yield from $this->callToolsAndYieldEvents($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults); + $hasPendingToolCalls = false; + yield from $this->callToolsAndYieldEvents($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults, $hasPendingToolCalls); + + if ($hasPendingToolCalls) { + $this->state->markStepFinished(); + yield from $this->yieldToolCallsFinishEvents($this->state); + + return; + } $request->addMessage(new AssistantMessage($text, $mappedToolCalls)); $request->addMessage(new ToolResultMessage($toolResults)); diff --git a/src/Providers/DeepSeek/Handlers/Text.php b/src/Providers/DeepSeek/Handlers/Text.php index 407b06c1d..52cd738e6 100644 --- a/src/Providers/DeepSeek/Handlers/Text.php +++ b/src/Providers/DeepSeek/Handlers/Text.php @@ -65,9 +65,11 @@ public function handle(Request $request): TextResponse */ protected function handleToolCalls(array $data, Request $request): TextResponse { + $hasPendingToolCalls = false; $toolResults = $this->callTools( $request->tools(), - ToolCallMap::map(data_get($data, 'choices.0.message.tool_calls', [])) + ToolCallMap::map(data_get($data, 'choices.0.message.tool_calls', [])), + $hasPendingToolCalls, ); $request = $request->addMessage(new ToolResultMessage($toolResults)); @@ -75,7 +77,7 @@ protected function handleToolCalls(array $data, Request $request): TextResponse $this->addStep($data, $request, $toolResults); - if ($this->shouldContinue($request)) { + if (! $hasPendingToolCalls && $this->shouldContinue($request)) { return $this->handle($request); } diff --git a/src/Providers/Gemini/Handlers/Stream.php b/src/Providers/Gemini/Handlers/Stream.php index 7b0f615a8..196a47746 100644 --- a/src/Providers/Gemini/Handlers/Stream.php +++ b/src/Providers/Gemini/Handlers/Stream.php @@ -326,6 +326,7 @@ protected function handleToolCalls( array $data = [] ): Generator { $mappedToolCalls = []; + $hasPendingToolCalls = false; // Convert tool calls to ToolCall objects foreach ($this->state->toolCalls() as $toolCallData) { @@ -334,8 +335,16 @@ protected function handleToolCalls( // Execute tools and emit results $toolResults = []; - yield from $this->callToolsAndYieldEvents($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults); + yield from $this->callToolsAndYieldEvents($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults, $hasPendingToolCalls); + if ($hasPendingToolCalls) { + $this->state->markStepFinished(); + yield from $this->yieldToolCallsFinishEvents($this->state); + + return; + } + + // Add messages for next turn and continue streaming if ($toolResults !== []) { $request->addMessage(new AssistantMessage($this->state->currentText(), $mappedToolCalls)); $request->addMessage(new ToolResultMessage($toolResults)); diff --git a/src/Providers/Gemini/Handlers/Structured.php b/src/Providers/Gemini/Handlers/Structured.php index 6d4870f59..4a7db980e 100644 --- a/src/Providers/Gemini/Handlers/Structured.php +++ b/src/Providers/Gemini/Handlers/Structured.php @@ -202,9 +202,11 @@ protected function handleStop(array $data, Request $request, FinishReason $finis */ protected function handleToolCalls(array $data, Request $request): StructuredResponse { + $hasPendingToolCalls = false; $toolResults = $this->callTools( $request->tools(), - ToolCallMap::map(data_get($data, 'candidates.0.content.parts', [])) + ToolCallMap::map(data_get($data, 'candidates.0.content.parts', [])), + $hasPendingToolCalls, ); $request->addMessage(new ToolResultMessage($toolResults)); @@ -212,7 +214,7 @@ protected function handleToolCalls(array $data, Request $request): StructuredRes $this->addStep($data, $request, FinishReason::ToolCalls, $toolResults); - if ($this->shouldContinue($request)) { + if (! $hasPendingToolCalls && $this->shouldContinue($request)) { return $this->handle($request); } diff --git a/src/Providers/Gemini/Handlers/Text.php b/src/Providers/Gemini/Handlers/Text.php index bbb294868..ab02927d6 100644 --- a/src/Providers/Gemini/Handlers/Text.php +++ b/src/Providers/Gemini/Handlers/Text.php @@ -147,9 +147,11 @@ protected function handleStop(array $data, Request $request, FinishReason $finis */ protected function handleToolCalls(array $data, Request $request): TextResponse { + $hasPendingToolCalls = false; $toolResults = $this->callTools( $request->tools(), - ToolCallMap::map(data_get($data, 'candidates.0.content.parts', [])) + ToolCallMap::map(data_get($data, 'candidates.0.content.parts', [])), + $hasPendingToolCalls, ); $request->addMessage(new ToolResultMessage($toolResults)); @@ -157,7 +159,7 @@ protected function handleToolCalls(array $data, Request $request): TextResponse $this->addStep($data, $request, FinishReason::ToolCalls, $toolResults); - if ($this->shouldContinue($request)) { + if (! $hasPendingToolCalls && $this->shouldContinue($request)) { return $this->handle($request); } diff --git a/src/Providers/Groq/Handlers/Stream.php b/src/Providers/Groq/Handlers/Stream.php index 528c46e45..da4a74527 100644 --- a/src/Providers/Groq/Handlers/Stream.php +++ b/src/Providers/Groq/Handlers/Stream.php @@ -277,7 +277,15 @@ protected function handleToolCalls( } $toolResults = []; - yield from $this->callToolsAndYieldEvents($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults); + $hasPendingToolCalls = false; + yield from $this->callToolsAndYieldEvents($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults, $hasPendingToolCalls); + + if ($hasPendingToolCalls) { + $this->state->markStepFinished(); + yield from $this->yieldToolCallsFinishEvents($this->state); + + return; + } $request->addMessage(new AssistantMessage($text, $mappedToolCalls)); $request->addMessage(new ToolResultMessage($toolResults)); diff --git a/src/Providers/Groq/Handlers/Text.php b/src/Providers/Groq/Handlers/Text.php index a5710629b..12908d4aa 100644 --- a/src/Providers/Groq/Handlers/Text.php +++ b/src/Providers/Groq/Handlers/Text.php @@ -86,9 +86,11 @@ protected function sendRequest(Request $request): ClientResponse */ protected function handleToolCalls(array $data, Request $request, ClientResponse $clientResponse): TextResponse { + $hasPendingToolCalls = false; $toolResults = $this->callTools( $request->tools(), $this->mapToolCalls(data_get($data, 'choices.0.message.tool_calls', []) ?? []), + $hasPendingToolCalls, ); $request->addMessage(new ToolResultMessage($toolResults)); @@ -96,7 +98,7 @@ protected function handleToolCalls(array $data, Request $request, ClientResponse $this->addStep($data, $request, $clientResponse, FinishReason::ToolCalls, $toolResults); - if ($this->shouldContinue($request)) { + if (! $hasPendingToolCalls && $this->shouldContinue($request)) { return $this->handle($request); } diff --git a/src/Providers/Mistral/Handlers/Stream.php b/src/Providers/Mistral/Handlers/Stream.php index 82a5bcbbb..f4e9940cc 100644 --- a/src/Providers/Mistral/Handlers/Stream.php +++ b/src/Providers/Mistral/Handlers/Stream.php @@ -321,7 +321,15 @@ protected function handleToolCalls( } $toolResults = []; - yield from $this->callToolsAndYieldEvents($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults); + $hasPendingToolCalls = false; + yield from $this->callToolsAndYieldEvents($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults, $hasPendingToolCalls); + + if ($hasPendingToolCalls) { + $this->state->markStepFinished(); + yield from $this->yieldToolCallsFinishEvents($this->state); + + return; + } $request->addMessage(new AssistantMessage($text, $mappedToolCalls)); $request->addMessage(new ToolResultMessage($toolResults)); diff --git a/src/Providers/Mistral/Handlers/Text.php b/src/Providers/Mistral/Handlers/Text.php index 2bb6d4084..9a2ffced8 100644 --- a/src/Providers/Mistral/Handlers/Text.php +++ b/src/Providers/Mistral/Handlers/Text.php @@ -72,9 +72,11 @@ public function handle(Request $request): Response */ protected function handleToolCalls(array $data, Request $request, ClientResponse $clientResponse): Response { + $hasPendingToolCalls = false; $toolResults = $this->callTools( $request->tools(), $this->mapToolCalls(data_get($data, 'choices.0.message.tool_calls', [])), + $hasPendingToolCalls, ); $request->addMessage(new ToolResultMessage($toolResults)); @@ -82,7 +84,7 @@ protected function handleToolCalls(array $data, Request $request, ClientResponse $this->addStep($data, $request, $clientResponse, $toolResults); - if ($this->shouldContinue($request)) { + if (! $hasPendingToolCalls && $this->shouldContinue($request)) { return $this->handle($request); } diff --git a/src/Providers/Ollama/Handlers/Stream.php b/src/Providers/Ollama/Handlers/Stream.php index b8833bdcd..bf9f7b5d1 100644 --- a/src/Providers/Ollama/Handlers/Stream.php +++ b/src/Providers/Ollama/Handlers/Stream.php @@ -292,7 +292,15 @@ protected function handleToolCalls( // Execute tools and emit results $toolResults = []; - yield from $this->callToolsAndYieldEvents($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults); + $hasPendingToolCalls = false; + yield from $this->callToolsAndYieldEvents($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults, $hasPendingToolCalls); + + if ($hasPendingToolCalls) { + $this->state->markStepFinished(); + yield from $this->yieldToolCallsFinishEvents($this->state); + + return; + } // Add messages for next turn $request->addMessage(new AssistantMessage($text, $mappedToolCalls)); diff --git a/src/Providers/Ollama/Handlers/Text.php b/src/Providers/Ollama/Handlers/Text.php index cf11d7ffb..73c7377c2 100644 --- a/src/Providers/Ollama/Handlers/Text.php +++ b/src/Providers/Ollama/Handlers/Text.php @@ -96,9 +96,11 @@ protected function sendRequest(Request $request): array */ protected function handleToolCalls(array $data, Request $request): Response { + $hasPendingToolCalls = false; $toolResults = $this->callTools( $request->tools(), $this->mapToolCalls(data_get($data, 'message.tool_calls', [])), + $hasPendingToolCalls, ); $request->addMessage(new ToolResultMessage($toolResults)); @@ -106,7 +108,7 @@ protected function handleToolCalls(array $data, Request $request): Response $this->addStep($data, $request, $toolResults); - if ($this->shouldContinue($request)) { + if (! $hasPendingToolCalls && $this->shouldContinue($request)) { return $this->handle($request); } @@ -134,10 +136,18 @@ protected function shouldContinue(Request $request): bool */ protected function addStep(array $data, Request $request, array $toolResults = []): void { + $toolCalls = $this->mapToolCalls(data_get($data, 'message.tool_calls', []) ?? []); + + // Ollama sends done_reason: "stop" even when there are tool calls + // Override finish reason to ToolCalls when tool calls are present + $finishReason = $toolCalls === [] + ? $this->mapFinishReason($data) + : FinishReason::ToolCalls; + $this->responseBuilder->addStep(new Step( text: data_get($data, 'message.content') ?? '', - finishReason: $this->mapFinishReason($data), - toolCalls: $this->mapToolCalls(data_get($data, 'message.tool_calls', []) ?? []), + finishReason: $finishReason, + toolCalls: $toolCalls, toolResults: $toolResults, providerToolCalls: [], usage: new Usage( diff --git a/src/Providers/OpenAI/Handlers/Stream.php b/src/Providers/OpenAI/Handlers/Stream.php index e307e6cd3..12a4a9694 100644 --- a/src/Providers/OpenAI/Handlers/Stream.php +++ b/src/Providers/OpenAI/Handlers/Stream.php @@ -396,7 +396,15 @@ protected function handleToolCalls(Request $request, int $depth): Generator { $mappedToolCalls = $this->mapToolCalls($this->state->toolCalls()); $toolResults = []; - yield from $this->callToolsAndYieldEvents($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults); + $hasPendingToolCalls = false; + yield from $this->callToolsAndYieldEvents($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults, $hasPendingToolCalls); + + if ($hasPendingToolCalls) { + $this->state->markStepFinished(); + yield from $this->yieldToolCallsFinishEvents($this->state); + + return; + } $request->addMessage(new AssistantMessage($this->state->currentText(), $mappedToolCalls)); $request->addMessage(new ToolResultMessage($toolResults)); diff --git a/src/Providers/OpenAI/Handlers/Structured.php b/src/Providers/OpenAI/Handlers/Structured.php index 25a36ebe3..ac1251a57 100644 --- a/src/Providers/OpenAI/Handlers/Structured.php +++ b/src/Providers/OpenAI/Handlers/Structured.php @@ -91,9 +91,11 @@ public function handle(Request $request): StructuredResponse */ protected function handleToolCalls(array $data, Request $request, ClientResponse $clientResponse): StructuredResponse { + $hasPendingToolCalls = false; $toolResults = $this->callTools( $request->tools(), ToolCallMap::map($this->extractFunctionCalls($data)), + $hasPendingToolCalls, ); $request->addMessage(new ToolResultMessage($toolResults)); @@ -101,7 +103,7 @@ protected function handleToolCalls(array $data, Request $request, ClientResponse $this->addStep($data, $request, $clientResponse, $toolResults); - if ($this->shouldContinue($request)) { + if (! $hasPendingToolCalls && $this->shouldContinue($request)) { return $this->handle($request); } diff --git a/src/Providers/OpenAI/Handlers/Text.php b/src/Providers/OpenAI/Handlers/Text.php index e1620b0b8..41494de93 100644 --- a/src/Providers/OpenAI/Handlers/Text.php +++ b/src/Providers/OpenAI/Handlers/Text.php @@ -88,12 +88,14 @@ public function handle(Request $request): Response */ protected function handleToolCalls(array $data, Request $request, ClientResponse $clientResponse): Response { + $hasPendingToolCalls = false; $toolResults = $this->callTools( $request->tools(), ToolCallMap::map(array_filter( data_get($data, 'output', []), fn (array $output): bool => $output['type'] === 'function_call') ), + $hasPendingToolCalls, ); $request->addMessage(new ToolResultMessage($toolResults)); @@ -101,7 +103,7 @@ protected function handleToolCalls(array $data, Request $request, ClientResponse $this->addStep($data, $request, $clientResponse, $toolResults); - if ($this->shouldContinue($request)) { + if (! $hasPendingToolCalls && $this->shouldContinue($request)) { return $this->handle($request); } diff --git a/src/Providers/OpenRouter/Handlers/Stream.php b/src/Providers/OpenRouter/Handlers/Stream.php index 168e4bc13..10954b025 100644 --- a/src/Providers/OpenRouter/Handlers/Stream.php +++ b/src/Providers/OpenRouter/Handlers/Stream.php @@ -389,7 +389,15 @@ protected function handleToolCalls( } $toolResults = []; - yield from $this->callToolsAndYieldEvents($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults); + $hasPendingToolCalls = false; + yield from $this->callToolsAndYieldEvents($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults, $hasPendingToolCalls); + + if ($hasPendingToolCalls) { + $this->state->markStepFinished(); + yield from $this->yieldToolCallsFinishEvents($this->state); + + return; + } $request->addMessage(new AssistantMessage($text, $mappedToolCalls)); $request->addMessage(new ToolResultMessage($toolResults)); diff --git a/src/Providers/OpenRouter/Handlers/Text.php b/src/Providers/OpenRouter/Handlers/Text.php index 9751d493d..671eaefe9 100644 --- a/src/Providers/OpenRouter/Handlers/Text.php +++ b/src/Providers/OpenRouter/Handlers/Text.php @@ -64,9 +64,11 @@ public function handle(Request $request): TextResponse */ protected function handleToolCalls(array $data, Request $request): TextResponse { + $hasPendingToolCalls = false; $toolResults = $this->callTools( $request->tools(), - ToolCallMap::map(data_get($data, 'choices.0.message.tool_calls', [])) + ToolCallMap::map(data_get($data, 'choices.0.message.tool_calls', [])), + $hasPendingToolCalls, ); $request = $request->addMessage(new ToolResultMessage($toolResults)); @@ -74,7 +76,7 @@ protected function handleToolCalls(array $data, Request $request): TextResponse $this->addStep($data, $request, $toolResults); - if ($this->shouldContinue($request)) { + if (! $hasPendingToolCalls && $this->shouldContinue($request)) { return $this->handle($request); } diff --git a/src/Providers/XAI/Handlers/Stream.php b/src/Providers/XAI/Handlers/Stream.php index bf0517edd..2c42e079d 100644 --- a/src/Providers/XAI/Handlers/Stream.php +++ b/src/Providers/XAI/Handlers/Stream.php @@ -368,7 +368,15 @@ protected function handleToolCalls( } $toolResults = []; - yield from $this->callToolsAndYieldEvents($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults); + $hasPendingToolCalls = false; + yield from $this->callToolsAndYieldEvents($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults, $hasPendingToolCalls); + + if ($hasPendingToolCalls) { + $this->state->markStepFinished(); + yield from $this->yieldToolCallsFinishEvents($this->state); + + return; + } $request->addMessage(new AssistantMessage($text, $mappedToolCalls)); $request->addMessage(new ToolResultMessage($toolResults)); diff --git a/src/Providers/XAI/Handlers/Text.php b/src/Providers/XAI/Handlers/Text.php index 6b637c024..186b1cff0 100644 --- a/src/Providers/XAI/Handlers/Text.php +++ b/src/Providers/XAI/Handlers/Text.php @@ -77,14 +77,15 @@ protected function handleToolCalls(array $data, Request $request): TextResponse throw new PrismException('XAI: finish reason is tool_calls but no tool calls found in response'); } - $toolResults = $this->callTools($request->tools(), $toolCalls); + $hasPendingToolCalls = false; + $toolResults = $this->callTools($request->tools(), $toolCalls, $hasPendingToolCalls); $request->addMessage(new ToolResultMessage($toolResults)); $request->resetToolChoice(); $this->addStep($data, $request, $toolResults); - if ($this->shouldContinue($request)) { + if (! $hasPendingToolCalls && $this->shouldContinue($request)) { return $this->handle($request); } diff --git a/src/Tool.php b/src/Tool.php index 8a24203ce..f25d3d83a 100644 --- a/src/Tool.php +++ b/src/Tool.php @@ -38,7 +38,7 @@ class Tool /** @var array */ protected array $requiredParameters = []; - /** @var Closure():mixed|callable():mixed */ + /** @var Closure():mixed|callable():mixed|null */ protected $fn; /** @var null|false|Closure(Throwable,array):string */ @@ -72,6 +72,19 @@ public function using(Closure|callable $fn): self return $this; } + /** + * Mark this tool as client-executed (no server-side handler). + * + * Client-executed tools are sent to the AI model but their execution + * is handled by the client application rather than the server. + */ + public function clientExecuted(): self + { + $this->fn = null; + + return $this; + } + public function make(string|object $tool): Tool { if (is_string($tool)) { @@ -245,6 +258,11 @@ public function hasParameters(): bool return (bool) count($this->parameters); } + public function isClientExecuted(): bool + { + return $this->fn === null; + } + /** * @return null|false|Closure(Throwable,array):string */ @@ -260,6 +278,10 @@ public function failedHandler(): null|false|Closure */ public function handle(...$args): string|ToolOutput { + if ($this->fn === null) { + throw PrismException::toolHandlerNotDefined($this->name); + } + try { $value = call_user_func($this->fn, ...$args); diff --git a/tests/Concerns/CallsToolsConcurrentTest.php b/tests/Concerns/CallsToolsConcurrentTest.php index 3b3664f16..8500ca5e6 100644 --- a/tests/Concerns/CallsToolsConcurrentTest.php +++ b/tests/Concerns/CallsToolsConcurrentTest.php @@ -49,11 +49,13 @@ class TestToolCaller ]; $toolResults = []; + $hasPendingToolCalls = false; $events = iterator_to_array($this->caller->callToolsAndYieldEvents( [$tool1, $tool2], $toolCalls, 'msg123', - $toolResults + $toolResults, + $hasPendingToolCalls )); expect($toolResults)->toHaveCount(2); @@ -99,11 +101,13 @@ class TestToolCaller ]; $toolResults = []; + $hasPendingToolCalls = false; $events = iterator_to_array($this->caller->callToolsAndYieldEvents( [$tool1, $tool2, $tool3], $toolCalls, 'msg123', - $toolResults + $toolResults, + $hasPendingToolCalls )); // Verify results are in original order despite parallel execution @@ -161,11 +165,13 @@ class TestToolCaller ]; $toolResults = []; + $hasPendingToolCalls = false; $events = iterator_to_array($this->caller->callToolsAndYieldEvents( [$concurrentTool1, $sequentialTool, $concurrentTool2], $toolCalls, 'msg123', - $toolResults + $toolResults, + $hasPendingToolCalls )); // Verify all tools executed @@ -211,11 +217,13 @@ class TestToolCaller ]; $toolResults = []; + $hasPendingToolCalls = false; $events = iterator_to_array($this->caller->callToolsAndYieldEvents( [$tool1, $tool3], $toolCalls, 'msg123', - $toolResults + $toolResults, + $hasPendingToolCalls )); // All tool calls should have results (including error) @@ -260,10 +268,11 @@ class TestToolCaller new ToolCall('call1', 'concurrent', ['input' => 'test1']), new ToolCall('call2', 'sequential', ['input' => 'test2']), ]; - + $hasPendingToolCalls = false; $grouped = $this->caller->groupToolCallsByConcurrency( [$concurrentTool, $sequentialTool], - $toolCalls + $toolCalls, + $hasPendingToolCalls ); expect($grouped)->toHaveKeys(['concurrent', 'sequential']); diff --git a/tests/Concerns/CallsToolsTest.php b/tests/Concerns/CallsToolsTest.php index 292abc9d8..562a77d19 100644 --- a/tests/Concerns/CallsToolsTest.php +++ b/tests/Concerns/CallsToolsTest.php @@ -15,14 +15,14 @@ class CallsToolsTestHandler { use CallsTools; - public function execute(array $tools, array $toolCalls): array + public function execute(array $tools, array $toolCalls, bool &$hasPendingToolCalls = false): array { - return $this->callTools($tools, $toolCalls); + return $this->callTools($tools, $toolCalls, $hasPendingToolCalls); } - public function stream(array $tools, array $toolCalls, string $messageId, array &$toolResults): Generator + public function stream(array $tools, array $toolCalls, string $messageId, array &$toolResults, bool &$hasPendingToolCalls = false): Generator { - return $this->callToolsAndYieldEvents($tools, $toolCalls, $messageId, $toolResults); + return $this->callToolsAndYieldEvents($tools, $toolCalls, $messageId, $toolResults, $hasPendingToolCalls); } } @@ -241,3 +241,78 @@ public function stream(array $tools, array $toolCalls, string $messageId, array expect($events)->toBeEmpty() ->and($toolResults)->toBeEmpty(); }); + +it('executes server tools and skips client-executed tools in mixed scenario', function (): void { + // Server-executed tool (has handler) + $serverTool = (new Tool) + ->as('server_tool') + ->for('A server-executed tool') + ->withStringParameter('input', 'Input parameter') + ->using(fn (string $input): string => "Server processed: {$input}"); + + // Client-executed tool (no handler) + $clientTool = (new Tool) + ->as('client_tool') + ->for('A client-executed tool') + ->withStringParameter('action', 'Action to perform'); + + $toolCalls = [ + new ToolCall(id: 'call-server', name: 'server_tool', arguments: ['input' => 'test data']), + new ToolCall(id: 'call-client', name: 'client_tool', arguments: ['action' => 'click button']), + ]; + + $handler = new CallsToolsTestHandler; + $hasPendingToolCalls = false; + $results = $handler->execute([$serverTool, $clientTool], $toolCalls, $hasPendingToolCalls); + + // Server tool should have executed + expect($results)->toHaveCount(1) + ->and($results[0]->toolName)->toBe('server_tool') + ->and($results[0]->result)->toBe('Server processed: test data'); + + // Flag should indicate client-executed tools are pending + expect($hasPendingToolCalls)->toBeTrue(); +}); + +it('executes server tools and skips client-executed tools in mixed streaming scenario', function (): void { + // Server-executed tool (has handler) + $serverTool = (new Tool) + ->as('server_tool') + ->for('A server-executed tool') + ->withStringParameter('input', 'Input parameter') + ->using(fn (string $input): string => "Server processed: {$input}"); + + // Client-executed tool (no handler) + $clientTool = (new Tool) + ->as('client_tool') + ->for('A client-executed tool') + ->withStringParameter('action', 'Action to perform'); + + $toolCalls = [ + new ToolCall(id: 'call-client-1', name: 'client_tool', arguments: ['action' => 'scroll']), + new ToolCall(id: 'call-server', name: 'server_tool', arguments: ['input' => 'test data']), + new ToolCall(id: 'call-client-2', name: 'client_tool', arguments: ['action' => 'click']), + ]; + + $handler = new CallsToolsTestHandler; + $toolResults = []; + $hasPendingToolCalls = false; + $events = []; + + foreach ($handler->stream([$serverTool, $clientTool], $toolCalls, 'msg-123', $toolResults, $hasPendingToolCalls) as $event) { + $events[] = $event; + } + + // Only server tool should have result event + expect($events)->toHaveCount(1) + ->and($events[0])->toBeInstanceOf(ToolResultEvent::class) + ->and($events[0]->toolResult->toolName)->toBe('server_tool') + ->and($events[0]->toolResult->result)->toBe('Server processed: test data'); + + // Only server tool results should be collected + expect($toolResults)->toHaveCount(1) + ->and($toolResults[0]->toolName)->toBe('server_tool'); + + // Flag should indicate client-executed tools are pending + expect($hasPendingToolCalls)->toBeTrue(); +}); diff --git a/tests/Fixtures/anthropic/stream-with-client-executed-tool-1.sse b/tests/Fixtures/anthropic/stream-with-client-executed-tool-1.sse new file mode 100644 index 000000000..e6fcba676 --- /dev/null +++ b/tests/Fixtures/anthropic/stream-with-client-executed-tool-1.sse @@ -0,0 +1,31 @@ +event: message_start +data: {"type":"message_start","message":{"id":"msg_client_executed_test","type":"message","role":"assistant","model":"claude-3-5-sonnet-20240620","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":100,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":1}}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"I'll use the client tool to help you."}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: content_block_start +data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_client_tool_stream","name":"client_tool","input":{}}} + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"input\": \"test input\"}"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":1} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":50}} + +event: message_stop +data: {"type":"message_stop"} + + diff --git a/tests/Fixtures/anthropic/structured-with-client-executed-tool-1.json b/tests/Fixtures/anthropic/structured-with-client-executed-tool-1.json new file mode 100644 index 000000000..980813079 --- /dev/null +++ b/tests/Fixtures/anthropic/structured-with-client-executed-tool-1.json @@ -0,0 +1,2 @@ +{"model":"claude-sonnet-4-20250514","id":"msg_client_executed_structured","type":"message","role":"assistant","content":[{"type":"text","text":"I'll use the client tool to help you with that request."},{"type":"tool_use","id":"toolu_client_structured","name":"client_tool","input":{"input":"test input"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":200,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":50}} + diff --git a/tests/Fixtures/anthropic/text-with-client-executed-tool-1.json b/tests/Fixtures/anthropic/text-with-client-executed-tool-1.json new file mode 100644 index 000000000..9d409fb32 --- /dev/null +++ b/tests/Fixtures/anthropic/text-with-client-executed-tool-1.json @@ -0,0 +1,2 @@ +{"id":"msg_01ClientExecutedTest","type":"message","role":"assistant","model":"claude-3-5-sonnet-20240620","content":[{"type":"text","text":"I'll use the client tool to help you with that."},{"type":"tool_use","id":"toolu_client_tool_123","name":"client_tool","input":{"input":"test input"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":100,"output_tokens":50}} + diff --git a/tests/Fixtures/anthropic/text-with-mixed-tools-1.json b/tests/Fixtures/anthropic/text-with-mixed-tools-1.json new file mode 100644 index 000000000..ae9b147d5 --- /dev/null +++ b/tests/Fixtures/anthropic/text-with-mixed-tools-1.json @@ -0,0 +1 @@ +{"id":"msg_01MixedToolsTest","type":"message","role":"assistant","model":"claude-3-5-sonnet-20240620","content":[{"type":"text","text":"I'll use both tools to help you."},{"type":"tool_use","id":"toolu_server_tool_123","name":"server_tool","input":{"query":"weather in SF"}},{"type":"tool_use","id":"toolu_client_tool_456","name":"client_tool","input":{"action":"click submit"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":100,"output_tokens":75}} diff --git a/tests/Fixtures/deepseek/stream-with-client-executed-tool-1.sse b/tests/Fixtures/deepseek/stream-with-client-executed-tool-1.sse new file mode 100644 index 000000000..5dc224e2c --- /dev/null +++ b/tests/Fixtures/deepseek/stream-with-client-executed-tool-1.sse @@ -0,0 +1,9 @@ +data: {"id":"chatcmpl-client-executed","object":"chat.completion.chunk","created":1737244481,"model":"deepseek-chat","choices":[{"index":0,"delta":{"role":"assistant","content":"","tool_calls":[{"index":0,"id":"call_client_tool_stream","type":"function","function":{"name":"client_tool","arguments":""}}]},"finish_reason":null}]} + +data: {"id":"chatcmpl-client-executed","object":"chat.completion.chunk","created":1737244481,"model":"deepseek-chat","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"input\": \"test input\"}"}}]},"finish_reason":null}]} + +data: {"id":"chatcmpl-client-executed","object":"chat.completion.chunk","created":1737244481,"model":"deepseek-chat","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":100,"completion_tokens":50,"total_tokens":150}} + +data: [DONE] + + diff --git a/tests/Fixtures/deepseek/text-with-client-executed-tool-1.json b/tests/Fixtures/deepseek/text-with-client-executed-tool-1.json new file mode 100644 index 000000000..cc22dab36 --- /dev/null +++ b/tests/Fixtures/deepseek/text-with-client-executed-tool-1.json @@ -0,0 +1,2 @@ +{"id":"client-executed-test","object":"chat.completion","created":1737244481,"model":"deepseek-chat","choices":[{"index":0,"message":{"role":"assistant","content":"","tool_calls":[{"index":0,"id":"call_client_tool_123","type":"function","function":{"name":"client_tool","arguments":"{\"input\":\"test input\"}"}}]},"logprobs":null,"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":100,"completion_tokens":50,"total_tokens":150},"system_fingerprint":"fp_test"} + diff --git a/tests/Fixtures/gemini/stream-with-client-executed-tool-1.json b/tests/Fixtures/gemini/stream-with-client-executed-tool-1.json new file mode 100644 index 000000000..972a0996c --- /dev/null +++ b/tests/Fixtures/gemini/stream-with-client-executed-tool-1.json @@ -0,0 +1,3 @@ +data: {"candidates": [{"content": {"parts": [{"functionCall": {"name": "client_tool","args": {"input": "test input"}}}],"role": "model"},"finishReason": "STOP","index": 0}],"usageMetadata": {"promptTokenCount": 100,"candidatesTokenCount": 50,"totalTokenCount": 150,"promptTokensDetails": [{"modality": "TEXT","tokenCount": 100}]},"modelVersion": "gemini-1.5-flash"} + + diff --git a/tests/Fixtures/gemini/structured-with-client-executed-tool-1.json b/tests/Fixtures/gemini/structured-with-client-executed-tool-1.json new file mode 100644 index 000000000..c5a633d1c --- /dev/null +++ b/tests/Fixtures/gemini/structured-with-client-executed-tool-1.json @@ -0,0 +1,40 @@ +{ + "candidates": [ + { + "content": { + "parts": [ + { + "functionCall": { + "name": "client_tool", + "args": { + "input": "test input" + } + } + } + ], + "role": "model" + }, + "finishReason": "STOP", + "avgLogprobs": -0.00003298009687569 + } + ], + "usageMetadata": { + "promptTokenCount": 200, + "candidatesTokenCount": 50, + "totalTokenCount": 250, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 200 + } + ], + "candidatesTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 50 + } + ] + }, + "modelVersion": "gemini-2.0-flash" +} + diff --git a/tests/Fixtures/gemini/text-with-client-executed-tool-1.json b/tests/Fixtures/gemini/text-with-client-executed-tool-1.json new file mode 100644 index 000000000..5fb57e5b9 --- /dev/null +++ b/tests/Fixtures/gemini/text-with-client-executed-tool-1.json @@ -0,0 +1,40 @@ +{ + "candidates": [ + { + "content": { + "parts": [ + { + "functionCall": { + "name": "client_tool", + "args": { + "input": "test input" + } + } + } + ], + "role": "model" + }, + "finishReason": "STOP", + "avgLogprobs": -0.00003298009687569 + } + ], + "usageMetadata": { + "promptTokenCount": 100, + "candidatesTokenCount": 50, + "totalTokenCount": 150, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 100 + } + ], + "candidatesTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 50 + } + ] + }, + "modelVersion": "gemini-1.5-flash" +} + diff --git a/tests/Fixtures/groq/stream-with-client-executed-tool-1.sse b/tests/Fixtures/groq/stream-with-client-executed-tool-1.sse new file mode 100644 index 000000000..c750092fc --- /dev/null +++ b/tests/Fixtures/groq/stream-with-client-executed-tool-1.sse @@ -0,0 +1,9 @@ +data: {"id":"chatcmpl-client-executed-stream","object":"chat.completion.chunk","created":1740311145,"model":"llama-3.3-70b-versatile","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_client_tool_stream","type":"function","function":{"name":"client_tool","arguments":""}}]},"logprobs":null,"finish_reason":null}],"x_groq":{"id":"req_test"}} + +data: {"id":"chatcmpl-client-executed-stream","object":"chat.completion.chunk","created":1740311145,"model":"llama-3.3-70b-versatile","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"input\": \"test input\"}"}}]},"logprobs":null,"finish_reason":null}],"x_groq":{"id":"req_test"}} + +data: {"id":"chatcmpl-client-executed-stream","object":"chat.completion.chunk","created":1740311145,"model":"llama-3.3-70b-versatile","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"tool_calls"}],"usage":{"queue_time":0.1,"prompt_tokens":100,"prompt_time":0.01,"completion_tokens":50,"completion_time":0.1,"total_tokens":150,"total_time":0.2},"x_groq":{"id":"req_test"}} + +data: [DONE] + + diff --git a/tests/Fixtures/groq/text-with-client-executed-tool-1.json b/tests/Fixtures/groq/text-with-client-executed-tool-1.json new file mode 100644 index 000000000..eecec7bb8 --- /dev/null +++ b/tests/Fixtures/groq/text-with-client-executed-tool-1.json @@ -0,0 +1,2 @@ +{"id":"chatcmpl-client-executed","object":"chat.completion","created":1740311145,"model":"llama-3.3-70b-versatile","choices":[{"index":0,"message":{"role":"assistant","tool_calls":[{"id":"call_client_tool","type":"function","function":{"name":"client_tool","arguments":"{\"input\": \"test input\"}"}}]},"logprobs":null,"finish_reason":"tool_calls"}],"usage":{"queue_time":0.1,"prompt_tokens":100,"prompt_time":0.01,"completion_tokens":50,"completion_time":0.1,"total_tokens":150,"total_time":0.2},"system_fingerprint":"fp_test"} + diff --git a/tests/Fixtures/mistral/stream-with-client-executed-tool-1.sse b/tests/Fixtures/mistral/stream-with-client-executed-tool-1.sse new file mode 100644 index 000000000..756442567 --- /dev/null +++ b/tests/Fixtures/mistral/stream-with-client-executed-tool-1.sse @@ -0,0 +1,6 @@ +data: {"id":"client-executed-test","object":"chat.completion.chunk","created":1759185828,"model":"mistral-large-latest","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]} + +data: {"id":"client-executed-test","object":"chat.completion.chunk","created":1759185828,"model":"mistral-large-latest","choices":[{"index":0,"delta":{"tool_calls":[{"id":"client_tool_stream","function":{"name":"client_tool","arguments":"{\"input\": \"test input\"}"},"index":0}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":100,"total_tokens":150,"completion_tokens":50}} + +data: [DONE] + diff --git a/tests/Fixtures/mistral/text-with-client-executed-tool-1.json b/tests/Fixtures/mistral/text-with-client-executed-tool-1.json new file mode 100644 index 000000000..21a812489 --- /dev/null +++ b/tests/Fixtures/mistral/text-with-client-executed-tool-1.json @@ -0,0 +1,32 @@ +{ + "id": "client_executed_test", + "object": "chat.completion", + "created": 1728462827, + "model": "mistral-large-latest", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "client_tool_123", + "type": "function", + "function": { + "name": "client_tool", + "arguments": "{\"input\": \"test input\"}" + } + } + ] + }, + "finish_reason": "tool_calls" + } + ], + "usage": { + "prompt_tokens": 100, + "total_tokens": 150, + "completion_tokens": 50 + } +} + diff --git a/tests/Fixtures/ollama/stream-with-client-executed-tool-1.sse b/tests/Fixtures/ollama/stream-with-client-executed-tool-1.sse new file mode 100644 index 000000000..044f3d26e --- /dev/null +++ b/tests/Fixtures/ollama/stream-with-client-executed-tool-1.sse @@ -0,0 +1,3 @@ +{"model":"qwen2.5:14b","created_at":"2025-06-09T18:55:26.684517Z","message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"client_tool","arguments":{"input":"test input"}}}]},"done_reason":"stop","done":true,"total_duration":8210142000,"load_duration":22224542,"prompt_eval_count":100,"prompt_eval_duration":269880958,"eval_count":50,"eval_duration":7916594250} + + diff --git a/tests/Fixtures/ollama/text-with-client-executed-tool-1.json b/tests/Fixtures/ollama/text-with-client-executed-tool-1.json new file mode 100644 index 000000000..d5d619328 --- /dev/null +++ b/tests/Fixtures/ollama/text-with-client-executed-tool-1.json @@ -0,0 +1,2 @@ +{"model":"qwen2.5:14b","created_at":"2025-06-09T18:55:26.684517Z","message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"client_tool","arguments":{"input":"test input"}}}]},"done_reason":"stop","done":true,"total_duration":8210142000,"load_duration":22224542,"prompt_eval_count":100,"prompt_eval_duration":269880958,"eval_count":50,"eval_duration":7916594250} + diff --git a/tests/Fixtures/openai/stream-with-client-executed-tool-1.json b/tests/Fixtures/openai/stream-with-client-executed-tool-1.json new file mode 100644 index 000000000..272daa16c --- /dev/null +++ b/tests/Fixtures/openai/stream-with-client-executed-tool-1.json @@ -0,0 +1,22 @@ +event: response.created +data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_client_executed_stream","object":"response","created_at":1750705330,"status":"in_progress","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":2048,"model":"gpt-4o-2024-08-06","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"summary":null},"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[{"type":"function","description":"A tool that executes on the client","name":"client_tool","parameters":{"type":"object","properties":{"input":{"description":"Input parameter","type":"string"}},"required":["input"]},"strict":true}],"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} + +event: response.in_progress +data: {"type":"response.in_progress","sequence_number":1,"response":{"id":"resp_client_executed_stream","object":"response","created_at":1750705330,"status":"in_progress","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":2048,"model":"gpt-4o-2024-08-06","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"summary":null},"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[{"type":"function","description":"A tool that executes on the client","name":"client_tool","parameters":{"type":"object","properties":{"input":{"description":"Input parameter","type":"string"}},"required":["input"]},"strict":true}],"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} + +event: response.output_item.added +data: {"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"id":"fc_client_tool_stream","type":"function_call","status":"in_progress","arguments":"","call_id":"call_client_tool_stream","name":"client_tool"}} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","sequence_number":3,"item_id":"fc_client_tool_stream","output_index":0,"delta":"{\"input\":\"test input\"}"} + +event: response.function_call_arguments.done +data: {"type":"response.function_call_arguments.done","sequence_number":4,"item_id":"fc_client_tool_stream","output_index":0,"arguments":"{\"input\":\"test input\"}"} + +event: response.output_item.done +data: {"type":"response.output_item.done","sequence_number":5,"output_index":0,"item":{"id":"fc_client_tool_stream","type":"function_call","status":"completed","arguments":"{\"input\":\"test input\"}","call_id":"call_client_tool_stream","name":"client_tool"}} + +event: response.completed +data: {"type":"response.completed","sequence_number":6,"response":{"id":"resp_client_executed_stream","object":"response","created_at":1750705330,"status":"completed","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":2048,"model":"gpt-4o-2024-08-06","output":[{"id":"fc_client_tool_stream","type":"function_call","status":"completed","arguments":"{\"input\":\"test input\"}","call_id":"call_client_tool_stream","name":"client_tool"}],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"summary":null},"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[{"type":"function","description":"A tool that executes on the client","name":"client_tool","parameters":{"type":"object","properties":{"input":{"description":"Input parameter","type":"string"}},"required":["input"]},"strict":true}],"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":100,"input_tokens_details":{"cached_tokens":0},"output_tokens":50,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":150},"user":null,"metadata":{}}} + + diff --git a/tests/Fixtures/openai/structured-with-client-executed-tool-1.json b/tests/Fixtures/openai/structured-with-client-executed-tool-1.json new file mode 100644 index 000000000..8aff9433b --- /dev/null +++ b/tests/Fixtures/openai/structured-with-client-executed-tool-1.json @@ -0,0 +1,31 @@ +{ + "id": "resp_structured_client_executed", + "object": "response", + "created_at": 1741989983, + "status": "completed", + "model": "gpt-4o-2024-08-06", + "output": [ + { + "id": "fc_client_tool_structured", + "type": "function_call", + "status": "completed", + "arguments": "{\"input\": \"test input\"}", + "call_id": "call_client_tool_structured", + "name": "client_tool" + } + ], + "usage": { + "input_tokens": 200, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 50, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 250 + }, + "service_tier": "default", + "system_fingerprint": "fp_test" +} + diff --git a/tests/Fixtures/openai/text-with-client-executed-tool-1.json b/tests/Fixtures/openai/text-with-client-executed-tool-1.json new file mode 100644 index 000000000..a62212d13 --- /dev/null +++ b/tests/Fixtures/openai/text-with-client-executed-tool-1.json @@ -0,0 +1,31 @@ +{ + "id": "resp_client_executed_test", + "object": "response", + "created_at": 1741989983, + "status": "completed", + "model": "gpt-4o-2024-08-06", + "output": [ + { + "id": "fc_client_tool_123", + "type": "function_call", + "status": "completed", + "arguments": "{\"input\": \"test input\"}", + "call_id": "call_client_tool_123", + "name": "client_tool" + } + ], + "usage": { + "input_tokens": 100, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 50, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 150 + }, + "service_tier": "default", + "system_fingerprint": "fp_test" +} + diff --git a/tests/Fixtures/openrouter/stream-with-client-executed-tool-1.sse b/tests/Fixtures/openrouter/stream-with-client-executed-tool-1.sse new file mode 100644 index 000000000..e0a4d5e6c --- /dev/null +++ b/tests/Fixtures/openrouter/stream-with-client-executed-tool-1.sse @@ -0,0 +1,9 @@ +data: {"id":"chatcmpl-client-executed","object":"chat.completion.chunk","created":1737243487,"model":"openai/gpt-4-turbo","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_client_tool_stream","type":"function","function":{"name":"client_tool","arguments":""}}]},"logprobs":null,"finish_reason":null}]} + +data: {"id":"chatcmpl-client-executed","object":"chat.completion.chunk","created":1737243487,"model":"openai/gpt-4-turbo","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"input\": \"test input\"}"}}]},"logprobs":null,"finish_reason":null}]} + +data: {"id":"chatcmpl-client-executed","object":"chat.completion.chunk","created":1737243487,"model":"openai/gpt-4-turbo","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":100,"completion_tokens":50,"total_tokens":150}} + +data: [DONE] + + diff --git a/tests/Fixtures/openrouter/text-with-client-executed-tool-1.json b/tests/Fixtures/openrouter/text-with-client-executed-tool-1.json new file mode 100644 index 000000000..5afde1944 --- /dev/null +++ b/tests/Fixtures/openrouter/text-with-client-executed-tool-1.json @@ -0,0 +1,2 @@ +{"id":"gen-client-executed","object":"chat.completion","created":1737243487,"model":"openai/gpt-4-turbo","choices":[{"index":0,"message":{"role":"assistant","content":"","tool_calls":[{"id":"call_client_tool","type":"function","function":{"name":"client_tool","arguments":"{\"input\":\"test input\"}"}}]},"logprobs":null,"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":100,"completion_tokens":50,"total_tokens":150}} + diff --git a/tests/Fixtures/xai/stream-with-client-executed-tool-1.json b/tests/Fixtures/xai/stream-with-client-executed-tool-1.json new file mode 100644 index 000000000..63313a45e --- /dev/null +++ b/tests/Fixtures/xai/stream-with-client-executed-tool-1.json @@ -0,0 +1,9 @@ +data: {"id":"chatcmpl-client-executed","object":"chat.completion.chunk","created":1731129810,"model":"grok-4","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"0","type":"function","function":{"name":"client_tool","arguments":""}}]},"logprobs":null,"finish_reason":null}]} + +data: {"id":"chatcmpl-client-executed","object":"chat.completion.chunk","created":1731129810,"model":"grok-4","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"input\":\"test input\"}"}}]},"logprobs":null,"finish_reason":null}]} + +data: {"id":"chatcmpl-client-executed","object":"chat.completion.chunk","created":1731129810,"model":"grok-4","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":100,"completion_tokens":50,"total_tokens":150}} + +data: [DONE] + + diff --git a/tests/Fixtures/xai/text-with-client-executed-tool-1.json b/tests/Fixtures/xai/text-with-client-executed-tool-1.json new file mode 100644 index 000000000..064707bc0 --- /dev/null +++ b/tests/Fixtures/xai/text-with-client-executed-tool-1.json @@ -0,0 +1,34 @@ +{ + "id": "client-executed-test", + "object": "chat.completion", + "created": 1731129810, + "model": "grok-beta", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "0", + "function": { + "name": "client_tool", + "arguments": "{\"input\":\"test input\"}" + }, + "type": "function" + } + ], + "refusal": null + }, + "finish_reason": "tool_calls" + } + ], + "usage": { + "prompt_tokens": 100, + "completion_tokens": 50, + "total_tokens": 150 + }, + "system_fingerprint": "fp_test" +} + diff --git a/tests/Providers/Anthropic/AnthropicTextTest.php b/tests/Providers/Anthropic/AnthropicTextTest.php index c04791bfe..fada62dd8 100644 --- a/tests/Providers/Anthropic/AnthropicTextTest.php +++ b/tests/Providers/Anthropic/AnthropicTextTest.php @@ -9,6 +9,7 @@ use Illuminate\Support\Facades\Http; use Prism\Prism\Enums\Citations\CitationSourcePositionType; use Prism\Prism\Enums\Citations\CitationSourceType; +use Prism\Prism\Enums\FinishReason; use Prism\Prism\Enums\Provider; use Prism\Prism\Exceptions\PrismProviderOverloadedException; use Prism\Prism\Exceptions\PrismRateLimitedException; @@ -523,6 +524,66 @@ }); }); +describe('client-executed tools', function (): void { + it('stops execution when client-executed tool is called', function (): void { + FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/text-with-client-executed-tool'); + + $tool = Tool::as('client_tool') + ->for('A tool that executes on the client') + ->withStringParameter('input', 'Input parameter'); + + $response = Prism::text() + ->using('anthropic', 'claude-3-5-sonnet-20240620') + ->withTools([$tool]) + ->withMaxSteps(3) + ->withPrompt('Use the client tool') + ->asText(); + + expect($response->finishReason)->toBe(FinishReason::ToolCalls); + expect($response->toolCalls)->toHaveCount(1); + expect($response->toolCalls[0]->name)->toBe('client_tool'); + expect($response->steps)->toHaveCount(1); + }); + + it('executes server tools and stops for client-executed tools in mixed scenario', function (): void { + FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/text-with-mixed-tools'); + + // Server-executed tool (has handler) + $serverTool = Tool::as('server_tool') + ->for('A server-side tool') + ->withStringParameter('query', 'Search query') + ->using(fn (string $query): string => "Result for: {$query}"); + + // Client-executed tool (no handler) + $clientTool = Tool::as('client_tool') + ->for('A client-side tool') + ->withStringParameter('action', 'Action to perform'); + + $response = Prism::text() + ->using('anthropic', 'claude-3-5-sonnet-20240620') + ->withTools([$serverTool, $clientTool]) + ->withMaxSteps(3) + ->withPrompt('Use both tools') + ->asText(); + + // Should stop with ToolCalls finish reason (client tool pending) + expect($response->finishReason)->toBe(FinishReason::ToolCalls); + + // Should have both tool calls in response + expect($response->toolCalls)->toHaveCount(2); + expect($response->toolCalls[0]->name)->toBe('server_tool'); + expect($response->toolCalls[1]->name)->toBe('client_tool'); + + // Server tool should have been executed (result in toolResults) + expect($response->toolResults)->toHaveCount(1); + expect($response->toolResults[0]->toolName)->toBe('server_tool'); + expect($response->toolResults[0]->result)->toBe('Result for: weather in SF'); + + // Only one step (stopped for client tool) + expect($response->steps)->toHaveCount(1); + }); +}); + describe('exceptions', function (): void { it('throws a RateLimitException if the Anthropic responds with a 429', function (): void { Http::fake([ diff --git a/tests/Providers/Anthropic/StreamTest.php b/tests/Providers/Anthropic/StreamTest.php index 6927430ac..b127dac81 100644 --- a/tests/Providers/Anthropic/StreamTest.php +++ b/tests/Providers/Anthropic/StreamTest.php @@ -711,6 +711,40 @@ })->throws(PrismRequestTooLargeException::class); }); +describe('client-executed tools', function (): void { + it('stops streaming when client-executed tool is called', function (): void { + FixtureResponse::fakeStreamResponses('v1/messages', 'anthropic/stream-with-client-executed-tool'); + + $tool = Tool::as('client_tool') + ->for('A tool that executes on the client') + ->withStringParameter('input', 'Input parameter'); + + $response = Prism::text() + ->using('anthropic', 'claude-3-5-sonnet-20240620') + ->withTools([$tool]) + ->withMaxSteps(3) + ->withPrompt('Use the client tool') + ->asStream(); + + $events = []; + $toolCallFound = false; + + foreach ($response as $event) { + $events[] = $event; + + if ($event instanceof ToolCallEvent) { + $toolCallFound = true; + } + } + + expect($toolCallFound)->toBeTrue(); + + $lastEvent = end($events); + expect($lastEvent)->toBeInstanceOf(StreamEndEvent::class); + expect($lastEvent->finishReason)->toBe(FinishReason::ToolCalls); + }); +}); + describe('basic stream events', function (): void { it('can generate text with a basic stream', function (): void { FixtureResponse::fakeStreamResponses('v1/messages', 'anthropic/stream-basic-text'); diff --git a/tests/Providers/Anthropic/StructuredWithToolsTest.php b/tests/Providers/Anthropic/StructuredWithToolsTest.php index eb0c2a557..ab09a9c94 100644 --- a/tests/Providers/Anthropic/StructuredWithToolsTest.php +++ b/tests/Providers/Anthropic/StructuredWithToolsTest.php @@ -202,6 +202,36 @@ expect($response->toolResults)->toBeArray(); }); + it('stops execution when client-executed tool is called', function (): void { + FixtureResponse::fakeResponseSequence('*', 'anthropic/structured-with-client-executed-tool'); + + $schema = new ObjectSchema( + 'output', + 'the output object', + [new StringSchema('result', 'The result', true)], + ['result'] + ); + + $tool = (new Tool) + ->as('client_tool') + ->for('A tool that executes on the client') + ->withStringParameter('input', 'Input parameter'); + + $response = Prism::structured() + ->using(Provider::Anthropic, 'claude-sonnet-4-0') + ->withSchema($schema) + ->withTools([$tool]) + ->withMaxSteps(3) + ->withProviderOptions(['use_tool_calling' => true]) + ->withPrompt('Use the client tool') + ->asStructured(); + + expect($response->finishReason)->toBe(FinishReason::ToolCalls); + expect($response->toolCalls)->toHaveCount(1); + expect($response->toolCalls[0]->name)->toBe('client_tool'); + expect($response->steps)->toHaveCount(1); + }); + it('includes strict field in tool definition when specified', function (): void { Prism::fake(); diff --git a/tests/Providers/DeepSeek/StreamTest.php b/tests/Providers/DeepSeek/StreamTest.php index f1b451cb4..0038a535f 100644 --- a/tests/Providers/DeepSeek/StreamTest.php +++ b/tests/Providers/DeepSeek/StreamTest.php @@ -136,6 +136,40 @@ }); }); +describe('client-executed tools', function (): void { + it('stops streaming when client-executed tool is called', function (): void { + FixtureResponse::fakeStreamResponses('chat/completions', 'deepseek/stream-with-client-executed-tool'); + + $tool = Tool::as('client_tool') + ->for('A tool that executes on the client') + ->withStringParameter('input', 'Input parameter'); + + $response = Prism::text() + ->using(Provider::DeepSeek, 'deepseek-chat') + ->withTools([$tool]) + ->withMaxSteps(3) + ->withPrompt('Use the client tool') + ->asStream(); + + $events = []; + $toolCallFound = false; + + foreach ($response as $event) { + $events[] = $event; + + if ($event instanceof ToolCallEvent) { + $toolCallFound = true; + } + } + + expect($toolCallFound)->toBeTrue(); + + $lastEvent = end($events); + expect($lastEvent)->toBeInstanceOf(StreamEndEvent::class); + expect($lastEvent->finishReason)->toBe(FinishReason::ToolCalls); + }); +}); + it('handles max_tokens parameter correctly', function (): void { FixtureResponse::fakeStreamResponses('chat/completions', 'deepseek/stream-max-tokens'); diff --git a/tests/Providers/DeepSeek/TextTest.php b/tests/Providers/DeepSeek/TextTest.php index e6fd3ccb6..2da324961 100644 --- a/tests/Providers/DeepSeek/TextTest.php +++ b/tests/Providers/DeepSeek/TextTest.php @@ -76,6 +76,28 @@ expect($response->finishReason)->toBe(FinishReason::Stop); }); +describe('client-executed tools', function (): void { + it('stops execution when client-executed tool is called', function (): void { + FixtureResponse::fakeResponseSequence('v1/chat/completions', 'deepseek/text-with-client-executed-tool'); + + $tool = Tool::as('client_tool') + ->for('A tool that executes on the client') + ->withStringParameter('input', 'Input parameter'); + + $response = Prism::text() + ->using(Provider::DeepSeek, 'deepseek-chat') + ->withTools([$tool]) + ->withMaxSteps(3) + ->withPrompt('Use the client tool') + ->generate(); + + expect($response->finishReason)->toBe(FinishReason::ToolCalls); + expect($response->toolCalls)->toHaveCount(1); + expect($response->toolCalls[0]->name)->toBe('client_tool'); + expect($response->steps)->toHaveCount(1); + }); +}); + it('can generate text using multiple tools and multiple steps', function (): void { FixtureResponse::fakeResponseSequence('v1/chat/completions', 'deepseek/generate-text-with-multiple-tools'); diff --git a/tests/Providers/Gemini/GeminiStreamTest.php b/tests/Providers/Gemini/GeminiStreamTest.php index 92e15dae3..65c136a82 100644 --- a/tests/Providers/Gemini/GeminiStreamTest.php +++ b/tests/Providers/Gemini/GeminiStreamTest.php @@ -232,6 +232,40 @@ }); }); +describe('client-executed tools', function (): void { + it('stops streaming when client-executed tool is called', function (): void { + FixtureResponse::fakeResponseSequence('*', 'gemini/stream-with-client-executed-tool'); + + $tool = Tool::as('client_tool') + ->for('A tool that executes on the client') + ->withStringParameter('input', 'Input parameter'); + + $response = Prism::text() + ->using(Provider::Gemini, 'gemini-1.5-flash') + ->withTools([$tool]) + ->withMaxSteps(3) + ->withPrompt('Use the client tool') + ->asStream(); + + $events = []; + $toolCallFound = false; + + foreach ($response as $event) { + $events[] = $event; + + if ($event instanceof ToolCallEvent) { + $toolCallFound = true; + } + } + + expect($toolCallFound)->toBeTrue(); + + $lastEvent = end($events); + expect($lastEvent)->toBeInstanceOf(StreamEndEvent::class); + expect($lastEvent->finishReason)->toBe(FinishReason::ToolCalls); + }); +}); + it('yields ToolCall events before ToolResult events', function (): void { FixtureResponse::fakeResponseSequence('*', 'gemini/stream-with-tools'); diff --git a/tests/Providers/Gemini/GeminiTextTest.php b/tests/Providers/Gemini/GeminiTextTest.php index fd929f290..76620426c 100644 --- a/tests/Providers/Gemini/GeminiTextTest.php +++ b/tests/Providers/Gemini/GeminiTextTest.php @@ -154,6 +154,29 @@ }); }); +describe('client-executed tools', function (): void { + it('stops execution when client-executed tool is called', function (): void { + FixtureResponse::fakeResponseSequence('*', 'gemini/text-with-client-executed-tool'); + + $tool = (new Tool) + ->as('client_tool') + ->for('A tool that executes on the client') + ->withStringParameter('input', 'Input parameter'); + + $response = Prism::text() + ->using(Provider::Gemini, 'gemini-1.5-flash') + ->withTools([$tool]) + ->withMaxSteps(3) + ->withPrompt('Use the client tool') + ->asText(); + + expect($response->finishReason)->toBe(FinishReason::ToolCalls); + expect($response->toolCalls)->toHaveCount(1); + expect($response->toolCalls[0]->name)->toBe('client_tool'); + expect($response->steps)->toHaveCount(1); + }); +}); + describe('Image support with Gemini', function (): void { it('can send images from path', function (): void { FixtureResponse::fakeResponseSequence('*', 'gemini/image-detection'); diff --git a/tests/Providers/Gemini/StructuredWithToolsTest.php b/tests/Providers/Gemini/StructuredWithToolsTest.php index 36d94de36..bd167bd27 100644 --- a/tests/Providers/Gemini/StructuredWithToolsTest.php +++ b/tests/Providers/Gemini/StructuredWithToolsTest.php @@ -120,6 +120,35 @@ expect($finalStep->structured)->toBeArray(); }); + it('stops execution when client-executed tool is called', function (): void { + FixtureResponse::fakeResponseSequence('*', 'gemini/structured-with-client-executed-tool'); + + $schema = new ObjectSchema( + 'output', + 'the output object', + [new StringSchema('result', 'The result', true)], + ['result'] + ); + + $tool = (new Tool) + ->as('client_tool') + ->for('A tool that executes on the client') + ->withStringParameter('input', 'Input parameter'); + + $response = Prism::structured() + ->using(Provider::Gemini, 'gemini-2.0-flash') + ->withSchema($schema) + ->withTools([$tool]) + ->withMaxSteps(3) + ->withPrompt('Use the client tool') + ->asStructured(); + + expect($response->finishReason)->toBe(FinishReason::ToolCalls); + expect($response->toolCalls)->toHaveCount(1); + expect($response->toolCalls[0]->name)->toBe('client_tool'); + expect($response->steps)->toHaveCount(1); + }); + it('returns structured output immediately when no tool calls needed', function (): void { FixtureResponse::fakeResponseSequence('*', 'gemini/structured-without-tool-calls'); diff --git a/tests/Providers/Groq/GroqTextTest.php b/tests/Providers/Groq/GroqTextTest.php index 6f3ae72c3..3ed0e0f39 100644 --- a/tests/Providers/Groq/GroqTextTest.php +++ b/tests/Providers/Groq/GroqTextTest.php @@ -7,6 +7,7 @@ use Illuminate\Http\Client\Request; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Http; +use Prism\Prism\Enums\FinishReason; use Prism\Prism\Enums\Provider; use Prism\Prism\Enums\ToolChoice; use Prism\Prism\Exceptions\PrismRateLimitedException; @@ -106,6 +107,26 @@ ); }); + it('stops execution when client-executed tool is called', function (): void { + FixtureResponse::fakeResponseSequence('chat/completions', 'groq/text-with-client-executed-tool'); + + $tool = Tool::as('client_tool') + ->for('A tool that executes on the client') + ->withStringParameter('input', 'Input parameter'); + + $response = Prism::text() + ->using('groq', 'llama-3.3-70b-versatile') + ->withTools([$tool]) + ->withMaxSteps(3) + ->withPrompt('Use the client tool') + ->generate(); + + expect($response->finishReason)->toBe(FinishReason::ToolCalls); + expect($response->toolCalls)->toHaveCount(1); + expect($response->toolCalls[0]->name)->toBe('client_tool'); + expect($response->steps)->toHaveCount(1); + }); + it('handles specific tool choice', function (): void { FixtureResponse::fakeResponseSequence('v1/chat/completions', 'groq/generate-text-with-required-tool-call'); diff --git a/tests/Providers/Groq/StreamTest.php b/tests/Providers/Groq/StreamTest.php index 86f7783dd..b872ff80d 100644 --- a/tests/Providers/Groq/StreamTest.php +++ b/tests/Providers/Groq/StreamTest.php @@ -124,6 +124,40 @@ expect($streamEndEvents)->toHaveCount(1); }); +describe('client-executed tools', function (): void { + it('stops streaming when client-executed tool is called', function (): void { + FixtureResponse::fakeStreamResponses('openai/v1/chat/completions', 'groq/stream-with-client-executed-tool'); + + $tool = Tool::as('client_tool') + ->for('A tool that executes on the client') + ->withStringParameter('input', 'Input parameter'); + + $response = Prism::text() + ->using(Provider::Groq, 'llama-3.1-70b-versatile') + ->withTools([$tool]) + ->withMaxSteps(3) + ->withPrompt('Use the client tool') + ->asStream(); + + $events = []; + $toolCallFound = false; + + foreach ($response as $event) { + $events[] = $event; + + if ($event instanceof ToolCallEvent) { + $toolCallFound = true; + } + } + + expect($toolCallFound)->toBeTrue(); + + $lastEvent = end($events); + expect($lastEvent)->toBeInstanceOf(StreamEndEvent::class); + expect($lastEvent->finishReason)->toBe(FinishReason::ToolCalls); + }); +}); + it('handles maximum tool call depth exceeded', function (): void { FixtureResponse::fakeStreamResponses('openai/v1/chat/completions', 'groq/stream-with-tools'); diff --git a/tests/Providers/Mistral/MistralTextTest.php b/tests/Providers/Mistral/MistralTextTest.php index 03aa42fd0..09d6df205 100644 --- a/tests/Providers/Mistral/MistralTextTest.php +++ b/tests/Providers/Mistral/MistralTextTest.php @@ -106,6 +106,26 @@ ); }); + it('stops execution when client-executed tool is called', function (): void { + FixtureResponse::fakeResponseSequence('v1/chat/completions', 'mistral/text-with-client-executed-tool'); + + $tool = Tool::as('client_tool') + ->for('A tool that executes on the client') + ->withStringParameter('input', 'Input parameter'); + + $response = Prism::text() + ->using('mistral', 'mistral-large-latest') + ->withTools([$tool]) + ->withMaxSteps(3) + ->withPrompt('Use the client tool') + ->generate(); + + expect($response->finishReason)->toBe(FinishReason::ToolCalls); + expect($response->toolCalls)->toHaveCount(1); + expect($response->toolCalls[0]->name)->toBe('client_tool'); + expect($response->steps)->toHaveCount(1); + }); + it('handles specific tool choice', function (): void { FixtureResponse::fakeResponseSequence('v1/chat/completions', 'mistral/generate-text-with-required-tool-call'); diff --git a/tests/Providers/Mistral/StreamTest.php b/tests/Providers/Mistral/StreamTest.php index 55673c68b..ea6abc971 100644 --- a/tests/Providers/Mistral/StreamTest.php +++ b/tests/Providers/Mistral/StreamTest.php @@ -127,6 +127,40 @@ expect($streamEndEvents)->toHaveCount(1); }); +describe('client-executed tools', function (): void { + it('stops streaming when client-executed tool is called', function (): void { + FixtureResponse::fakeStreamResponses('v1/chat/completions', 'mistral/stream-with-client-executed-tool'); + + $tool = Tool::as('client_tool') + ->for('A tool that executes on the client') + ->withStringParameter('input', 'Input parameter'); + + $response = Prism::text() + ->using(Provider::Mistral, 'mistral-large-latest') + ->withTools([$tool]) + ->withMaxSteps(3) + ->withPrompt('Use the client tool') + ->asStream(); + + $events = []; + $toolCallFound = false; + + foreach ($response as $event) { + $events[] = $event; + + if ($event instanceof ToolCallEvent) { + $toolCallFound = true; + } + } + + expect($toolCallFound)->toBeTrue(); + + $lastEvent = end($events); + expect($lastEvent)->toBeInstanceOf(StreamEndEvent::class); + expect($lastEvent->finishReason)->toBe(FinishReason::ToolCalls); + }); +}); + it('handles maximum tool call depth exceeded', function (): void { FixtureResponse::fakeStreamResponses('v1/chat/completions', 'mistral/stream-with-tools-1'); diff --git a/tests/Providers/Ollama/StreamTest.php b/tests/Providers/Ollama/StreamTest.php index cfd2721a1..8fce1ac7c 100644 --- a/tests/Providers/Ollama/StreamTest.php +++ b/tests/Providers/Ollama/StreamTest.php @@ -126,6 +126,40 @@ expect($streamEndEvents)->toHaveCount(1); }); +describe('client-executed tools', function (): void { + it('stops streaming when client-executed tool is called', function (): void { + FixtureResponse::fakeStreamResponses('api/chat', 'ollama/stream-with-client-executed-tool'); + + $tool = Tool::as('client_tool') + ->for('A tool that executes on the client') + ->withStringParameter('input', 'Input parameter'); + + $response = Prism::text() + ->using('ollama', 'qwen2.5:14b') + ->withTools([$tool]) + ->withMaxSteps(3) + ->withPrompt('Use the client tool') + ->asStream(); + + $events = []; + $toolCallFound = false; + + foreach ($response as $event) { + $events[] = $event; + + if ($event instanceof ToolCallEvent) { + $toolCallFound = true; + } + } + + expect($toolCallFound)->toBeTrue(); + + $lastEvent = end($events); + expect($lastEvent)->toBeInstanceOf(StreamEndEvent::class); + expect($lastEvent->finishReason)->toBe(FinishReason::ToolCalls); + }); +}); + it('throws a PrismRateLimitedException with a 429 response code', function (): void { Http::fake([ '*' => Http::response( diff --git a/tests/Providers/Ollama/TextTest.php b/tests/Providers/Ollama/TextTest.php index 9b355d7c5..a7e0af70f 100644 --- a/tests/Providers/Ollama/TextTest.php +++ b/tests/Providers/Ollama/TextTest.php @@ -6,6 +6,7 @@ use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; +use Prism\Prism\Enums\FinishReason; use Prism\Prism\Enums\Provider; use Prism\Prism\Facades\Prism; use Prism\Prism\Facades\Tool; @@ -104,6 +105,28 @@ }); }); +describe('client-executed tools', function (): void { + it('stops execution when client-executed tool is called', function (): void { + FixtureResponse::fakeResponseSequence('api/chat', 'ollama/text-with-client-executed-tool'); + + $tool = Tool::as('client_tool') + ->for('A tool that executes on the client') + ->withStringParameter('input', 'Input parameter'); + + $response = Prism::text() + ->using('ollama', 'qwen2.5:14b') + ->withTools([$tool]) + ->withMaxSteps(3) + ->withPrompt('Use the client tool') + ->asText(); + + expect($response->finishReason)->toBe(FinishReason::ToolCalls); + expect($response->toolCalls)->toHaveCount(1); + expect($response->toolCalls[0]->name)->toBe('client_tool'); + expect($response->steps)->toHaveCount(1); + }); +}); + describe('Thinking parameter', function (): void { it('includes think parameter when thinking is enabled', function (): void { FixtureResponse::fakeResponseSequence('api/chat', 'ollama/text-with-thinking-enabled'); diff --git a/tests/Providers/OpenAI/StreamTest.php b/tests/Providers/OpenAI/StreamTest.php index 2354948a4..8101de93b 100644 --- a/tests/Providers/OpenAI/StreamTest.php +++ b/tests/Providers/OpenAI/StreamTest.php @@ -6,6 +6,7 @@ use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; +use Prism\Prism\Enums\FinishReason; use Prism\Prism\Enums\Provider; use Prism\Prism\Exceptions\PrismException; use Prism\Prism\Facades\Prism; @@ -463,6 +464,40 @@ Http::assertSent(fn (Request $request): bool => $request->data()['parallel_tool_calls'] === false); }); +describe('client-executed tools', function (): void { + it('stops streaming when client-executed tool is called', function (): void { + FixtureResponse::fakeResponseSequence('v1/responses', 'openai/stream-with-client-executed-tool'); + + $tool = Tool::as('client_tool') + ->for('A tool that executes on the client') + ->withStringParameter('input', 'Input parameter'); + + $response = Prism::text() + ->using('openai', 'gpt-4o') + ->withTools([$tool]) + ->withMaxSteps(3) + ->withPrompt('Use the client tool') + ->asStream(); + + $events = []; + $toolCallFound = false; + + foreach ($response as $event) { + $events[] = $event; + + if ($event instanceof ToolCallEvent) { + $toolCallFound = true; + } + } + + expect($toolCallFound)->toBeTrue(); + + $lastEvent = end($events); + expect($lastEvent)->toBeInstanceOf(StreamEndEvent::class); + expect($lastEvent->finishReason)->toBe(FinishReason::ToolCalls); + }); +}); + it('emits usage information', function (): void { FixtureResponse::fakeResponseSequence('v1/responses', 'openai/stream-basic-text-responses'); diff --git a/tests/Providers/OpenAI/StructuredWithToolsTest.php b/tests/Providers/OpenAI/StructuredWithToolsTest.php index 7afd8910f..f0661be6a 100644 --- a/tests/Providers/OpenAI/StructuredWithToolsTest.php +++ b/tests/Providers/OpenAI/StructuredWithToolsTest.php @@ -148,6 +148,35 @@ expect($response->steps)->toHaveCount(1); }); + it('stops execution when client-executed tool is called', function (): void { + FixtureResponse::fakeResponseSequence('v1/responses', 'openai/structured-with-client-executed-tool'); + + $schema = new ObjectSchema( + 'output', + 'the output object', + [new StringSchema('result', 'The result', true)], + ['result'] + ); + + $tool = (new Tool) + ->as('client_tool') + ->for('A tool that executes on the client') + ->withStringParameter('input', 'Input parameter'); + + $response = Prism::structured() + ->using(Provider::OpenAI, 'gpt-4o') + ->withSchema($schema) + ->withTools([$tool]) + ->withMaxSteps(3) + ->withPrompt('Use the client tool') + ->asStructured(); + + expect($response->finishReason)->toBe(FinishReason::ToolCalls); + expect($response->toolCalls)->toHaveCount(1); + expect($response->toolCalls[0]->name)->toBe('client_tool'); + expect($response->steps)->toHaveCount(1); + }); + it('handles tool orchestration correctly with multiple tool types', function (): void { FixtureResponse::fakeResponseSequence('v1/responses', 'openai/structured-with-tool-orchestration'); diff --git a/tests/Providers/OpenAI/TextTest.php b/tests/Providers/OpenAI/TextTest.php index 81ebec89f..04cab03b0 100644 --- a/tests/Providers/OpenAI/TextTest.php +++ b/tests/Providers/OpenAI/TextTest.php @@ -318,6 +318,28 @@ }); }); +describe('client-executed tools', function (): void { + it('stops execution when client-executed tool is called', function (): void { + FixtureResponse::fakeResponseSequence('v1/responses', 'openai/text-with-client-executed-tool'); + + $tool = Tool::as('client_tool') + ->for('A tool that executes on the client') + ->withStringParameter('input', 'Input parameter'); + + $response = Prism::text() + ->using('openai', 'gpt-4o') + ->withTools([$tool]) + ->withMaxSteps(3) + ->withPrompt('Use the client tool') + ->asText(); + + expect($response->finishReason)->toBe(FinishReason::ToolCalls); + expect($response->toolCalls)->toHaveCount(1); + expect($response->toolCalls[0]->name)->toBe('client_tool'); + expect($response->steps)->toHaveCount(1); + }); +}); + it('sets usage correctly with automatic caching', function (): void { FixtureResponse::fakeResponseSequence( 'v1/responses', diff --git a/tests/Providers/OpenRouter/StreamTest.php b/tests/Providers/OpenRouter/StreamTest.php index af61bc271..3d78a91c0 100644 --- a/tests/Providers/OpenRouter/StreamTest.php +++ b/tests/Providers/OpenRouter/StreamTest.php @@ -248,6 +248,40 @@ expect($streamEndEvents)->not->toBeEmpty(); }); +describe('client-executed tools', function (): void { + it('stops streaming when client-executed tool is called', function (): void { + FixtureResponse::fakeStreamResponses('v1/chat/completions', 'openrouter/stream-with-client-executed-tool'); + + $tool = Tool::as('client_tool') + ->for('A tool that executes on the client') + ->withStringParameter('input', 'Input parameter'); + + $response = Prism::text() + ->using(Provider::OpenRouter, 'openai/gpt-4-turbo') + ->withTools([$tool]) + ->withMaxSteps(3) + ->withPrompt('Use the client tool') + ->asStream(); + + $events = []; + $toolCallFound = false; + + foreach ($response as $event) { + $events[] = $event; + + if ($event instanceof ToolCallEvent) { + $toolCallFound = true; + } + } + + expect($toolCallFound)->toBeTrue(); + + $lastEvent = end($events); + expect($lastEvent)->toBeInstanceOf(StreamEndEvent::class); + expect($lastEvent->finishReason)->toBe(FinishReason::ToolCalls); + }); +}); + it('can handle reasoning/thinking tokens in streaming', function (): void { FixtureResponse::fakeStreamResponses('v1/chat/completions', 'openrouter/stream-text-with-reasoning'); diff --git a/tests/Providers/OpenRouter/TextTest.php b/tests/Providers/OpenRouter/TextTest.php index 25d09318b..a78271eba 100644 --- a/tests/Providers/OpenRouter/TextTest.php +++ b/tests/Providers/OpenRouter/TextTest.php @@ -156,6 +156,28 @@ expect($response->finishReason)->toBe(FinishReason::Stop); }); +describe('client-executed tools', function (): void { + it('stops execution when client-executed tool is called', function (): void { + FixtureResponse::fakeResponseSequence('v1/chat/completions', 'openrouter/text-with-client-executed-tool'); + + $tool = Tool::as('client_tool') + ->for('A tool that executes on the client') + ->withStringParameter('input', 'Input parameter'); + + $response = Prism::text() + ->using(Provider::OpenRouter, 'openai/gpt-4-turbo') + ->withTools([$tool]) + ->withMaxSteps(3) + ->withPrompt('Use the client tool') + ->generate(); + + expect($response->finishReason)->toBe(FinishReason::ToolCalls); + expect($response->toolCalls)->toHaveCount(1); + expect($response->toolCalls[0]->name)->toBe('client_tool'); + expect($response->steps)->toHaveCount(1); + }); +}); + it('forwards advanced provider options to openrouter', function (): void { FixtureResponse::fakeResponseSequence('v1/chat/completions', 'openrouter/generate-text-with-a-prompt'); diff --git a/tests/Providers/XAI/StreamTest.php b/tests/Providers/XAI/StreamTest.php index 4e3ffcd1f..e9fc1ca73 100644 --- a/tests/Providers/XAI/StreamTest.php +++ b/tests/Providers/XAI/StreamTest.php @@ -133,6 +133,40 @@ }); }); +describe('client-executed tools', function (): void { + it('stops streaming when client-executed tool is called', function (): void { + FixtureResponse::fakeResponseSequence('v1/chat/completions', 'xai/stream-with-client-executed-tool'); + + $tool = Tool::as('client_tool') + ->for('A tool that executes on the client') + ->withStringParameter('input', 'Input parameter'); + + $response = Prism::text() + ->using('xai', 'grok-4') + ->withTools([$tool]) + ->withMaxSteps(3) + ->withPrompt('Use the client tool') + ->asStream(); + + $events = []; + $toolCallFound = false; + + foreach ($response as $event) { + $events[] = $event; + + if ($event instanceof ToolCallEvent) { + $toolCallFound = true; + } + } + + expect($toolCallFound)->toBeTrue(); + + $lastEvent = end($events); + expect($lastEvent)->toBeInstanceOf(StreamEndEvent::class); + expect($lastEvent->finishReason)->toBe(FinishReason::ToolCalls); + }); +}); + it('handles max_tokens parameter correctly', function (): void { FixtureResponse::fakeResponseSequence('v1/chat/completions', 'xai/stream-basic-text-responses'); diff --git a/tests/Providers/XAI/XAITextTest.php b/tests/Providers/XAI/XAITextTest.php index 5665b2c65..52a19a6a5 100644 --- a/tests/Providers/XAI/XAITextTest.php +++ b/tests/Providers/XAI/XAITextTest.php @@ -142,6 +142,28 @@ }); }); +describe('client-executed tools', function (): void { + it('stops execution when client-executed tool is called', function (): void { + FixtureResponse::fakeResponseSequence('chat/completions', 'xai/text-with-client-executed-tool'); + + $tool = Tool::as('client_tool') + ->for('A tool that executes on the client') + ->withStringParameter('input', 'Input parameter'); + + $response = Prism::text() + ->using(Provider::XAI, 'grok-beta') + ->withTools([$tool]) + ->withMaxSteps(3) + ->withPrompt('Use the client tool') + ->asText(); + + expect($response->finishReason)->toBe(FinishReason::ToolCalls); + expect($response->toolCalls)->toHaveCount(1); + expect($response->toolCalls[0]->name)->toBe('client_tool'); + expect($response->steps)->toHaveCount(1); + }); +}); + describe('Image support with XAI', function (): void { it('can send images from path', function (): void { FixtureResponse::fakeResponseSequence('chat/completions', 'xai/image-detection'); diff --git a/tests/ToolTest.php b/tests/ToolTest.php index f7865ec8b..61abf4a67 100644 --- a/tests/ToolTest.php +++ b/tests/ToolTest.php @@ -181,3 +181,15 @@ public function __invoke(string $query): string $searchTool->handle('What time is the event?'); }); + +it('can throw a prism exception when handle is called on a tool without a handler', function (): void { + $tool = (new Tool) + ->as('client_tool') + ->for('A tool without a handler') + ->withParameter(new StringSchema('query', 'the search query')); + + $this->expectException(PrismException::class); + $this->expectExceptionMessage('Tool (client_tool) has no handler defined'); + + $tool->handle('test'); +});