From b8fd3597d2808834d534de01cc9294eed44694a8 Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Wed, 11 Feb 2026 19:42:15 +0100 Subject: [PATCH] Add tool/function calling support for Anthropic adapter - Create ToolFormatter for Anthropic-specific tool format (uses input_schema instead of parameters, no type/function wrapper like OpenAI) - Update AnthropicChatAdapter to handle tool requests and responses: - Add tools to request parameters when hasTools() is true - Handle ToolCallsPart (converts to tool_use content blocks) - Handle ToolCallPart (converts to tool_result content blocks) - Parse tool_use from response to create AIChatToolCall objects - Update Messages.php to allow streaming with tools - Update MessagesInterface types to include tool role and improved input_schema - Add comprehensive tests for tool support --- .../anthropic-adapter/phpstan-baseline.neon | 8 +- .../src/Chat/AnthropicChatAdapter.php | 61 +++- .../src/Chat/ToolFormatter.php | 146 +++++++++ .../Unit/Chat/AnthropicChatAdapterTest.php | 276 +++++++++++++++++- .../tests/Unit/Chat/ToolFormatterTest.php | 256 ++++++++++++++++ packages/anthropic/composer.lock | 14 +- packages/anthropic/phpstan-baseline.neon | 2 +- packages/anthropic/src/Resources/Messages.php | 16 +- .../src/Resources/MessagesInterface.php | 3 +- 9 files changed, 759 insertions(+), 23 deletions(-) create mode 100644 packages/anthropic-adapter/src/Chat/ToolFormatter.php create mode 100644 packages/anthropic-adapter/tests/Unit/Chat/ToolFormatterTest.php diff --git a/packages/anthropic-adapter/phpstan-baseline.neon b/packages/anthropic-adapter/phpstan-baseline.neon index e014fa3c..8b70c64c 100644 --- a/packages/anthropic-adapter/phpstan-baseline.neon +++ b/packages/anthropic-adapter/phpstan-baseline.neon @@ -33,5 +33,11 @@ parameters: - message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertInstanceOf\(\) with ''ModelflowAi\\\\Chat\\\\Response\\\\AIChatResponse'' and ModelflowAi\\Chat\\Response\\AIChatResponse will always evaluate to true\.$#' identifier: method.alreadyNarrowedType - count: 4 + count: 8 + path: tests/Unit/Chat/AnthropicChatAdapterTest.php + + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertSame\(\) with ModelflowAi\\Chat\\ToolInfo\\ToolTypeEnum\:\:FUNCTION and ModelflowAi\\Chat\\ToolInfo\\ToolTypeEnum will always evaluate to true\.$#' + identifier: method.alreadyNarrowedType + count: 1 path: tests/Unit/Chat/AnthropicChatAdapterTest.php diff --git a/packages/anthropic-adapter/src/Chat/AnthropicChatAdapter.php b/packages/anthropic-adapter/src/Chat/AnthropicChatAdapter.php index 5cccf644..840f7cf3 100644 --- a/packages/anthropic-adapter/src/Chat/AnthropicChatAdapter.php +++ b/packages/anthropic-adapter/src/Chat/AnthropicChatAdapter.php @@ -23,14 +23,18 @@ use ModelflowAi\Chat\Request\Message\AIChatMessageRoleEnum; use ModelflowAi\Chat\Request\Message\ImageBase64Part; use ModelflowAi\Chat\Request\Message\TextPart; +use ModelflowAi\Chat\Request\Message\ToolCallPart; +use ModelflowAi\Chat\Request\Message\ToolCallsPart; use ModelflowAi\Chat\Request\ResponseFormat\JsonSchemaResponseFormat; use ModelflowAi\Chat\Request\ResponseFormat\ResponseFormatInterface; use ModelflowAi\Chat\Request\ResponseFormat\SupportsResponseFormatInterface; use ModelflowAi\Chat\Response\AIChatResponse; use ModelflowAi\Chat\Response\AIChatResponseMessage; use ModelflowAi\Chat\Response\AIChatResponseStream; +use ModelflowAi\Chat\Response\AIChatToolCall; use ModelflowAi\Chat\Response\StreamingUsageTracker; use ModelflowAi\Chat\Response\Usage; +use ModelflowAi\Chat\ToolInfo\ToolTypeEnum; /** * @phpstan-import-type Parameters from MessagesInterface @@ -41,6 +45,7 @@ AIChatMessageRoleEnum::SYSTEM, AIChatMessageRoleEnum::ASSISTANT, AIChatMessageRoleEnum::USER, + AIChatMessageRoleEnum::TOOL, ]; /** @@ -87,6 +92,10 @@ public function handleRequest(AIChatRequest $request): AIChatResponse $parameters['temperature'] = $temperature; } + if ($request->hasTools()) { + $parameters['tools'] = ToolFormatter::formatTools($request->getToolInfos()); + } + $messages = []; /** @var AIChatMessage $aiMessage */ foreach ($request->getMessages() as $aiMessage) { @@ -94,8 +103,16 @@ public function handleRequest(AIChatRequest $request): AIChatResponse throw new \Exception('Not supported message role.'); } + // Anthropic has no dedicated "tool" role; tool results are sent as a user message + // containing tool_result content blocks. + $role = match ($aiMessage->role) { + AIChatMessageRoleEnum::USER, AIChatMessageRoleEnum::TOOL => 'user', + AIChatMessageRoleEnum::ASSISTANT => 'assistant', + AIChatMessageRoleEnum::SYSTEM => 'system', + }; + $message = [ - 'role' => $aiMessage->role->value, + 'role' => $role, 'content' => [], ]; @@ -114,6 +131,26 @@ public function handleRequest(AIChatRequest $request): AIChatResponse 'data' => $part->content, ], ]; + } elseif ($part instanceof ToolCallsPart) { + foreach ($part->toolCalls as $toolCall) { + $message['content'][] = [ + 'type' => 'tool_use', + 'id' => $toolCall->id, + 'name' => $toolCall->name, + 'input' => $toolCall->arguments, + ]; + } + } elseif ($part instanceof ToolCallPart) { + $message['content'][] = [ + 'type' => 'tool_result', + 'tool_use_id' => $part->toolCallId, + 'content' => [ + [ + 'type' => 'text', + 'text' => $part->content, + ], + ], + ]; } else { throw new \Exception('Not supported message part type.'); } @@ -149,6 +186,7 @@ public function handleRequest(AIChatRequest $request): AIChatResponse ]; } + /** @var Parameters $parameters */ if ($request instanceof AIChatStreamedRequest) { return $this->createStreamed($request, $parameters); } @@ -163,7 +201,22 @@ private function create(AIChatRequest $request, array $parameters): AIChatRespon { $result = $this->client->messages()->create($parameters); - $content = $result->content[0]->text ?? ''; + $content = ''; + $toolCalls = []; + + foreach ($result->content as $contentBlock) { + if ('text' === $contentBlock->type) { + $content .= $contentBlock->text ?? ''; + } elseif ('tool_use' === $contentBlock->type && null !== $contentBlock->toolUse) { + $toolCalls[] = new AIChatToolCall( + ToolTypeEnum::FUNCTION, + $contentBlock->toolUse->id, + $contentBlock->toolUse->name, + $contentBlock->toolUse->input, + ); + } + } + if ('json' === $request->getFormat() && \str_ends_with($content, '}')) { $content = '{' . $content; } @@ -173,6 +226,7 @@ private function create(AIChatRequest $request, array $parameters): AIChatRespon new AIChatResponseMessage( AIChatMessageRoleEnum::from($result->role), $content, + [] !== $toolCalls ? $toolCalls : null, ), new Usage( $result->usage->promptTokens, @@ -243,8 +297,7 @@ private function createStreamedMessages(\Iterator $responses, string $prefix, St public function supports(object $request): bool { - return $request instanceof AIChatRequest - && !$request->hasTools(); + return $request instanceof AIChatRequest; } public function supportsResponseFormat(ResponseFormatInterface $responseFormat): bool diff --git a/packages/anthropic-adapter/src/Chat/ToolFormatter.php b/packages/anthropic-adapter/src/Chat/ToolFormatter.php new file mode 100644 index 00000000..6271c04b --- /dev/null +++ b/packages/anthropic-adapter/src/Chat/ToolFormatter.php @@ -0,0 +1,146 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ModelflowAi\AnthropicAdapter\Chat; + +use ModelflowAi\Chat\ToolInfo\Parameter; +use ModelflowAi\Chat\ToolInfo\ToolInfo; + +final class ToolFormatter +{ + /** + * @return array{ + * name: string, + * description: string, + * input_schema: array{ + * type: string, + * properties: array>, + * required?: string[], + * }, + * } + */ + public static function formatTool(ToolInfo $tool): array + { + $parameters = []; + foreach ($tool->parameters as $parameter) { + $param = self::formatParameter($parameter); + $parameters[$parameter->name] = $param; + } + + $requiredParameters = []; + foreach ($tool->requiredParameters as $requiredParameter) { + $requiredParameters[] = $requiredParameter->name; + } + + return [ + 'name' => $tool->name, + 'description' => $tool->description, + 'input_schema' => [ + 'type' => 'object', + 'properties' => $parameters, + 'required' => $requiredParameters, + ], + ]; + } + + /** + * @param ToolInfo[] $tools + * + * @return array>, + * required?: string[], + * }, + * }> + */ + public static function formatTools(array $tools): array + { + return \array_map( + self::formatTool(...), + $tools, + ); + } + + /** + * @return array + */ + private static function formatParameter(Parameter $parameter): array + { + $type = $parameter->nullable ? [$parameter->type, 'null'] : $parameter->type; + + $param = [ + 'type' => $type, + 'description' => $parameter->description, + ]; + + if ('array' === $parameter->type) { + if (null === $parameter->itemsOrProperties) { + throw new \Exception('Array type parameter must have items description. Define a type or use the Parameter class for object.'); + } + + if (\is_string($parameter->itemsOrProperties)) { + $param['items'] = [ + 'type' => $parameter->itemsOrProperties, + ]; + } else { + $properties = []; + /** @var Parameter $property */ + foreach ($parameter->itemsOrProperties as $property) { + $properties[$property->name] = self::formatParameter($property); + } + + $items = [ + 'type' => 'object', + 'properties' => $properties, + ]; + + if ([] !== $parameter->required) { + $items['required'] = $parameter->required; + } + + $param['items'] = $items; + } + } + + if ('object' === $parameter->type) { + if (!\is_array($parameter->itemsOrProperties)) { + throw new \Exception('Object type parameter must have properties description. You need to pass an array of Parameter.'); + } + + $properties = []; + /** @var Parameter $item */ + foreach ($parameter->itemsOrProperties as $item) { + $properties[$item->name] = self::formatParameter($item); + } + + $param['properties'] = $properties; + + if ([] !== $parameter->required) { + $param['required'] = $parameter->required; + } + } + + if ([] !== $parameter->enum) { + $param['enum'] = $parameter->enum; + } + + if (null !== $parameter->format) { + $param['format'] = $parameter->format; + } + + return $param; + } +} diff --git a/packages/anthropic-adapter/tests/Unit/Chat/AnthropicChatAdapterTest.php b/packages/anthropic-adapter/tests/Unit/Chat/AnthropicChatAdapterTest.php index dac98f07..52254b56 100644 --- a/packages/anthropic-adapter/tests/Unit/Chat/AnthropicChatAdapterTest.php +++ b/packages/anthropic-adapter/tests/Unit/Chat/AnthropicChatAdapterTest.php @@ -29,12 +29,16 @@ use ModelflowAi\Chat\Request\AIChatStreamedRequest; use ModelflowAi\Chat\Request\Message\AIChatMessage; use ModelflowAi\Chat\Request\Message\AIChatMessageRoleEnum; +use ModelflowAi\Chat\Request\Message\ToolCallPart; +use ModelflowAi\Chat\Request\Message\ToolCallsPart; use ModelflowAi\Chat\Request\ResponseFormat\JsonResponseFormat; use ModelflowAi\Chat\Request\ResponseFormat\JsonSchemaResponseFormat; use ModelflowAi\Chat\Request\ResponseFormat\ResponseFormatInterface; use ModelflowAi\Chat\Response\AIChatResponse; use ModelflowAi\Chat\Response\AIChatResponseStream; +use ModelflowAi\Chat\Response\AIChatToolCall; use ModelflowAi\Chat\ToolInfo\ToolInfoBuilder; +use ModelflowAi\Chat\ToolInfo\ToolTypeEnum; use ModelflowAi\DecisionTree\Criteria\CriteriaCollection; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; @@ -84,7 +88,7 @@ public function testSupportsWithTools(): void static fn () => null, ); - $this->assertFalse($adapter->supports($request)); + $this->assertTrue($adapter->supports($request)); } public function testHandleRequest(): void @@ -352,6 +356,276 @@ public function testHandleRequestStreamed(): void } } + public function testHandleRequestWithTools(): void + { + $expectedPayload = [ + 'model' => Model::CLAUDE_3_HAIKU->value, + 'messages' => [ + ['role' => 'user', 'content' => 'Hello world!'], + ], + 'tools' => [ + [ + 'name' => 'get_weather', + 'description' => 'Get the current weather in a given location.', + 'input_schema' => [ + 'type' => 'object', + 'properties' => [ + 'location' => [ + 'type' => 'string', + 'description' => 'the location to get the weather for', + ], + 'timestamp' => [ + 'type' => 'integer', + 'description' => 'timestamp to get the weather', + ], + ], + 'required' => ['location'], + ], + ], + ], + 'max_tokens' => 100, + 'system' => 'You are an angry bot!', + ]; + + $mockResponseMatcher = new MockResponseMatcher(); + $mockResponseMatcher->addResponse( + PartialPayload::create('messages', $expectedPayload), + new ObjectResponse(DataFixtures::MESSAGES_CREATE_WITH_TOOLS_RESPONSE, MetaInformation::empty()), + ); + + $client = new Client(new MockTransport($mockResponseMatcher)); + + $request = new AIChatRequest( + new AIChatMessageCollection( + new AIChatMessage( + AIChatMessageRoleEnum::SYSTEM, + DataFixtures::MESSAGES_CREATE_WITH_TOOLS_REQUEST_RAW['messages'][0]['content'], + ), + new AIChatMessage( + AIChatMessageRoleEnum::USER, + DataFixtures::MESSAGES_CREATE_WITH_TOOLS_REQUEST_RAW['messages'][1]['content'], + ), + ), + new CriteriaCollection(), + [ + 'get_weather' => [$this, 'getWeatherMethod'], + ], + [ + ToolInfoBuilder::buildToolInfo($this, 'getWeatherMethod', 'get_weather'), + ], + [], + static fn () => null, + ); + + $adapter = new AnthropicChatAdapter($client, Model::CLAUDE_3_HAIKU->value, 100); + $result = $adapter->handleRequest($request); + + $this->assertInstanceOf(AIChatResponse::class, $result); + $this->assertSame(AIChatMessageRoleEnum::ASSISTANT, $result->getMessage()->role); + $this->assertSame('', $result->getMessage()->content); + $this->assertNotNull($result->getMessage()->toolCalls); + $this->assertCount(1, $result->getMessage()->toolCalls); + + $toolCall = $result->getMessage()->toolCalls[0]; + $this->assertSame(ToolTypeEnum::FUNCTION, $toolCall->type); + $this->assertSame('toolu_01W7iPphiNtxfbEfsisKFGtd', $toolCall->id); + $this->assertSame('get_weather', $toolCall->name); + $this->assertSame(['location' => 'New York', 'timestamp' => 1_681_926_000], $toolCall->arguments); + } + + public function testHandleRequestWithToolCallsPart(): void + { + $expectedPayload = [ + 'model' => Model::CLAUDE_3_HAIKU->value, + 'messages' => [ + [ + 'role' => 'user', + 'content' => 'What is the weather in New York?', + ], + [ + 'role' => 'assistant', + 'content' => [ + [ + 'type' => 'tool_use', + 'id' => 'toolu_123', + 'name' => 'get_weather', + 'input' => ['location' => 'New York'], + ], + ], + ], + [ + 'role' => 'user', + 'content' => [ + [ + 'type' => 'tool_result', + 'tool_use_id' => 'toolu_123', + 'content' => [ + [ + 'type' => 'text', + 'text' => 'Sunny, 72°F', + ], + ], + ], + ], + ], + ], + 'max_tokens' => 100, + 'system' => '', + ]; + + $mockResponseMatcher = new MockResponseMatcher(); + $mockResponseMatcher->addResponse( + PartialPayload::create('messages', $expectedPayload), + new ObjectResponse(DataFixtures::MESSAGES_CREATE_RESPONSE, MetaInformation::empty()), + ); + + $client = new Client(new MockTransport($mockResponseMatcher)); + + $toolCall = new AIChatToolCall( + ToolTypeEnum::FUNCTION, + 'toolu_123', + 'get_weather', + ['location' => 'New York'], + ); + + $request = new AIChatRequest( + new AIChatMessageCollection( + new AIChatMessage(AIChatMessageRoleEnum::USER, 'What is the weather in New York?'), + new AIChatMessage(AIChatMessageRoleEnum::ASSISTANT, ToolCallsPart::create([$toolCall])), + new AIChatMessage(AIChatMessageRoleEnum::USER, ToolCallPart::create('toolu_123', 'get_weather', 'Sunny, 72°F')), + ), + new CriteriaCollection(), + [], + [], + [], + static fn () => null, + ); + + $adapter = new AnthropicChatAdapter($client, Model::CLAUDE_3_HAIKU->value, 100); + $result = $adapter->handleRequest($request); + + $this->assertInstanceOf(AIChatResponse::class, $result); + $this->assertSame(AIChatMessageRoleEnum::ASSISTANT, $result->getMessage()->role); + } + + public function testHandleRequestWithToolCallPart(): void + { + $expectedPayload = [ + 'model' => Model::CLAUDE_3_HAIKU->value, + 'messages' => [ + [ + 'role' => 'user', + 'content' => [ + [ + 'type' => 'tool_result', + 'tool_use_id' => 'toolu_456', + 'content' => [ + [ + 'type' => 'text', + 'text' => 'Result from tool execution', + ], + ], + ], + ], + ], + ], + 'max_tokens' => 100, + 'system' => '', + ]; + + $mockResponseMatcher = new MockResponseMatcher(); + $mockResponseMatcher->addResponse( + PartialPayload::create('messages', $expectedPayload), + new ObjectResponse(DataFixtures::MESSAGES_CREATE_RESPONSE, MetaInformation::empty()), + ); + + $client = new Client(new MockTransport($mockResponseMatcher)); + + $request = new AIChatRequest( + new AIChatMessageCollection( + new AIChatMessage( + AIChatMessageRoleEnum::USER, + ToolCallPart::create('toolu_456', 'some_tool', 'Result from tool execution'), + ), + ), + new CriteriaCollection(), + [], + [], + [], + static fn () => null, + ); + + $adapter = new AnthropicChatAdapter($client, Model::CLAUDE_3_HAIKU->value, 100); + $result = $adapter->handleRequest($request); + + $this->assertInstanceOf(AIChatResponse::class, $result); + } + + public function testHandleRequestMapsToolRoleToUser(): void + { + // Anthropic has no "tool" role: a TOOL-role message must be sent as a user message. + $expectedPayload = [ + 'model' => Model::CLAUDE_3_HAIKU->value, + 'messages' => [ + [ + 'role' => 'user', + 'content' => [ + [ + 'type' => 'tool_result', + 'tool_use_id' => 'toolu_789', + 'content' => [ + [ + 'type' => 'text', + 'text' => 'Result from tool execution', + ], + ], + ], + ], + ], + ], + 'max_tokens' => 100, + 'system' => '', + ]; + + $mockResponseMatcher = new MockResponseMatcher(); + $mockResponseMatcher->addResponse( + PartialPayload::create('messages', $expectedPayload), + new ObjectResponse(DataFixtures::MESSAGES_CREATE_RESPONSE, MetaInformation::empty()), + ); + + $client = new Client(new MockTransport($mockResponseMatcher)); + + $request = new AIChatRequest( + new AIChatMessageCollection( + new AIChatMessage( + AIChatMessageRoleEnum::TOOL, + ToolCallPart::create('toolu_789', 'some_tool', 'Result from tool execution'), + ), + ), + new CriteriaCollection(), + [], + [], + [], + static fn () => null, + ); + + $adapter = new AnthropicChatAdapter($client, Model::CLAUDE_3_HAIKU->value, 100); + $result = $adapter->handleRequest($request); + + $this->assertInstanceOf(AIChatResponse::class, $result); + } + + /** + * Get the current weather in a given location. + * + * @param string $location the location to get the weather for + * @param int $timestamp timestamp to get the weather + */ + public function getWeatherMethod(string $location, int $timestamp = 0): string + { + return 'Sunny, 72°F in ' . $location; + } + /** * This is a description. * diff --git a/packages/anthropic-adapter/tests/Unit/Chat/ToolFormatterTest.php b/packages/anthropic-adapter/tests/Unit/Chat/ToolFormatterTest.php new file mode 100644 index 00000000..df0339f4 --- /dev/null +++ b/packages/anthropic-adapter/tests/Unit/Chat/ToolFormatterTest.php @@ -0,0 +1,256 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ModelflowAi\AnthropicAdapter\Tests\Unit\Chat; + +use ModelflowAi\AnthropicAdapter\Chat\ToolFormatter; +use ModelflowAi\Chat\ToolInfo\Parameter; +use ModelflowAi\Chat\ToolInfo\ToolInfo; +use ModelflowAi\Chat\ToolInfo\ToolInfoBuilder; +use ModelflowAi\Chat\ToolInfo\ToolTypeEnum; +use PHPUnit\Framework\TestCase; + +class ToolFormatterTest extends TestCase +{ + public function testFormatTool(): void + { + $tool = ToolInfoBuilder::buildToolInfo($this, 'toolMethod1', 'test'); + + $this->assertSame([ + 'name' => 'test', + 'description' => 'This is a description.', + 'input_schema' => [ + 'type' => 'object', + 'properties' => [ + 'required' => [ + 'type' => 'string', + 'description' => 'this is a required parameter', + ], + 'optional' => [ + 'type' => 'string', + 'description' => 'this is an optional parameter', + ], + ], + 'required' => [ + 'required', + ], + ], + ], ToolFormatter::formatTool($tool)); + } + + public function testFormatTools(): void + { + $tool1 = ToolInfoBuilder::buildToolInfo($this, 'toolMethod1', 'test'); + $tool2 = ToolInfoBuilder::buildToolInfo($this, 'toolMethod2', 'test'); + + $this->assertSame( + [ + [ + 'name' => 'test', + 'description' => 'This is a description.', + 'input_schema' => [ + 'type' => 'object', + 'properties' => [ + 'required' => [ + 'type' => 'string', + 'description' => 'this is a required parameter', + ], + 'optional' => [ + 'type' => 'string', + 'description' => 'this is an optional parameter', + ], + ], + 'required' => [ + 'required', + ], + ], + ], + [ + 'name' => 'test', + 'description' => '', + 'input_schema' => [ + 'type' => 'object', + 'properties' => [ + 'test' => [ + 'type' => 'string', + 'description' => '', + ], + ], + 'required' => [ + 'test', + ], + ], + ], + ], + ToolFormatter::formatTools([ + $tool1, + $tool2, + ]), + ); + } + + /** + * This is a description. + * + * @param string $required this is a required parameter + * @param string $optional this is an optional parameter + */ + public function toolMethod1(string $required, string $optional = ''): string + { + return $required . $optional; + } + + public function toolMethod2(string $test): void + { + } + + public function testFormatToolWithNestedObject(): void + { + $nestedProperties = [ + new Parameter('id', 'integer', 'The ID'), + new Parameter('name', 'string', 'The name'), + new Parameter('optional', 'string', 'Optional field'), + ]; + + $objectParam = new Parameter( + name: 'user', + type: 'object', + description: 'User object', + enum: [], + format: null, + itemsOrProperties: $nestedProperties, + ); + + $tool = new ToolInfo( + type: ToolTypeEnum::FUNCTION, + name: 'create_user', + description: 'Create a user', + parameters: [$objectParam], + requiredParameters: [$objectParam], + ); + + $formatted = ToolFormatter::formatTool($tool); + + $this->assertSame([ + 'name' => 'create_user', + 'description' => 'Create a user', + 'input_schema' => [ + 'type' => 'object', + 'properties' => [ + 'user' => [ + 'type' => 'object', + 'description' => 'User object', + 'properties' => [ + 'id' => [ + 'type' => 'integer', + 'description' => 'The ID', + ], + 'name' => [ + 'type' => 'string', + 'description' => 'The name', + ], + 'optional' => [ + 'type' => 'string', + 'description' => 'Optional field', + ], + ], + ], + ], + 'required' => ['user'], + ], + ], $formatted); + } + + public function testFormatToolWithArrayOfObjects(): void + { + $nestedProperties = [ + new Parameter('id', 'integer', 'Item ID'), + new Parameter('name', 'string', 'Item name'), + ]; + + $arrayParam = new Parameter( + name: 'items', + type: 'array', + description: 'List of items', + enum: [], + format: null, + itemsOrProperties: $nestedProperties, + ); + + $tool = new ToolInfo( + type: ToolTypeEnum::FUNCTION, + name: 'process_items', + description: 'Process items', + parameters: [$arrayParam], + requiredParameters: [$arrayParam], + ); + + $formatted = ToolFormatter::formatTool($tool); + + $this->assertSame([ + 'name' => 'process_items', + 'description' => 'Process items', + 'input_schema' => [ + 'type' => 'object', + 'properties' => [ + 'items' => [ + 'type' => 'array', + 'description' => 'List of items', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'type' => 'integer', + 'description' => 'Item ID', + ], + 'name' => [ + 'type' => 'string', + 'description' => 'Item name', + ], + ], + ], + ], + ], + 'required' => ['items'], + ], + ], $formatted); + } + + public function testFormatToolWithOptionalParameter(): void + { + $nestedProperties = [ + new Parameter('id', 'integer', 'The ID'), + ]; + + $objectParam = new Parameter( + name: 'data', + type: 'object', + description: 'Data object', + enum: [], + format: null, + itemsOrProperties: $nestedProperties, + ); + + $tool = new ToolInfo( + type: ToolTypeEnum::FUNCTION, + name: 'test', + description: 'Test', + parameters: [$objectParam], + requiredParameters: [], + ); + + $formatted = ToolFormatter::formatTool($tool); + + $this->assertSame([], $formatted['input_schema']['required'] ?? []); + } +} diff --git a/packages/anthropic/composer.lock b/packages/anthropic/composer.lock index 6f45fea6..02f2d035 100644 --- a/packages/anthropic/composer.lock +++ b/packages/anthropic/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "477c8fd33925276c6a62536c851e27a8", + "content-hash": "4bb8db8ac667ea187815c92218f71962", "packages": [ { "name": "modelflow-ai/api-client", @@ -1521,8 +1521,8 @@ "version": "2.1.x-dev", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/861cd9a1257fc5deaebb655d9d1d7cfb7830fc89", - "reference": "861cd9a1257fc5deaebb655d9d1d7cfb7830fc89", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/b148e11fc2e55696915c118e3f028eddc7facf32", + "reference": "b148e11fc2e55696915c118e3f028eddc7facf32", "shasum": "" }, "require": { @@ -1568,7 +1568,7 @@ "type": "github" } ], - "time": "2026-01-29T16:51:43+00:00" + "time": "2026-02-11T17:38:58+00:00" }, { "name": "phpstan/phpstan-phpunit", @@ -3464,14 +3464,12 @@ ], "aliases": [], "minimum-stability": "dev", - "stability-flags": { - "phpspec/prophecy-phpunit": 0 - }, + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { "php": "^8.2" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/packages/anthropic/phpstan-baseline.neon b/packages/anthropic/phpstan-baseline.neon index 2bde0711..e973e4a3 100644 --- a/packages/anthropic/phpstan-baseline.neon +++ b/packages/anthropic/phpstan-baseline.neon @@ -37,7 +37,7 @@ parameters: path: src/Resources/Messages.php - - message: '#^Call to static method Webmozart\\Assert\\Assert\:\:isArray\(\) with array\{type\: ''text'', text\: string\} will always evaluate to true\.$#' + message: '#^Call to static method Webmozart\\Assert\\Assert\:\:isArray\(\) with array\{type\: ''text''\|''tool_use''.*\} will always evaluate to true\.$#' identifier: staticMethod.alreadyNarrowedType count: 1 path: src/Resources/Messages.php diff --git a/packages/anthropic/src/Resources/Messages.php b/packages/anthropic/src/Resources/Messages.php index 9af8a114..6b624bb2 100644 --- a/packages/anthropic/src/Resources/Messages.php +++ b/packages/anthropic/src/Resources/Messages.php @@ -50,8 +50,6 @@ public function create(array $parameters): CreateResponse public function createStreamed(array $parameters): \Iterator { - Assert::keyNotExists($parameters, 'tools'); - $this->validateParameters($parameters); $parameters['stream'] = true; @@ -98,8 +96,11 @@ public function createStreamed(array $parameters): \Iterator /** @var array{ * index: int, * content_block: array{ - * type: "text", - * text: string, + * type: "text"|"tool_use", + * text?: string, + * id?: string, + * name?: string, + * input?: array, * }, * } $object */ @@ -112,8 +113,9 @@ public function createStreamed(array $parameters): \Iterator /** @var array{ * index: int, * delta: array{ - * type: "text_delta", - * text: string, + * type: "text_delta"|"input_json_delta", + * text?: string, + * partial_json?: string, * }, * } $object */ @@ -246,7 +248,7 @@ private function validateParameters(array $parameters): void foreach ($parameters['messages'] as $message) { Assert::keyExists($message, 'role'); Assert::string($message['role']); - Assert::inArray($message['role'], ['system', 'user', 'assistant', 'tool']); + Assert::inArray($message['role'], ['system', 'user', 'assistant']); Assert::keyExists($message, 'content'); if (\is_string($message['content'])) { diff --git a/packages/anthropic/src/Resources/MessagesInterface.php b/packages/anthropic/src/Resources/MessagesInterface.php index 7f0bc6e6..022ea461 100644 --- a/packages/anthropic/src/Resources/MessagesInterface.php +++ b/packages/anthropic/src/Resources/MessagesInterface.php @@ -28,7 +28,8 @@ * description: string, * input_schema: array{ * type: string, - * properties: array, + * properties: array>, + * required?: string[], * } * } * @phpstan-type OutputConfig array{