diff --git a/src/Bedrock.php b/src/Bedrock.php index f141dcb..198ba77 100644 --- a/src/Bedrock.php +++ b/src/Bedrock.php @@ -99,7 +99,7 @@ public function schema(PrismRequest $request): BedrockSchema { $override = $request->providerOptions(); - $override = data_get($override, 'apiSchema', null); + $override = data_get($override, 'apiSchema'); return $override ?? BedrockSchema::fromModelString($request->model()); } diff --git a/src/BedrockServiceProvider.php b/src/BedrockServiceProvider.php index 476db45..8ede8c5 100644 --- a/src/BedrockServiceProvider.php +++ b/src/BedrockServiceProvider.php @@ -35,7 +35,7 @@ public static function getCredentials(array $config): Credentials protected function registerWithPrism(): void { $this->app->extend(PrismManager::class, function (PrismManager $prismManager): \Prism\Prism\PrismManager { - $prismManager->extend(Bedrock::KEY, fn ($app, $config): Bedrock => new Bedrock( + $prismManager->extend(Bedrock::KEY, fn ($app, array $config): Bedrock => new Bedrock( credentials: BedrockServiceProvider::getCredentials($config), region: $config['region'] )); diff --git a/src/Schemas/Anthropic/AnthropicStructuredHandler.php b/src/Schemas/Anthropic/AnthropicStructuredHandler.php index 285ee92..896f7a2 100644 --- a/src/Schemas/Anthropic/AnthropicStructuredHandler.php +++ b/src/Schemas/Anthropic/AnthropicStructuredHandler.php @@ -105,8 +105,8 @@ protected function prepareTempResponse(): void usage: new Usage( promptTokens: data_get($data, 'usage.input_tokens'), completionTokens: data_get($data, 'usage.output_tokens'), - cacheWriteInputTokens: data_get($data, 'usage.cache_creation_input_tokens', null), - cacheReadInputTokens: data_get($data, 'usage.cache_read_input_tokens', null) + cacheWriteInputTokens: data_get($data, 'usage.cache_creation_input_tokens'), + cacheReadInputTokens: data_get($data, 'usage.cache_read_input_tokens') ), meta: new Meta( id: data_get($data, 'id'), diff --git a/src/Schemas/Anthropic/AnthropicTextHandler.php b/src/Schemas/Anthropic/AnthropicTextHandler.php index 5033b62..a17ca7f 100644 --- a/src/Schemas/Anthropic/AnthropicTextHandler.php +++ b/src/Schemas/Anthropic/AnthropicTextHandler.php @@ -155,6 +155,7 @@ protected function addStep(Request $request, array $toolResults = []): void finishReason: $this->tempResponse->finishReason, toolCalls: $this->tempResponse->toolCalls, toolResults: $toolResults, + providerToolCalls: [], usage: $this->tempResponse->usage, meta: $this->tempResponse->meta, messages: $request->messages(), diff --git a/src/Schemas/Anthropic/Concerns/ExtractsToolCalls.php b/src/Schemas/Anthropic/Concerns/ExtractsToolCalls.php index 68d0593..906b7e5 100644 --- a/src/Schemas/Anthropic/Concerns/ExtractsToolCalls.php +++ b/src/Schemas/Anthropic/Concerns/ExtractsToolCalls.php @@ -14,10 +14,12 @@ protected function extractToolCalls(array $data): array { $toolCalls = array_map(function ($content): ?ToolCall { if (data_get($content, 'type') === 'tool_use') { + $input = data_get($content, 'input'); + return new ToolCall( id: data_get($content, 'id'), name: data_get($content, 'name'), - arguments: data_get($content, 'input') + arguments: is_string($input) ? (json_decode($input, true) ?? []) : ($input ?? []) ); } diff --git a/src/Schemas/Anthropic/Maps/MessageMap.php b/src/Schemas/Anthropic/Maps/MessageMap.php index c8cc9f0..fcf5142 100644 --- a/src/Schemas/Anthropic/Maps/MessageMap.php +++ b/src/Schemas/Anthropic/Maps/MessageMap.php @@ -28,7 +28,7 @@ public static function map(array $messages): array } return array_map( - fn (Message $message): array => self::mapMessage($message), + self::mapMessage(...), $messages ); } @@ -40,7 +40,7 @@ public static function map(array $messages): array public static function mapSystemMessages(array $messages): array { return array_map( - fn (Message $message): array => self::mapSystemMessage($message), + self::mapSystemMessage(...), $messages ); } @@ -65,7 +65,7 @@ protected static function mapSystemMessage(SystemMessage $systemMessage): array { $providerOptions = $systemMessage->providerOptions(); - $cacheType = data_get($providerOptions, 'cacheType', null); + $cacheType = data_get($providerOptions, 'cacheType'); return array_filter([ 'type' => 'text', @@ -96,7 +96,7 @@ protected static function mapUserMessage(UserMessage $message): array { $providerOptions = $message->providerOptions(); - $cacheType = data_get($providerOptions, 'cacheType', null); + $cacheType = data_get($providerOptions, 'cacheType'); $cache_control = $cacheType ? ['type' => $cacheType instanceof BackedEnum ? $cacheType->value : $cacheType] : null; if ($message->documents() !== []) { @@ -123,7 +123,7 @@ protected static function mapAssistantMessage(AssistantMessage $message): array { $providerOptions = $message->providerOptions(); - $cacheType = data_get($providerOptions, 'cacheType', null); + $cacheType = data_get($providerOptions, 'cacheType'); $content = []; @@ -150,7 +150,7 @@ protected static function mapAssistantMessage(AssistantMessage $message): array 'type' => 'tool_use', 'id' => $toolCall->id, 'name' => $toolCall->name, - 'input' => $toolCall->arguments(), + 'input' => $toolCall->arguments() === [] ? new \stdClass : $toolCall->arguments(), ], $message->toolCalls) : []; diff --git a/src/Schemas/Anthropic/Maps/ToolMap.php b/src/Schemas/Anthropic/Maps/ToolMap.php index 26ff68c..6e72840 100644 --- a/src/Schemas/Anthropic/Maps/ToolMap.php +++ b/src/Schemas/Anthropic/Maps/ToolMap.php @@ -16,14 +16,14 @@ class ToolMap public static function map(array $tools): array { return array_map(function (PrismTool $tool): array { - $cacheType = data_get($tool->providerOptions(), 'cacheType', null); + $cacheType = data_get($tool->providerOptions(), 'cacheType'); return array_filter([ 'name' => $tool->name(), 'description' => $tool->description(), 'input_schema' => [ 'type' => 'object', - 'properties' => $tool->parametersAsArray(), + 'properties' => $tool->parametersAsArray() ?: (object) [], 'required' => $tool->requiredParameters(), ], 'cache_control' => $cacheType diff --git a/src/Schemas/Cohere/CohereEmbeddingsHandler.php b/src/Schemas/Cohere/CohereEmbeddingsHandler.php index 5dea71d..09a6fe4 100644 --- a/src/Schemas/Cohere/CohereEmbeddingsHandler.php +++ b/src/Schemas/Cohere/CohereEmbeddingsHandler.php @@ -65,7 +65,7 @@ protected function buildResponse(): EmbeddingsResponse $body = $this->httpResponse->json(); return new EmbeddingsResponse( - embeddings: array_map(fn (array $item): Embedding => Embedding::fromArray($item), data_get($body, 'embeddings', [])), + embeddings: array_map(Embedding::fromArray(...), data_get($body, 'embeddings', [])), usage: new EmbeddingsUsage( tokens: (int) $this->httpResponse->header('X-Amzn-Bedrock-Input-Token-Count') ), diff --git a/src/Schemas/Converse/Concerns/ExtractsToolCalls.php b/src/Schemas/Converse/Concerns/ExtractsToolCalls.php index 5fbac60..d42d770 100644 --- a/src/Schemas/Converse/Concerns/ExtractsToolCalls.php +++ b/src/Schemas/Converse/Concerns/ExtractsToolCalls.php @@ -18,10 +18,12 @@ protected function extractToolCalls(array $data): array return; } + $input = data_get($use, 'input'); + return new ToolCall( id: data_get($use, 'toolUseId'), name: data_get($use, 'name'), - arguments: data_get($use, 'input') + arguments: is_string($input) ? (json_decode($input, true) ?? []) : ($input ?? []) ); }, data_get($data, 'output.message.content', [])); diff --git a/src/Schemas/Converse/ConverseTextHandler.php b/src/Schemas/Converse/ConverseTextHandler.php index 2ba6e5e..91505ec 100644 --- a/src/Schemas/Converse/ConverseTextHandler.php +++ b/src/Schemas/Converse/ConverseTextHandler.php @@ -161,6 +161,7 @@ protected function addStep(Request $request, array $toolResults = []): void finishReason: $this->tempResponse->finishReason, toolCalls: $this->tempResponse->toolCalls, toolResults: $toolResults, + providerToolCalls: [], usage: $this->tempResponse->usage, meta: $this->tempResponse->meta, messages: $request->messages(), diff --git a/src/Schemas/Converse/Maps/MessageMap.php b/src/Schemas/Converse/Maps/MessageMap.php index d3af8f7..9d24158 100644 --- a/src/Schemas/Converse/Maps/MessageMap.php +++ b/src/Schemas/Converse/Maps/MessageMap.php @@ -28,7 +28,7 @@ public static function map(array $messages): array } return array_map( - fn (Message $message): array => self::mapMessage($message), + self::mapMessage(...), $messages ); } @@ -44,7 +44,7 @@ public static function mapSystemMessages(array $systemPrompts): array foreach ($systemPrompts as $prompt) { $output[] = self::mapSystemMessage($prompt); - $cacheType = data_get($prompt->providerOptions(), 'cacheType', null); + $cacheType = data_get($prompt->providerOptions(), 'cacheType'); if ($cacheType) { $output[] = ['cachePoint' => ['type' => $cacheType]]; @@ -102,7 +102,7 @@ protected static function mapToolResultMessage(ToolResultMessage $message): arra */ protected static function mapUserMessage(UserMessage $message): array { - $cacheType = data_get($message->providerOptions(), 'cacheType', null); + $cacheType = data_get($message->providerOptions(), 'cacheType'); return [ 'role' => 'user', @@ -120,7 +120,7 @@ protected static function mapUserMessage(UserMessage $message): array */ protected static function mapAssistantMessage(AssistantMessage $message): array { - $cacheType = data_get($message->providerOptions(), 'cacheType', null); + $cacheType = data_get($message->providerOptions(), 'cacheType'); return [ 'role' => 'assistant', @@ -142,7 +142,7 @@ protected static function mapToolCalls(array $parts): array 'toolUse' => [ 'toolUseId' => $toolCall->id, 'name' => $toolCall->name, - 'input' => $toolCall->arguments(), + 'input' => $toolCall->arguments() === [] ? new \stdClass : $toolCall->arguments(), ], ], $parts); } diff --git a/tests/BedrockServiceProviderTest.php b/tests/BedrockServiceProviderTest.php index c1cc40e..7cff4db 100644 --- a/tests/BedrockServiceProviderTest.php +++ b/tests/BedrockServiceProviderTest.php @@ -7,7 +7,7 @@ use Prism\Prism\Prism; it('registers itself as a provider with prism', function (): void { - $pendingRequest = Prism::text()->using('bedrock', 'test-model'); + $pendingRequest = (new Prism())->text()->using('bedrock', 'test-model'); expect($pendingRequest->provider())->toBeInstanceOf(Bedrock::class); }); @@ -16,7 +16,7 @@ Http::fake(); Http::preventStrayRequests(); - Prism::embeddings() + (new Prism())->embeddings() ->using('bedrock', 'test-model') ->withProviderOptions(['apiSchema' => BedrockSchema::Anthropic]) ->fromInput('Hello world') @@ -27,7 +27,7 @@ Http::fake(); Http::preventStrayRequests(); - Prism::embeddings() + (new Prism())->embeddings() ->using('bedrock', 'test-model') ->fromInput('Hello world') ->asEmbeddings(); diff --git a/tests/Schemas/Anthropic/AnthropicStructuredHandlerTest.php b/tests/Schemas/Anthropic/AnthropicStructuredHandlerTest.php index 98712f7..25dae9b 100644 --- a/tests/Schemas/Anthropic/AnthropicStructuredHandlerTest.php +++ b/tests/Schemas/Anthropic/AnthropicStructuredHandlerTest.php @@ -26,7 +26,7 @@ ['weather', 'game_time', 'coat_required'] ); - $response = Prism::structured() + $response = (new Prism())->structured() ->withSchema($schema) ->using('bedrock', 'anthropic.claude-3-5-haiku-20241022-v1:0') ->withSystemPrompt('The tigers game is at 3pm and the temperature will be 70º') @@ -60,7 +60,7 @@ $customMessage = 'Please return a JSON response using this custom format instruction'; - Prism::structured() + (new Prism())->structured() ->withSchema($schema) ->using('bedrock', 'anthropic.claude-3-5-haiku-20241022-v1:0') ->withProviderOptions([ @@ -95,7 +95,7 @@ $defaultMessage = 'Respond with ONLY JSON (i.e. not in backticks or a code block, with NO CONTENT outside the JSON) that matches the following schema:'; - Prism::structured() + (new Prism())->structured() ->withSchema($schema) ->using('bedrock', 'anthropic.claude-3-5-haiku-20241022-v1:0') ->withSystemPrompt('The tigers game is at 3pm and the temperature will be 70º') @@ -125,7 +125,7 @@ ['weather', 'game_time', 'coat_required'] ); - Prism::structured() + (new Prism())->structured() ->withSchema($schema) ->using('bedrock', 'anthropic.claude-3-5-haiku-20241022-v1:0') ->withProviderOptions([ diff --git a/tests/Schemas/Anthropic/AnthropicTextHandlerTest.php b/tests/Schemas/Anthropic/AnthropicTextHandlerTest.php index 3f03fe4..e5cfa43 100644 --- a/tests/Schemas/Anthropic/AnthropicTextHandlerTest.php +++ b/tests/Schemas/Anthropic/AnthropicTextHandlerTest.php @@ -14,7 +14,7 @@ it('can generate text with a prompt', function (): void { FixtureResponse::fakeResponseSequence('invoke', 'anthropic/generate-text-with-a-prompt'); - $response = Prism::text() + $response = (new Prism())->text() ->using('bedrock', 'anthropic.claude-3-5-haiku-20241022-v1:0') ->withPrompt('Who are you?') ->asText(); @@ -33,7 +33,7 @@ it('can generate text with a system prompt', function (): void { FixtureResponse::fakeResponseSequence('invoke', 'anthropic/generate-text-with-system-prompt'); - $response = Prism::text() + $response = (new Prism())->text() ->using('bedrock', 'anthropic.claude-3-5-haiku-20241022-v1:0') ->withSystemPrompt('MODEL ADOPTS ROLE of [PERSONA: Nyx the Cthulhu]!') ->withPrompt('Who are you?') @@ -62,7 +62,7 @@ ->using(fn (string $query): string => 'The tigers game is at 3pm in detroit'), ]; - $response = Prism::text() + $response = (new Prism())->text() ->using('bedrock', 'anthropic.claude-3-5-haiku-20241022-v1:0') ->withTools($tools) ->withMaxSteps(3) @@ -102,7 +102,7 @@ it('can send images from file', function (): void { FixtureResponse::fakeResponseSequence('invoke', 'anthropic/generate-text-with-image'); - Prism::text() + (new Prism())->text() ->using('bedrock', 'anthropic.claude-3-5-haiku-20241022-v1:0') ->withMessages([ new UserMessage( @@ -146,7 +146,7 @@ ->using(fn (string $query): string => 'The tigers game is at 3pm in detroit'), ]; - $response = Prism::text() + $response = (new Prism())->text() ->using('bedrock', 'anthropic.claude-3-5-haiku-20241022-v1:0') ->withPrompt('Do something') ->withTools($tools) @@ -159,7 +159,7 @@ it('can calculate cache usage correctly', function (): void { FixtureResponse::fakeResponseSequence('invoke', 'anthropic/calculate-cache-usage'); - $response = Prism::text() + $response = (new Prism())->text() ->using('bedrock', 'anthropic.claude-3-5-haiku-20241022-v1:0') ->withSystemPrompt(new SystemMessage('Old context'))->withProviderOptions(['cacheType' => 'ephemeral']) ->withMessages([ @@ -174,7 +174,7 @@ it('does not enable prompt caching if the enableCaching provider meta is not set on the request', function (): void { FixtureResponse::fakeResponseSequence('invoke', 'anthropic/generate-text-with-a-prompt'); - Prism::text() + (new Prism())->text() ->using('bedrock', 'anthropic.claude-3-5-haiku-20241022-v1:0') ->withPrompt('Who are you?') ->asText(); @@ -185,7 +185,7 @@ it('enables prompt caching if the enableCaching provider meta is set on the request', function (): void { FixtureResponse::fakeResponseSequence('invoke', 'anthropic/generate-text-with-a-prompt'); - Prism::text() + (new Prism())->text() ->using('bedrock', 'anthropic.claude-3-5-haiku-20241022-v1:0') ->withProviderOptions(['enableCaching' => true]) ->withPrompt('Who are you?') @@ -197,7 +197,7 @@ it('does not remove 0 values from payloads', function (): void { FixtureResponse::fakeResponseSequence('invoke', 'anthropic/generate-text-with-a-prompt'); - Prism::text() + (new Prism())->text() ->using('bedrock', 'anthropic.claude-3-5-haiku-20241022-v1:0') ->withPrompt('Who are you?') ->usingTemperature(0) diff --git a/tests/Schemas/Anthropic/Concerns/ExtractsToolCallsTest.php b/tests/Schemas/Anthropic/Concerns/ExtractsToolCallsTest.php new file mode 100644 index 0000000..06b5e5c --- /dev/null +++ b/tests/Schemas/Anthropic/Concerns/ExtractsToolCallsTest.php @@ -0,0 +1,159 @@ +extractToolCalls($data); + } + }; + + $data = [ + 'content' => [ + [ + 'type' => 'tool_use', + 'id' => 'tool_123', + 'name' => 'search', + 'input' => [ + 'query' => 'Laravel docs', + ], + ], + ], + ]; + + $result = $extractor->extract($data); + + expect($result)->toHaveCount(1); + expect($result[0]->id)->toBe('tool_123'); + expect($result[0]->name)->toBe('search'); + expect($result[0]->arguments())->toBe(['query' => 'Laravel docs']); +}); + +it('extracts tool calls with string JSON input', function (): void { + $extractor = new class + { + use ExtractsToolCalls; + + public function extract(array $data): array + { + return $this->extractToolCalls($data); + } + }; + + $data = [ + 'content' => [ + [ + 'type' => 'tool_use', + 'id' => 'tool_456', + 'name' => 'weather', + 'input' => '{"city": "Detroit"}', + ], + ], + ]; + + $result = $extractor->extract($data); + + expect($result)->toHaveCount(1); + expect($result[0]->id)->toBe('tool_456'); + expect($result[0]->name)->toBe('weather'); + expect($result[0]->arguments())->toBe(['city' => 'Detroit']); +}); + +it('extracts tool calls with invalid JSON string input defaults to empty array', function (): void { + $extractor = new class + { + use ExtractsToolCalls; + + public function extract(array $data): array + { + return $this->extractToolCalls($data); + } + }; + + $data = [ + 'content' => [ + [ + 'type' => 'tool_use', + 'id' => 'tool_789', + 'name' => 'get_time', + 'input' => 'invalid json', + ], + ], + ]; + + $result = $extractor->extract($data); + + expect($result)->toHaveCount(1); + expect($result[0]->id)->toBe('tool_789'); + expect($result[0]->name)->toBe('get_time'); + expect($result[0]->arguments())->toBe([]); +}); + +it('extracts tool calls with null input defaults to empty array', function (): void { + $extractor = new class + { + use ExtractsToolCalls; + + public function extract(array $data): array + { + return $this->extractToolCalls($data); + } + }; + + $data = [ + 'content' => [ + [ + 'type' => 'tool_use', + 'id' => 'tool_abc', + 'name' => 'parameterless_tool', + 'input' => null, + ], + ], + ]; + + $result = $extractor->extract($data); + + expect($result)->toHaveCount(1); + expect($result[0]->id)->toBe('tool_abc'); + expect($result[0]->name)->toBe('parameterless_tool'); + expect($result[0]->arguments())->toBe([]); +}); + +it('extracts tool calls with empty array input', function (): void { + $extractor = new class + { + use ExtractsToolCalls; + + public function extract(array $data): array + { + return $this->extractToolCalls($data); + } + }; + + $data = [ + 'content' => [ + [ + 'type' => 'tool_use', + 'id' => 'tool_def', + 'name' => 'no_params', + 'input' => [], + ], + ], + ]; + + $result = $extractor->extract($data); + + expect($result)->toHaveCount(1); + expect($result[0]->id)->toBe('tool_def'); + expect($result[0]->name)->toBe('no_params'); + expect($result[0]->arguments())->toBe([]); +}); diff --git a/tests/Schemas/Anthropic/Maps/MessageMapTest.php b/tests/Schemas/Anthropic/Maps/MessageMapTest.php index 8be9954..a66c587 100644 --- a/tests/Schemas/Anthropic/Maps/MessageMapTest.php +++ b/tests/Schemas/Anthropic/Maps/MessageMapTest.php @@ -114,6 +114,34 @@ ]); }); +it('maps assistant message with tool calls with empty arguments as stdClass', function (): void { + expect(MessageMap::map([ + new AssistantMessage('Running tool', [ + new ToolCall( + 'tool_5678', + 'get_time', + [] + ), + ]), + ]))->toEqual([ + [ + 'role' => 'assistant', + 'content' => [ + [ + 'type' => 'text', + 'text' => 'Running tool', + ], + [ + 'type' => 'tool_use', + 'id' => 'tool_5678', + 'name' => 'get_time', + 'input' => new \stdClass, + ], + ], + ], + ]); +}); + it('maps tool result messages', function (): void { expect(MessageMap::map([ new ToolResultMessage([ diff --git a/tests/Schemas/Anthropic/Maps/ToolMapTest.php b/tests/Schemas/Anthropic/Maps/ToolMapTest.php index a0a1cab..31a398a 100644 --- a/tests/Schemas/Anthropic/Maps/ToolMapTest.php +++ b/tests/Schemas/Anthropic/Maps/ToolMapTest.php @@ -31,6 +31,23 @@ ]]); }); +it('maps parameterless tools with empty object properties', function (): void { + $tool = (new Tool) + ->as('get_time') + ->for('Get the current time') + ->using(fn (): string => '12:00 PM'); + + expect(ToolMap::map([$tool]))->toEqual([[ + 'name' => 'get_time', + 'description' => 'Get the current time', + 'input_schema' => [ + 'type' => 'object', + 'properties' => (object) [], + 'required' => [], + ], + ]]); +}); + it('sets the cache typeif cacheType providerOptions is set on tool', function (mixed $cacheType): void { $tool = (new Tool) ->as('search') diff --git a/tests/Schemas/Cohere/CohereEmbeddingsTest.php b/tests/Schemas/Cohere/CohereEmbeddingsTest.php index d5cbd65..da5637f 100644 --- a/tests/Schemas/Cohere/CohereEmbeddingsTest.php +++ b/tests/Schemas/Cohere/CohereEmbeddingsTest.php @@ -14,7 +14,7 @@ 'X-Amzn-Bedrock-Input-Token-Count' => 4, ]); - $response = Prism::embeddings() + $response = (new Prism())->embeddings() ->using('bedrock', 'cohere.embed-english-v3') ->fromInput('Hello, world!') ->asEmbeddings(); @@ -32,7 +32,7 @@ 'X-Amzn-Bedrock-Input-Token-Count' => 1, ]); - $response = Prism::embeddings() + $response = (new Prism())->embeddings() ->using('bedrock', 'cohere.embed-english-v3') ->fromFile('tests/Fixtures/document.md') ->asEmbeddings(); @@ -50,7 +50,7 @@ 'X-Amzn-Bedrock-Input-Token-Count' => 1, ]); - $response = Prism::embeddings() + $response = (new Prism())->embeddings() ->using('bedrock', 'cohere.embed-english-v3') ->fromInput('The food was delicious.') ->fromInput('The drinks were not so good.') @@ -70,7 +70,7 @@ 'X-Amzn-Bedrock-Input-Token-Count' => 4, ]); - $response = Prism::embeddings() + $response = (new Prism())->embeddings() ->using('bedrock', 'cohere.embed-english-v3') ->withProviderOptions([ 'input_type' => 'search_query', diff --git a/tests/Schemas/Converse/Concerns/ExtractsToolCallsTest.php b/tests/Schemas/Converse/Concerns/ExtractsToolCallsTest.php new file mode 100644 index 0000000..b4f8007 --- /dev/null +++ b/tests/Schemas/Converse/Concerns/ExtractsToolCallsTest.php @@ -0,0 +1,220 @@ +extractToolCalls($data); + } + }; + + $data = [ + 'output' => [ + 'message' => [ + 'content' => [ + [ + 'toolUse' => [ + 'toolUseId' => 'tool_123', + 'name' => 'search', + 'input' => [ + 'query' => 'Laravel docs', + ], + ], + ], + ], + ], + ], + ]; + + $result = $extractor->extract($data); + + expect($result)->toHaveCount(1); + expect($result[0]->id)->toBe('tool_123'); + expect($result[0]->name)->toBe('search'); + expect($result[0]->arguments())->toBe(['query' => 'Laravel docs']); +}); + +it('extracts tool calls with string JSON input', function (): void { + $extractor = new class + { + use ExtractsToolCalls; + + public function extract(array $data): array + { + return $this->extractToolCalls($data); + } + }; + + $data = [ + 'output' => [ + 'message' => [ + 'content' => [ + [ + 'toolUse' => [ + 'toolUseId' => 'tool_456', + 'name' => 'weather', + 'input' => '{"city": "Detroit"}', + ], + ], + ], + ], + ], + ]; + + $result = $extractor->extract($data); + + expect($result)->toHaveCount(1); + expect($result[0]->id)->toBe('tool_456'); + expect($result[0]->name)->toBe('weather'); + expect($result[0]->arguments())->toBe(['city' => 'Detroit']); +}); + +it('extracts tool calls with invalid JSON string input defaults to empty array', function (): void { + $extractor = new class + { + use ExtractsToolCalls; + + public function extract(array $data): array + { + return $this->extractToolCalls($data); + } + }; + + $data = [ + 'output' => [ + 'message' => [ + 'content' => [ + [ + 'toolUse' => [ + 'toolUseId' => 'tool_789', + 'name' => 'get_time', + 'input' => 'invalid json', + ], + ], + ], + ], + ], + ]; + + $result = $extractor->extract($data); + + expect($result)->toHaveCount(1); + expect($result[0]->id)->toBe('tool_789'); + expect($result[0]->name)->toBe('get_time'); + expect($result[0]->arguments())->toBe([]); +}); + +it('extracts tool calls with null input defaults to empty array', function (): void { + $extractor = new class + { + use ExtractsToolCalls; + + public function extract(array $data): array + { + return $this->extractToolCalls($data); + } + }; + + $data = [ + 'output' => [ + 'message' => [ + 'content' => [ + [ + 'toolUse' => [ + 'toolUseId' => 'tool_abc', + 'name' => 'parameterless_tool', + 'input' => null, + ], + ], + ], + ], + ], + ]; + + $result = $extractor->extract($data); + + expect($result)->toHaveCount(1); + expect($result[0]->id)->toBe('tool_abc'); + expect($result[0]->name)->toBe('parameterless_tool'); + expect($result[0]->arguments())->toBe([]); +}); + +it('extracts tool calls with empty array input', function (): void { + $extractor = new class + { + use ExtractsToolCalls; + + public function extract(array $data): array + { + return $this->extractToolCalls($data); + } + }; + + $data = [ + 'output' => [ + 'message' => [ + 'content' => [ + [ + 'toolUse' => [ + 'toolUseId' => 'tool_def', + 'name' => 'no_params', + 'input' => [], + ], + ], + ], + ], + ], + ]; + + $result = $extractor->extract($data); + + expect($result)->toHaveCount(1); + expect($result[0]->id)->toBe('tool_def'); + expect($result[0]->name)->toBe('no_params'); + expect($result[0]->arguments())->toBe([]); +}); + +it('filters out non-tool-use content blocks', function (): void { + $extractor = new class + { + use ExtractsToolCalls; + + public function extract(array $data): array + { + return $this->extractToolCalls($data); + } + }; + + $data = [ + 'output' => [ + 'message' => [ + 'content' => [ + [ + 'text' => 'Some text response', + ], + [ + 'toolUse' => [ + 'toolUseId' => 'tool_xyz', + 'name' => 'search', + 'input' => ['query' => 'test'], + ], + ], + ], + ], + ], + ]; + + $result = $extractor->extract($data); + + expect($result)->toHaveCount(1); + expect($result[0]->name)->toBe('search'); +}); diff --git a/tests/Schemas/Converse/ConverseStructuredHandlerTest.php b/tests/Schemas/Converse/ConverseStructuredHandlerTest.php index 63b7337..bf29d27 100644 --- a/tests/Schemas/Converse/ConverseStructuredHandlerTest.php +++ b/tests/Schemas/Converse/ConverseStructuredHandlerTest.php @@ -29,7 +29,7 @@ ['weather', 'game_time', 'coat_required'] ); - $response = Prism::structured() + $response = (new Prism())->structured() ->withSchema($schema) ->using('bedrock', 'anthropic.claude-3-5-haiku-20241022-v1:0') ->withProviderOptions(['apiSchema' => BedrockSchema::Converse]) @@ -86,7 +86,7 @@ ['weather', 'game_time', 'coat_required'] ); - $response = Prism::structured() + $response = (new Prism())->structured() ->withSchema($schema) ->using('bedrock', 'anthropic.claude-3-5-haiku-20241022-v1:0') ->withProviderOptions($providerOptions) @@ -113,7 +113,7 @@ $customMessage = 'Please return a JSON response using this custom format instruction'; - Prism::structured() + (new Prism())->structured() ->withSchema($schema) ->using('bedrock', 'anthropic.claude-3-5-haiku-20241022-v1:0') ->withProviderOptions([ @@ -149,7 +149,7 @@ $defaultMessage = 'Respond with ONLY JSON (i.e. not in backticks or a code block, with NO CONTENT outside the JSON) that matches the following schema:'; - Prism::structured() + (new Prism())->structured() ->withSchema($schema) ->using('bedrock', 'anthropic.claude-3-5-haiku-20241022-v1:0') ->withProviderOptions([ @@ -182,7 +182,7 @@ ['weather', 'game_time', 'coat_required'] ); - Prism::structured() + (new Prism())->structured() ->withSchema($schema) ->using('bedrock', 'anthropic.claude-3-5-haiku-20241022-v1:0') ->withProviderOptions([ diff --git a/tests/Schemas/Converse/ConverseTextHandlerTest.php b/tests/Schemas/Converse/ConverseTextHandlerTest.php index 5284319..1764801 100644 --- a/tests/Schemas/Converse/ConverseTextHandlerTest.php +++ b/tests/Schemas/Converse/ConverseTextHandlerTest.php @@ -19,7 +19,7 @@ it('can generate text with a prompt', function (): void { FixtureResponse::fakeResponseSequence('converse', 'converse/generate-text-with-a-prompt'); - $response = Prism::text() + $response = (new Prism())->text() ->using('bedrock', 'amazon.nova-micro-v1:0') ->withPrompt('Who are you?') ->asText(); @@ -36,7 +36,7 @@ it('can generate text with reasoning content', function (): void { FixtureResponse::fakeResponseSequence('converse', 'converse/generate-text-with-reasoning-content'); - $response = Prism::text() + $response = (new Prism())->text() ->using('bedrock', 'openai.gpt-oss-120b-1:0') ->withPrompt('Tell me a short story about a brave knight.') ->asText(); @@ -51,7 +51,7 @@ it('can generate text with a system prompt', function (): void { FixtureResponse::fakeResponseSequence('converse', 'converse/generate-text-with-system-prompt'); - $response = Prism::text() + $response = (new Prism())->text() ->using('bedrock', 'amazon.nova-micro-v1:0') ->withSystemPrompt('MODEL ADOPTS ROLE of [PERSONA: Nyx the Cthulhu]!') ->withPrompt('Who are you?') @@ -67,7 +67,7 @@ it('can query a md or txt document', function (): void { FixtureResponse::fakeResponseSequence('converse', 'converse/query-a-txt-document'); - $response = Prism::text() + $response = (new Prism())->text() ->using('bedrock', 'amazon.nova-micro-v1:0') ->withMessages([ new UserMessage( @@ -89,7 +89,7 @@ it('can query a pdf document', function (): void { FixtureResponse::fakeResponseSequence('converse', 'converse/query-a-pdf-document'); - $response = Prism::text() + $response = (new Prism())->text() ->using('bedrock', 'amazon.nova-micro-v1:0') ->withMessages([ new UserMessage( @@ -111,7 +111,7 @@ it('can send images from file', function (): void { FixtureResponse::fakeResponseSequence('converse', 'converse/generate-text-with-image'); - $response = Prism::text() + $response = (new Prism())->text() ->using('bedrock', 'anthropic.claude-3-5-sonnet-20241022-v2:0') ->withProviderOptions(['apiSchema' => BedrockSchema::Converse]) ->withMessages([ @@ -145,7 +145,7 @@ ->using(fn (string $query): string => 'The tigers game is at 3pm in detroit'), ]; - $response = Prism::text() + $response = (new Prism())->text() ->using('bedrock', 'us.amazon.nova-micro-v1:0') ->withPrompt('What is the weather like in Detroit today?') ->withMaxSteps(2) @@ -180,7 +180,7 @@ ->using(fn (string $query): string => 'The tigers game is at 3pm in detroit'), ]; - $response = Prism::text() + $response = (new Prism())->text() ->using('bedrock', 'us.amazon.nova-micro-v1:0') ->withPrompt('Where is the tigers game and what will the weather be like?') ->withMaxSteps(3) @@ -205,7 +205,7 @@ ->using(fn (string $query): string => 'The tigers game is at 3pm in detroit'), ]; - $response = Prism::text() + $response = (new Prism())->text() ->using('bedrock', 'us.amazon.nova-micro-v1:0') ->withPrompt('WHat is the weather like in London UK today?') ->withTools($tools) @@ -229,7 +229,7 @@ ->using(fn (string $query): string => 'The tigers game is at 3pm in detroit'), ]; - $response = Prism::text() + $response = (new Prism())->text() ->using('bedrock', 'us.amazon.nova-micro-v1:0') ->withPrompt('WHat is the weather like in London UK today?') ->withMaxSteps(2) @@ -243,7 +243,7 @@ it('does not enable prompt caching if the enableCaching provider meta is not set on the request', function (): void { FixtureResponse::fakeResponseSequence('converse', 'converse/generate-text-with-a-prompt'); - Prism::text() + (new Prism())->text() ->using('bedrock', 'amazon.nova-micro-v1:0') ->withPrompt('Who are you?') ->asText(); @@ -254,7 +254,7 @@ it('enables prompt caching if the enableCaching provider meta is set on the request', function (): void { FixtureResponse::fakeResponseSequence('converse', 'converse/generate-text-with-a-prompt'); - Prism::text() + (new Prism())->text() ->using('bedrock', 'amazon.nova-micro-v1:0') ->withProviderOptions(['enableCaching' => true]) ->withPrompt('Who are you?') @@ -280,7 +280,7 @@ 'requestMetadata' => ['requestId' => 'abc-123'], ]; - Prism::text() + (new Prism())->text() ->using('bedrock', 'us.amazon.nova-micro-v1:0') ->withProviderOptions($providerOptions) ->withPrompt('Who are you?') @@ -292,7 +292,7 @@ it('does not remove zero values from payload', function (): void { FixtureResponse::fakeResponseSequence('converse', 'converse/generate-text-with-a-prompt'); - Prism::text() + (new Prism())->text() ->using('bedrock', 'amazon.nova-micro-v1:0') ->withPrompt('Who are you?') ->usingTemperature(0) diff --git a/tests/Schemas/Converse/Maps/MessageMapTest.php b/tests/Schemas/Converse/Maps/MessageMapTest.php index ce03ec7..766f637 100644 --- a/tests/Schemas/Converse/Maps/MessageMapTest.php +++ b/tests/Schemas/Converse/Maps/MessageMapTest.php @@ -127,6 +127,32 @@ ]); }); +it('maps assistant message with tool calls with empty arguments as stdClass', function (): void { + expect(MessageMap::map([ + new AssistantMessage('Running tool', [ + new ToolCall( + 'tool_5678', + 'get_time', + [] + ), + ]), + ]))->toEqual([ + [ + 'role' => 'assistant', + 'content' => [ + ['text' => 'Running tool'], + [ + 'toolUse' => [ + 'toolUseId' => 'tool_5678', + 'name' => 'get_time', + 'input' => new \stdClass, + ], + ], + ], + ], + ]); +}); + it('maps tool result messages', function (): void { expect(MessageMap::map([ new ToolResultMessage([ diff --git a/tests/Schemas/Converse/Maps/ToolMapTest.php b/tests/Schemas/Converse/Maps/ToolMapTest.php index 1dc5ac0..8fc98b8 100644 --- a/tests/Schemas/Converse/Maps/ToolMapTest.php +++ b/tests/Schemas/Converse/Maps/ToolMapTest.php @@ -35,3 +35,26 @@ ], ]); }); + +it('maps parameterless tools with empty object properties', function (): void { + $tool = (new Tool) + ->as('get_time') + ->for('Get the current time') + ->using(fn (): string => '12:00 PM'); + + expect(ToolMap::map([$tool]))->toEqual([ + [ + 'toolSpec' => [ + 'name' => 'get_time', + 'description' => 'Get the current time', + 'inputSchema' => [ + 'json' => [ + 'type' => 'object', + 'properties' => (object) [], + 'required' => [], + ], + ], + ], + ], + ]); +});