From 39ff98f69fe48e08a14496ecdbe350dd79e852cd Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Tue, 2 Sep 2025 09:47:16 -0600 Subject: [PATCH 01/11] feat: adds MessageBuilder class --- src/Builders/MessageBuilder.php | 236 ++++++++++++++++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 src/Builders/MessageBuilder.php diff --git a/src/Builders/MessageBuilder.php b/src/Builders/MessageBuilder.php new file mode 100644 index 00000000..5ca04580 --- /dev/null +++ b/src/Builders/MessageBuilder.php @@ -0,0 +1,236 @@ + The parts that make up the message. + */ + protected array $parts = []; + + /** + * Constructor. + * + * @since n.e.x.t + * + * @param string|null $text Optional initial text content. + * @param MessageRoleEnum|null $role Optional role. + */ + public function __construct(?string $text = null, ?MessageRoleEnum $role = null) + { + $this->role = $role; + + if ($text !== null) { + $this->withText($text); + } + } + + /** + * Sets the role of the message sender. + * + * @since n.e.x.t + * + * @param MessageRoleEnum $role The role to set. + * @return self + */ + public function usingRole(MessageRoleEnum $role): self + { + $this->role = $role; + return $this; + } + + /** + * Sets the role to user. + * + * @since n.e.x.t + * + * @return self + */ + public function usingUserRole(): self + { + return $this->usingRole(MessageRoleEnum::user()); + } + + /** + * Sets the role to model. + * + * @since n.e.x.t + * + * @return self + */ + public function usingModelRole(): self + { + return $this->usingRole(MessageRoleEnum::model()); + } + + /** + * Adds text content to the message. + * + * @since n.e.x.t + * + * @param string $text The text to add. + * @return self + * @throws InvalidArgumentException If the text is empty. + */ + public function withText(string $text): self + { + if (trim($text) === '') { + throw new InvalidArgumentException('Text content cannot be empty.'); + } + + $this->parts[] = new MessagePart($text); + return $this; + } + + /** + * Adds a file to the message. + * + * Accepts: + * - File object + * - URL string (remote file) + * - Base64-encoded data string + * - Data URI string (data:mime/type;base64,data) + * - Local file path string + * + * @since n.e.x.t + * + * @param string|File $file The file to add. + * @param string|null $mimeType Optional MIME type (ignored if File object provided). + * @return self + * @throws InvalidArgumentException If the file is invalid. + */ + public function withFile($file, ?string $mimeType = null): self + { + $file = $file instanceof File ? $file : new File($file, $mimeType); + $this->parts[] = new MessagePart($file); + return $this; + } + + /** + * Adds a function call to the message. + * + * @since n.e.x.t + * + * @param FunctionCall $functionCall The function call to add. + * @return self + */ + public function withFunctionCall(FunctionCall $functionCall): self + { + $this->parts[] = new MessagePart($functionCall); + return $this; + } + + /** + * Adds a function response to the message. + * + * @since n.e.x.t + * + * @param FunctionResponse $functionResponse The function response to add. + * @return self + */ + public function withFunctionResponse(FunctionResponse $functionResponse): self + { + $this->parts[] = new MessagePart($functionResponse); + return $this; + } + + /** + * Adds multiple message parts to the message. + * + * @since n.e.x.t + * + * @param MessagePart ...$parts The message parts to add. + * @return self + */ + public function withMessageParts(MessagePart ...$parts): self + { + foreach ($parts as $part) { + $this->parts[] = $part; + } + + return $this; + } + + /** + * Validates that the message is ready to be built. + * + * @since n.e.x.t + * + * @return void + * @throws InvalidArgumentException If validation fails. + */ + private function validateForBuild(): void + { + if (empty($this->parts)) { + throw new InvalidArgumentException( + 'Cannot build an empty message. Add content using withText() or similar methods.' + ); + } + + if ($this->role === null) { + throw new InvalidArgumentException( + 'Cannot build a message with no role. Set a role using usingRole() or similar methods.' + ); + } + + // Validate parts are appropriate for the role + foreach ($this->parts as $part) { + if ($this->role->isUser() && $part->getType()->isFunctionCall()) { + throw new InvalidArgumentException( + 'User messages cannot contain function calls.' + ); + } + + if ($this->role->isModel() && $part->getType()->isFunctionResponse()) { + throw new InvalidArgumentException( + 'Model messages cannot contain function responses.' + ); + } + } + } + + /** + * Builds and returns the Message object. + * + * @since n.e.x.t + * + * @return Message The built message. + * @throws InvalidArgumentException If the message validation fails. + */ + public function get(): Message + { + $this->validateForBuild(); + + // At this point, we've validated that $this->role is not null + /** @var MessageRoleEnum $role */ + $role = $this->role; + + return new Message($role, $this->parts); + } +} From 0b50eefdb23abf98df748c472687853347a5f443 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Tue, 2 Sep 2025 09:47:34 -0600 Subject: [PATCH 02/11] chore: ignores IDE folders --- .gitignore | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitignore b/.gitignore index 82c305d0..32a2e42a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,13 @@ vendor/ *.DS_store .DS_store? +############ +## IDEs +############ + +.idea/ +.vscode/ + ############ ## AI Tools ############ From 181ae18d52650103e3eb950276bb9698ba0ecc93 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Tue, 2 Sep 2025 09:47:48 -0600 Subject: [PATCH 03/11] chore: updates architecture to reflect builder --- docs/ARCHITECTURE.md | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 03654156..da43b528 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -353,9 +353,7 @@ direction LR class MessageBuilder { +usingRole(MessageRole $role) self +withText(string $text) self - +withImageFile(File $file) self - +withAudioFile(File $file) self - +withVideoFile(File $file) self + +withFile(File $file, ?string $mimeType = null) self +withFunctionCall(FunctionCall $functionCall) self +withFunctionResponse(FunctionResponse $functionResponse) self +withMessageParts(...MessagePart $part) self @@ -514,14 +512,14 @@ direction LR } class MessageBuilder { - +usingRole(MessageRole $role) self + +usingRole(MessageRoleEnum $role) self + +usingUserRole() self + +usingModelRole() self +withText(string $text) self - +withImageFile(File $file) self - +withAudioFile(File $file) self - +withVideoFile(File $file) self + +withFile($file, ?string $mimeType) self +withFunctionCall(FunctionCall $functionCall) self +withFunctionResponse(FunctionResponse $functionResponse) self - +withMessageParts(...MessagePart $part) self + +withMessageParts(...MessagePart $parts) self +get() Message } } From c7495b381ae35d581cdd69acd024c2f98a689c3f Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Tue, 2 Sep 2025 09:48:05 -0600 Subject: [PATCH 04/11] test: adds MessageBuilder tests --- tests/unit/Builders/MessageBuilderTest.php | 403 +++++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 tests/unit/Builders/MessageBuilderTest.php diff --git a/tests/unit/Builders/MessageBuilderTest.php b/tests/unit/Builders/MessageBuilderTest.php new file mode 100644 index 00000000..2382445e --- /dev/null +++ b/tests/unit/Builders/MessageBuilderTest.php @@ -0,0 +1,403 @@ +usingUserRole()->get(); + + $this->assertInstanceOf(Message::class, $message); + $this->assertTrue($message->getRole()->isUser()); + + $parts = $message->getParts(); + $this->assertCount(1, $parts); + $this->assertTrue($parts[0]->getType()->isText()); + $this->assertEquals('Hello, AI!', $parts[0]->getText()); + } + + /** + * Tests that text can be added with the withText method. + * + * @return void + */ + public function testWithTextAddsTextPart(): void + { + $builder = new MessageBuilder(); + $message = $builder + ->withText('First text') + ->withText('Second text') + ->usingModelRole() + ->get(); + + $parts = $message->getParts(); + $this->assertCount(2, $parts); + $this->assertEquals('First text', $parts[0]->getText()); + $this->assertEquals('Second text', $parts[1]->getText()); + } + + /** + * Tests that empty text throws an exception. + * + * @return void + */ + public function testWithTextThrowsExceptionForEmptyText(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Text content cannot be empty.'); + + $builder = new MessageBuilder(); + $builder->withText(' '); + } + + /** + * Tests that a file can be added to the message. + * + * @return void + */ + public function testWithFileAddsFilePart(): void + { + $builder = new MessageBuilder(); + $message = $builder + ->withFile('data:image/png;base64,iVBORw0KGgo=', 'image/png') + ->usingUserRole() + ->get(); + + $parts = $message->getParts(); + $this->assertCount(1, $parts); + $this->assertTrue($parts[0]->getType()->isFile()); + + $file = $parts[0]->getFile(); + $this->assertInstanceOf(File::class, $file); + $this->assertEquals('image/png', $file->getMimeType()); + } + + /** + * Tests that a File object can be passed directly. + * + * @return void + */ + public function testWithFileAcceptsFileObject(): void + { + $file = new File('data:image/png;base64,iVBORw0KGgo=', 'image/png'); + + $builder = new MessageBuilder(); + $message = $builder + ->withFile($file) + ->usingUserRole() + ->get(); + + $parts = $message->getParts(); + $this->assertCount(1, $parts); + $this->assertTrue($parts[0]->getType()->isFile()); + $this->assertSame($file, $parts[0]->getFile()); + } + + /** + * Tests that function calls can be added to model messages. + * + * @return void + */ + public function testWithFunctionCallAddsToModelMessage(): void + { + $functionCall = new FunctionCall('call_id', 'test_function', ['arg' => 'value']); + + $builder = new MessageBuilder(); + $message = $builder + ->usingModelRole() + ->withFunctionCall($functionCall) + ->get(); + + $parts = $message->getParts(); + $this->assertCount(1, $parts); + $this->assertTrue($parts[0]->getType()->isFunctionCall()); + $this->assertSame($functionCall, $parts[0]->getFunctionCall()); + } + + /** + * Tests that function responses can be added to user messages. + * + * @return void + */ + public function testWithFunctionResponseAddsToUserMessage(): void + { + $functionResponse = new FunctionResponse('response_id', 'test_function', ['result' => 'success']); + + $builder = new MessageBuilder(); + $message = $builder + ->usingUserRole() + ->withFunctionResponse($functionResponse) + ->get(); + + $parts = $message->getParts(); + $this->assertCount(1, $parts); + $this->assertTrue($parts[0]->getType()->isFunctionResponse()); + $this->assertSame($functionResponse, $parts[0]->getFunctionResponse()); + } + + /** + * Tests that multiple message parts can be added at once. + * + * @return void + */ + public function testWithMessagePartsAddsMultipleParts(): void + { + $part1 = new MessagePart('Text 1'); + $part2 = new MessagePart('Text 2'); + $part3 = new MessagePart(new File('data:image/png;base64,test', 'image/png')); + + $builder = new MessageBuilder(); + $message = $builder + ->usingUserRole() + ->withMessageParts($part1, $part2, $part3) + ->get(); + + $parts = $message->getParts(); + $this->assertCount(3, $parts); + $this->assertSame($part1, $parts[0]); + $this->assertSame($part2, $parts[1]); + $this->assertSame($part3, $parts[2]); + } + + /** + * Tests that roles can be set using usingRole method. + * + * @return void + */ + public function testUsingRoleSetsRole(): void + { + $builder = new MessageBuilder('Test'); + + $userMessage = $builder->usingRole(MessageRoleEnum::user())->get(); + $this->assertTrue($userMessage->getRole()->isUser()); + + $builder = new MessageBuilder('Test'); + $modelMessage = $builder->usingRole(MessageRoleEnum::model())->get(); + $this->assertTrue($modelMessage->getRole()->isModel()); + } + + /** + * Tests that usingUserRole sets the role to user. + * + * @return void + */ + public function testUsingUserRoleSetsUserRole(): void + { + $builder = new MessageBuilder('Test'); + $message = $builder->usingUserRole()->get(); + + $this->assertTrue($message->getRole()->isUser()); + } + + /** + * Tests that usingModelRole sets the role to model. + * + * @return void + */ + public function testUsingModelRoleSetsModelRole(): void + { + $builder = new MessageBuilder('Test'); + $message = $builder->usingModelRole()->get(); + + $this->assertTrue($message->getRole()->isModel()); + } + + /** + * Tests that building without parts throws an exception. + * + * @return void + */ + public function testGetThrowsExceptionForEmptyParts(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot build an empty message. Add content using withText() or similar methods.'); + + $builder = new MessageBuilder(); + $builder->usingUserRole()->get(); + } + + /** + * Tests that building without a role throws an exception. + * + * @return void + */ + public function testGetThrowsExceptionForNoRole(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot build a message with no role. Set a role using usingRole() or similar methods.'); + + $builder = new MessageBuilder(); + $builder->withText('Test')->get(); + } + + /** + * Tests that function calls in user messages are rejected during validation. + * + * @return void + */ + public function testValidationRejectsFunctionCallsInUserMessages(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('User messages cannot contain function calls.'); + + $functionCall = new FunctionCall(null, 'test', []); + + $builder = new MessageBuilder(); + $builder + ->withFunctionCall($functionCall) + ->usingUserRole() + ->get(); + } + + /** + * Tests that function responses in model messages are rejected during validation. + * + * @return void + */ + public function testValidationRejectsFunctionResponsesInModelMessages(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Model messages cannot contain function responses.'); + + $functionResponse = new FunctionResponse('id', 'test', []); + + $builder = new MessageBuilder(); + $builder + ->withFunctionResponse($functionResponse) + ->usingModelRole() + ->get(); + } + + /** + * Tests that role can be set after adding parts. + * + * @return void + */ + public function testRoleCanBeSetAfterAddingParts(): void + { + $builder = new MessageBuilder(); + $message = $builder + ->withText('Hello') + ->withText('World') + ->usingUserRole() + ->get(); + + $this->assertTrue($message->getRole()->isUser()); + $this->assertCount(2, $message->getParts()); + } + + /** + * Tests that the builder is fluent. + * + * @return void + */ + public function testBuilderIsFluent(): void + { + $builder = new MessageBuilder(); + + $result1 = $builder->withText('Test'); + $this->assertSame($builder, $result1); + + $result2 = $builder->usingUserRole(); + $this->assertSame($builder, $result2); + + $result3 = $builder->withFile('data:text/plain;base64,test', 'text/plain'); + $this->assertSame($builder, $result3); + } + + /** + * Tests that mixed content types can be added to a message. + * + * @return void + */ + public function testMixedContentMessage(): void + { + $file = new File('data:image/png;base64,test', 'image/png'); + + $builder = new MessageBuilder(); + $message = $builder + ->withText('Analyze this image:') + ->withFile($file) + ->withText('What do you see?') + ->usingUserRole() + ->get(); + + $parts = $message->getParts(); + $this->assertCount(3, $parts); + $this->assertTrue($parts[0]->getType()->isText()); + $this->assertTrue($parts[1]->getType()->isFile()); + $this->assertTrue($parts[2]->getType()->isText()); + } + + /** + * Tests constructor with initial text and role. + * + * @return void + */ + public function testConstructorWithTextAndRole(): void + { + $builder = new MessageBuilder('Initial text', MessageRoleEnum::model()); + $message = $builder->get(); + + $this->assertTrue($message->getRole()->isModel()); + $parts = $message->getParts(); + $this->assertCount(1, $parts); + $this->assertEquals('Initial text', $parts[0]->getText()); + } + + /** + * Tests that validation allows valid combinations. + * + * @return void + */ + public function testValidationAllowsValidCombinations(): void + { + // User message with function response - should work + $functionResponse = new FunctionResponse('resp_id', 'test', ['result' => 'ok']); + $builder1 = new MessageBuilder(); + $message1 = $builder1 + ->usingUserRole() + ->withText('Here is the result:') + ->withFunctionResponse($functionResponse) + ->get(); + + $this->assertTrue($message1->getRole()->isUser()); + $this->assertCount(2, $message1->getParts()); + + // Model message with function call - should work + $functionCall = new FunctionCall(null, 'test', ['param' => 'value']); + $builder2 = new MessageBuilder(); + $message2 = $builder2 + ->usingModelRole() + ->withText('I will call a function:') + ->withFunctionCall($functionCall) + ->get(); + + $this->assertTrue($message2->getRole()->isModel()); + $this->assertCount(2, $message2->getParts()); + } +} \ No newline at end of file From f50e7262c1d8674968c52d74c1fe20d7ed072b59 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Tue, 2 Sep 2025 09:55:26 -0600 Subject: [PATCH 05/11] chore: adds missing methods to architecture --- docs/ARCHITECTURE.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index da43b528..f2bd344f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -351,12 +351,14 @@ direction LR } class MessageBuilder { - +usingRole(MessageRole $role) self + +usingRole(MessageRoleEnum $role) self + +usingUserRole() self + +usingModelRole() self +withText(string $text) self - +withFile(File $file, ?string $mimeType = null) self + +withFile($file, ?string $mimeType) self +withFunctionCall(FunctionCall $functionCall) self +withFunctionResponse(FunctionResponse $functionResponse) self - +withMessageParts(...MessagePart $part) self + +withMessageParts(...MessagePart $parts) self +get() Message } } From 288741352acead6206476ce5c5cb2f9e4dbc5b17 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Tue, 2 Sep 2025 09:55:36 -0600 Subject: [PATCH 06/11] test: resolving linting issues --- tests/unit/Builders/MessageBuilderTest.php | 46 ++++++++++++---------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/tests/unit/Builders/MessageBuilderTest.php b/tests/unit/Builders/MessageBuilderTest.php index 2382445e..b6c9a2ba 100644 --- a/tests/unit/Builders/MessageBuilderTest.php +++ b/tests/unit/Builders/MessageBuilderTest.php @@ -33,7 +33,7 @@ public function testBuildsSimpleTextMessage(): void $this->assertInstanceOf(Message::class, $message); $this->assertTrue($message->getRole()->isUser()); - + $parts = $message->getParts(); $this->assertCount(1, $parts); $this->assertTrue($parts[0]->getType()->isText()); @@ -90,7 +90,7 @@ public function testWithFileAddsFilePart(): void $parts = $message->getParts(); $this->assertCount(1, $parts); $this->assertTrue($parts[0]->getType()->isFile()); - + $file = $parts[0]->getFile(); $this->assertInstanceOf(File::class, $file); $this->assertEquals('image/png', $file->getMimeType()); @@ -104,7 +104,7 @@ public function testWithFileAddsFilePart(): void public function testWithFileAcceptsFileObject(): void { $file = new File('data:image/png;base64,iVBORw0KGgo=', 'image/png'); - + $builder = new MessageBuilder(); $message = $builder ->withFile($file) @@ -125,7 +125,7 @@ public function testWithFileAcceptsFileObject(): void public function testWithFunctionCallAddsToModelMessage(): void { $functionCall = new FunctionCall('call_id', 'test_function', ['arg' => 'value']); - + $builder = new MessageBuilder(); $message = $builder ->usingModelRole() @@ -146,7 +146,7 @@ public function testWithFunctionCallAddsToModelMessage(): void public function testWithFunctionResponseAddsToUserMessage(): void { $functionResponse = new FunctionResponse('response_id', 'test_function', ['result' => 'success']); - + $builder = new MessageBuilder(); $message = $builder ->usingUserRole() @@ -169,7 +169,7 @@ public function testWithMessagePartsAddsMultipleParts(): void $part1 = new MessagePart('Text 1'); $part2 = new MessagePart('Text 2'); $part3 = new MessagePart(new File('data:image/png;base64,test', 'image/png')); - + $builder = new MessageBuilder(); $message = $builder ->usingUserRole() @@ -191,10 +191,10 @@ public function testWithMessagePartsAddsMultipleParts(): void public function testUsingRoleSetsRole(): void { $builder = new MessageBuilder('Test'); - + $userMessage = $builder->usingRole(MessageRoleEnum::user())->get(); $this->assertTrue($userMessage->getRole()->isUser()); - + $builder = new MessageBuilder('Test'); $modelMessage = $builder->usingRole(MessageRoleEnum::model())->get(); $this->assertTrue($modelMessage->getRole()->isModel()); @@ -209,7 +209,7 @@ public function testUsingUserRoleSetsUserRole(): void { $builder = new MessageBuilder('Test'); $message = $builder->usingUserRole()->get(); - + $this->assertTrue($message->getRole()->isUser()); } @@ -222,7 +222,7 @@ public function testUsingModelRoleSetsModelRole(): void { $builder = new MessageBuilder('Test'); $message = $builder->usingModelRole()->get(); - + $this->assertTrue($message->getRole()->isModel()); } @@ -234,7 +234,9 @@ public function testUsingModelRoleSetsModelRole(): void public function testGetThrowsExceptionForEmptyParts(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Cannot build an empty message. Add content using withText() or similar methods.'); + $this->expectExceptionMessage( + 'Cannot build an empty message. Add content using withText() or similar methods.' + ); $builder = new MessageBuilder(); $builder->usingUserRole()->get(); @@ -248,7 +250,9 @@ public function testGetThrowsExceptionForEmptyParts(): void public function testGetThrowsExceptionForNoRole(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Cannot build a message with no role. Set a role using usingRole() or similar methods.'); + $this->expectExceptionMessage( + 'Cannot build a message with no role. Set a role using usingRole() or similar methods.' + ); $builder = new MessageBuilder(); $builder->withText('Test')->get(); @@ -265,7 +269,7 @@ public function testValidationRejectsFunctionCallsInUserMessages(): void $this->expectExceptionMessage('User messages cannot contain function calls.'); $functionCall = new FunctionCall(null, 'test', []); - + $builder = new MessageBuilder(); $builder ->withFunctionCall($functionCall) @@ -284,7 +288,7 @@ public function testValidationRejectsFunctionResponsesInModelMessages(): void $this->expectExceptionMessage('Model messages cannot contain function responses.'); $functionResponse = new FunctionResponse('id', 'test', []); - + $builder = new MessageBuilder(); $builder ->withFunctionResponse($functionResponse) @@ -318,13 +322,13 @@ public function testRoleCanBeSetAfterAddingParts(): void public function testBuilderIsFluent(): void { $builder = new MessageBuilder(); - + $result1 = $builder->withText('Test'); $this->assertSame($builder, $result1); - + $result2 = $builder->usingUserRole(); $this->assertSame($builder, $result2); - + $result3 = $builder->withFile('data:text/plain;base64,test', 'text/plain'); $this->assertSame($builder, $result3); } @@ -337,7 +341,7 @@ public function testBuilderIsFluent(): void public function testMixedContentMessage(): void { $file = new File('data:image/png;base64,test', 'image/png'); - + $builder = new MessageBuilder(); $message = $builder ->withText('Analyze this image:') @@ -384,7 +388,7 @@ public function testValidationAllowsValidCombinations(): void ->withText('Here is the result:') ->withFunctionResponse($functionResponse) ->get(); - + $this->assertTrue($message1->getRole()->isUser()); $this->assertCount(2, $message1->getParts()); @@ -396,8 +400,8 @@ public function testValidationAllowsValidCombinations(): void ->withText('I will call a function:') ->withFunctionCall($functionCall) ->get(); - + $this->assertTrue($message2->getRole()->isModel()); $this->assertCount(2, $message2->getParts()); } -} \ No newline at end of file +} From 739345e3f337fec5d2eb9184156881a07b391ce7 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Tue, 2 Sep 2025 14:51:04 -0600 Subject: [PATCH 07/11] refactor: removes validation already handled by Message --- src/Builders/MessageBuilder.php | 36 ++++----------------------------- 1 file changed, 4 insertions(+), 32 deletions(-) diff --git a/src/Builders/MessageBuilder.php b/src/Builders/MessageBuilder.php index 5ca04580..bd9453db 100644 --- a/src/Builders/MessageBuilder.php +++ b/src/Builders/MessageBuilder.php @@ -178,14 +178,14 @@ public function withMessageParts(MessagePart ...$parts): self } /** - * Validates that the message is ready to be built. + * Builds and returns the Message object. * * @since n.e.x.t * - * @return void - * @throws InvalidArgumentException If validation fails. + * @return Message The built message. + * @throws InvalidArgumentException If the message validation fails. */ - private function validateForBuild(): void + public function get(): Message { if (empty($this->parts)) { throw new InvalidArgumentException( @@ -199,34 +199,6 @@ private function validateForBuild(): void ); } - // Validate parts are appropriate for the role - foreach ($this->parts as $part) { - if ($this->role->isUser() && $part->getType()->isFunctionCall()) { - throw new InvalidArgumentException( - 'User messages cannot contain function calls.' - ); - } - - if ($this->role->isModel() && $part->getType()->isFunctionResponse()) { - throw new InvalidArgumentException( - 'Model messages cannot contain function responses.' - ); - } - } - } - - /** - * Builds and returns the Message object. - * - * @since n.e.x.t - * - * @return Message The built message. - * @throws InvalidArgumentException If the message validation fails. - */ - public function get(): Message - { - $this->validateForBuild(); - // At this point, we've validated that $this->role is not null /** @var MessageRoleEnum $role */ $role = $this->role; From 5ea1fefba167bee2bd68f62020a3c555a97f8fe2 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Tue, 2 Sep 2025 14:51:43 -0600 Subject: [PATCH 08/11] chore: corrects class description due to last commit --- src/Builders/MessageBuilder.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Builders/MessageBuilder.php b/src/Builders/MessageBuilder.php index bd9453db..b9b21f85 100644 --- a/src/Builders/MessageBuilder.php +++ b/src/Builders/MessageBuilder.php @@ -17,8 +17,6 @@ * * This class provides a fluent interface for building messages with various * content types including text, files, function calls, and function responses. - * It automatically handles role-specific validation and creates the appropriate - * message subclass based on the configured role. * * @since n.e.x.t */ From b51ca5763867ac0285989db643308ba8a6bd0a0a Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Fri, 5 Sep 2025 08:52:27 -0600 Subject: [PATCH 09/11] chore: updates PromptBuilder architecture methods --- docs/ARCHITECTURE.md | 90 ++++++++++++++++++++++---------------------- 1 file changed, 44 insertions(+), 46 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f2bd344f..dc32ecde 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -303,15 +303,13 @@ direction LR namespace AiClientNamespace.Builders { class PromptBuilder { +withText(string $text) self - +withInlineImage(string $base64Blob, string $mimeType) - +withRemoteImage(string $uri, string $mimeType) - +withImageFile(File $file) self - +withAudioFile(File $file) self - +withVideoFile(File $file) self + +withFile($file, ?string $mimeType) self +withFunctionResponse(FunctionResponse $functionResponse) self - +withMessageParts(...MessagePart $part) self + +withMessageParts(...MessagePart $parts) self +withHistory(...Message $messages) self +usingModel(ModelInterface $model) self + +usingModelConfig(ModelConfig $config) self + +usingProvider(string $providerIdOrClassName) self +usingSystemInstruction(string $systemInstruction) self +usingMaxTokens(int $maxTokens) self +usingTemperature(float $temperature) self @@ -319,35 +317,36 @@ direction LR +usingTopK(int $topK) self +usingStopSequences(...string $stopSequences) self +usingCandidateCount(int $candidateCount) self - +usingOutputMime(string $mimeType) self - +usingOutputSchema(array< string, mixed > $schema) self - +usingOutputModalities(...ModalityEnum $modalities) self + +usingFunctionDeclarations(...FunctionDeclaration $functionDeclarations) self + +usingPresencePenalty(float $presencePenalty) self + +usingFrequencyPenalty(float $frequencyPenalty) self + +usingWebSearch(WebSearch $webSearch) self + +usingTopLogprobs(?int $topLogprobs) self + +asOutputMimeType(string $mimeType) self + +asOutputSchema(array< string, mixed > $schema) self + +asOutputModalities(...ModalityEnum $modalities) self + +asOutputFileType(FileTypeEnum $fileType) self +asJsonResponse(?array< string, mixed > $schema) self - +generateResult() GenerativeAiResult - +generateOperation() GenerativeAiOperation + +generateResult(?CapabilityEnum $capability) GenerativeAiResult +generateTextResult() GenerativeAiResult - +streamGenerateTextResult() Generator< GenerativeAiResult > +generateImageResult() GenerativeAiResult - +convertTextToSpeechResult() GenerativeAiResult +generateSpeechResult() GenerativeAiResult - +generateEmbeddingsResult() EmbeddingResult - +generateTextOperation() GenerativeAiOperation - +generateImageOperation() GenerativeAiOperation - +convertTextToSpeechOperation() GenerativeAiOperation - +generateSpeechOperation() GenerativeAiOperation - +generateEmbeddingsOperation() EmbeddingOperation + +convertTextToSpeechResult() GenerativeAiResult +generateText() string +generateTexts(?int $candidateCount) string[] - +streamGenerateText() Generator< string > +generateImage() File +generateImages(?int $candidateCount) File[] +convertTextToSpeech() File +convertTextToSpeeches(?int $candidateCount) File[] +generateSpeech() File +generateSpeeches(?int $candidateCount) File[] - +generateEmbeddings() Embedding[] - +getModelRequirements() ModelRequirements - +isSupported() bool + +isSupportedForTextGeneration() bool + +isSupportedForImageGeneration() bool + +isSupportedForTextToSpeechConversion() bool + +isSupportedForVideoGeneration() bool + +isSupportedForSpeechGeneration() bool + +isSupportedForMusicGeneration() bool + +isSupportedForEmbeddingGeneration() bool } class MessageBuilder { @@ -466,15 +465,13 @@ direction LR namespace AiClientNamespace.Builders { class PromptBuilder { +withText(string $text) self - +withInlineImage(string $base64Blob, string $mimeType) - +withRemoteImage(string $uri, string $mimeType) - +withImageFile(File $file) self - +withAudioFile(File $file) self - +withVideoFile(File $file) self + +withFile($file, ?string $mimeType) self +withFunctionResponse(FunctionResponse $functionResponse) self - +withMessageParts(...MessagePart $part) self + +withMessageParts(...MessagePart $parts) self +withHistory(...Message $messages) self +usingModel(ModelInterface $model) self + +usingModelConfig(ModelConfig $config) self + +usingProvider(string $providerIdOrClassName) self +usingSystemInstruction(string $systemInstruction) self +usingMaxTokens(int $maxTokens) self +usingTemperature(float $temperature) self @@ -482,35 +479,36 @@ direction LR +usingTopK(int $topK) self +usingStopSequences(...string $stopSequences) self +usingCandidateCount(int $candidateCount) self - +usingOutputMime(string $mimeType) self - +usingOutputSchema(array< string, mixed > $schema) self - +usingOutputModalities(...ModalityEnum $modalities) self + +usingFunctionDeclarations(...FunctionDeclaration $functionDeclarations) self + +usingPresencePenalty(float $presencePenalty) self + +usingFrequencyPenalty(float $frequencyPenalty) self + +usingWebSearch(WebSearch $webSearch) self + +usingTopLogprobs(?int $topLogprobs) self + +asOutputMimeType(string $mimeType) self + +asOutputSchema(array< string, mixed > $schema) self + +asOutputModalities(...ModalityEnum $modalities) self + +asOutputFileType(FileTypeEnum $fileType) self +asJsonResponse(?array< string, mixed > $schema) self - +generateResult() GenerativeAiResult - +generateOperation() GenerativeAiOperation + +generateResult(?CapabilityEnum $capability) GenerativeAiResult +generateTextResult() GenerativeAiResult - +streamGenerateTextResult() Generator< GenerativeAiResult > +generateImageResult() GenerativeAiResult - +convertTextToSpeechResult() GenerativeAiResult +generateSpeechResult() GenerativeAiResult - +generateEmbeddingsResult() EmbeddingResult - +generateTextOperation() GenerativeAiOperation - +generateImageOperation() GenerativeAiOperation - +convertTextToSpeechOperation() GenerativeAiOperation - +generateSpeechOperation() GenerativeAiOperation - +generateEmbeddingsOperation() EmbeddingOperation + +convertTextToSpeechResult() GenerativeAiResult +generateText() string +generateTexts(?int $candidateCount) string[] - +streamGenerateText() Generator< string > +generateImage() File +generateImages(?int $candidateCount) File[] +convertTextToSpeech() File +convertTextToSpeeches(?int $candidateCount) File[] +generateSpeech() File +generateSpeeches(?int $candidateCount) File[] - +generateEmbeddings() Embedding[] - +getModelRequirements() ModelRequirements - +isSupported() bool + +isSupportedForTextGeneration() bool + +isSupportedForImageGeneration() bool + +isSupportedForTextToSpeechConversion() bool + +isSupportedForVideoGeneration() bool + +isSupportedForSpeechGeneration() bool + +isSupportedForMusicGeneration() bool + +isSupportedForEmbeddingGeneration() bool } class MessageBuilder { From 13a35292371cdd6c0145b6e27edf2b5da2242d05 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Fri, 5 Sep 2025 08:59:02 -0600 Subject: [PATCH 10/11] feat: adds support for additional input types --- docs/ARCHITECTURE.md | 4 +- src/Builders/MessageBuilder.php | 31 +++++- tests/unit/Builders/MessageBuilderTest.php | 122 +++++++++++++++++++++ 3 files changed, 151 insertions(+), 6 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index dc32ecde..66e810d4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -296,7 +296,7 @@ direction LR namespace AiClientNamespace { class AiClient { +prompt(string|Message|null $text = null) PromptBuilder$ - +message(?string $text) MessageBuilder$ + +message($input = null) MessageBuilder$ } } @@ -443,7 +443,7 @@ direction LR namespace AiClientNamespace { class AiClient { +prompt(string|Message|null $text = null) PromptBuilder$ - +message(?string $text) MessageBuilder$ + +message($input = null) MessageBuilder$ +defaultRegistry() ProviderRegistry$ +isConfigured(ProviderAvailabilityInterface $availability) bool$ +generateResult(string|MessagePart|MessagePart[]|Message|Message[] $prompt, ModelInterface $model) GenerativeAiResult$ diff --git a/src/Builders/MessageBuilder.php b/src/Builders/MessageBuilder.php index b9b21f85..66dd1236 100644 --- a/src/Builders/MessageBuilder.php +++ b/src/Builders/MessageBuilder.php @@ -19,6 +19,10 @@ * content types including text, files, function calls, and function responses. * * @since n.e.x.t + * + * @phpstan-import-type MessagePartArrayShape from MessagePart + * + * @phpstan-type Input string|MessagePart|MessagePartArrayShape|File|FunctionCall|FunctionResponse|null */ class MessageBuilder { @@ -37,15 +41,34 @@ class MessageBuilder * * @since n.e.x.t * - * @param string|null $text Optional initial text content. + * @param Input $input Optional initial content. * @param MessageRoleEnum|null $role Optional role. */ - public function __construct(?string $text = null, ?MessageRoleEnum $role = null) + public function __construct($input = null, ?MessageRoleEnum $role = null) { $this->role = $role; - if ($text !== null) { - $this->withText($text); + if ($input === null) { + return; + } + + // Handle different input types + if ($input instanceof MessagePart) { + $this->parts[] = $input; + } elseif (is_string($input)) { + $this->withText($input); + } elseif ($input instanceof File) { + $this->withFile($input); + } elseif ($input instanceof FunctionCall) { + $this->withFunctionCall($input); + } elseif ($input instanceof FunctionResponse) { + $this->withFunctionResponse($input); + } elseif (is_array($input) && MessagePart::isArrayShape($input)) { + $this->parts[] = MessagePart::fromArray($input); + } else { + throw new InvalidArgumentException( + 'Input must be a string, MessagePart, MessagePartArrayShape, File, FunctionCall, or FunctionResponse.' + ); } } diff --git a/tests/unit/Builders/MessageBuilderTest.php b/tests/unit/Builders/MessageBuilderTest.php index b6c9a2ba..6f46a706 100644 --- a/tests/unit/Builders/MessageBuilderTest.php +++ b/tests/unit/Builders/MessageBuilderTest.php @@ -404,4 +404,126 @@ public function testValidationAllowsValidCombinations(): void $this->assertTrue($message2->getRole()->isModel()); $this->assertCount(2, $message2->getParts()); } + + /** + * Tests constructor with MessagePart input. + * + * @return void + */ + public function testConstructorWithMessagePartInput(): void + { + $messagePart = new MessagePart('Test text'); + $builder = new MessageBuilder($messagePart, MessageRoleEnum::user()); + $message = $builder->get(); + + $this->assertTrue($message->getRole()->isUser()); + $parts = $message->getParts(); + $this->assertCount(1, $parts); + $this->assertSame($messagePart, $parts[0]); + } + + /** + * Tests constructor with File input. + * + * @return void + */ + public function testConstructorWithFileInput(): void + { + $file = new File('data:image/png;base64,test', 'image/png'); + $builder = new MessageBuilder($file, MessageRoleEnum::user()); + $message = $builder->get(); + + $this->assertTrue($message->getRole()->isUser()); + $parts = $message->getParts(); + $this->assertCount(1, $parts); + $this->assertTrue($parts[0]->getType()->isFile()); + $this->assertSame($file, $parts[0]->getFile()); + } + + /** + * Tests constructor with FunctionCall input. + * + * @return void + */ + public function testConstructorWithFunctionCallInput(): void + { + $functionCall = new FunctionCall('id', 'test_func', ['arg' => 'val']); + $builder = new MessageBuilder($functionCall, MessageRoleEnum::model()); + $message = $builder->get(); + + $this->assertTrue($message->getRole()->isModel()); + $parts = $message->getParts(); + $this->assertCount(1, $parts); + $this->assertTrue($parts[0]->getType()->isFunctionCall()); + $this->assertSame($functionCall, $parts[0]->getFunctionCall()); + } + + /** + * Tests constructor with FunctionResponse input. + * + * @return void + */ + public function testConstructorWithFunctionResponseInput(): void + { + $functionResponse = new FunctionResponse('id', 'test_func', ['result' => 'success']); + $builder = new MessageBuilder($functionResponse, MessageRoleEnum::user()); + $message = $builder->get(); + + $this->assertTrue($message->getRole()->isUser()); + $parts = $message->getParts(); + $this->assertCount(1, $parts); + $this->assertTrue($parts[0]->getType()->isFunctionResponse()); + $this->assertSame($functionResponse, $parts[0]->getFunctionResponse()); + } + + /** + * Tests constructor with MessagePartArrayShape input. + * + * @return void + */ + public function testConstructorWithMessagePartArrayShapeInput(): void + { + $partArray = ['text' => 'Hello from array']; + $builder = new MessageBuilder($partArray, MessageRoleEnum::user()); + $message = $builder->get(); + + $this->assertTrue($message->getRole()->isUser()); + $parts = $message->getParts(); + $this->assertCount(1, $parts); + $this->assertTrue($parts[0]->getType()->isText()); + $this->assertEquals('Hello from array', $parts[0]->getText()); + } + + /** + * Tests constructor with invalid input throws exception. + * + * @return void + */ + public function testConstructorWithInvalidInputThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Input must be a string, MessagePart, MessagePartArrayShape, File, FunctionCall, or FunctionResponse.' + ); + + new MessageBuilder(['invalid' => 'array']); + } + + /** + * Tests constructor with null input creates empty builder. + * + * @return void + */ + public function testConstructorWithNullInputCreatesEmptyBuilder(): void + { + $builder = new MessageBuilder(null, MessageRoleEnum::user()); + + // Should be able to add content and build + $message = $builder->withText('Added later')->get(); + + $this->assertTrue($message->getRole()->isUser()); + $parts = $message->getParts(); + $this->assertCount(1, $parts); + $this->assertEquals('Added later', $parts[0]->getText()); + } } From 6244bf2924c68fb2ca0a66ab3dd297ff40027554 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Fri, 5 Sep 2025 09:01:12 -0600 Subject: [PATCH 11/11] chore: fixes linting error --- tests/unit/Builders/MessageBuilderTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/Builders/MessageBuilderTest.php b/tests/unit/Builders/MessageBuilderTest.php index 6f46a706..7ec01e54 100644 --- a/tests/unit/Builders/MessageBuilderTest.php +++ b/tests/unit/Builders/MessageBuilderTest.php @@ -517,10 +517,10 @@ public function testConstructorWithInvalidInputThrowsException(): void public function testConstructorWithNullInputCreatesEmptyBuilder(): void { $builder = new MessageBuilder(null, MessageRoleEnum::user()); - + // Should be able to add content and build $message = $builder->withText('Added later')->get(); - + $this->assertTrue($message->getRole()->isUser()); $parts = $message->getParts(); $this->assertCount(1, $parts);