From 9d1c48cf9ecfe28e7a0cc1ab34de73f4bf3b95d2 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Tue, 5 Aug 2025 14:42:12 -0500 Subject: [PATCH 01/77] Remove tokenCount from Candidate DTO. --- src/Results/DTO/Candidate.php | 34 +--- .../DTO/GenerativeAiOperationTest.php | 56 +++---- tests/unit/Results/DTO/CandidateTest.php | 152 ++++++------------ .../Results/DTO/GenerativeAiResultTest.php | 141 ++++++++-------- 4 files changed, 148 insertions(+), 235 deletions(-) diff --git a/src/Results/DTO/Candidate.php b/src/Results/DTO/Candidate.php index 9c1e783a..ea6a623d 100644 --- a/src/Results/DTO/Candidate.php +++ b/src/Results/DTO/Candidate.php @@ -19,7 +19,7 @@ * * @phpstan-import-type MessageArrayShape from Message * - * @phpstan-type CandidateArrayShape array{message: MessageArrayShape, finishReason: string, tokenCount: int} + * @phpstan-type CandidateArrayShape array{message: MessageArrayShape, finishReason: string} * * @extends AbstractDataValueObject */ @@ -27,7 +27,6 @@ class Candidate extends AbstractDataValueObject { public const KEY_MESSAGE = 'message'; public const KEY_FINISH_REASON = 'finishReason'; - public const KEY_TOKEN_COUNT = 'tokenCount'; /** * @var Message The generated message. */ @@ -38,11 +37,6 @@ class Candidate extends AbstractDataValueObject */ private FinishReasonEnum $finishReason; - /** - * @var int The number of tokens in this candidate. - */ - private int $tokenCount; - /** * Constructor. * @@ -50,9 +44,8 @@ class Candidate extends AbstractDataValueObject * * @param Message $message The generated message. * @param FinishReasonEnum $finishReason The reason generation stopped. - * @param int $tokenCount The number of tokens in this candidate. */ - public function __construct(Message $message, FinishReasonEnum $finishReason, int $tokenCount) + public function __construct(Message $message, FinishReasonEnum $finishReason) { if (!$message->getRole()->isModel()) { throw new InvalidArgumentException( @@ -62,7 +55,6 @@ public function __construct(Message $message, FinishReasonEnum $finishReason, in $this->message = $message; $this->finishReason = $finishReason; - $this->tokenCount = $tokenCount; } /** @@ -89,18 +81,6 @@ public function getFinishReason(): FinishReasonEnum return $this->finishReason; } - /** - * Gets the token count. - * - * @since n.e.x.t - * - * @return int The token count. - */ - public function getTokenCount(): int - { - return $this->tokenCount; - } - /** * {@inheritDoc} * @@ -117,12 +97,8 @@ public static function getJsonSchema(): array 'enum' => FinishReasonEnum::getValues(), 'description' => 'The reason generation stopped.', ], - self::KEY_TOKEN_COUNT => [ - 'type' => 'integer', - 'description' => 'The number of tokens in this candidate.', - ], ], - 'required' => [self::KEY_MESSAGE, self::KEY_FINISH_REASON, self::KEY_TOKEN_COUNT], + 'required' => [self::KEY_MESSAGE, self::KEY_FINISH_REASON], ]; } @@ -138,7 +114,6 @@ public function toArray(): array return [ self::KEY_MESSAGE => $this->message->toArray(), self::KEY_FINISH_REASON => $this->finishReason->value, - self::KEY_TOKEN_COUNT => $this->tokenCount, ]; } @@ -149,14 +124,13 @@ public function toArray(): array */ public static function fromArray(array $array): self { - static::validateFromArrayData($array, [self::KEY_MESSAGE, self::KEY_FINISH_REASON, self::KEY_TOKEN_COUNT]); + static::validateFromArrayData($array, [self::KEY_MESSAGE, self::KEY_FINISH_REASON]); $messageData = $array[self::KEY_MESSAGE]; return new self( Message::fromArray($messageData), FinishReasonEnum::from($array[self::KEY_FINISH_REASON]), - $array[self::KEY_TOKEN_COUNT] ); } } diff --git a/tests/unit/Operations/DTO/GenerativeAiOperationTest.php b/tests/unit/Operations/DTO/GenerativeAiOperationTest.php index 5d929202..d5aa4cfa 100644 --- a/tests/unit/Operations/DTO/GenerativeAiOperationTest.php +++ b/tests/unit/Operations/DTO/GenerativeAiOperationTest.php @@ -24,6 +24,7 @@ class GenerativeAiOperationTest extends TestCase { use ArrayTransformationTestTrait; + /** * Tests creating operation in starting state. * @@ -35,7 +36,7 @@ public function testCreateInStartingState(): void 'op_123', OperationStateEnum::starting() ); - + $this->assertEquals('op_123', $operation->getId()); $this->assertEquals(OperationStateEnum::starting(), $operation->getState()); $this->assertNull($operation->getResult()); @@ -52,7 +53,7 @@ public function testCreateInProcessingState(): void 'op_456', OperationStateEnum::processing() ); - + $this->assertEquals('op_456', $operation->getId()); $this->assertTrue($operation->getState()->isProcessing()); $this->assertNull($operation->getResult()); @@ -80,13 +81,13 @@ public function testCreateInSucceededStateWithResult(): void $tokenUsage, ['provider' => 'test'] ); - + $operation = new GenerativeAiOperation( 'op_789', OperationStateEnum::succeeded(), $result ); - + $this->assertEquals('op_789', $operation->getId()); $this->assertTrue($operation->getState()->isSucceeded()); $this->assertSame($result, $operation->getResult()); @@ -103,7 +104,7 @@ public function testCreateInFailedState(): void 'op_failed', OperationStateEnum::failed() ); - + $this->assertEquals('op_failed', $operation->getId()); $this->assertTrue($operation->getState()->isFailed()); $this->assertNull($operation->getResult()); @@ -120,7 +121,7 @@ public function testCreateInCanceledState(): void 'op_canceled', OperationStateEnum::canceled() ); - + $this->assertEquals('op_canceled', $operation->getId()); $this->assertTrue($operation->getState()->isCanceled()); $this->assertNull($operation->getResult()); @@ -137,7 +138,7 @@ public function testImplementsOperationInterface(): void 'op_test', OperationStateEnum::starting() ); - + $this->assertInstanceOf( \WordPress\AiClient\Operations\Contracts\OperationInterface::class, $operation @@ -157,7 +158,7 @@ public function testWithDifferentIdFormats(string $id): void $id, OperationStateEnum::processing() ); - + $this->assertEquals($id, $operation->getId()); } @@ -227,10 +228,10 @@ public function testStateTransitions(): void public function testJsonSchemaForSucceededState(): void { $schema = GenerativeAiOperation::getJsonSchema(); - + $this->assertArrayHasKey('oneOf', $schema); $this->assertCount(2, $schema['oneOf']); - + // First schema is for succeeded state with result $succeededSchema = $schema['oneOf'][0]; $this->assertEquals('object', $succeededSchema['type']); @@ -238,13 +239,13 @@ public function testJsonSchemaForSucceededState(): void $this->assertArrayHasKey(GenerativeAiOperation::KEY_ID, $succeededSchema['properties']); $this->assertArrayHasKey(GenerativeAiOperation::KEY_STATE, $succeededSchema['properties']); $this->assertArrayHasKey(GenerativeAiOperation::KEY_RESULT, $succeededSchema['properties']); - + // State should be const for succeeded $this->assertEquals( OperationStateEnum::succeeded()->value, $succeededSchema['properties'][GenerativeAiOperation::KEY_STATE]['const'] ); - + // Required fields $this->assertEquals([GenerativeAiOperation::KEY_ID, GenerativeAiOperation::KEY_STATE, GenerativeAiOperation::KEY_RESULT], $succeededSchema['required']); } @@ -257,7 +258,7 @@ public function testJsonSchemaForSucceededState(): void public function testJsonSchemaForNonSucceededStates(): void { $schema = GenerativeAiOperation::getJsonSchema(); - + // Second schema is for all other states without result $otherStatesSchema = $schema['oneOf'][1]; $this->assertEquals('object', $otherStatesSchema['type']); @@ -265,14 +266,14 @@ public function testJsonSchemaForNonSucceededStates(): void $this->assertArrayHasKey(GenerativeAiOperation::KEY_ID, $otherStatesSchema['properties']); $this->assertArrayHasKey(GenerativeAiOperation::KEY_STATE, $otherStatesSchema['properties']); $this->assertArrayNotHasKey(GenerativeAiOperation::KEY_RESULT, $otherStatesSchema['properties']); - + // State should be enum for other states $stateEnum = $otherStatesSchema['properties'][GenerativeAiOperation::KEY_STATE]['enum']; $this->assertContains(OperationStateEnum::starting()->value, $stateEnum); $this->assertContains(OperationStateEnum::processing()->value, $stateEnum); $this->assertContains(OperationStateEnum::failed()->value, $stateEnum); $this->assertContains(OperationStateEnum::canceled()->value, $stateEnum); - + // Required fields $this->assertEquals([GenerativeAiOperation::KEY_ID, GenerativeAiOperation::KEY_STATE], $otherStatesSchema['required']); } @@ -288,7 +289,7 @@ public function testWithEmptyStringId(): void '', OperationStateEnum::starting() ); - + $this->assertEquals('', $operation->getId()); } @@ -303,9 +304,9 @@ public function testToArrayStartingState(): void 'op_start_123', OperationStateEnum::starting() ); - + $json = $this->assertToArrayReturnsArray($operation); - + $this->assertArrayHasKeys($json, [GenerativeAiOperation::KEY_ID, GenerativeAiOperation::KEY_STATE]); $this->assertArrayNotHasKeys($json, [GenerativeAiOperation::KEY_RESULT]); $this->assertEquals('op_start_123', $json[GenerativeAiOperation::KEY_ID]); @@ -333,15 +334,15 @@ public function testToArraySucceededState(): void [$candidate], $tokenUsage ); - + $operation = new GenerativeAiOperation( 'op_success_456', OperationStateEnum::succeeded(), $result ); - + $json = $this->assertToArrayReturnsArray($operation); - + $this->assertArrayHasKeys($json, [GenerativeAiOperation::KEY_ID, GenerativeAiOperation::KEY_STATE, GenerativeAiOperation::KEY_RESULT]); $this->assertEquals('op_success_456', $json[GenerativeAiOperation::KEY_ID]); $this->assertEquals(OperationStateEnum::succeeded()->value, $json[GenerativeAiOperation::KEY_STATE]); @@ -360,9 +361,9 @@ public function testFromArrayStartingState(): void GenerativeAiOperation::KEY_ID => 'op_from_json_start', GenerativeAiOperation::KEY_STATE => OperationStateEnum::starting()->value ]; - + $operation = GenerativeAiOperation::fromArray($json); - + $this->assertInstanceOf(GenerativeAiOperation::class, $operation); $this->assertEquals('op_from_json_start', $operation->getId()); $this->assertEquals(OperationStateEnum::starting(), $operation->getState()); @@ -388,7 +389,6 @@ public function testFromArraySucceededState(): void Message::KEY_PARTS => [[MessagePart::KEY_TYPE => 'text', MessagePart::KEY_TEXT => 'Response text']] ], Candidate::KEY_FINISH_REASON => FinishReasonEnum::stop()->value, - Candidate::KEY_TOKEN_COUNT => 30 ] ], GenerativeAiResult::KEY_TOKEN_USAGE => [ @@ -398,9 +398,9 @@ public function testFromArraySucceededState(): void ] ] ]; - + $operation = GenerativeAiOperation::fromArray($json); - + $this->assertInstanceOf(GenerativeAiOperation::class, $operation); $this->assertEquals('op_from_json_success', $operation->getId()); $this->assertEquals(OperationStateEnum::succeeded(), $operation->getState()); @@ -449,7 +449,7 @@ public function testArrayRoundTripSucceededState(): void [$candidate], $tokenUsage ); - + $this->assertArrayRoundTrip( new GenerativeAiOperation( 'op_roundtrip_success', @@ -478,4 +478,4 @@ public function testImplementsWithArrayTransformationInterface(): void ); $this->assertImplementsArrayTransformation($operation); } -} \ No newline at end of file +} diff --git a/tests/unit/Results/DTO/CandidateTest.php b/tests/unit/Results/DTO/CandidateTest.php index a3eed1eb..dde69fdc 100644 --- a/tests/unit/Results/DTO/CandidateTest.php +++ b/tests/unit/Results/DTO/CandidateTest.php @@ -24,6 +24,7 @@ class CandidateTest extends TestCase { use ArrayTransformationTestTrait; + /** * Tests creating candidate with basic properties. * @@ -34,16 +35,14 @@ public function testCreateWithBasicProperties(): void $message = new ModelMessage([ new MessagePart('This is the generated response.') ]); - + $candidate = new Candidate( $message, FinishReasonEnum::stop(), - 25 ); - + $this->assertSame($message, $candidate->getMessage()); $this->assertEquals(FinishReasonEnum::stop(), $candidate->getFinishReason()); - $this->assertEquals(25, $candidate->getTokenCount()); } /** @@ -56,9 +55,9 @@ public function testCreateWithBasicProperties(): void public function testWithDifferentFinishReasons(FinishReasonEnum $finishReason): void { $message = new ModelMessage([new MessagePart('Response')]); - - $candidate = new Candidate($message, $finishReason, 10); - + + $candidate = new Candidate($message, $finishReason); + $this->assertEquals($finishReason, $candidate->getFinishReason()); } @@ -90,7 +89,7 @@ public function testWithComplexMessage(): void 'searchWeb', ['query' => 'PHP best practices'] ); - + $message = new ModelMessage([ new MessagePart('Let me search for that information.'), new MessagePart($functionCall), @@ -99,16 +98,14 @@ public function testWithComplexMessage(): void new MessagePart('2. Use type declarations'), new MessagePart('3. Write unit tests'), ]); - + $candidate = new Candidate( $message, - FinishReasonEnum::toolCalls(), - 150 + FinishReasonEnum::toolCalls() ); - + $this->assertCount(6, $candidate->getMessage()->getParts()); $this->assertTrue($candidate->getFinishReason()->isToolCalls()); - $this->assertEquals(150, $candidate->getTokenCount()); } /** @@ -119,61 +116,24 @@ public function testWithComplexMessage(): void public function testWithMessageContainingFiles(): void { $file = new File('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVQI12P4DwABAQEAG7buVgAAAABJRU5ErkJggg==', 'image/png'); - + $message = new ModelMessage([ new MessagePart('I\'ve generated the requested image:'), new MessagePart($file), new MessagePart('The image shows a flowchart of the process.'), ]); - + $candidate = new Candidate( $message, FinishReasonEnum::stop(), - 85 ); - + $parts = $candidate->getMessage()->getParts(); $this->assertEquals('I\'ve generated the requested image:', $parts[0]->getText()); $this->assertSame($file, $parts[1]->getFile()); $this->assertEquals('The image shows a flowchart of the process.', $parts[2]->getText()); } - /** - * Tests candidate with different token counts. - * - * @dataProvider tokenCountProvider - * @param int $tokenCount - * @return void - */ - public function testWithDifferentTokenCounts(int $tokenCount): void - { - $message = new ModelMessage([new MessagePart('Response')]); - - $candidate = new Candidate( - $message, - FinishReasonEnum::stop(), - $tokenCount - ); - - $this->assertEquals($tokenCount, $candidate->getTokenCount()); - } - - /** - * Provides different token counts. - * - * @return array - */ - public function tokenCountProvider(): array - { - return [ - 'zero' => [0], - 'small' => [10], - 'medium' => [500], - 'large' => [4000], - 'very_large' => [100000], - ]; - } - /** * Tests candidate rejects non-model message. * @@ -184,14 +144,13 @@ public function testRejectsNonModelMessage(): void $userMessage = new UserMessage([ new MessagePart('This is a user message.') ]); - + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Message must be a model message.'); - + new Candidate( $userMessage, - FinishReasonEnum::stop(), - 10 + FinishReasonEnum::stop() ); } @@ -206,14 +165,13 @@ public function testRejectsMessageWithDifferentRole(): void MessageRoleEnum::user(), [new MessagePart('User message')] ); - + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Message must be a model message.'); - + new Candidate( $message, - FinishReasonEnum::stop(), - 10 + FinishReasonEnum::stop() ); } @@ -225,16 +183,15 @@ public function testRejectsMessageWithDifferentRole(): void public function testJsonSchema(): void { $schema = Candidate::getJsonSchema(); - + $this->assertIsArray($schema); $this->assertEquals('object', $schema['type']); - + // Check properties $this->assertArrayHasKey('properties', $schema); $this->assertArrayHasKey(Candidate::KEY_MESSAGE, $schema['properties']); $this->assertArrayHasKey(Candidate::KEY_FINISH_REASON, $schema['properties']); - $this->assertArrayHasKey(Candidate::KEY_TOKEN_COUNT, $schema['properties']); - + // Check finishReason property $finishReasonSchema = $schema['properties'][Candidate::KEY_FINISH_REASON]; $this->assertEquals('string', $finishReasonSchema['type']); @@ -244,14 +201,10 @@ public function testJsonSchema(): void $this->assertContains('content_filter', $finishReasonSchema['enum']); $this->assertContains('tool_calls', $finishReasonSchema['enum']); $this->assertContains('error', $finishReasonSchema['enum']); - - // Check tokenCount property - $tokenCountSchema = $schema['properties'][Candidate::KEY_TOKEN_COUNT]; - $this->assertEquals('integer', $tokenCountSchema['type']); - + // Check required fields $this->assertArrayHasKey('required', $schema); - $this->assertEquals([Candidate::KEY_MESSAGE, Candidate::KEY_FINISH_REASON, Candidate::KEY_TOKEN_COUNT], $schema['required']); + $this->assertEquals([Candidate::KEY_MESSAGE, Candidate::KEY_FINISH_REASON], $schema['required']); } /** @@ -262,15 +215,13 @@ public function testJsonSchema(): void public function testWithEmptyMessageParts(): void { $message = new ModelMessage([]); - + $candidate = new Candidate( $message, - FinishReasonEnum::stop(), - 0 + FinishReasonEnum::stop() ); - + $this->assertCount(0, $candidate->getMessage()->getParts()); - $this->assertEquals(0, $candidate->getTokenCount()); } /** @@ -283,15 +234,13 @@ public function testWithMaxLengthFinishReason(): void $message = new ModelMessage([ new MessagePart('This is a long response that was cut off due to reaching the maximum token limit...') ]); - + $candidate = new Candidate( $message, - FinishReasonEnum::length(), - 4096 + FinishReasonEnum::length() ); - + $this->assertTrue($candidate->getFinishReason()->isLength()); - $this->assertEquals(4096, $candidate->getTokenCount()); } /** @@ -304,13 +253,12 @@ public function testWithContentFilterFinishReason(): void $message = new ModelMessage([ new MessagePart('I cannot provide that information.') ]); - + $candidate = new Candidate( $message, - FinishReasonEnum::contentFilter(), - 8 + FinishReasonEnum::contentFilter() ); - + $this->assertTrue($candidate->getFinishReason()->isContentFilter()); } @@ -324,13 +272,12 @@ public function testWithErrorFinishReason(): void $message = new ModelMessage([ new MessagePart('An error occurred while generating the response.') ]); - + $candidate = new Candidate( $message, - FinishReasonEnum::error(), - 9 + FinishReasonEnum::error() ); - + $this->assertTrue($candidate->getFinishReason()->isError()); } @@ -345,19 +292,17 @@ public function testToArray(): void new MessagePart('This is the AI response.'), new MessagePart('It contains multiple parts.') ]); - + $candidate = new Candidate( $message, - FinishReasonEnum::stop(), - 45 + FinishReasonEnum::stop() ); - + $json = $this->assertToArrayReturnsArray($candidate); - - $this->assertArrayHasKeys($json, [Candidate::KEY_MESSAGE, Candidate::KEY_FINISH_REASON, Candidate::KEY_TOKEN_COUNT]); + + $this->assertArrayHasKeys($json, [Candidate::KEY_MESSAGE, Candidate::KEY_FINISH_REASON]); $this->assertIsArray($json[Candidate::KEY_MESSAGE]); $this->assertEquals(FinishReasonEnum::stop()->value, $json[Candidate::KEY_FINISH_REASON]); - $this->assertEquals(45, $json[Candidate::KEY_TOKEN_COUNT]); } /** @@ -376,14 +321,12 @@ public function testFromArray(): void ] ], Candidate::KEY_FINISH_REASON => FinishReasonEnum::stop()->value, - Candidate::KEY_TOKEN_COUNT => 75 ]; - + $candidate = Candidate::fromArray($json); - + $this->assertInstanceOf(Candidate::class, $candidate); $this->assertEquals(FinishReasonEnum::stop(), $candidate->getFinishReason()); - $this->assertEquals(75, $candidate->getTokenCount()); $this->assertCount(2, $candidate->getMessage()->getParts()); $this->assertEquals('Response text 1', $candidate->getMessage()->getParts()[0]->getText()); $this->assertEquals('Response text 2', $candidate->getMessage()->getParts()[1]->getText()); @@ -402,12 +345,10 @@ public function testArrayRoundTrip(): void new MessagePart('Generated response'), new MessagePart(new FunctionCall('call_123', 'search', ['q' => 'test'])) ]), - FinishReasonEnum::toolCalls(), - 120 + FinishReasonEnum::toolCalls() ), function ($original, $restored) { $this->assertEquals($original->getFinishReason()->value, $restored->getFinishReason()->value); - $this->assertEquals($original->getTokenCount(), $restored->getTokenCount()); $this->assertCount( count($original->getMessage()->getParts()), $restored->getMessage()->getParts() @@ -433,9 +374,8 @@ public function testImplementsWithArrayTransformationInterface(): void { $candidate = new Candidate( new ModelMessage([new MessagePart('test')]), - FinishReasonEnum::stop(), - 10 + FinishReasonEnum::stop() ); $this->assertImplementsArrayTransformation($candidate); } -} \ No newline at end of file +} diff --git a/tests/unit/Results/DTO/GenerativeAiResultTest.php b/tests/unit/Results/DTO/GenerativeAiResultTest.php index bbc2f490..fbccfa4e 100644 --- a/tests/unit/Results/DTO/GenerativeAiResultTest.php +++ b/tests/unit/Results/DTO/GenerativeAiResultTest.php @@ -38,13 +38,13 @@ public function testCreateWithSingleCandidate(): void ]); $candidate = new Candidate($message, FinishReasonEnum::stop(), 10); $tokenUsage = new TokenUsage(20, 10, 30); - + $result = new GenerativeAiResult( 'result_123', [$candidate], $tokenUsage ); - + $this->assertEquals('result_123', $result->getId()); $this->assertCount(1, $result->getCandidates()); $this->assertSame($candidate, $result->getCandidates()[0]); @@ -67,13 +67,13 @@ public function testCreateWithMultipleCandidates(): void $candidates[] = new Candidate($message, FinishReasonEnum::stop(), $i * 10); } $tokenUsage = new TokenUsage(20, 90, 110); - + $result = new GenerativeAiResult( 'result_multi', $candidates, $tokenUsage ); - + $this->assertCount(3, $result->getCandidates()); $this->assertEquals(3, $result->getCandidateCount()); $this->assertTrue($result->hasMultipleCandidates()); @@ -95,14 +95,14 @@ public function testCreateWithProviderMetadata(): void 'max_tokens' => 1000, 'custom_data' => ['key' => 'value'] ]; - + $result = new GenerativeAiResult( 'result_meta', [$candidate], $tokenUsage, $metadata ); - + $this->assertEquals($metadata, $result->getProviderMetadata()); } @@ -114,10 +114,10 @@ public function testCreateWithProviderMetadata(): void public function testRejectsEmptyCandidatesArray(): void { $tokenUsage = new TokenUsage(0, 0, 0); - + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('At least one candidate must be provided'); - + new GenerativeAiResult('result_empty', [], $tokenUsage); } @@ -134,13 +134,13 @@ public function testToText(): void ]); $candidate = new Candidate($message, FinishReasonEnum::stop(), 8); $tokenUsage = new TokenUsage(10, 8, 18); - + $result = new GenerativeAiResult( 'result_text', [$candidate], $tokenUsage ); - + $this->assertEquals($text, $result->toText()); } @@ -157,16 +157,16 @@ public function testToTextThrowsExceptionWhenNoTextContent(): void ]); $candidate = new Candidate($message, FinishReasonEnum::stop(), 5); $tokenUsage = new TokenUsage(10, 5, 15); - + $result = new GenerativeAiResult( 'result_no_text', [$candidate], $tokenUsage ); - + $this->expectException(RuntimeException::class); $this->expectExceptionMessage('No text content found in first candidate'); - + $result->toText(); } @@ -184,13 +184,13 @@ public function testToFile(): void ]); $candidate = new Candidate($message, FinishReasonEnum::stop(), 20); $tokenUsage = new TokenUsage(15, 20, 35); - + $result = new GenerativeAiResult( 'result_file', [$candidate], $tokenUsage ); - + $this->assertSame($file, $result->toFile()); } @@ -206,16 +206,16 @@ public function testToFileThrowsExceptionWhenNoFileContent(): void ]); $candidate = new Candidate($message, FinishReasonEnum::stop(), 5); $tokenUsage = new TokenUsage(10, 5, 15); - + $result = new GenerativeAiResult( 'result_no_file', [$candidate], $tokenUsage ); - + $this->expectException(RuntimeException::class); $this->expectExceptionMessage('No file content found in first candidate'); - + $result->toFile(); } @@ -232,13 +232,13 @@ public function testToImageFile(): void ]); $candidate = new Candidate($message, FinishReasonEnum::stop(), 10); $tokenUsage = new TokenUsage(5, 10, 15); - + $result = new GenerativeAiResult( 'result_image', [$candidate], $tokenUsage ); - + $this->assertSame($imageFile, $result->toImageFile()); } @@ -255,16 +255,16 @@ public function testToImageFileThrowsExceptionForNonImageFile(): void ]); $candidate = new Candidate($message, FinishReasonEnum::stop(), 10); $tokenUsage = new TokenUsage(5, 10, 15); - + $result = new GenerativeAiResult( 'result_pdf', [$candidate], $tokenUsage ); - + $this->expectException(RuntimeException::class); $this->expectExceptionMessage('File is not an image. MIME type: application/pdf'); - + $result->toImageFile(); } @@ -281,13 +281,13 @@ public function testToAudioFile(): void ]); $candidate = new Candidate($message, FinishReasonEnum::stop(), 10); $tokenUsage = new TokenUsage(5, 10, 15); - + $result = new GenerativeAiResult( 'result_audio', [$candidate], $tokenUsage ); - + $this->assertSame($audioFile, $result->toAudioFile()); } @@ -304,13 +304,13 @@ public function testToVideoFile(): void ]); $candidate = new Candidate($message, FinishReasonEnum::stop(), 10); $tokenUsage = new TokenUsage(5, 10, 15); - + $result = new GenerativeAiResult( 'result_video', [$candidate], $tokenUsage ); - + $this->assertSame($videoFile, $result->toVideoFile()); } @@ -326,13 +326,13 @@ public function testToMessage(): void ]); $candidate = new Candidate($message, FinishReasonEnum::stop(), 3); $tokenUsage = new TokenUsage(5, 3, 8); - + $result = new GenerativeAiResult( 'result_msg', [$candidate], $tokenUsage ); - + $this->assertSame($message, $result->toMessage()); } @@ -345,21 +345,21 @@ public function testToTextsWithMultipleCandidates(): void { $texts = ['First response', 'Second response', 'Third response']; $candidates = []; - + foreach ($texts as $text) { $message = new ModelMessage([ new MessagePart($text) ]); $candidates[] = new Candidate($message, FinishReasonEnum::stop(), 5); } - + $tokenUsage = new TokenUsage(20, 15, 35); $result = new GenerativeAiResult( 'result_texts', $candidates, $tokenUsage ); - + $this->assertEquals($texts, $result->toTexts()); } @@ -373,7 +373,7 @@ public function testToFilesWithMultipleCandidates(): void $file1 = new File('https://example.com/image1.jpg', 'image/jpeg'); $file2 = new File('https://example.com/image2.png', 'image/png'); $file3 = new File('https://example.com/doc.pdf', 'application/pdf'); - + $candidates = []; foreach ([$file1, $file2, $file3] as $file) { $message = new ModelMessage([ @@ -382,14 +382,14 @@ public function testToFilesWithMultipleCandidates(): void ]); $candidates[] = new Candidate($message, FinishReasonEnum::stop(), 10); } - + $tokenUsage = new TokenUsage(30, 30, 60); $result = new GenerativeAiResult( 'result_files', $candidates, $tokenUsage ); - + $files = $result->toFiles(); $this->assertCount(3, $files); $this->assertSame($file1, $files[0]); @@ -407,20 +407,20 @@ public function testToImageFilesFiltersOnlyImages(): void $imageFile1 = new File('https://example.com/image1.jpg', 'image/jpeg'); $pdfFile = new File('https://example.com/doc.pdf', 'application/pdf'); $imageFile2 = new File('https://example.com/image2.png', 'image/png'); - + $candidates = []; foreach ([$imageFile1, $pdfFile, $imageFile2] as $file) { $message = new ModelMessage([new MessagePart($file)]); $candidates[] = new Candidate($message, FinishReasonEnum::stop(), 10); } - + $tokenUsage = new TokenUsage(30, 30, 60); $result = new GenerativeAiResult( 'result_mixed', $candidates, $tokenUsage ); - + $images = $result->toImageFiles(); $this->assertCount(2, $images); $this->assertSame($imageFile1, $images[0]); @@ -437,20 +437,20 @@ public function testToAudioFilesFiltersOnlyAudio(): void $audioFile1 = new File('https://example.com/song.mp3', 'audio/mpeg'); $imageFile = new File('https://example.com/image.jpg', 'image/jpeg'); $audioFile2 = new File('https://example.com/podcast.wav', 'audio/wav'); - + $candidates = []; foreach ([$audioFile1, $imageFile, $audioFile2] as $file) { $message = new ModelMessage([new MessagePart($file)]); $candidates[] = new Candidate($message, FinishReasonEnum::stop(), 10); } - + $tokenUsage = new TokenUsage(30, 30, 60); $result = new GenerativeAiResult( 'result_audio_mix', $candidates, $tokenUsage ); - + $audioFiles = $result->toAudioFiles(); $this->assertCount(2, $audioFiles); $this->assertSame($audioFile1, $audioFiles[0]); @@ -467,20 +467,20 @@ public function testToVideoFilesFiltersOnlyVideo(): void $videoFile1 = new File('https://example.com/movie.mp4', 'video/mp4'); $imageFile = new File('https://example.com/image.jpg', 'image/jpeg'); $videoFile2 = new File('https://example.com/clip.webm', 'video/webm'); - + $candidates = []; foreach ([$videoFile1, $imageFile, $videoFile2] as $file) { $message = new ModelMessage([new MessagePart($file)]); $candidates[] = new Candidate($message, FinishReasonEnum::stop(), 10); } - + $tokenUsage = new TokenUsage(30, 30, 60); $result = new GenerativeAiResult( 'result_video_mix', $candidates, $tokenUsage ); - + $videoFiles = $result->toVideoFiles(); $this->assertCount(2, $videoFiles); $this->assertSame($videoFile1, $videoFiles[0]); @@ -496,7 +496,7 @@ public function testToMessages(): void { $messages = []; $candidates = []; - + for ($i = 1; $i <= 3; $i++) { $message = new ModelMessage([ new MessagePart("Message $i") @@ -504,14 +504,14 @@ public function testToMessages(): void $messages[] = $message; $candidates[] = new Candidate($message, FinishReasonEnum::stop(), 5); } - + $tokenUsage = new TokenUsage(15, 15, 30); $result = new GenerativeAiResult( 'result_messages', $candidates, $tokenUsage ); - + $extractedMessages = $result->toMessages(); $this->assertCount(3, $extractedMessages); foreach ($messages as $index => $message) { @@ -527,30 +527,30 @@ public function testToMessages(): void public function testJsonSchema(): void { $schema = GenerativeAiResult::getJsonSchema(); - + $this->assertIsArray($schema); $this->assertEquals('object', $schema['type']); - + // Check properties $this->assertArrayHasKey('properties', $schema); $this->assertArrayHasKey(GenerativeAiResult::KEY_ID, $schema['properties']); $this->assertArrayHasKey(GenerativeAiResult::KEY_CANDIDATES, $schema['properties']); $this->assertArrayHasKey(GenerativeAiResult::KEY_TOKEN_USAGE, $schema['properties']); $this->assertArrayHasKey(GenerativeAiResult::KEY_PROVIDER_METADATA, $schema['properties']); - + // Check id property $this->assertEquals('string', $schema['properties'][GenerativeAiResult::KEY_ID]['type']); - + // Check candidates property $candidatesSchema = $schema['properties'][GenerativeAiResult::KEY_CANDIDATES]; $this->assertEquals('array', $candidatesSchema['type']); $this->assertEquals(1, $candidatesSchema['minItems']); - + // Check providerMetadata property $metadataSchema = $schema['properties'][GenerativeAiResult::KEY_PROVIDER_METADATA]; $this->assertEquals('object', $metadataSchema['type']); $this->assertTrue($metadataSchema['additionalProperties']); - + // Check required fields $this->assertArrayHasKey('required', $schema); $this->assertContains(GenerativeAiResult::KEY_ID, $schema['required']); @@ -569,13 +569,13 @@ public function testImplementsResultInterface(): void $message = new ModelMessage([new MessagePart('Test')]); $candidate = new Candidate($message, FinishReasonEnum::stop(), 1); $tokenUsage = new TokenUsage(1, 1, 2); - + $result = new GenerativeAiResult( 'result_interface', [$candidate], $tokenUsage ); - + $this->assertInstanceOf( \WordPress\AiClient\Results\Contracts\ResultInterface::class, $result @@ -592,13 +592,13 @@ public function testHasMultipleCandidatesReturnsFalseForSingle(): void $message = new ModelMessage([new MessagePart('Single response')]); $candidate = new Candidate($message, FinishReasonEnum::stop(), 3); $tokenUsage = new TokenUsage(5, 3, 8); - + $result = new GenerativeAiResult( 'result_single', [$candidate], $tokenUsage ); - + $this->assertFalse($result->hasMultipleCandidates()); $this->assertEquals(1, $result->getCandidateCount()); } @@ -617,16 +617,16 @@ public function testToArray(): void $candidate = new Candidate($message, FinishReasonEnum::stop(), 15); $tokenUsage = new TokenUsage(10, 15, 25); $metadata = ['model' => 'test-model', 'version' => '1.0']; - + $result = new GenerativeAiResult( 'result_json_123', [$candidate], $tokenUsage, $metadata ); - + $json = $this->assertToArrayReturnsArray($result); - + $this->assertArrayHasKeys($json, [GenerativeAiResult::KEY_ID, GenerativeAiResult::KEY_CANDIDATES, GenerativeAiResult::KEY_TOKEN_USAGE, GenerativeAiResult::KEY_PROVIDER_METADATA]); $this->assertEquals('result_json_123', $json[GenerativeAiResult::KEY_ID]); $this->assertIsArray($json[GenerativeAiResult::KEY_CANDIDATES]); @@ -654,7 +654,6 @@ public function testFromArray(): void ] ], Candidate::KEY_FINISH_REASON => FinishReasonEnum::stop()->value, - Candidate::KEY_TOKEN_COUNT => 20 ] ], GenerativeAiResult::KEY_TOKEN_USAGE => [ @@ -664,9 +663,9 @@ public function testFromArray(): void ], GenerativeAiResult::KEY_PROVIDER_METADATA => ['provider' => 'test'] ]; - + $result = GenerativeAiResult::fromArray($json); - + $this->assertInstanceOf(GenerativeAiResult::class, $result); $this->assertEquals('result_from_json', $result->getId()); $this->assertCount(1, $result->getCandidates()); @@ -691,7 +690,7 @@ public function testArrayRoundTripWithMultipleCandidates(): void ]); $candidates[] = new Candidate($message, FinishReasonEnum::toolCalls(), 25 * $i); } - + $this->assertArrayRoundTrip( new GenerativeAiResult( 'result_roundtrip', @@ -702,10 +701,10 @@ public function testArrayRoundTripWithMultipleCandidates(): void function ($original, $restored) { $this->assertEquals($original->getId(), $restored->getId()); $this->assertCount(count($original->getCandidates()), $restored->getCandidates()); - $this->assertEquals($original->getTokenUsage()->getTotalTokens(), + $this->assertEquals($original->getTokenUsage()->getTotalTokens(), $restored->getTokenUsage()->getTotalTokens()); $this->assertEquals($original->getProviderMetadata(), $restored->getProviderMetadata()); - + // Check first candidate details $originalFirst = $original->getCandidates()[0]; $restoredFirst = $restored->getCandidates()[0]; @@ -731,15 +730,15 @@ public function testToArrayWithoutProviderMetadata(): void $message = new ModelMessage([new MessagePart('Simple response')]); $candidate = new Candidate($message, FinishReasonEnum::stop(), 5); $tokenUsage = new TokenUsage(3, 5, 8); - + $result = new GenerativeAiResult( 'result_no_meta', [$candidate], $tokenUsage ); - + $json = $this->assertToArrayReturnsArray($result); - + $this->assertArrayHasKeys($json, [GenerativeAiResult::KEY_ID, GenerativeAiResult::KEY_CANDIDATES, GenerativeAiResult::KEY_TOKEN_USAGE, GenerativeAiResult::KEY_PROVIDER_METADATA]); $this->assertEquals([], $json[GenerativeAiResult::KEY_PROVIDER_METADATA]); } @@ -754,8 +753,8 @@ public function testImplementsWithArrayTransformationInterface(): void $message = new ModelMessage([new MessagePart('test')]); $candidate = new Candidate($message, FinishReasonEnum::stop(), 1); $tokenUsage = new TokenUsage(1, 1, 2); - + $result = new GenerativeAiResult('test', [$candidate], $tokenUsage); $this->assertImplementsArrayTransformation($result); } -} \ No newline at end of file +} From 3962f5b6317e90894df26da9850e98dc39ad84fd Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Tue, 5 Aug 2025 14:50:34 -0500 Subject: [PATCH 02/77] Add initial base classes for API based and OpenAI API compatible provider infrastructure, as well as an actual OpenAI implementation based on it (WIP). --- .../OpenAi/OpenAiModelMetadataDirectory.php | 44 ++ .../OpenAi/OpenAiProvider.php | 82 ++++ .../OpenAi/OpenAiTextGenerationModel.php | 25 + src/Providers/AbstractApiBasedModel.php | 103 ++++ ...AbstractApiBasedModelMetadataDirectory.php | 105 ++++ ...OpenAiCompatibleModelMetadataDirectory.php | 63 +++ ...actOpenAiCompatibleTextGenerationModel.php | 453 ++++++++++++++++++ src/Providers/AbstractProvider.php | 146 ++++++ ...nerateTextApiBasedProviderAvailability.php | 74 +++ ...ListModelsApiBasedProviderAvailability.php | 54 +++ .../Traits/WithHttpTransporterTrait.php | 33 ++ 11 files changed, 1182 insertions(+) create mode 100644 src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php create mode 100644 src/ProviderImplementations/OpenAi/OpenAiProvider.php create mode 100644 src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php create mode 100644 src/Providers/AbstractApiBasedModel.php create mode 100644 src/Providers/AbstractApiBasedModelMetadataDirectory.php create mode 100644 src/Providers/AbstractOpenAiCompatibleModelMetadataDirectory.php create mode 100644 src/Providers/AbstractOpenAiCompatibleTextGenerationModel.php create mode 100644 src/Providers/AbstractProvider.php create mode 100644 src/Providers/GenerateTextApiBasedProviderAvailability.php create mode 100644 src/Providers/ListModelsApiBasedProviderAvailability.php create mode 100644 src/Providers/Models/Traits/WithHttpTransporterTrait.php diff --git a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php new file mode 100644 index 00000000..cd11151f --- /dev/null +++ b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php @@ -0,0 +1,44 @@ +getData(); + if (!isset($responseData['data']) || !$responseData['data']) { + throw new RuntimeException( + 'Unexpected API response: Missing the data key.' + ); + } + return array_map( + static function (array $modelData): ModelMetadata { + // TODO: Create ModelMetadata object from API data. + }, + $responseData['data'] + ); + } +} diff --git a/src/ProviderImplementations/OpenAi/OpenAiProvider.php b/src/ProviderImplementations/OpenAi/OpenAiProvider.php new file mode 100644 index 00000000..82891a35 --- /dev/null +++ b/src/ProviderImplementations/OpenAi/OpenAiProvider.php @@ -0,0 +1,82 @@ +getCapabilities(); + foreach ($capabilities as $capability) { + if ($capability->isTextGeneration()) { + return new OpenAiTextGenerationModel($modelMetadata, $providerMetadata); + } + if ($capability->isImageGeneration()) { + // TODO: Implement OpenAiImageGenerationModel. + return new OpenAiImageGenerationModel($modelMetadata, $providerMetadata); + } + if ($capability->isTextToSpeechConversion()) { + // TODO: Implement OpenAiTextToSpeechConversionModel. + return new OpenAiTextToSpeechConversionModel($modelMetadata, $providerMetadata); + } + } + + throw new RuntimeException( + 'Unsupported model capabilities: ' . implode(', ', $capabilities) + ); + } + + /** + * @inheritDoc + */ + protected static function createProviderMetadata(): ProviderMetadata + { + return new ProviderMetadata( + 'openai', + 'OpenAI', + ProviderTypeEnum::cloud() + ); + } + + /** + * @inheritDoc + */ + protected static function createProviderAvailability(): ProviderAvailabilityInterface + { + // Check valid API access by attempting to list models. + return new ListModelsApiBasedProviderAvailability( + static::modelMetadataDirectory() + ); + } + + /** + * @inheritDoc + */ + protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface + { + return new OpenAiModelMetadataDirectory(); + } +} diff --git a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php new file mode 100644 index 00000000..7871857d --- /dev/null +++ b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php @@ -0,0 +1,25 @@ +metadata = $metadata; + $this->providerMetadata = $providerMetadata; + $this->config = ModelConfig::fromArray([]); + } + + /** + * Returns the metadata for the model. + * + * @since n.e.x.t + * + * @return ModelMetadata The model metadata. + */ + public function metadata(): ModelMetadata + { + return $this->metadata; + } + + /** + * Returns the metadata for the model's provider. + * + * @since n.e.x.t + * + * @return ProviderMetadata The provider metadata. + */ + public function providerMetadata(): ProviderMetadata + { + return $this->providerMetadata; + } + + /** + * Sets the configuration for the model. + * + * @since n.e.x.t + * + * @param ModelConfig $config The configuration for the model. + */ + public function setConfig(ModelConfig $config): void + { + $this->config = $config; + } + + /** + * Returns the configuration for the model. + * + * @since n.e.x.t + * + * @return ModelConfig The model configuration. + */ + public function getConfig(): ModelConfig + { + return $this->config; + } +} diff --git a/src/Providers/AbstractApiBasedModelMetadataDirectory.php b/src/Providers/AbstractApiBasedModelMetadataDirectory.php new file mode 100644 index 00000000..9e2de8d5 --- /dev/null +++ b/src/Providers/AbstractApiBasedModelMetadataDirectory.php @@ -0,0 +1,105 @@ + Map of model ID to model metadata, effectively for caching. + */ + private ?array $modelMetadataMap = null; + + /** + * Lists the metadata for all models from the provider. + * + * @since n.e.x.t + * + * @return list List of model metadata objects. + */ + final public function listModelMetadata(): array + { + $modelsMetadata = $this->getModelMetadataMap(); + return array_values($modelsMetadata); + } + + /** + * Checks whether model metadata for the given model ID exists. + * + * This is effectively a check for whether the given model ID is for a valid model from the provider. + * + * @since n.e.x.t + * + * @param string $modelId The model ID. + * @return bool True if there is metadata for the model. + */ + final public function hasModelMetadata(string $modelId): bool + { + try { + $this->getModelMetadata(); + } catch (InvalidArgumentException $e) { + return false; + } + return true; + } + + /** + * Gets the model metadata for the given model ID. + * + * @since n.e.x.t + * + * @param string $modelId The model ID. + * @return ModelMetadata The model metadata. + * @throws InvalidArgumentException If the model for the given ID does not exist. + */ + final public function getModelMetadata(string $modelId): ModelMetadata + { + $modelsMetadata = $this->getModelMetadataMap(); + if (!isset($modelsMetadata[$modelId])) { + throw new InvalidArgumentException( + sprintf('No model with ID %s was found in the provider', $modelId) + ); + } + return $modelsMetadata[$modelId]; + } + + /** + * Returns the map of model ID to model metadata for all models from the provider. + * + * @since n.e.x.t + * + * @return array Map of model ID to model metadata. + */ + private function getModelMetadataMap(): array + { + if ($this->modelMetadataMap === null) { + $this->modelMetadataMap = $this->sendListModelsRequest(); + } + return $this->modelMetadataMap; + } + + /** + * Sends the API request to list models from the provider and returns the map of model ID to model metadata. + * + * @since n.e.x.t + * + * @return array Map of model ID to model metadata. + */ + abstract protected function sendListModelsRequest(): array; +} diff --git a/src/Providers/AbstractOpenAiCompatibleModelMetadataDirectory.php b/src/Providers/AbstractOpenAiCompatibleModelMetadataDirectory.php new file mode 100644 index 00000000..42e8bedf --- /dev/null +++ b/src/Providers/AbstractOpenAiCompatibleModelMetadataDirectory.php @@ -0,0 +1,63 @@ + Map of model ID to model metadata. + */ + protected function sendListModelsRequest(): array + { + $httpTransporter = $this->getHttpTransporter(); + + // Something like this. + $request = $this->createRequest('models'); + $response = $httpTransporter->sendRequest($request); + + $modelsMetadataList = $this->parseResponseToModelMetadataList($response); + + // Parse list to map. + return array_reduce( + $modelsMetadataList, + static function (array $carry, ModelMetadata $metadata) { + $carry[$metadata->getId()] = $metadata; + return $carry; + }, + [] + ); + } + + /** + * Creates a request object for the provider's API. + * + * @since n.e.x.t + * + * @param string $path The API endpoint path, relative to the base URI. + * @return RequestInterface The request object. + */ + abstract protected function createRequest(string $path): RequestInterface; + + /** + * Parses the response from the API endpoint to list models into a list of model metadata objects. + * + * @since n.e.x.t + * + * @param ResponseInterface $response The response from the API endpoint to list models. + * @return list List of model metadata objects. + */ + abstract protected function parseResponseToModelMetadataList(ResponseInterface $response): array; +} diff --git a/src/Providers/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/AbstractOpenAiCompatibleTextGenerationModel.php new file mode 100644 index 00000000..104db758 --- /dev/null +++ b/src/Providers/AbstractOpenAiCompatibleTextGenerationModel.php @@ -0,0 +1,453 @@ +getHttpTransporter(); + + $params = $this->prepareGenerateTextParams($prompt); + + // Something like this. + $request = $this->createRequest('chat/completions', $params); + $response = $httpTransporter->sendRequest($request); + + return $this->parseResponseToGenerativeAiResult($response); + } + + /** + * @inheritDoc + */ + final public function streamGenerateTextResult(array $prompt): Generator + { + $params = $this->prepareGenerateTextParams($prompt); + + // TODO: Implement streaming support. + throw new RuntimeException( + 'Streaming is not yet implemented.' + ); + } + + /** + * Prepares the given prompt and the model configuration into parameters for the API request. + * + * @since n.e.x.t + * + * @param list $prompt The prompt to generate text for. Either a single message or a list of messages + * from a chat. + * @return array The parameters for the API request. + */ + protected function prepareGenerateTextParams(array $prompt): array + { + $config = $this->getConfig(); + + $systemInstruction = $config->getSystemInstruction(); + if ($systemInstruction) { + $prompt = $this->mergeSystemInstruction($prompt, $systemInstruction); + } + + $params = [ + 'model' => $this->metadata()->getId(), + 'messages' => $this->prepareMessagesParam($prompt), + ]; + + $outputModalities = $config->getOutputModalities(); + $this->validateOutputModalities($outputModalities); + if (count($outputModalities) > 1) { + $params['modalities'] = $this->prepareModalitiesParam($outputModalities); + } + + // TODO: Prepare other parameters based on config. + + return $params; + } + + /** + * Merges the system instruction into the prompt, ensuring that it is the first message. + * + * @since n.e.x.t + * + * @param list $prompt The prompt to merge the system instruction into. + * @param string $systemInstruction The system instruction to merge. + * @return list The updated prompt with the system instruction as the first message. + * @throws InvalidArgumentException If the first message in the prompt is already a system message. + */ + protected function mergeSystemInstruction(array $prompt, string $systemInstruction): array + { + // If the first message is a system message, throw an exception due to a conflict. + if (isset($prompt[0]) && $prompt[0]->getRole() === MessageRoleEnum::system()) { + throw new InvalidArgumentException( + 'The first message in the prompt cannot be a system message when using a system instruction.' + ); + } + + $systemMessage = new SystemMessage([ + new MessagePart($systemInstruction), + ]); + array_unshift($prompt, $systemMessage); + return $prompt; + } + + /** + * Prepares the messages parameter for the API request. + * + * @since n.e.x.t + * + * @param list $messages The messages to prepare. + * @return list> The prepared messages parameter. + */ + protected function prepareMessagesParam(array $messages): array + { + return array_map( + function (Message $message): array { + // Special case: Function response. + $messageParts = $message->getParts(); + if (count($messageParts) === 1 && $messageParts[0]->getType()->isFunctionResponse()) { + $functionResponse = $messageParts[0]->getFunctionResponse(); + return [ + 'role' => 'tool', + 'content' => json_encode($functionResponse->getResponse()), + 'tool_call_id' => $functionResponse->getId(), + ]; + } + return [ + 'role' => $this->getMessageRoleString($message->getRole()), + 'content' => array_filter(array_map( + function (MessagePart $part): ?array { + return $this->getMessagePartContentData($part); + }, + $messageParts() + )), + 'tool_calls' => array_filter(array_map( + function (MessagePart $part): ?array { + return $this->getMessagePartToolCallData($part); + }, + $messageParts() + )), + ]; + }, + $messages + ); + } + + /** + * Returns the OpenAI API specific role string for the given message role. + * + * @since n.e.x.t + * + * @param MessageRoleEnum $role The message role. + * @return string The role for the API request. + */ + protected function getMessageRoleString(MessageRoleEnum $role): string + { + if ($role === MessageRoleEnum::model()) { + return 'assistant'; + } + if ($role === MessageRoleEnum::system()) { + return 'system'; + } + return 'user'; + } + + /** + * Returns the OpenAI API specific content data for a message part. + * + * @since n.e.x.t + * + * @param MessagePart $part The message part to get the data for. + * @return ?array The data for the message content part, or null if not applicable. + * @throws InvalidArgumentException If the message part type or data is unsupported. + */ + protected function getMessagePartContentData(MessagePart $part): ?array + { + $type = $part->getType(); + if ($type->isText()) { + return [ + 'type' => 'text', + 'text' => $part->getText(), + ]; + } + if ($type->isFile()) { + $file = $part->getFile(); + if ($file->getFileType()->isRemote()) { + if ($file->isImage()) { + return [ + 'type' => 'image_url', + 'image_url' => [ + 'url' => $file->getUrl(), + ], + ]; + } + throw new InvalidArgumentException( + sprintf( + 'Unsupported MIME type "%s" for remote file message part.', + $file->getMimeType() + ) + ); + } + // Else, it is an inline file. + if ($file->isImage()) { + return [ + 'type' => 'image_url', + 'image_url' => [ + 'url' => $file->getBase64Data(), + ], + ]; + } + if ($file->isAudio()) { + return [ + 'type' => 'input_audio', + 'input_audio' => [ + 'data' => $file->getBase64Data(), + 'format' => '', // TODO: Add method to transform MIME type into file extension. + ], + ]; + } + throw new InvalidArgumentException( + sprintf( + 'Unsupported MIME type "%s" for inline file message part.', + $file->getMimeType() + ) + ); + } + if ($type->isFunctionCall()) { + // Skip, as this is separately included. See `getMessagePartToolCallData()`. + return null; + } + if ($type->isFunctionResponse()) { + // Special case: Function response. + throw new InvalidArgumentException( + 'The API only allows a single function response, as the only content of the message.' + ); + } + throw new InvalidArgumentException( + sprintf( + 'Unsupported message part type "%s".', + $type + ) + ); + } + + /** + * Returns the OpenAI API specific tool calls data for a message part. + * + * @since n.e.x.t + * + * @param MessagePart $part The message part to get the data for. + * @return ?array The data for the message tool call part, or null if not applicable. + * @throws InvalidArgumentException If the message part type or data is unsupported. + */ + protected function getMessagePartToolCallData(MessagePart $part): array + { + $type = $part->getType(); + if ($type->isFunctionCall()) { + $functionCall = $part->getFunctionCall(); + return [ + 'type' => 'function', + 'id' => $functionCall->getId(), + 'function' => [ + 'name' => $functionCall->getName(), + 'arguments' => json_encode($functionCall->getArgs()), + ], + ]; + } + // All other types are handled in `getMessagePartContentData()`. + return null; + } + + /** + * Validates that the given output modalities to ensure that at least one output modality is text. + * + * @since n.e.x.t + * + * @param array $outputModalities The output modalities to validate. + * @throws InvalidArgumentException If no text output modality is present. + */ + protected function validateOutputModalities(array $outputModalities): void + { + // If no output modalities are set, it's fine, as we can assume text. + if (count($outputModalities) === 0) { + return; + } + + foreach ($outputModalities as $modality) { + if ($modality->isText()) { + return; + } + } + + throw new InvalidArgumentException( + 'A text output modality must be present when generating text.' + ); + } + + /** + * Prepares the modalities parameter for the API request. + * + * @since n.e.x.t + * + * @param array $modalities The modalities to prepare. + * @return array The prepared modalities parameter. + */ + protected function prepareModalitiesParam(array $modalities): array + { + $prepared = []; + foreach ($modalities as $modality) { + if ($modality->isText()) { + $prepared[] = 'text'; + } elseif ($modality->isImage()) { + $prepared[] = 'image'; + } elseif ($modality->isAudio()) { + $prepared[] = 'audio'; + } else { + throw new InvalidArgumentException( + sprintf( + 'Unsupported output modality "%s".', + $modality + ) + ); + } + } + return $prepared; + } + + /** + * Creates a request object for the provider's API. + * + * @since n.e.x.t + * + * @param string $path The API endpoint path, relative to the base URI. + * @param array $params The parameters for the API request. + * @return RequestInterface The request object. + */ + abstract protected function createRequest(string $path, array $params): RequestInterface; + + /** + * Parses the response from the API endpoint to a generative AI result. + * + * @since n.e.x.t + * + * @param ResponseInterface $response The response from the API endpoint. + * @return GenerativeAiResult The parsed generative AI result. + */ + protected function parseResponseToGenerativeAiResult(ResponseInterface $response): GenerativeAiResult + { + $responseData = $response->getData(); + if (!isset($responseData['choices']) || !$responseData['choices']) { + throw new RuntimeException( + 'Unexpected API response: Missing the choices key.' + ); + } + if (!is_array($responseData['choices'])) { + throw new RuntimeException( + 'Unexpected API response: The choices key must contain an array.' + ); + } + + $candidates = []; + foreach ($responseData['choices'] as $choice) { + if (!is_array($choice)) { + throw new RuntimeException( + 'Unexpected API response: Each element in the choices key must be an associative array.' + ); + } + $candidates[] = $this->parseResponseChoiceToCandidate($choice); + } + + $id = $responseData['id'] ?? ''; + $tokenUsage = new TokenUsage( + $responseData['usage']['prompt_tokens'] ?? 0, + $responseData['usage']['completion_tokens'] ?? 0, + $responseData['usage']['total_tokens'] ?? 0 + ); + + // Use any other data from the response as provider metadata. + $providerMetadata = $responseData; + unset($providerMetadata['id'], $providerMetadata['choices'], $providerMetadata['usage']); + + return new GenerativeAiResult( + $id, + $candidates, + $tokenUsage, + $providerMetadata + ); + } + + /** + * Parses a single choice from the API response into a Candidate object. + * + * @since n.e.x.t + * + * @param array $choice The choice data from the API response. + * @return Candidate The parsed candidate. + * @throws RuntimeException If the choice data is invalid. + */ + protected function parseResponseChoiceToCandidate(array $choice): Candidate + { + if (!isset($choice['message']) || !is_array($choice['message'])) { + throw new RuntimeException( + 'Unexpected API response: Each choice must contain a message key with an associative array.' + ); + } + + // TODO: Correctly implement this, as this is not correct - 'message' isn't just a string. + $message = new Message($choice['message']); + + if (!isset($choice['finish_reason']) || !is_string($choice['finish_reason'])) { + throw new RuntimeException( + 'Unexpected API response: Each choice must contain a finish_reason key with a string value.' + ); + } + switch ($choice['finish_reason']) { + case 'stop': + $finishReason = FinishReasonEnum::stop(); + break; + case 'length': + $finishReason = FinishReasonEnum::length(); + break; + case 'content_filter': + $finishReason = FinishReasonEnum::contentFilter(); + break; + case 'tool_calls': + $finishReason = FinishReasonEnum::toolCalls(); + break; + default: + throw new RuntimeException( + sprintf( + 'Unexpected API response: Invalid finish reason "%s".', + $choice['finish_reason'] + ) + ); + } + + return new Candidate($message, $finishReason); + } +} diff --git a/src/Providers/AbstractProvider.php b/src/Providers/AbstractProvider.php new file mode 100644 index 00000000..3268d6a1 --- /dev/null +++ b/src/Providers/AbstractProvider.php @@ -0,0 +1,146 @@ + Cache for provider metadata per class. + */ + private static array $metadataCache = []; + + /** + * @var array Cache for provider availability per class. + */ + private static array $availabilityCache = []; + + /** + * @var array Cache for model metadata directory per class. + */ + private static array $modelMetadataDirectoryCache = []; + + /** + * Returns the metadata for the provider. + * + * @since n.e.x.t + * + * @return ProviderMetadata The provider metadata. + */ + final public static function metadata(): ProviderMetadata + { + $className = static::class; + if (!isset(self::$metadataCache[$className])) { + self::$metadataCache[$className] = static::createProviderMetadata(); + } + return self::$metadataCache[$className]; + } + + /** + * Creates a model instance based on the given model ID and configuration. + * + * @since n.e.x.t + * + * @param string $modelId The model's unique identifier. + * @param ModelConfig|null $modelConfig Optional configuration for the model. + * @return ModelInterface The new model instance. + */ + final public static function model(string $modelId, ?ModelConfig $modelConfig = null): ModelInterface + { + $providerMetadata = static::metadata(); + $modelMetadata = static::modelMetadataDirectory()->getModelMetadata($modelId); + + $model = static::createModel($modelMetadata, $providerMetadata); + if ($modelConfig) { + $model->setConfig($modelConfig); + } + return $model; + } + + /** + * Returns the availability for the provider. + * + * @since n.e.x.t + * + * @return ProviderAvailabilityInterface The availability. + */ + final public static function availability(): ProviderAvailabilityInterface + { + $className = static::class; + if (!isset(self::$availabilityCache[$className])) { + self::$availabilityCache[$className] = static::createProviderAvailability(); + } + return self::$availabilityCache[$className]; + } + + /** + * Returns the model metadata directory for the provider. + * + * @since n.e.x.t + * + * @return ModelMetadataDirectoryInterface The model metadata directory. + */ + final public static function modelMetadataDirectory(): ModelMetadataDirectoryInterface + { + $className = static::class; + if (!isset(self::$modelMetadataDirectoryCache[$className])) { + self::$modelMetadataDirectoryCache[$className] = static::createModelMetadataDirectory(); + } + return self::$modelMetadataDirectoryCache[$className]; + } + + /** + * Creates a model instance based on the given model metadata and provider metadata. + * + * @since n.e.x.t + * + * @param ModelMetadata $modelMetadata The model metadata. + * @param ProviderMetadata $providerMetadata The provider metadata. + * @return ModelInterface The new model instance. + */ + abstract protected static function createModel( + ModelMetadata $modelMetadata, + ProviderMetadata $providerMetadata + ): ModelInterface; + + /** + * Creates the provider metadata instance. + * + * @since n.e.x.t + * + * @return ProviderMetadata The provider metadata. + */ + abstract protected static function createProviderMetadata(): ProviderMetadata; + + /** + * Creates the provider availability instance. + * + * @since n.e.x.t + * + * @return ProviderAvailabilityInterface The provider availability. + */ + abstract protected static function createProviderAvailability(): ProviderAvailabilityInterface; + + /** + * Creates the model metadata directory instance. + * + * @since n.e.x.t + * + * @return ModelMetadataDirectoryInterface The model metadata directory. + */ + abstract protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface; +} diff --git a/src/Providers/GenerateTextApiBasedProviderAvailability.php b/src/Providers/GenerateTextApiBasedProviderAvailability.php new file mode 100644 index 00000000..ef5ebbc9 --- /dev/null +++ b/src/Providers/GenerateTextApiBasedProviderAvailability.php @@ -0,0 +1,74 @@ +model = $model; + } + + /** + * Checks whether the provider is available. + * + * @since n.e.x.t + * + * @return bool True if the provider is available, false otherwise. + */ + public function isConfigured(): bool + { + // Set config to use as few resources as possible for the test. + $modelConfig = ModelConfig::fromArray([ + ModelConfig::KEY_MAX_TOKENS => 1, + ]); + $this->model->setConfig($modelConfig); + + try { + // Attempt to generate text to check if the provider is available. + $this->model->generateTextResult([ + Message::fromArray([ + Message::KEY_ROLE => MessageRoleEnum::user(), + Message::KEY_PARTS => [[MessagePart::KEY_TEXT => 'a']], + ]), + ]); + return true; + } catch (Exception $e) { + // If an exception occurs, the provider is not available. + return false; + } + } +} diff --git a/src/Providers/ListModelsApiBasedProviderAvailability.php b/src/Providers/ListModelsApiBasedProviderAvailability.php new file mode 100644 index 00000000..bedf81b4 --- /dev/null +++ b/src/Providers/ListModelsApiBasedProviderAvailability.php @@ -0,0 +1,54 @@ +modelMetadataDirectory = $modelMetadataDirectory; + } + + /** + * Checks whether the provider is available. + * + * @since n.e.x.t + * + * @return bool True if the provider is available, false otherwise. + */ + public function isConfigured(): bool + { + try { + // Attempt to list models to check if the provider is available. + $this->modelMetadataDirectory->listModelMetadata(); + return true; + } catch (Exception $e) { + // If an exception occurs, the provider is not available. + return false; + } + } +} diff --git a/src/Providers/Models/Traits/WithHttpTransporterTrait.php b/src/Providers/Models/Traits/WithHttpTransporterTrait.php new file mode 100644 index 00000000..251d7544 --- /dev/null +++ b/src/Providers/Models/Traits/WithHttpTransporterTrait.php @@ -0,0 +1,33 @@ +httpTransporter = $httpTransporter; + } + + public function getHttpTransporter(): HttpTransporterInterface + { + if ($this->httpTransporter === null) { + throw new RuntimeException( + 'HttpTransporterInterface instance not set. Make sure you use the AiClient class for all requests.' + ); + } + return $this->httpTransporter; + } +} From 72012124086c2cab4f25dfe1304b5e0297e9216f Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Sat, 9 Aug 2025 13:42:33 -0500 Subject: [PATCH 03/77] Integrate base class infra with the provider and model interfaces and fix some minor bugs. --- .../OpenAi/OpenAiModelMetadataDirectory.php | 13 ++++--- .../OpenAi/OpenAiProvider.php | 3 +- src/Providers/AbstractApiBasedModel.php | 35 ++++++----------- ...AbstractApiBasedModelMetadataDirectory.php | 23 ++--------- ...OpenAiCompatibleModelMetadataDirectory.php | 6 +-- ...actOpenAiCompatibleTextGenerationModel.php | 38 ++++++++++++++----- src/Providers/AbstractProvider.php | 26 ++----------- ...nerateTextApiBasedProviderAvailability.php | 14 +++---- ...ListModelsApiBasedProviderAvailability.php | 6 +-- .../Models/Contracts/ModelInterface.php | 10 +++++ 10 files changed, 74 insertions(+), 100 deletions(-) diff --git a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php index cd11151f..285de456 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php +++ b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php @@ -4,6 +4,7 @@ namespace WordPress\AiClient\ProviderImplementations\OpenAi; +use RuntimeException; use WordPress\AiClient\Providers\AbstractOpenAiCompatibleModelMetadataDirectory; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; @@ -34,11 +35,13 @@ protected function parseResponseToModelMetadataList(ResponseInterface $response) 'Unexpected API response: Missing the data key.' ); } - return array_map( - static function (array $modelData): ModelMetadata { - // TODO: Create ModelMetadata object from API data. - }, - $responseData['data'] + return array_values( + array_map( + static function (array $modelData): ModelMetadata { + // TODO: Create ModelMetadata object from API data. + }, + (array) $responseData['data'] + ) ); } } diff --git a/src/ProviderImplementations/OpenAi/OpenAiProvider.php b/src/ProviderImplementations/OpenAi/OpenAiProvider.php index 82891a35..cba2c73e 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiProvider.php +++ b/src/ProviderImplementations/OpenAi/OpenAiProvider.php @@ -4,6 +4,7 @@ namespace WordPress\AiClient\ProviderImplementations\OpenAi; +use RuntimeException; use WordPress\AiClient\Providers\AbstractProvider; use WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; @@ -29,7 +30,7 @@ protected static function createModel( ModelMetadata $modelMetadata, ProviderMetadata $providerMetadata ): ModelInterface { - $capabilities = $modelMetadata->getCapabilities(); + $capabilities = $modelMetadata->getSupportedCapabilities(); foreach ($capabilities as $capability) { if ($capability->isTextGeneration()) { return new OpenAiTextGenerationModel($modelMetadata, $providerMetadata); diff --git a/src/Providers/AbstractApiBasedModel.php b/src/Providers/AbstractApiBasedModel.php index b2b17542..6247b26f 100644 --- a/src/Providers/AbstractApiBasedModel.php +++ b/src/Providers/AbstractApiBasedModel.php @@ -15,6 +15,9 @@ /** * Base class for an API-based model for a provider. * + * While this class contains no abstract methods, it is still abstract to ensure that each model class can actually + * perform generative AI tasks by implementing the corresponding interfaces. + * * @since n.e.x.t */ abstract class AbstractApiBasedModel implements @@ -54,49 +57,33 @@ public function __construct(ModelMetadata $metadata, ProviderMetadata $providerM } /** - * Returns the metadata for the model. - * - * @since n.e.x.t - * - * @return ModelMetadata The model metadata. + * @inheritdoc */ - public function metadata(): ModelMetadata + final public function metadata(): ModelMetadata { return $this->metadata; } /** - * Returns the metadata for the model's provider. - * - * @since n.e.x.t - * - * @return ProviderMetadata The provider metadata. + * @inheritdoc */ - public function providerMetadata(): ProviderMetadata + final public function providerMetadata(): ProviderMetadata { return $this->providerMetadata; } /** - * Sets the configuration for the model. - * - * @since n.e.x.t - * - * @param ModelConfig $config The configuration for the model. + * @inheritdoc */ - public function setConfig(ModelConfig $config): void + final public function setConfig(ModelConfig $config): void { $this->config = $config; } /** - * Returns the configuration for the model. - * - * @since n.e.x.t - * - * @return ModelConfig The model configuration. + * @inheritdoc */ - public function getConfig(): ModelConfig + final public function getConfig(): ModelConfig { return $this->config; } diff --git a/src/Providers/AbstractApiBasedModelMetadataDirectory.php b/src/Providers/AbstractApiBasedModelMetadataDirectory.php index 9e2de8d5..5365c885 100644 --- a/src/Providers/AbstractApiBasedModelMetadataDirectory.php +++ b/src/Providers/AbstractApiBasedModelMetadataDirectory.php @@ -27,11 +27,7 @@ abstract class AbstractApiBasedModelMetadataDirectory implements private ?array $modelMetadataMap = null; /** - * Lists the metadata for all models from the provider. - * - * @since n.e.x.t - * - * @return list List of model metadata objects. + * @inheritdoc */ final public function listModelMetadata(): array { @@ -40,14 +36,7 @@ final public function listModelMetadata(): array } /** - * Checks whether model metadata for the given model ID exists. - * - * This is effectively a check for whether the given model ID is for a valid model from the provider. - * - * @since n.e.x.t - * - * @param string $modelId The model ID. - * @return bool True if there is metadata for the model. + * @inheritdoc */ final public function hasModelMetadata(string $modelId): bool { @@ -60,13 +49,7 @@ final public function hasModelMetadata(string $modelId): bool } /** - * Gets the model metadata for the given model ID. - * - * @since n.e.x.t - * - * @param string $modelId The model ID. - * @return ModelMetadata The model metadata. - * @throws InvalidArgumentException If the model for the given ID does not exist. + * @inheritdoc */ final public function getModelMetadata(string $modelId): ModelMetadata { diff --git a/src/Providers/AbstractOpenAiCompatibleModelMetadataDirectory.php b/src/Providers/AbstractOpenAiCompatibleModelMetadataDirectory.php index 42e8bedf..3417d6c2 100644 --- a/src/Providers/AbstractOpenAiCompatibleModelMetadataDirectory.php +++ b/src/Providers/AbstractOpenAiCompatibleModelMetadataDirectory.php @@ -14,11 +14,7 @@ abstract class AbstractOpenAiCompatibleModelMetadataDirectory extends AbstractApiBasedModelMetadataDirectory { /** - * Sends the API request to list models from the provider and returns the map of model ID to model metadata. - * - * @since n.e.x.t - * - * @return array Map of model ID to model metadata. + * @inheritdoc */ protected function sendListModelsRequest(): array { diff --git a/src/Providers/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/AbstractOpenAiCompatibleTextGenerationModel.php index 104db758..90a4c1df 100644 --- a/src/Providers/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/AbstractOpenAiCompatibleTextGenerationModel.php @@ -80,9 +80,11 @@ protected function prepareGenerateTextParams(array $prompt): array ]; $outputModalities = $config->getOutputModalities(); - $this->validateOutputModalities($outputModalities); - if (count($outputModalities) > 1) { - $params['modalities'] = $this->prepareModalitiesParam($outputModalities); + if (is_array($outputModalities)) { + $this->validateOutputModalities($outputModalities); + if (count($outputModalities) > 1) { + $params['modalities'] = $this->prepareOutputModalitiesParam($outputModalities); + } } // TODO: Prepare other parameters based on config. @@ -132,6 +134,12 @@ function (Message $message): array { $messageParts = $message->getParts(); if (count($messageParts) === 1 && $messageParts[0]->getType()->isFunctionResponse()) { $functionResponse = $messageParts[0]->getFunctionResponse(); + if (!$functionResponse) { + // This should be impossible due to class internals, but still needs to be checked. + throw new RuntimeException( + 'The function response typed message part must contain a function response.' + ); + } return [ 'role' => 'tool', 'content' => json_encode($functionResponse->getResponse()), @@ -144,13 +152,13 @@ function (Message $message): array { function (MessagePart $part): ?array { return $this->getMessagePartContentData($part); }, - $messageParts() + $messageParts )), 'tool_calls' => array_filter(array_map( function (MessagePart $part): ?array { return $this->getMessagePartToolCallData($part); }, - $messageParts() + $messageParts )), ]; }, @@ -197,6 +205,12 @@ protected function getMessagePartContentData(MessagePart $part): ?array } if ($type->isFile()) { $file = $part->getFile(); + if (!$file) { + // This should be impossible due to class internals, but still needs to be checked. + throw new RuntimeException( + 'The file typed message part must contain a file.' + ); + } if ($file->getFileType()->isRemote()) { if ($file->isImage()) { return [ @@ -265,11 +279,17 @@ protected function getMessagePartContentData(MessagePart $part): ?array * @return ?array The data for the message tool call part, or null if not applicable. * @throws InvalidArgumentException If the message part type or data is unsupported. */ - protected function getMessagePartToolCallData(MessagePart $part): array + protected function getMessagePartToolCallData(MessagePart $part): ?array { $type = $part->getType(); if ($type->isFunctionCall()) { $functionCall = $part->getFunctionCall(); + if (!$functionCall) { + // This should be impossible due to class internals, but still needs to be checked. + throw new RuntimeException( + 'The function call typed message part must contain a function call.' + ); + } return [ 'type' => 'function', 'id' => $functionCall->getId(), @@ -310,14 +330,14 @@ protected function validateOutputModalities(array $outputModalities): void } /** - * Prepares the modalities parameter for the API request. + * Prepares the output modalities parameter for the API request. * * @since n.e.x.t * * @param array $modalities The modalities to prepare. - * @return array The prepared modalities parameter. + * @return list The prepared modalities parameter. */ - protected function prepareModalitiesParam(array $modalities): array + protected function prepareOutputModalitiesParam(array $modalities): array { $prepared = []; foreach ($modalities as $modality) { diff --git a/src/Providers/AbstractProvider.php b/src/Providers/AbstractProvider.php index 3268d6a1..5d8e2ef9 100644 --- a/src/Providers/AbstractProvider.php +++ b/src/Providers/AbstractProvider.php @@ -35,11 +35,7 @@ abstract class AbstractProvider implements ProviderInterface private static array $modelMetadataDirectoryCache = []; /** - * Returns the metadata for the provider. - * - * @since n.e.x.t - * - * @return ProviderMetadata The provider metadata. + * @inheritdoc */ final public static function metadata(): ProviderMetadata { @@ -51,13 +47,7 @@ final public static function metadata(): ProviderMetadata } /** - * Creates a model instance based on the given model ID and configuration. - * - * @since n.e.x.t - * - * @param string $modelId The model's unique identifier. - * @param ModelConfig|null $modelConfig Optional configuration for the model. - * @return ModelInterface The new model instance. + * @inheritdoc */ final public static function model(string $modelId, ?ModelConfig $modelConfig = null): ModelInterface { @@ -72,11 +62,7 @@ final public static function model(string $modelId, ?ModelConfig $modelConfig = } /** - * Returns the availability for the provider. - * - * @since n.e.x.t - * - * @return ProviderAvailabilityInterface The availability. + * @inheritdoc */ final public static function availability(): ProviderAvailabilityInterface { @@ -88,11 +74,7 @@ final public static function availability(): ProviderAvailabilityInterface } /** - * Returns the model metadata directory for the provider. - * - * @since n.e.x.t - * - * @return ModelMetadataDirectoryInterface The model metadata directory. + * @inheritdoc */ final public static function modelMetadataDirectory(): ModelMetadataDirectoryInterface { diff --git a/src/Providers/GenerateTextApiBasedProviderAvailability.php b/src/Providers/GenerateTextApiBasedProviderAvailability.php index ef5ebbc9..5c2fe8f4 100644 --- a/src/Providers/GenerateTextApiBasedProviderAvailability.php +++ b/src/Providers/GenerateTextApiBasedProviderAvailability.php @@ -43,11 +43,7 @@ public function __construct(ModelInterface $model) } /** - * Checks whether the provider is available. - * - * @since n.e.x.t - * - * @return bool True if the provider is available, false otherwise. + * @inheritdoc */ public function isConfigured(): bool { @@ -60,10 +56,10 @@ public function isConfigured(): bool try { // Attempt to generate text to check if the provider is available. $this->model->generateTextResult([ - Message::fromArray([ - Message::KEY_ROLE => MessageRoleEnum::user(), - Message::KEY_PARTS => [[MessagePart::KEY_TEXT => 'a']], - ]), + new Message( + MessageRoleEnum::user(), + [new MessagePart('a')] + ), ]); return true; } catch (Exception $e) { diff --git a/src/Providers/ListModelsApiBasedProviderAvailability.php b/src/Providers/ListModelsApiBasedProviderAvailability.php index bedf81b4..491fa64d 100644 --- a/src/Providers/ListModelsApiBasedProviderAvailability.php +++ b/src/Providers/ListModelsApiBasedProviderAvailability.php @@ -34,11 +34,7 @@ public function __construct(ModelMetadataDirectoryInterface $modelMetadataDirect } /** - * Checks whether the provider is available. - * - * @since n.e.x.t - * - * @return bool True if the provider is available, false otherwise. + * @inheritdoc */ public function isConfigured(): bool { diff --git a/src/Providers/Models/Contracts/ModelInterface.php b/src/Providers/Models/Contracts/ModelInterface.php index e0448e0f..1c6adbc7 100644 --- a/src/Providers/Models/Contracts/ModelInterface.php +++ b/src/Providers/Models/Contracts/ModelInterface.php @@ -4,6 +4,7 @@ namespace WordPress\AiClient\Providers\Models\Contracts; +use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; @@ -26,6 +27,15 @@ interface ModelInterface */ public function metadata(): ModelMetadata; + /** + * Returns the metadata for the model's provider. + * + * @since n.e.x.t + * + * @return ProviderMetadata The provider metadata. + */ + public function providerMetadata(): ProviderMetadata; + /** * Sets model configuration. * From c2a7c3e52ba1abdcfe382e3df3896a037316debb Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Sat, 9 Aug 2025 13:46:00 -0500 Subject: [PATCH 04/77] Move abstract model classes to Models namespace. --- .../OpenAi/OpenAiTextGenerationModel.php | 2 +- src/Providers/{ => Models}/AbstractApiBasedModel.php | 2 +- .../AbstractOpenAiCompatibleTextGenerationModel.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename src/Providers/{ => Models}/AbstractApiBasedModel.php (97%) rename src/Providers/{ => Models}/AbstractOpenAiCompatibleTextGenerationModel.php (99%) diff --git a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php index 7871857d..27d3db44 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php +++ b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\ProviderImplementations\OpenAi; -use WordPress\AiClient\Providers\AbstractOpenAiCompatibleTextGenerationModel; +use WordPress\AiClient\Providers\Models\AbstractOpenAiCompatibleTextGenerationModel; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; /** diff --git a/src/Providers/AbstractApiBasedModel.php b/src/Providers/Models/AbstractApiBasedModel.php similarity index 97% rename from src/Providers/AbstractApiBasedModel.php rename to src/Providers/Models/AbstractApiBasedModel.php index 6247b26f..f553019a 100644 --- a/src/Providers/AbstractApiBasedModel.php +++ b/src/Providers/Models/AbstractApiBasedModel.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace WordPress\AiClient\Providers; +namespace WordPress\AiClient\Providers\Models; use InvalidArgumentException; use WordPress\AiClient\Providers\DTO\ProviderMetadata; diff --git a/src/Providers/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php similarity index 99% rename from src/Providers/AbstractOpenAiCompatibleTextGenerationModel.php rename to src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php index 90a4c1df..738b6901 100644 --- a/src/Providers/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace WordPress\AiClient\Providers; +namespace WordPress\AiClient\Providers\Models; use Generator; use InvalidArgumentException; From d9b32fcc66e2ca662f1efce2ed460dffc43dbbba Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Sat, 9 Aug 2025 14:47:50 -0500 Subject: [PATCH 05/77] Implement OpenAI specific logic for parsing model metadata from the API. --- .../OpenAi/OpenAiModelMetadataDirectory.php | 139 +++++++++++++++++- 1 file changed, 137 insertions(+), 2 deletions(-) diff --git a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php index 285de456..68390da0 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php +++ b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php @@ -5,8 +5,13 @@ namespace WordPress\AiClient\ProviderImplementations\OpenAi; use RuntimeException; +use WordPress\AiClient\Files\Enums\FileTypeEnum; +use WordPress\AiClient\Messages\Enums\ModalityEnum; use WordPress\AiClient\Providers\AbstractOpenAiCompatibleModelMetadataDirectory; +use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; +use WordPress\AiClient\Providers\Models\DTO\SupportedOption; +use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; /** * Class for the OpenAI model metadata directory. @@ -35,10 +40,140 @@ protected function parseResponseToModelMetadataList(ResponseInterface $response) 'Unexpected API response: Missing the data key.' ); } + + // Unfortunately, the OpenAI API does not return model capabilities, so we have to hardcode them here. + $gptCapabilities = [ + CapabilityEnum::textGeneration(), + CapabilityEnum::chatHistory(), + ]; + $gptOptions = [ + new SupportedOption(ModelConfig::KEY_SYSTEM_INSTRUCTION), + new SupportedOption(ModelConfig::KEY_CANDIDATE_COUNT), + new SupportedOption(ModelConfig::KEY_MAX_TOKENS), + new SupportedOption(ModelConfig::KEY_TEMPERATURE), + new SupportedOption(ModelConfig::KEY_TOP_P), + new SupportedOption(ModelConfig::KEY_STOP_SEQUENCES), + new SupportedOption(ModelConfig::KEY_PRESENCE_PENALTY), + new SupportedOption(ModelConfig::KEY_FREQUENCY_PENALTY), + new SupportedOption(ModelConfig::KEY_LOGPROBS), + new SupportedOption(ModelConfig::KEY_TOP_LOGPROBS), + new SupportedOption(ModelConfig::KEY_OUTPUT_MIME_TYPE, ['text/plain', 'application/json']), + new SupportedOption(ModelConfig::KEY_OUTPUT_SCHEMA), + // TODO: Where to put this as a constant? + new SupportedOption('functionCalling'), + ]; + $gptMultimodalInputOptions = $gptOptions + [ + new SupportedOption( + // TODO: Where to put this as a constant? + 'inputModalities', + [ + [ModalityEnum::text()], + [ModalityEnum::text(), ModalityEnum::image()], + [ModalityEnum::text(), ModalityEnum::image(), ModalityEnum::audio()], + ] + ), + ]; + $gptMultimodalSpeechOutputOptions = $gptMultimodalInputOptions + [ + new SupportedOption( + ModelConfig::KEY_OUTPUT_MODALITIES, + [ + [ModalityEnum::text()], + [ModalityEnum::text(), ModalityEnum::audio()], + ] + ), + ]; + $imageCapabilities = [ + CapabilityEnum::imageGeneration(), + ]; + $dalleImageOptions = [ + new SupportedOption(ModelConfig::KEY_CANDIDATE_COUNT), + new SupportedOption(ModelConfig::KEY_OUTPUT_MIME_TYPE, ['image/png']), + // TODO: Where to put this as a constant? + new SupportedOption('outputFileType', [FileTypeEnum::inline(), FileTypeEnum::remote()]), + // TODO: Where to put this as a constant? + new SupportedOption('imageOrientation', ['square', 'landscape', 'portrait']), + // TODO: Where to put this as a constant? + new SupportedOption('imageAspectRatio', ['1:1', '7:4', '4:7']), + ]; + $gptImageOptions = [ + new SupportedOption(ModelConfig::KEY_CANDIDATE_COUNT), + new SupportedOption(ModelConfig::KEY_OUTPUT_MIME_TYPE, ['image/png', 'image/jpeg', 'image/webp']), + // TODO: Where to put this as a constant? + new SupportedOption('outputFileType', [FileTypeEnum::inline()]), + // TODO: Where to put this as a constant? + new SupportedOption('imageOrientation', ['square', 'landscape', 'portrait']), + // TODO: Where to put this as a constant? + new SupportedOption('imageAspectRatio', ['1:1', '3:2', '2:3']), + ]; + $ttsCapabilities = [ + CapabilityEnum::textToSpeechConversion(), + ]; + $ttsOptions = [ + new SupportedOption(ModelConfig::KEY_OUTPUT_MIME_TYPE, ['audio/mpeg', 'audio/ogg', 'audio/wav']), + // TODO: Where to put this as a constant? + new SupportedOption('voice'), + ]; + return array_values( array_map( - static function (array $modelData): ModelMetadata { - // TODO: Create ModelMetadata object from API data. + static function (array $modelData) use ( + $gptCapabilities, + $gptOptions, + $gptMultimodalInputOptions, + $gptMultimodalSpeechOutputOptions, + $imageCapabilities, + $dalleImageOptions, + $gptImageOptions, + $ttsCapabilities, + $ttsOptions, + ): ModelMetadata { + $modelId = $modelData['id']; + if ( + str_starts_with($modelId, 'dall-e-') || + str_starts_with($modelId, 'gpt-image-') + ) { + $modelCaps = $imageCapabilities; + if (str_starts_with($modelId, 'gpt-image-')) { + $modelOptions = $gptImageOptions; + } else { + $modelOptions = $dalleImageOptions; + } + } elseif ( + str_starts_with($modelId, 'tts-') || + str_contains($modelId, '-tts') + ) { + $modelCaps = $ttsCapabilities; + $modelOptions = $ttsOptions; + } elseif ( + (str_starts_with($modelId, 'gpt-') || str_starts_with($modelId, 'o1-')) + && !str_contains($modelId, '-instruct') + && !str_contains($modelId, '-realtime') + ) { + if (str_starts_with($modelId, 'gpt-4o')) { + $modelCaps = $gptCapabilities; + $modelOptions = $gptMultimodalInputOptions; + // New multimodal output model for audio generation. + if (str_contains($modelId, '-audio')) { + $modelOptions = $gptMultimodalSpeechOutputOptions; + } + } elseif (!str_contains($modelId, '-audio')) { + $modelCaps = $gptCapabilities; + $modelOptions = $gptOptions; + } else { + $modelCaps = []; + $modelOptions = []; + } + } else { + $modelCaps = []; + $modelOptions = []; + } + + return new ModelMetadata( + $modelId, + $modelId, // The OpenAI API does not return a display name. + $modelCaps, + $modelOptions + ); }, (array) $responseData['data'] ) From cd539639a36e1bb0426a3c7729f5eca322b891c4 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Thu, 14 Aug 2025 15:36:03 -0500 Subject: [PATCH 06/77] Fix unnecessary use statements. --- src/ProviderImplementations/OpenAi/OpenAiProvider.php | 2 -- .../OpenAi/OpenAiTextGenerationModel.php | 1 - src/Providers/Models/AbstractApiBasedModel.php | 1 - .../Models/AbstractOpenAiCompatibleTextGenerationModel.php | 1 - 4 files changed, 5 deletions(-) diff --git a/src/ProviderImplementations/OpenAi/OpenAiProvider.php b/src/ProviderImplementations/OpenAi/OpenAiProvider.php index cba2c73e..5b2941d6 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiProvider.php +++ b/src/ProviderImplementations/OpenAi/OpenAiProvider.php @@ -8,12 +8,10 @@ use WordPress\AiClient\Providers\AbstractProvider; use WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; -use WordPress\AiClient\Providers\Contracts\ProviderInterface; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; use WordPress\AiClient\Providers\ListModelsApiBasedProviderAvailability; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; -use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; /** diff --git a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php index 27d3db44..6f5ddf69 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php +++ b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php @@ -5,7 +5,6 @@ namespace WordPress\AiClient\ProviderImplementations\OpenAi; use WordPress\AiClient\Providers\Models\AbstractOpenAiCompatibleTextGenerationModel; -use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; /** * Class for an OpenAI text generation model. diff --git a/src/Providers/Models/AbstractApiBasedModel.php b/src/Providers/Models/AbstractApiBasedModel.php index f553019a..282a14a2 100644 --- a/src/Providers/Models/AbstractApiBasedModel.php +++ b/src/Providers/Models/AbstractApiBasedModel.php @@ -4,7 +4,6 @@ namespace WordPress\AiClient\Providers\Models; -use InvalidArgumentException; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\Contracts\WithHttpTransporterInterface; diff --git a/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php index 738b6901..4435d3b2 100644 --- a/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php @@ -7,7 +7,6 @@ use Generator; use InvalidArgumentException; use RuntimeException; -use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\SystemMessage; From 30e63ba24b08d0de43f0ced3b68a283f5fc89343 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Thu, 14 Aug 2025 15:43:53 -0500 Subject: [PATCH 07/77] Use new ModelConfig constants for supported options. --- .../OpenAi/OpenAiModelMetadataDirectory.php | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php index 68390da0..4fd0111d 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php +++ b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php @@ -6,6 +6,7 @@ use RuntimeException; use WordPress\AiClient\Files\Enums\FileTypeEnum; +use WordPress\AiClient\Files\Enums\MediaOrientationEnum; use WordPress\AiClient\Messages\Enums\ModalityEnum; use WordPress\AiClient\Providers\AbstractOpenAiCompatibleModelMetadataDirectory; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; @@ -59,8 +60,7 @@ protected function parseResponseToModelMetadataList(ResponseInterface $response) new SupportedOption(ModelConfig::KEY_TOP_LOGPROBS), new SupportedOption(ModelConfig::KEY_OUTPUT_MIME_TYPE, ['text/plain', 'application/json']), new SupportedOption(ModelConfig::KEY_OUTPUT_SCHEMA), - // TODO: Where to put this as a constant? - new SupportedOption('functionCalling'), + new SupportedOption(ModelConfig::KEY_FUNCTION_DECLARATIONS), ]; $gptMultimodalInputOptions = $gptOptions + [ new SupportedOption( @@ -88,22 +88,24 @@ protected function parseResponseToModelMetadataList(ResponseInterface $response) $dalleImageOptions = [ new SupportedOption(ModelConfig::KEY_CANDIDATE_COUNT), new SupportedOption(ModelConfig::KEY_OUTPUT_MIME_TYPE, ['image/png']), - // TODO: Where to put this as a constant? - new SupportedOption('outputFileType', [FileTypeEnum::inline(), FileTypeEnum::remote()]), - // TODO: Where to put this as a constant? - new SupportedOption('imageOrientation', ['square', 'landscape', 'portrait']), - // TODO: Where to put this as a constant? - new SupportedOption('imageAspectRatio', ['1:1', '7:4', '4:7']), + new SupportedOption(ModelConfig::KEY_OUTPUT_FILE_TYPE, [FileTypeEnum::inline(), FileTypeEnum::remote()]), + new SupportedOption(ModelConfig::KEY_OUTPUT_MEDIA_ORIENTATION, [ + MediaOrientationEnum::square(), + MediaOrientationEnum::landscape(), + MediaOrientationEnum::portrait(), + ]), + new SupportedOption(ModelConfig::KEY_OUTPUT_MEDIA_ASPECT_RATIO, ['1:1', '7:4', '4:7']), ]; $gptImageOptions = [ new SupportedOption(ModelConfig::KEY_CANDIDATE_COUNT), new SupportedOption(ModelConfig::KEY_OUTPUT_MIME_TYPE, ['image/png', 'image/jpeg', 'image/webp']), - // TODO: Where to put this as a constant? - new SupportedOption('outputFileType', [FileTypeEnum::inline()]), - // TODO: Where to put this as a constant? - new SupportedOption('imageOrientation', ['square', 'landscape', 'portrait']), - // TODO: Where to put this as a constant? - new SupportedOption('imageAspectRatio', ['1:1', '3:2', '2:3']), + new SupportedOption(ModelConfig::KEY_OUTPUT_FILE_TYPE, [FileTypeEnum::inline()]), + new SupportedOption(ModelConfig::KEY_OUTPUT_MEDIA_ORIENTATION, [ + MediaOrientationEnum::square(), + MediaOrientationEnum::landscape(), + MediaOrientationEnum::portrait(), + ]), + new SupportedOption(ModelConfig::KEY_OUTPUT_MEDIA_ASPECT_RATIO, ['1:1', '3:2', '2:3']), ]; $ttsCapabilities = [ CapabilityEnum::textToSpeechConversion(), From 5cc0e0302ec251de817e158a86dab75e105f82e4 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Thu, 14 Aug 2025 16:26:52 -0500 Subject: [PATCH 08/77] Implement OpenAI compatible response message parsing. --- ...actOpenAiCompatibleTextGenerationModel.php | 95 ++++++++++++++++++- 1 file changed, 92 insertions(+), 3 deletions(-) diff --git a/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php index 4435d3b2..a80ecc77 100644 --- a/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php @@ -10,6 +10,7 @@ use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\SystemMessage; +use WordPress\AiClient\Messages\Enums\MessagePartChannelEnum; use WordPress\AiClient\Messages\Enums\MessageRoleEnum; use WordPress\AiClient\Messages\Enums\ModalityEnum; use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface; @@ -17,6 +18,7 @@ use WordPress\AiClient\Results\DTO\GenerativeAiResult; use WordPress\AiClient\Results\DTO\TokenUsage; use WordPress\AiClient\Results\Enums\FinishReasonEnum; +use WordPress\AiClient\Tools\DTO\FunctionCall; /** * Base class for a text generation model for an OpenAI compatible provider. @@ -437,14 +439,14 @@ protected function parseResponseChoiceToCandidate(array $choice): Candidate ); } - // TODO: Correctly implement this, as this is not correct - 'message' isn't just a string. - $message = new Message($choice['message']); - if (!isset($choice['finish_reason']) || !is_string($choice['finish_reason'])) { throw new RuntimeException( 'Unexpected API response: Each choice must contain a finish_reason key with a string value.' ); } + + $message = $this->parseResponseChoiceMessage($choice['message']); + switch ($choice['finish_reason']) { case 'stop': $finishReason = FinishReasonEnum::stop(); @@ -469,4 +471,91 @@ protected function parseResponseChoiceToCandidate(array $choice): Candidate return new Candidate($message, $finishReason); } + + /** + * Parses the message from a choice in the API response. + * + * @since n.e.x.t + * + * @param array $message The message data from the API response. + * @return Message The parsed message. + */ + protected function parseResponseChoiceMessage(array $message): Message + { + $role = isset($message['role']) && 'user' === $message['role'] + ? MessageRoleEnum::user() + : MessageRoleEnum::model(); + + $parts = $this->parseResponseChoiceMessageParts($message); + + return new Message($role, $parts); + } + + /** + * Parses the message parts from a choice in the API response. + * + * @since n.e.x.t + * + * @param array $message The message data from the API response. + * @return MessagePart[] The parsed message parts. + */ + protected function parseResponseChoiceMessageParts(array $message): array + { + $parts = []; + + if (isset($message['reasoning_content']) && is_string($message['reasoning_content'])) { + $parts[] = new MessagePart($message['reasoning_content'], MessagePartChannelEnum::thought()); + } + + if (isset($message['content']) && is_string($message['content'])) { + $parts[] = new MessagePart($message['content']); + } + + if (isset($message['tool_calls']) && is_array($message['tool_calls'])) { + foreach ($message['tool_calls'] as $toolCall) { + $toolCallPart = $this->parseResponseChoiceMessageToolCallPart($toolCall); + if (!$toolCallPart) { + throw new RuntimeException( + 'Unexpected API response: The response includes a tool call of an unexpected type.' + ); + } + $parts[] = $toolCallPart; + } + } + + return $parts; + } + + /** + * Parses a tool call part from the API response. + * + * @since n.e.x.t + * + * @param array $toolCall The tool call data from the API response. + * @return MessagePart|null The parsed message part for the tool call, or null if not applicable. + */ + protected function parseResponseChoiceMessageToolCallPart(array $toolCall): ?MessagePart + { + /* + * For now, only function calls are supported. + * + * Not all OpenAI compatible APIs include a 'type' key, so we only check its value if it is set. + */ + if ( + (isset($toolCall['type']) && 'function' !== $toolCall['type']) || + !isset($toolCall['function']) + ) { + return null; + } + + $functionCall = new FunctionCall( + $toolCall['id'] ?? null, + $toolCall['function']['name'], + is_string($toolCall['function']['arguments']) + ? json_decode($toolCall['function']['arguments'], true) + : $toolCall['function']['arguments'] + ); + + return new MessagePart($functionCall); + } } From 50c3c0e56846e832333976e8ed02305df11223d3 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Fri, 15 Aug 2025 15:46:28 -0500 Subject: [PATCH 09/77] Integrate with actual Http classes now that they're implemented. --- .../OpenAi/OpenAiModelMetadataDirectory.php | 6 +++-- .../OpenAi/OpenAiTextGenerationModel.php | 3 ++- ...AbstractApiBasedModelMetadataDirectory.php | 4 +-- ...OpenAiCompatibleModelMetadataDirectory.php | 25 +++++++++---------- .../Traits/WithHttpTransporterTrait.php | 4 +-- .../Models/AbstractApiBasedModel.php | 4 +-- ...actOpenAiCompatibleTextGenerationModel.php | 12 +++++---- 7 files changed, 31 insertions(+), 27 deletions(-) rename src/Providers/{Models => Http}/Traits/WithHttpTransporterTrait.php (85%) diff --git a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php index 4fd0111d..139e4b15 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php +++ b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php @@ -9,6 +9,8 @@ use WordPress\AiClient\Files\Enums\MediaOrientationEnum; use WordPress\AiClient\Messages\Enums\ModalityEnum; use WordPress\AiClient\Providers\AbstractOpenAiCompatibleModelMetadataDirectory; +use WordPress\AiClient\Providers\Http\DTO\Request; +use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; use WordPress\AiClient\Providers\Models\DTO\SupportedOption; @@ -24,7 +26,7 @@ class OpenAiModelMetadataDirectory extends AbstractOpenAiCompatibleModelMetadata /** * @inheritDoc */ - protected function createRequest(string $path): RequestInterface + protected function createRequest(string $path): Request { // Something like this. return new OpenAiCompatibleRequest('https://api.openai.com/v1', $path); @@ -33,7 +35,7 @@ protected function createRequest(string $path): RequestInterface /** * @inheritDoc */ - protected function parseResponseToModelMetadataList(ResponseInterface $response): array + protected function parseResponseToModelMetadataList(Response $response): array { $responseData = $response->getData(); if (!isset($responseData['data']) || !$responseData['data']) { diff --git a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php index 6f5ddf69..d25d7a16 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php +++ b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php @@ -4,6 +4,7 @@ namespace WordPress\AiClient\ProviderImplementations\OpenAi; +use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Models\AbstractOpenAiCompatibleTextGenerationModel; /** @@ -16,7 +17,7 @@ class OpenAiTextGenerationModel extends AbstractOpenAiCompatibleTextGenerationMo /** * @inheritDoc */ - protected function createRequest(string $path, array $params): RequestInterface + protected function createRequest(string $path, array $params): Request { // Something like this. return new OpenAiCompatibleRequest('https://api.openai.com/v1', $path); diff --git a/src/Providers/AbstractApiBasedModelMetadataDirectory.php b/src/Providers/AbstractApiBasedModelMetadataDirectory.php index 5365c885..873dd35d 100644 --- a/src/Providers/AbstractApiBasedModelMetadataDirectory.php +++ b/src/Providers/AbstractApiBasedModelMetadataDirectory.php @@ -6,9 +6,9 @@ use InvalidArgumentException; use WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface; -use WordPress\AiClient\Providers\Models\Contracts\WithHttpTransporterInterface; +use WordPress\AiClient\Providers\Http\Contracts\WithHttpTransporterInterface; +use WordPress\AiClient\Providers\Http\Traits\WithHttpTransporterTrait; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; -use WordPress\AiClient\Providers\Models\Traits\WithHttpTransporterTrait; /** * Base class for an API-based model metadata directory for a provider. diff --git a/src/Providers/AbstractOpenAiCompatibleModelMetadataDirectory.php b/src/Providers/AbstractOpenAiCompatibleModelMetadataDirectory.php index 3417d6c2..33c122c7 100644 --- a/src/Providers/AbstractOpenAiCompatibleModelMetadataDirectory.php +++ b/src/Providers/AbstractOpenAiCompatibleModelMetadataDirectory.php @@ -4,6 +4,8 @@ namespace WordPress\AiClient\Providers; +use WordPress\AiClient\Providers\Http\DTO\Request; +use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; /** @@ -22,19 +24,16 @@ protected function sendListModelsRequest(): array // Something like this. $request = $this->createRequest('models'); - $response = $httpTransporter->sendRequest($request); + $response = $httpTransporter->send($request); $modelsMetadataList = $this->parseResponseToModelMetadataList($response); // Parse list to map. - return array_reduce( - $modelsMetadataList, - static function (array $carry, ModelMetadata $metadata) { - $carry[$metadata->getId()] = $metadata; - return $carry; - }, - [] - ); + $modelMetadataMap = []; + foreach ($modelsMetadataList as $modelMetadata) { + $modelMetadataMap[$modelMetadata->getId()] = $modelMetadata; + } + return $modelMetadataMap; } /** @@ -43,17 +42,17 @@ static function (array $carry, ModelMetadata $metadata) { * @since n.e.x.t * * @param string $path The API endpoint path, relative to the base URI. - * @return RequestInterface The request object. + * @return Request The request object. */ - abstract protected function createRequest(string $path): RequestInterface; + abstract protected function createRequest(string $path): Request; /** * Parses the response from the API endpoint to list models into a list of model metadata objects. * * @since n.e.x.t * - * @param ResponseInterface $response The response from the API endpoint to list models. + * @param Response $response The response from the API endpoint to list models. * @return list List of model metadata objects. */ - abstract protected function parseResponseToModelMetadataList(ResponseInterface $response): array; + abstract protected function parseResponseToModelMetadataList(Response $response): array; } diff --git a/src/Providers/Models/Traits/WithHttpTransporterTrait.php b/src/Providers/Http/Traits/WithHttpTransporterTrait.php similarity index 85% rename from src/Providers/Models/Traits/WithHttpTransporterTrait.php rename to src/Providers/Http/Traits/WithHttpTransporterTrait.php index 251d7544..29313be0 100644 --- a/src/Providers/Models/Traits/WithHttpTransporterTrait.php +++ b/src/Providers/Http/Traits/WithHttpTransporterTrait.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace WordPress\AiClient\Providers\Models\Traits; +namespace WordPress\AiClient\Providers\Http\Traits; use RuntimeException; -use WordPress\AiClient\Providers\Contracts\HttpTransporterInterface; +use WordPress\AiClient\Providers\Http\Contracts\HttpTransporterInterface; /** * Trait for a class that implements WithHttpTransporterInterface. diff --git a/src/Providers/Models/AbstractApiBasedModel.php b/src/Providers/Models/AbstractApiBasedModel.php index 282a14a2..c94dba18 100644 --- a/src/Providers/Models/AbstractApiBasedModel.php +++ b/src/Providers/Models/AbstractApiBasedModel.php @@ -5,11 +5,11 @@ namespace WordPress\AiClient\Providers\Models; use WordPress\AiClient\Providers\DTO\ProviderMetadata; +use WordPress\AiClient\Providers\Http\Contracts\WithHttpTransporterInterface; +use WordPress\AiClient\Providers\Http\Traits\WithHttpTransporterTrait; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; -use WordPress\AiClient\Providers\Models\Contracts\WithHttpTransporterInterface; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; -use WordPress\AiClient\Providers\Models\Traits\WithHttpTransporterTrait; /** * Base class for an API-based model for a provider. diff --git a/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php index a80ecc77..009ec226 100644 --- a/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php @@ -13,6 +13,8 @@ use WordPress\AiClient\Messages\Enums\MessagePartChannelEnum; use WordPress\AiClient\Messages\Enums\MessageRoleEnum; use WordPress\AiClient\Messages\Enums\ModalityEnum; +use WordPress\AiClient\Providers\Http\DTO\Request; +use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface; use WordPress\AiClient\Results\DTO\Candidate; use WordPress\AiClient\Results\DTO\GenerativeAiResult; @@ -39,7 +41,7 @@ final public function generateTextResult(array $prompt): GenerativeAiResult // Something like this. $request = $this->createRequest('chat/completions', $params); - $response = $httpTransporter->sendRequest($request); + $response = $httpTransporter->send($request); return $this->parseResponseToGenerativeAiResult($response); } @@ -367,19 +369,19 @@ protected function prepareOutputModalitiesParam(array $modalities): array * * @param string $path The API endpoint path, relative to the base URI. * @param array $params The parameters for the API request. - * @return RequestInterface The request object. + * @return Request The request object. */ - abstract protected function createRequest(string $path, array $params): RequestInterface; + abstract protected function createRequest(string $path, array $params): Request; /** * Parses the response from the API endpoint to a generative AI result. * * @since n.e.x.t * - * @param ResponseInterface $response The response from the API endpoint. + * @param Response $response The response from the API endpoint. * @return GenerativeAiResult The parsed generative AI result. */ - protected function parseResponseToGenerativeAiResult(ResponseInterface $response): GenerativeAiResult + protected function parseResponseToGenerativeAiResult(Response $response): GenerativeAiResult { $responseData = $response->getData(); if (!isset($responseData['choices']) || !$responseData['choices']) { From c73e812e67df37c49648d8f4f069495e22ad4b08 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Fri, 15 Aug 2025 15:46:59 -0500 Subject: [PATCH 10/77] Fix missing parameter. --- src/Providers/AbstractApiBasedModelMetadataDirectory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Providers/AbstractApiBasedModelMetadataDirectory.php b/src/Providers/AbstractApiBasedModelMetadataDirectory.php index 873dd35d..114907ac 100644 --- a/src/Providers/AbstractApiBasedModelMetadataDirectory.php +++ b/src/Providers/AbstractApiBasedModelMetadataDirectory.php @@ -41,7 +41,7 @@ final public function listModelMetadata(): array final public function hasModelMetadata(string $modelId): bool { try { - $this->getModelMetadata(); + $this->getModelMetadata($modelId); } catch (InvalidArgumentException $e) { return false; } From 90f187c9349b9953c71a9eafd95972fcdb91c85b Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Fri, 15 Aug 2025 16:06:10 -0500 Subject: [PATCH 11/77] Fix remaining PHPStan problems in abstract text generation model class. --- ...actOpenAiCompatibleTextGenerationModel.php | 101 +++++++++++------- 1 file changed, 63 insertions(+), 38 deletions(-) diff --git a/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php index 009ec226..49aa35e3 100644 --- a/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php @@ -396,21 +396,31 @@ protected function parseResponseToGenerativeAiResult(Response $response): Genera } $candidates = []; - foreach ($responseData['choices'] as $choice) { - if (!is_array($choice)) { + foreach ($responseData['choices'] as $choiceData) { + if (!is_array($choiceData) || array_is_list($choiceData)) { throw new RuntimeException( 'Unexpected API response: Each element in the choices key must be an associative array.' ); } - $candidates[] = $this->parseResponseChoiceToCandidate($choice); + + /** @var array $choiceData */ + $candidates[] = $this->parseResponseChoiceToCandidate($choiceData); } - $id = $responseData['id'] ?? ''; - $tokenUsage = new TokenUsage( - $responseData['usage']['prompt_tokens'] ?? 0, - $responseData['usage']['completion_tokens'] ?? 0, - $responseData['usage']['total_tokens'] ?? 0 - ); + $id = isset($responseData['id']) && is_string($responseData['id']) ? $responseData['id'] : ''; + + if (isset($responseData['usage']) && is_array($responseData['usage'])) { + /** @var array $usage */ + $usage = $responseData['usage']; + + $tokenUsage = new TokenUsage( + $usage['prompt_tokens'] ?? 0, + $usage['completion_tokens'] ?? 0, + $usage['total_tokens'] ?? 0 + ); + } else { + $tokenUsage = new TokenUsage(0, 0, 0); + } // Use any other data from the response as provider metadata. $providerMetadata = $responseData; @@ -429,27 +439,33 @@ protected function parseResponseToGenerativeAiResult(Response $response): Genera * * @since n.e.x.t * - * @param array $choice The choice data from the API response. + * @param array $choiceData The choice data from the API response. * @return Candidate The parsed candidate. * @throws RuntimeException If the choice data is invalid. */ - protected function parseResponseChoiceToCandidate(array $choice): Candidate + protected function parseResponseChoiceToCandidate(array $choiceData): Candidate { - if (!isset($choice['message']) || !is_array($choice['message'])) { + if ( + !isset($choiceData['message']) || + !is_array($choiceData['message']) || + array_is_list($choiceData['message']) + ) { throw new RuntimeException( 'Unexpected API response: Each choice must contain a message key with an associative array.' ); } - if (!isset($choice['finish_reason']) || !is_string($choice['finish_reason'])) { + if (!isset($choiceData['finish_reason']) || !is_string($choiceData['finish_reason'])) { throw new RuntimeException( 'Unexpected API response: Each choice must contain a finish_reason key with a string value.' ); } - $message = $this->parseResponseChoiceMessage($choice['message']); + /** @var array $messageData */ + $messageData = $choiceData['message']; + $message = $this->parseResponseChoiceMessage($messageData); - switch ($choice['finish_reason']) { + switch ($choiceData['finish_reason']) { case 'stop': $finishReason = FinishReasonEnum::stop(); break; @@ -466,7 +482,7 @@ protected function parseResponseChoiceToCandidate(array $choice): Candidate throw new RuntimeException( sprintf( 'Unexpected API response: Invalid finish reason "%s".', - $choice['finish_reason'] + $choiceData['finish_reason'] ) ); } @@ -479,16 +495,16 @@ protected function parseResponseChoiceToCandidate(array $choice): Candidate * * @since n.e.x.t * - * @param array $message The message data from the API response. + * @param array $messageData The message data from the API response. * @return Message The parsed message. */ - protected function parseResponseChoiceMessage(array $message): Message + protected function parseResponseChoiceMessage(array $messageData): Message { - $role = isset($message['role']) && 'user' === $message['role'] + $role = isset($messageData['role']) && 'user' === $messageData['role'] ? MessageRoleEnum::user() : MessageRoleEnum::model(); - $parts = $this->parseResponseChoiceMessageParts($message); + $parts = $this->parseResponseChoiceMessageParts($messageData); return new Message($role, $parts); } @@ -498,24 +514,25 @@ protected function parseResponseChoiceMessage(array $message): Message * * @since n.e.x.t * - * @param array $message The message data from the API response. + * @param array $messageData The message data from the API response. * @return MessagePart[] The parsed message parts. */ - protected function parseResponseChoiceMessageParts(array $message): array + protected function parseResponseChoiceMessageParts(array $messageData): array { $parts = []; - if (isset($message['reasoning_content']) && is_string($message['reasoning_content'])) { - $parts[] = new MessagePart($message['reasoning_content'], MessagePartChannelEnum::thought()); + if (isset($messageData['reasoning_content']) && is_string($messageData['reasoning_content'])) { + $parts[] = new MessagePart($messageData['reasoning_content'], MessagePartChannelEnum::thought()); } - if (isset($message['content']) && is_string($message['content'])) { - $parts[] = new MessagePart($message['content']); + if (isset($messageData['content']) && is_string($messageData['content'])) { + $parts[] = new MessagePart($messageData['content']); } - if (isset($message['tool_calls']) && is_array($message['tool_calls'])) { - foreach ($message['tool_calls'] as $toolCall) { - $toolCallPart = $this->parseResponseChoiceMessageToolCallPart($toolCall); + if (isset($messageData['tool_calls']) && is_array($messageData['tool_calls'])) { + foreach ($messageData['tool_calls'] as $toolCallData) { + /** @var array $toolCallData */ + $toolCallPart = $this->parseResponseChoiceMessageToolCallPart($toolCallData); if (!$toolCallPart) { throw new RuntimeException( 'Unexpected API response: The response includes a tool call of an unexpected type.' @@ -533,10 +550,10 @@ protected function parseResponseChoiceMessageParts(array $message): array * * @since n.e.x.t * - * @param array $toolCall The tool call data from the API response. + * @param array $toolCallData The tool call data from the API response. * @return MessagePart|null The parsed message part for the tool call, or null if not applicable. */ - protected function parseResponseChoiceMessageToolCallPart(array $toolCall): ?MessagePart + protected function parseResponseChoiceMessageToolCallPart(array $toolCallData): ?MessagePart { /* * For now, only function calls are supported. @@ -544,18 +561,26 @@ protected function parseResponseChoiceMessageToolCallPart(array $toolCall): ?Mes * Not all OpenAI compatible APIs include a 'type' key, so we only check its value if it is set. */ if ( - (isset($toolCall['type']) && 'function' !== $toolCall['type']) || - !isset($toolCall['function']) + (isset($toolCallData['type']) && 'function' !== $toolCallData['type']) || + !isset($toolCallData['function']) || + !is_array($toolCallData['function']) ) { return null; } + /** @var array $functionArguments */ + $functionArguments = is_string($toolCallData['function']['arguments']) + ? json_decode($toolCallData['function']['arguments'], true) + : $toolCallData['function']['arguments']; + $functionCall = new FunctionCall( - $toolCall['id'] ?? null, - $toolCall['function']['name'], - is_string($toolCall['function']['arguments']) - ? json_decode($toolCall['function']['arguments'], true) - : $toolCall['function']['arguments'] + isset($toolCallData['id']) && is_string($toolCallData['id']) ? + $toolCallData['id'] : + null, + isset($toolCallData['function']['name']) && is_string($toolCallData['function']['name']) ? + $toolCallData['function']['name'] : + null, + $functionArguments ); return new MessagePart($functionCall); From 8bd9ee92e6a4cf3b88a534ae635dd05826f90839 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Fri, 15 Aug 2025 16:18:44 -0500 Subject: [PATCH 12/77] Properly implement OpenAI API compatible request creation. --- .../OpenAi/OpenAiModelMetadataDirectory.php | 11 ++++++++--- .../OpenAi/OpenAiProvider.php | 2 ++ .../OpenAi/OpenAiTextGenerationModel.php | 11 ++++++++--- ...ractOpenAiCompatibleModelMetadataDirectory.php | 14 +++++++++++--- ...bstractOpenAiCompatibleTextGenerationModel.php | 15 +++++++++++---- 5 files changed, 40 insertions(+), 13 deletions(-) diff --git a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php index 139e4b15..e6e1d2d9 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php +++ b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php @@ -11,6 +11,7 @@ use WordPress\AiClient\Providers\AbstractOpenAiCompatibleModelMetadataDirectory; use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\DTO\Response; +use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; use WordPress\AiClient\Providers\Models\DTO\SupportedOption; @@ -26,10 +27,14 @@ class OpenAiModelMetadataDirectory extends AbstractOpenAiCompatibleModelMetadata /** * @inheritDoc */ - protected function createRequest(string $path): Request + protected function createRequest(HttpMethodEnum $method, string $path, array $headers = [], $data = null): Request { - // Something like this. - return new OpenAiCompatibleRequest('https://api.openai.com/v1', $path); + return new Request( + $method, + OpenAiProvider::BASE_URI . '/' . ltrim($path, '/'), + $headers, + $data + ); } /** diff --git a/src/ProviderImplementations/OpenAi/OpenAiProvider.php b/src/ProviderImplementations/OpenAi/OpenAiProvider.php index 5b2941d6..ee30c446 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiProvider.php +++ b/src/ProviderImplementations/OpenAi/OpenAiProvider.php @@ -21,6 +21,8 @@ */ class OpenAiProvider extends AbstractProvider { + public const BASE_URI = 'https://api.openai.com/v1'; + /** * @inheritDoc */ diff --git a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php index d25d7a16..5c6acdc4 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php +++ b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php @@ -5,6 +5,7 @@ namespace WordPress\AiClient\ProviderImplementations\OpenAi; use WordPress\AiClient\Providers\Http\DTO\Request; +use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; use WordPress\AiClient\Providers\Models\AbstractOpenAiCompatibleTextGenerationModel; /** @@ -17,9 +18,13 @@ class OpenAiTextGenerationModel extends AbstractOpenAiCompatibleTextGenerationMo /** * @inheritDoc */ - protected function createRequest(string $path, array $params): Request + protected function createRequest(HttpMethodEnum $method, string $path, array $headers = [], $data = null): Request { - // Something like this. - return new OpenAiCompatibleRequest('https://api.openai.com/v1', $path); + return new Request( + $method, + OpenAiProvider::BASE_URI . '/' . ltrim($path, '/'), + $headers, + $data + ); } } diff --git a/src/Providers/AbstractOpenAiCompatibleModelMetadataDirectory.php b/src/Providers/AbstractOpenAiCompatibleModelMetadataDirectory.php index 33c122c7..d7a1a118 100644 --- a/src/Providers/AbstractOpenAiCompatibleModelMetadataDirectory.php +++ b/src/Providers/AbstractOpenAiCompatibleModelMetadataDirectory.php @@ -6,6 +6,7 @@ use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\DTO\Response; +use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; /** @@ -22,8 +23,7 @@ protected function sendListModelsRequest(): array { $httpTransporter = $this->getHttpTransporter(); - // Something like this. - $request = $this->createRequest('models'); + $request = $this->createRequest(HttpMethodEnum::GET(), 'models'); $response = $httpTransporter->send($request); $modelsMetadataList = $this->parseResponseToModelMetadataList($response); @@ -41,10 +41,18 @@ protected function sendListModelsRequest(): array * * @since n.e.x.t * + * @param HttpMethodEnum $method The HTTP method. * @param string $path The API endpoint path, relative to the base URI. + * @param array> $headers The request headers. + * @param string|array|null $data The request data. * @return Request The request object. */ - abstract protected function createRequest(string $path): Request; + abstract protected function createRequest( + HttpMethodEnum $method, + string $path, + array $headers = [], + $data = null + ): Request; /** * Parses the response from the API endpoint to list models into a list of model metadata objects. diff --git a/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php index 49aa35e3..5df893a4 100644 --- a/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php @@ -15,6 +15,7 @@ use WordPress\AiClient\Messages\Enums\ModalityEnum; use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\DTO\Response; +use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface; use WordPress\AiClient\Results\DTO\Candidate; use WordPress\AiClient\Results\DTO\GenerativeAiResult; @@ -39,8 +40,7 @@ final public function generateTextResult(array $prompt): GenerativeAiResult $params = $this->prepareGenerateTextParams($prompt); - // Something like this. - $request = $this->createRequest('chat/completions', $params); + $request = $this->createRequest(HttpMethodEnum::POST(), 'chat/completions', [], $params); $response = $httpTransporter->send($request); return $this->parseResponseToGenerativeAiResult($response); @@ -367,11 +367,18 @@ protected function prepareOutputModalitiesParam(array $modalities): array * * @since n.e.x.t * + * @param HttpMethodEnum $method The HTTP method. * @param string $path The API endpoint path, relative to the base URI. - * @param array $params The parameters for the API request. + * @param array> $headers The request headers. + * @param string|array|null $data The request data. * @return Request The request object. */ - abstract protected function createRequest(string $path, array $params): Request; + abstract protected function createRequest( + HttpMethodEnum $method, + string $path, + array $headers = [], + $data = null + ): Request; /** * Parses the response from the API endpoint to a generative AI result. From 83f3268a77658f92c5d1fbe5ce291338a8e6e94b Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Sat, 16 Aug 2025 11:42:38 -0500 Subject: [PATCH 13/77] Fix PHPStan problems in OpenAiModelMetadataDirectory. --- .../OpenAi/OpenAiModelMetadataDirectory.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php index e6e1d2d9..6468b7b2 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php +++ b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php @@ -123,6 +123,9 @@ protected function parseResponseToModelMetadataList(Response $response): array new SupportedOption('voice'), ]; + /** @var array> $modelsData */ + $modelsData = (array) $responseData['data']; + return array_values( array_map( static function (array $modelData) use ( @@ -136,6 +139,7 @@ static function (array $modelData) use ( $ttsCapabilities, $ttsOptions, ): ModelMetadata { + /** @var string $modelId */ $modelId = $modelData['id']; if ( str_starts_with($modelId, 'dall-e-') || @@ -184,7 +188,7 @@ static function (array $modelData) use ( $modelOptions ); }, - (array) $responseData['data'] + $modelsData ) ); } From 3c53649ea16d632a35f26f9153643f25be060fa5 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Sat, 16 Aug 2025 11:47:23 -0500 Subject: [PATCH 14/77] Allow getting extension for (common) MIME types and use it where needed in OpenAI text generation model logic. --- src/Files/ValueObjects/MimeType.php | 21 +++++ ...actOpenAiCompatibleTextGenerationModel.php | 2 +- .../unit/Files/ValueObjects/MimeTypeTest.php | 76 +++++++++++++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) diff --git a/src/Files/ValueObjects/MimeType.php b/src/Files/ValueObjects/MimeType.php index 3dfd18da..c44eef7d 100644 --- a/src/Files/ValueObjects/MimeType.php +++ b/src/Files/ValueObjects/MimeType.php @@ -130,6 +130,27 @@ public function __construct(string $value) $this->value = strtolower($value); } + /** + * Gets the primary known file extension for this MIME type. + * + * @since n.e.x.t + * + * @return string The file extension (without the dot). + * @throws InvalidArgumentException If no known extension exists for this MIME type. + */ + public function toExtension(): string + { + // Reverse lookup for the MIME type to find the extension. + $extension = array_search($this->value, self::$extensionMap, true); + if ($extension === false) { + throw new InvalidArgumentException( + sprintf('No known extension for MIME type: %s', $this->value) + ); + } + + return $extension; + } + /** * Creates a MimeType from a file extension. * diff --git a/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php index 5df893a4..1039b0e6 100644 --- a/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php @@ -244,7 +244,7 @@ protected function getMessagePartContentData(MessagePart $part): ?array 'type' => 'input_audio', 'input_audio' => [ 'data' => $file->getBase64Data(), - 'format' => '', // TODO: Add method to transform MIME type into file extension. + 'format' => $file->getMimeTypeObject()->toExtension(), ], ]; } diff --git a/tests/unit/Files/ValueObjects/MimeTypeTest.php b/tests/unit/Files/ValueObjects/MimeTypeTest.php index 306d5286..4481aef1 100644 --- a/tests/unit/Files/ValueObjects/MimeTypeTest.php +++ b/tests/unit/Files/ValueObjects/MimeTypeTest.php @@ -153,6 +153,82 @@ public function testUnknownExtensionThrowsException(): void MimeType::fromExtension('xyz'); } + /** + * Tests toExtension method. + * + * @dataProvider mimeTypeToExtensionProvider + * @param string $mimeType + * @param string $expectedExtension + * @return void + */ + public function testToExtension(string $mimeType, string $expectedExtension): void + { + $mimeType = new MimeType($mimeType); + $this->assertEquals($expectedExtension, $mimeType->toExtension()); + } + + /** + * Provides MIME types and expected extensions. + * + * @return array + */ + public function mimeTypeToExtensionProvider(): array + { + return [ + // Text + ['text/plain', 'txt'], + ['text/html', 'html'], + ['text/css', 'css'], + ['application/javascript', 'js'], + ['application/json', 'json'], + ['application/xml', 'xml'], + ['text/csv', 'csv'], + + // Images + ['image/jpeg', 'jpg'], + ['image/png', 'png'], + ['image/gif', 'gif'], + ['image/webp', 'webp'], + ['image/svg+xml', 'svg'], + ['image/x-icon', 'ico'], + + // Documents + ['application/pdf', 'pdf'], + ['application/msword', 'doc'], + ['application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'docx'], + ['application/vnd.ms-excel', 'xls'], + ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'xlsx'], + + // Audio + ['audio/mpeg', 'mp3'], + ['audio/wav', 'wav'], + ['audio/ogg', 'ogg'], + + // Video + ['video/mp4', 'mp4'], + ['video/x-msvideo', 'avi'], + ['video/webm', 'webm'], + + // Archives + ['application/zip', 'zip'], + ['application/x-tar', 'tar'], + ['application/gzip', 'gz'], + ]; + } + + /** + * Tests toExtension throws exception for unknown MIME type. + * + * @return void + */ + public function testToExtensionThrowsExceptionForUnknownMimeType(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No known extension for MIME type: application/octet-stream'); + + (new MimeType('application/octet-stream'))->toExtension(); + } + /** * Tests isValid method. * From faec411d4930e6b135297e8dda339ac8d761cb08 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Sat, 16 Aug 2025 12:12:14 -0500 Subject: [PATCH 15/77] Implement support for handling remaining OpenAI compatible text generation params. --- ...actOpenAiCompatibleTextGenerationModel.php | 119 +++++++++++++++++- 1 file changed, 118 insertions(+), 1 deletion(-) diff --git a/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php index 1039b0e6..80e2d97d 100644 --- a/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php @@ -22,6 +22,7 @@ use WordPress\AiClient\Results\DTO\TokenUsage; use WordPress\AiClient\Results\Enums\FinishReasonEnum; use WordPress\AiClient\Tools\DTO\FunctionCall; +use WordPress\AiClient\Tools\DTO\FunctionDeclaration; /** * Base class for a text generation model for an OpenAI compatible provider. @@ -90,7 +91,78 @@ protected function prepareGenerateTextParams(array $prompt): array } } - // TODO: Prepare other parameters based on config. + $candidateCount = $config->getCandidateCount(); + if ($candidateCount !== null) { + $params['n'] = $candidateCount; + } + + $maxTokens = $config->getMaxTokens(); + if ($maxTokens !== null) { + $params['max_tokens'] = $maxTokens; + } + + $temperature = $config->getTemperature(); + if ($temperature !== null) { + $params['temperature'] = $temperature; + } + + $topP = $config->getTopP(); + if ($topP !== null) { + $params['top_p'] = $topP; + } + + $stopSequences = $config->getStopSequences(); + if (is_array($stopSequences)) { + $params['stop'] = $stopSequences; + } + + $presencePenalty = $config->getPresencePenalty(); + if ($presencePenalty !== null) { + $params['presence_penalty'] = $presencePenalty; + } + + $frequencyPenalty = $config->getFrequencyPenalty(); + if ($frequencyPenalty !== null) { + $params['frequency_penalty'] = $frequencyPenalty; + } + + $logprobs = $config->getLogprobs(); + if ($logprobs !== null) { + $params['logprobs'] = $logprobs; + } + + $topLogprobs = $config->getTopLogprobs(); + if ($topLogprobs !== null) { + $params['top_logprobs'] = $topLogprobs; + } + + $functionDeclarations = $config->getFunctionDeclarations(); + if (is_array($functionDeclarations)) { + $params['tools'] = $this->prepareToolsParam($functionDeclarations); + } + + $outputMimeType = $config->getOutputMimeType(); + if ('application/json' === $outputMimeType) { + $outputSchema = $config->getOutputSchema(); + $params['response_format'] = $this->prepareResponseFormatParam($outputSchema); + } + + /* + * Any custom options are added to the parameters as well. + * This allows developers to pass other options that may be more niche or not yet supported by the SDK. + */ + $customOptions = $config->getCustomOptions(); + foreach ($customOptions as $key => $value) { + if (isset($params[$key])) { + throw new InvalidArgumentException( + sprintf( + 'The custom option "%s" conflicts with an existing parameter.', + $key + ) + ); + } + $params[$key] = $value; + } return $params; } @@ -362,6 +434,51 @@ protected function prepareOutputModalitiesParam(array $modalities): array return $prepared; } + /** + * Prepares the tools parameter for the API request. + * + * @since n.e.x.t + * + * @param list $functionDeclarations The function declarations. + * @return list> The prepared tools parameter. + */ + protected function prepareToolsParam(array $functionDeclarations): array + { + $tools = []; + foreach ($functionDeclarations as $functionDeclaration) { + $tools[] = [ + 'type' => 'function', + 'function' => $functionDeclaration->toArray(), + ]; + } + + return $tools; + } + + /** + * Prepares the response format parameter for the API request. + * + * This is only called if the output MIME type is `application/json`. + * + * @since n.e.x.t + * + * @param array|null $outputSchema The output schema. + * @return array The prepared response format parameter. + */ + protected function prepareResponseFormatParam(?array $outputSchema): array + { + if (is_array($outputSchema)) { + return [ + 'type' => 'json_schema', + 'json_schema' => $outputSchema, + ]; + } + + return [ + 'type' => 'json_object', + ]; + } + /** * Creates a request object for the provider's API. * From eb1192e90b78dce6207f183408014766ba1de745 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Sat, 16 Aug 2025 12:24:56 -0500 Subject: [PATCH 16/77] Implement remaining constants and logic for model support discovery. --- .../OpenAi/OpenAiModelMetadataDirectory.php | 6 +-- src/Providers/Models/DTO/ModelConfig.php | 50 +++++++++++++++++++ src/Providers/Models/DTO/SupportedOption.php | 15 ++++++ .../Providers/Models/DTO/ModelConfigTest.php | 13 +++++ 4 files changed, 80 insertions(+), 4 deletions(-) diff --git a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php index 6468b7b2..b49525aa 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php +++ b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php @@ -71,8 +71,7 @@ protected function parseResponseToModelMetadataList(Response $response): array ]; $gptMultimodalInputOptions = $gptOptions + [ new SupportedOption( - // TODO: Where to put this as a constant? - 'inputModalities', + ModelConfig::KEY_INPUT_MODALITIES, [ [ModalityEnum::text()], [ModalityEnum::text(), ModalityEnum::image()], @@ -119,8 +118,7 @@ protected function parseResponseToModelMetadataList(Response $response): array ]; $ttsOptions = [ new SupportedOption(ModelConfig::KEY_OUTPUT_MIME_TYPE, ['audio/mpeg', 'audio/ogg', 'audio/wav']), - // TODO: Where to put this as a constant? - new SupportedOption('voice'), + new SupportedOption(ModelConfig::KEY_OUTPUT_SPEECH_VOICE), ]; /** @var array> $modelsData */ diff --git a/src/Providers/Models/DTO/ModelConfig.php b/src/Providers/Models/DTO/ModelConfig.php index e3504232..2f8d069b 100644 --- a/src/Providers/Models/DTO/ModelConfig.php +++ b/src/Providers/Models/DTO/ModelConfig.php @@ -44,6 +44,7 @@ * outputSchema?: array, * outputMediaOrientation?: string, * outputMediaAspectRatio?: string, + * outputSpeechVoice?: string, * customOptions?: array * } * @@ -70,8 +71,16 @@ class ModelConfig extends AbstractDataTransferObject public const KEY_OUTPUT_SCHEMA = 'outputSchema'; public const KEY_OUTPUT_MEDIA_ORIENTATION = 'outputMediaOrientation'; public const KEY_OUTPUT_MEDIA_ASPECT_RATIO = 'outputMediaAspectRatio'; + public const KEY_OUTPUT_SPEECH_VOICE = 'outputSpeechVoice'; public const KEY_CUSTOM_OPTIONS = 'customOptions'; + /* + * Note: This key is not an actual model config key, but specified here for convenience. + * It is relevant for model discovery, to determine which models support which input modalities. + * The actual input modalities are part of the message sent to the model, not the model config. + */ + public const KEY_INPUT_MODALITIES = 'inputModalities'; + /** * @var list|null Output modalities for the model. */ @@ -167,6 +176,11 @@ class ModelConfig extends AbstractDataTransferObject */ protected ?string $outputMediaAspectRatio = null; + /** + * @var string|null Output speech voice. + */ + protected ?string $outputSpeechVoice = null; + /** * @var array Custom provider-specific options. */ @@ -661,6 +675,30 @@ public function getOutputMediaAspectRatio(): ?string return $this->outputMediaAspectRatio; } + /** + * Sets the output speech voice. + * + * @since n.e.x.t + * + * @param string $outputSpeechVoice The output speech voice. + */ + public function setOutputSpeechVoice(string $outputSpeechVoice): void + { + $this->outputSpeechVoice = $outputSpeechVoice; + } + + /** + * Gets the output speech voice. + * + * @since n.e.x.t + * + * @return string|null The output speech voice. + */ + public function getOutputSpeechVoice(): ?string + { + return $this->outputSpeechVoice; + } + /** * Sets a single custom option. * @@ -801,6 +839,10 @@ public static function getJsonSchema(): array 'pattern' => '^\d+:\d+$', 'description' => 'Output media aspect ratio.', ], + self::KEY_OUTPUT_SPEECH_VOICE => [ + 'type' => 'string', + 'description' => 'Output speech voice.', + ], self::KEY_CUSTOM_OPTIONS => [ 'type' => 'object', 'additionalProperties' => true, @@ -908,6 +950,10 @@ static function (FunctionDeclaration $function_declaration): array { $data[self::KEY_OUTPUT_MEDIA_ASPECT_RATIO] = $this->outputMediaAspectRatio; } + if ($this->outputSpeechVoice !== null) { + $data[self::KEY_OUTPUT_SPEECH_VOICE] = $this->outputSpeechVoice; + } + $data[self::KEY_CUSTOM_OPTIONS] = $this->customOptions; return $data; @@ -1006,6 +1052,10 @@ static function (array $function_declaration_data): FunctionDeclaration { $config->setOutputMediaAspectRatio($array[self::KEY_OUTPUT_MEDIA_ASPECT_RATIO]); } + if (isset($array[self::KEY_OUTPUT_SPEECH_VOICE])) { + $config->setOutputSpeechVoice($array[self::KEY_OUTPUT_SPEECH_VOICE]); + } + if (isset($array[self::KEY_CUSTOM_OPTIONS])) { $config->setCustomOptions($array[self::KEY_CUSTOM_OPTIONS]); } diff --git a/src/Providers/Models/DTO/SupportedOption.php b/src/Providers/Models/DTO/SupportedOption.php index ddf76b62..dc640761 100644 --- a/src/Providers/Models/DTO/SupportedOption.php +++ b/src/Providers/Models/DTO/SupportedOption.php @@ -84,6 +84,21 @@ public function isSupportedValue($value): bool return true; } + // If the value is an array, consider it a set (i.e. order doesn't matter). + if (is_array($value)) { + foreach ($this->supportedValues as $supportedValue) { + if (!is_array($supportedValue)) { + continue; + } + sort($value); + sort($supportedValue); + if ($value === $supportedValue) { + return true; + } + } + return false; + } + return in_array($value, $this->supportedValues, true); } diff --git a/tests/unit/Providers/Models/DTO/ModelConfigTest.php b/tests/unit/Providers/Models/DTO/ModelConfigTest.php index 7f5b353b..bac9b5ad 100644 --- a/tests/unit/Providers/Models/DTO/ModelConfigTest.php +++ b/tests/unit/Providers/Models/DTO/ModelConfigTest.php @@ -70,6 +70,7 @@ public function testDefaultConstructor(): void $this->assertNull($config->getOutputSchema()); $this->assertNull($config->getOutputMediaOrientation()); $this->assertNull($config->getOutputMediaAspectRatio()); + $this->assertNull($config->getOutputSpeechVoice()); $this->assertEquals([], $config->getCustomOptions()); } @@ -169,6 +170,10 @@ public function testSettersAndGetters(): void $config->setOutputMediaAspectRatio('4:3'); $this->assertEquals('4:3', $config->getOutputMediaAspectRatio()); + // Test output speech voice + $config->setOutputSpeechVoice('alloy'); + $this->assertEquals('alloy', $config->getOutputSpeechVoice()); + // Test custom options $customOptions = ['custom_param' => 'value', 'another_param' => 123]; $config->setCustomOptions($customOptions); @@ -210,6 +215,7 @@ public function testGetJsonSchema(): void ModelConfig::KEY_OUTPUT_SCHEMA, ModelConfig::KEY_OUTPUT_MEDIA_ORIENTATION, ModelConfig::KEY_OUTPUT_MEDIA_ASPECT_RATIO, + ModelConfig::KEY_OUTPUT_SPEECH_VOICE, ModelConfig::KEY_CUSTOM_OPTIONS ]; @@ -228,6 +234,7 @@ public function testGetJsonSchema(): void $this->assertEquals('string', $schema['properties'][ModelConfig::KEY_OUTPUT_FILE_TYPE]['type']); $this->assertEquals('string', $schema['properties'][ModelConfig::KEY_OUTPUT_MEDIA_ORIENTATION]['type']); $this->assertEquals('string', $schema['properties'][ModelConfig::KEY_OUTPUT_MEDIA_ASPECT_RATIO]['type']); + $this->assertEquals('string', $schema['properties'][ModelConfig::KEY_OUTPUT_SPEECH_VOICE]['type']); $this->assertEquals('object', $schema['properties'][ModelConfig::KEY_CUSTOM_OPTIONS]['type']); // Check constraints @@ -266,6 +273,7 @@ public function testToArrayAllProperties(): void $config->setOutputSchema(['type' => 'object']); $config->setOutputMediaOrientation(MediaOrientationEnum::portrait()); $config->setOutputMediaAspectRatio('9:16'); + $config->setOutputSpeechVoice('onyx'); $config->setCustomOptions(['key' => 'value']); $array = $config->toArray(); @@ -290,6 +298,7 @@ public function testToArrayAllProperties(): void $this->assertEquals(['type' => 'object'], $array[ModelConfig::KEY_OUTPUT_SCHEMA]); $this->assertEquals('portrait', $array[ModelConfig::KEY_OUTPUT_MEDIA_ORIENTATION]); $this->assertEquals('9:16', $array[ModelConfig::KEY_OUTPUT_MEDIA_ASPECT_RATIO]); + $this->assertEquals('onyx', $array[ModelConfig::KEY_OUTPUT_SPEECH_VOICE]); $this->assertEquals(['key' => 'value'], $array[ModelConfig::KEY_CUSTOM_OPTIONS]); } @@ -367,6 +376,7 @@ public function testFromArrayAllProperties(): void ModelConfig::KEY_OUTPUT_FILE_TYPE => 'inline', ModelConfig::KEY_OUTPUT_MEDIA_ORIENTATION => 'landscape', ModelConfig::KEY_OUTPUT_MEDIA_ASPECT_RATIO => '16:9', + ModelConfig::KEY_OUTPUT_SPEECH_VOICE => 'fable', ModelConfig::KEY_CUSTOM_OPTIONS => ['custom' => true] ]; @@ -396,6 +406,7 @@ public function testFromArrayAllProperties(): void $this->assertEquals(FileTypeEnum::inline(), $config->getOutputFileType()); $this->assertEquals(MediaOrientationEnum::landscape(), $config->getOutputMediaOrientation()); $this->assertEquals('16:9', $config->getOutputMediaAspectRatio()); + $this->assertEquals('fable', $config->getOutputSpeechVoice()); $this->assertEquals(['custom' => true], $config->getCustomOptions()); } @@ -430,6 +441,7 @@ public function testArrayRoundTrip(): void $original->setOutputFileType(FileTypeEnum::inline()); $original->setOutputMediaOrientation(MediaOrientationEnum::square()); $original->setOutputMediaAspectRatio('1:1'); + $original->setOutputSpeechVoice('shimmer'); $original->setCustomOptions(['test' => 'value']); $array = $original->toArray(); @@ -443,6 +455,7 @@ public function testArrayRoundTrip(): void $this->assertEquals($original->getOutputFileType(), $restored->getOutputFileType()); $this->assertEquals($original->getOutputMediaOrientation(), $restored->getOutputMediaOrientation()); $this->assertEquals($original->getOutputMediaAspectRatio(), $restored->getOutputMediaAspectRatio()); + $this->assertEquals($original->getOutputSpeechVoice(), $restored->getOutputSpeechVoice()); $this->assertEquals($original->getCustomOptions(), $restored->getCustomOptions()); } From 78bf88779cf7167554858848eb2b2f0a79f85439 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Sat, 16 Aug 2025 13:14:18 -0500 Subject: [PATCH 17/77] Implement provider registry logic to hook up providers with HTTP transporter instance. --- src/Providers/Contracts/ProviderInterface.php | 2 +- .../Http/Traits/WithHttpTransporterTrait.php | 9 +++ src/Providers/ProviderRegistry.php | 63 ++++++++++++++++++- 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/src/Providers/Contracts/ProviderInterface.php b/src/Providers/Contracts/ProviderInterface.php index 42ec540d..5ff5c808 100644 --- a/src/Providers/Contracts/ProviderInterface.php +++ b/src/Providers/Contracts/ProviderInterface.php @@ -33,7 +33,7 @@ public static function metadata(): ProviderMetadata; * * @since n.e.x.t * - * @param string $modelId Model identifier. + * @param string $modelId Model identifier. * @param ?ModelConfig $modelConfig Model configuration. * @return ModelInterface Model instance. * @throws InvalidArgumentException If model not found or configuration invalid. diff --git a/src/Providers/Http/Traits/WithHttpTransporterTrait.php b/src/Providers/Http/Traits/WithHttpTransporterTrait.php index 29313be0..212df386 100644 --- a/src/Providers/Http/Traits/WithHttpTransporterTrait.php +++ b/src/Providers/Http/Traits/WithHttpTransporterTrait.php @@ -14,13 +14,22 @@ */ trait WithHttpTransporterTrait { + /** + * @var HttpTransporterInterface|null The HTTP transporter instance. + */ private ?HttpTransporterInterface $httpTransporter = null; + /** + * @inheritDoc + */ public function setHttpTransporter(HttpTransporterInterface $httpTransporter): void { $this->httpTransporter = $httpTransporter; } + /** + * @inheritDoc + */ public function getHttpTransporter(): HttpTransporterInterface { if ($this->httpTransporter === null) { diff --git a/src/Providers/ProviderRegistry.php b/src/Providers/ProviderRegistry.php index 8fc39c76..99d528a6 100644 --- a/src/Providers/ProviderRegistry.php +++ b/src/Providers/ProviderRegistry.php @@ -5,9 +5,14 @@ namespace WordPress\AiClient\Providers; use InvalidArgumentException; +use RuntimeException; use WordPress\AiClient\Providers\Contracts\ProviderInterface; +use WordPress\AiClient\Providers\Contracts\ProviderWithOperationsHandlerInterface; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\DTO\ProviderModelsMetadata; +use WordPress\AiClient\Providers\Http\Contracts\HttpTransporterInterface; +use WordPress\AiClient\Providers\Http\Contracts\WithHttpTransporterInterface; +use WordPress\AiClient\Providers\Http\Traits\WithHttpTransporterTrait; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; @@ -21,8 +26,12 @@ * * @since n.e.x.t */ -class ProviderRegistry +class ProviderRegistry implements WithHttpTransporterInterface { + use WithHttpTransporterTrait { + setHttpTransporter as setHttpTransporterOriginal; + } + /** * @var array> Mapping of provider IDs to class names. */ @@ -33,7 +42,6 @@ class ProviderRegistry */ private array $registeredClassNames = []; - /** * Registers a provider class with the registry. * @@ -66,6 +74,14 @@ public function registerProvider(string $className): void ); } + // If there is already a HTTP transporter instance set, hook it up to the provider as needed. + try { + $httpTransporter = $this->getHttpTransporter(); + $this->setHttpTransporterForProvider($className, $httpTransporter); + } catch (RuntimeException $e) { + // Ignore. + } + $this->providerClassNames[$metadata->getId()] = $className; $this->registeredClassNames[$className] = true; } @@ -228,4 +244,47 @@ private function resolveProviderClassName(string $idOrClassName): string // @phpstan-ignore-next-line return.type (Interface implementation guaranteed by registration validation) return $className; } + + /** + * @inheritDoc + */ + public function setHttpTransporter(HttpTransporterInterface $httpTransporter): void + { + $this->setHttpTransporterOriginal($httpTransporter); + + // Make sure all registered providers have the HTTP transporter hooked up as needed. + foreach ($this->providerClassNames as $className) { + $this->setHttpTransporterForProvider($className, $httpTransporter); + } + } + + /** + * Sets the HTTP transporter for a specific provider. + * + * @since n.e.x.t + * + * @param class-string $className The provider class name. + * @param HttpTransporterInterface $httpTransporter The HTTP transporter instance. + */ + private function setHttpTransporterForProvider( + string $className, + HttpTransporterInterface $httpTransporter + ): void { + $availability = $className::availability(); + if ($availability instanceof WithHttpTransporterInterface) { + $availability->setHttpTransporter($httpTransporter); + } + + $modelMetadataDirectory = $className::modelMetadataDirectory(); + if ($modelMetadataDirectory instanceof WithHttpTransporterInterface) { + $modelMetadataDirectory->setHttpTransporter($httpTransporter); + } + + if (is_subclass_of($className, ProviderWithOperationsHandlerInterface::class)) { + $operationsHandler = $className::operationsHandler(); + if ($operationsHandler instanceof WithHttpTransporterInterface) { + $operationsHandler->setHttpTransporter($httpTransporter); + } + } + } } From 7ca7bc81d542b45a0d960c0144cc5e6509071070 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Sat, 16 Aug 2025 20:16:34 -0500 Subject: [PATCH 18/77] Properly implement request authentication infrastructure and include it in the ProviderRegistry logic. --- docs/ARCHITECTURE.md | 2 +- ...AbstractApiBasedModelMetadataDirectory.php | 6 +- .../RequestAuthenticationInterface.php | 9 +- .../Http/DTO/ApiKeyRequestAuthentication.php | 106 +++++++++++ .../Http/DTO/NullRequestAuthentication.php | 56 ++++++ .../Traits/WithRequestAuthenticationTrait.php | 43 +++++ .../Models/AbstractApiBasedModel.php | 6 +- src/Providers/ProviderRegistry.php | 176 +++++++++++++++++- 8 files changed, 397 insertions(+), 7 deletions(-) create mode 100644 src/Providers/Http/DTO/ApiKeyRequestAuthentication.php create mode 100644 src/Providers/Http/DTO/NullRequestAuthentication.php create mode 100644 src/Providers/Http/Traits/WithRequestAuthenticationTrait.php diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9aa8040b..b1a2dc8b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -897,7 +897,7 @@ direction LR +send(Request $request) Response } class RequestAuthenticationInterface { - +authenticate(Request $request) void + +authenticateRequest(Request $request) Request +getJsonSchema() array< string, mixed >$ } class WithHttpTransporterInterface { diff --git a/src/Providers/AbstractApiBasedModelMetadataDirectory.php b/src/Providers/AbstractApiBasedModelMetadataDirectory.php index 114907ac..5b2b0f69 100644 --- a/src/Providers/AbstractApiBasedModelMetadataDirectory.php +++ b/src/Providers/AbstractApiBasedModelMetadataDirectory.php @@ -7,7 +7,9 @@ use InvalidArgumentException; use WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface; use WordPress\AiClient\Providers\Http\Contracts\WithHttpTransporterInterface; +use WordPress\AiClient\Providers\Http\Contracts\WithRequestAuthenticationInterface; use WordPress\AiClient\Providers\Http\Traits\WithHttpTransporterTrait; +use WordPress\AiClient\Providers\Http\Traits\WithRequestAuthenticationTrait; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; /** @@ -17,9 +19,11 @@ */ abstract class AbstractApiBasedModelMetadataDirectory implements ModelMetadataDirectoryInterface, - WithHttpTransporterInterface + WithHttpTransporterInterface, + WithRequestAuthenticationInterface { use WithHttpTransporterTrait; + use WithRequestAuthenticationTrait; /** * @var ?array Map of model ID to model metadata, effectively for caching. diff --git a/src/Providers/Http/Contracts/RequestAuthenticationInterface.php b/src/Providers/Http/Contracts/RequestAuthenticationInterface.php index a49aedb2..be878305 100644 --- a/src/Providers/Http/Contracts/RequestAuthenticationInterface.php +++ b/src/Providers/Http/Contracts/RequestAuthenticationInterface.php @@ -4,6 +4,7 @@ namespace WordPress\AiClient\Providers\Http\Contracts; +use WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface; use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; use WordPress\AiClient\Providers\Http\DTO\Request; @@ -12,7 +13,9 @@ * * @since n.e.x.t */ -interface RequestAuthenticationInterface extends WithJsonSchemaInterface +interface RequestAuthenticationInterface extends + WithArrayTransformationInterface, + WithJsonSchemaInterface { /** * Authenticates an HTTP request. @@ -20,7 +23,7 @@ interface RequestAuthenticationInterface extends WithJsonSchemaInterface * @since n.e.x.t * * @param Request $request The request to authenticate. - * @return void + * @return Request The authenticated request. */ - public function authenticate(Request $request): void; + public function authenticateRequest(Request $request): Request; } diff --git a/src/Providers/Http/DTO/ApiKeyRequestAuthentication.php b/src/Providers/Http/DTO/ApiKeyRequestAuthentication.php new file mode 100644 index 00000000..6ef3d877 --- /dev/null +++ b/src/Providers/Http/DTO/ApiKeyRequestAuthentication.php @@ -0,0 +1,106 @@ + + */ +class ApiKeyRequestAuthentication extends AbstractDataTransferObject implements RequestAuthenticationInterface +{ + public const KEY_API_KEY = 'apiKey'; + + /** + * @var string The API key used for authentication. + */ + protected string $apiKey; + + /** + * Constructor. + * + * @since n.e.x.t + * + * @param string $apiKey The API key used for authentication. + */ + public function __construct(string $apiKey) + { + $this->apiKey = $apiKey; + } + + /** + * @inheritDoc + */ + public function authenticateRequest(Request $request): Request + { + // Add the API key to the request headers. + return $request->withHeader('Authorization', 'Bearer ' . $this->apiKey); + } + + /** + * Gets the API key. + * + * @since n.e.x.t + * + * @return string The API key. + */ + public function getApiKey(): string + { + return $this->apiKey; + } + + /** + * @inheritDoc + * + * @since n.e.x.t + * + * @return ApiKeyRequestAuthenticationArrayShape + */ + public function toArray(): array + { + return [ + self::KEY_API_KEY => $this->apiKey, + ]; + } + + /** + * @inheritDoc + * + * @since n.e.x.t + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [self::KEY_API_KEY]); + + return new self($array[self::KEY_API_KEY]); + } + + /** + * @inheritDoc + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + self::KEY_API_KEY => [ + 'type' => 'string', + 'title' => 'API Key', + 'description' => 'The API key used for authentication.', + ], + ], + 'required' => [self::KEY_API_KEY], + ]; + } +} diff --git a/src/Providers/Http/DTO/NullRequestAuthentication.php b/src/Providers/Http/DTO/NullRequestAuthentication.php new file mode 100644 index 00000000..b10ff194 --- /dev/null +++ b/src/Providers/Http/DTO/NullRequestAuthentication.php @@ -0,0 +1,56 @@ + + */ +class NullRequestAuthentication extends AbstractDataTransferObject implements RequestAuthenticationInterface +{ + /** + * @inheritDoc + */ + public function authenticateRequest(Request $request): Request + { + return $request; + } + + /** + * @inheritDoc + */ + public function toArray(): array + { + return []; + } + + /** + * @inheritDoc + */ + public static function fromArray(array $array): self + { + return new self(); + } + + /** + * @inheritDoc + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [], + 'required' => [], + ]; + } +} diff --git a/src/Providers/Http/Traits/WithRequestAuthenticationTrait.php b/src/Providers/Http/Traits/WithRequestAuthenticationTrait.php new file mode 100644 index 00000000..b8fca6a2 --- /dev/null +++ b/src/Providers/Http/Traits/WithRequestAuthenticationTrait.php @@ -0,0 +1,43 @@ +requestAuthentication = $requestAuthentication; + } + + /** + * @inheritDoc + */ + public function getRequestAuthentication(): RequestAuthenticationInterface + { + if ($this->requestAuthentication === null) { + throw new RuntimeException( + 'RequestAuthenticationInterface instance not set. ' . + 'Make sure you use the AiClient class for all requests.' + ); + } + return $this->requestAuthentication; + } +} diff --git a/src/Providers/Models/AbstractApiBasedModel.php b/src/Providers/Models/AbstractApiBasedModel.php index c94dba18..82f922bb 100644 --- a/src/Providers/Models/AbstractApiBasedModel.php +++ b/src/Providers/Models/AbstractApiBasedModel.php @@ -6,7 +6,9 @@ use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Http\Contracts\WithHttpTransporterInterface; +use WordPress\AiClient\Providers\Http\Contracts\WithRequestAuthenticationInterface; use WordPress\AiClient\Providers\Http\Traits\WithHttpTransporterTrait; +use WordPress\AiClient\Providers\Http\Traits\WithRequestAuthenticationTrait; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; @@ -21,9 +23,11 @@ */ abstract class AbstractApiBasedModel implements ModelInterface, - WithHttpTransporterInterface + WithHttpTransporterInterface, + WithRequestAuthenticationInterface { use WithHttpTransporterTrait; + use WithRequestAuthenticationTrait; /** * @var ModelMetadata The metadata for the model. diff --git a/src/Providers/ProviderRegistry.php b/src/Providers/ProviderRegistry.php index 99d528a6..d36a81e9 100644 --- a/src/Providers/ProviderRegistry.php +++ b/src/Providers/ProviderRegistry.php @@ -11,7 +11,11 @@ use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\DTO\ProviderModelsMetadata; use WordPress\AiClient\Providers\Http\Contracts\HttpTransporterInterface; +use WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface; use WordPress\AiClient\Providers\Http\Contracts\WithHttpTransporterInterface; +use WordPress\AiClient\Providers\Http\Contracts\WithRequestAuthenticationInterface; +use WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication; +use WordPress\AiClient\Providers\Http\DTO\NullRequestAuthentication; use WordPress\AiClient\Providers\Http\Traits\WithHttpTransporterTrait; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; @@ -42,6 +46,12 @@ class ProviderRegistry implements WithHttpTransporterInterface */ private array $registeredClassNames = []; + /** + * @var array, RequestAuthenticationInterface> Mapping of provider class names to + * authentication instances. + */ + private array $providerAuthenticationInstances = []; + /** * Registers a provider class with the registry. * @@ -82,6 +92,14 @@ public function registerProvider(string $className): void // Ignore. } + // Hook up the request authentication instance, using a default if not set. + if (!isset($this->providerAuthenticationInstances[$className])) { + $this->providerAuthenticationInstances[$className] = $this->createDefaultProviderRequestAuthentication( + $className + ); + } + $this->setRequestAuthenticationForProvider($className, $this->providerAuthenticationInstances[$className]); + $this->providerClassNames[$metadata->getId()] = $className; $this->registeredClassNames[$className] = true; } @@ -259,7 +277,40 @@ public function setHttpTransporter(HttpTransporterInterface $httpTransporter): v } /** - * Sets the HTTP transporter for a specific provider. + * Sets the request authentication instance for the given provider. + * + * @since n.e.x.t + * + * @param string|class-string $idOrClassName The provider ID or class name. + * @param RequestAuthenticationInterface $requestAuthentication The request authentication instance. + */ + public function setProviderRequestAuthentication( + string $idOrClassName, + RequestAuthenticationInterface $requestAuthentication + ): void { + $className = $this->resolveProviderClassName($idOrClassName); + + $this->providerAuthenticationInstances[$className] = $requestAuthentication; + + $this->setRequestAuthenticationForProvider($className, $requestAuthentication); + } + + /** + * Gets the request authentication instance for the given provider. + * + * @since n.e.x.t + * + * @param string|class-string $idOrClassName The provider ID or class name. + * @return RequestAuthenticationInterface The request authentication instance. + */ + public function getProviderRequestAuthentication(string $idOrClassName): RequestAuthenticationInterface + { + $className = $this->resolveProviderClassName($idOrClassName); + return $this->providerAuthenticationInstances[$className]; + } + + /** + * Sets the HTTP transporter for a specific provider, hooking up its class instances. * * @since n.e.x.t * @@ -287,4 +338,127 @@ private function setHttpTransporterForProvider( } } } + + /** + * Sets the request authentication for a specific provider, hooking up its class instances. + * + * @since n.e.x.t + * + * @param class-string $className The provider class name. + * @param RequestAuthenticationInterface $requestAuthentication The authentication instance. + */ + private function setRequestAuthenticationForProvider( + string $className, + RequestAuthenticationInterface $requestAuthentication + ): void { + $availability = $className::availability(); + if ($availability instanceof WithRequestAuthenticationInterface) { + $availability->setRequestAuthentication($requestAuthentication); + } + + $modelMetadataDirectory = $className::modelMetadataDirectory(); + if ($modelMetadataDirectory instanceof WithRequestAuthenticationInterface) { + $modelMetadataDirectory->setRequestAuthentication($requestAuthentication); + } + + if (is_subclass_of($className, ProviderWithOperationsHandlerInterface::class)) { + $operationsHandler = $className::operationsHandler(); + if ($operationsHandler instanceof WithRequestAuthenticationInterface) { + $operationsHandler->setRequestAuthentication($requestAuthentication); + } + } + } + + /** + * Creates a default request authentication instance for a provider. + * + * @since n.e.x.t + * + * @param class-string $className The provider class name. + * @return RequestAuthenticationInterface The default request authentication instance. + */ + private function createDefaultProviderRequestAuthentication( + string $className + ): RequestAuthenticationInterface { + $providerId = $className::metadata()->getId(); + + /* + * For now, we assume API key authentication is used by default. + * In the future, this could be made more flexible by allowing the provider to express a specific type of + * request authentication to use. + */ + $authenticationClass = ApiKeyRequestAuthentication::class; + $authenticationSchema = $authenticationClass::getJsonSchema(); + + // Iterate over all JSON schema object properties to try to determine the necessary authentication data. + $authenticationData = []; + if (isset($authenticationSchema['properties']) && is_array($authenticationSchema['properties'])) { + /** @var array $details */ + foreach ($authenticationSchema['properties'] as $property => $details) { + $envVarName = $this->getEnvVarName($providerId, $property); + + // Try to get the value from environment variable or constant. + $envValue = getenv($envVarName); + if ($envValue === false) { + if (!defined($envVarName)) { + continue; // Skip if neither environment variable nor constant is defined. + } + $envValue = constant($envVarName); + if (!is_scalar($envValue)) { + continue; + } + } + + if (isset($details['type'])) { + switch ($details['type']) { + case 'boolean': + $authenticationData[$property] = filter_var($envValue, FILTER_VALIDATE_BOOLEAN); + break; + case 'number': + $authenticationData[$property] = (int) $envValue; + break; + case 'string': + default: + $authenticationData[$property] = (string) $envValue; + } + } else { + // Default to string if no type is specified. + $authenticationData[$property] = (string) $envValue; + } + } + + // If any required fields are missing, use an empty authentication instance to avoid errors. + if (isset($authenticationSchema['required']) && is_array($authenticationSchema['required'])) { + /** @var list $requiredProperties */ + $requiredProperties = $authenticationSchema['required']; + if (array_diff_key($authenticationData, array_flip($requiredProperties))) { + $authenticationClass = NullRequestAuthentication::class; + } + } + } + + return $authenticationClass::fromArray($authenticationData); + } + + /** + * Converts a provider ID and field name to a constant case environment variable name. + * + * @since n.e.x.t + * + * @param string $providerId The provider ID. + * @param string $field The field name. + * @return string The environment variable name in CONSTANT_CASE. + */ + private function getEnvVarName(string $providerId, string $field): string + { + // Convert camelCase or kebab-case or snake_case to CONSTANT_CASE. + $constantCaseProviderId = strtoupper( + (string) preg_replace('/([a-z])([A-Z])/', '$1_$2', str_replace('-', '_', $providerId)) + ); + $constantCaseField = strtoupper( + (string) preg_replace('/([a-z])([A-Z])/', '$1_$2', str_replace('-', '_', $field)) + ); + + return "{$constantCaseProviderId}_{$constantCaseField}"; + } } From b4e2bcc9bfbfe3365d9d9db9fc4caac5c8012f1e Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Sun, 17 Aug 2025 21:06:39 -0500 Subject: [PATCH 19/77] Fix remaining PHPStan errors related to request authentication infra. --- src/Providers/Http/Contracts/RequestAuthenticationInterface.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Providers/Http/Contracts/RequestAuthenticationInterface.php b/src/Providers/Http/Contracts/RequestAuthenticationInterface.php index be878305..038481ae 100644 --- a/src/Providers/Http/Contracts/RequestAuthenticationInterface.php +++ b/src/Providers/Http/Contracts/RequestAuthenticationInterface.php @@ -4,7 +4,6 @@ namespace WordPress\AiClient\Providers\Http\Contracts; -use WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface; use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; use WordPress\AiClient\Providers\Http\DTO\Request; @@ -14,7 +13,6 @@ * @since n.e.x.t */ interface RequestAuthenticationInterface extends - WithArrayTransformationInterface, WithJsonSchemaInterface { /** From 03cf0f7efea2017d0d6fef7cfaec627a2a4bd159 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Sun, 17 Aug 2025 21:09:51 -0500 Subject: [PATCH 20/77] Actually use request authentication instance to authenticate requests. --- src/Providers/AbstractOpenAiCompatibleModelMetadataDirectory.php | 1 + .../Models/AbstractOpenAiCompatibleTextGenerationModel.php | 1 + 2 files changed, 2 insertions(+) diff --git a/src/Providers/AbstractOpenAiCompatibleModelMetadataDirectory.php b/src/Providers/AbstractOpenAiCompatibleModelMetadataDirectory.php index d7a1a118..afba1f1c 100644 --- a/src/Providers/AbstractOpenAiCompatibleModelMetadataDirectory.php +++ b/src/Providers/AbstractOpenAiCompatibleModelMetadataDirectory.php @@ -24,6 +24,7 @@ protected function sendListModelsRequest(): array $httpTransporter = $this->getHttpTransporter(); $request = $this->createRequest(HttpMethodEnum::GET(), 'models'); + $request = $this->getRequestAuthentication()->authenticateRequest($request); $response = $httpTransporter->send($request); $modelsMetadataList = $this->parseResponseToModelMetadataList($response); diff --git a/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php index 80e2d97d..437dfb9a 100644 --- a/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php @@ -42,6 +42,7 @@ final public function generateTextResult(array $prompt): GenerativeAiResult $params = $this->prepareGenerateTextParams($prompt); $request = $this->createRequest(HttpMethodEnum::POST(), 'chat/completions', [], $params); + $request = $this->getRequestAuthentication()->authenticateRequest($request); $response = $httpTransporter->send($request); return $this->parseResponseToGenerativeAiResult($response); From ff8ce6b42763502634368bee81a84068459955e0 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Sun, 17 Aug 2025 21:58:46 -0500 Subject: [PATCH 21/77] Fix logic bug when setting up default request authentication. --- src/Providers/ProviderRegistry.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Providers/ProviderRegistry.php b/src/Providers/ProviderRegistry.php index d36a81e9..02a98a25 100644 --- a/src/Providers/ProviderRegistry.php +++ b/src/Providers/ProviderRegistry.php @@ -431,7 +431,7 @@ private function createDefaultProviderRequestAuthentication( if (isset($authenticationSchema['required']) && is_array($authenticationSchema['required'])) { /** @var list $requiredProperties */ $requiredProperties = $authenticationSchema['required']; - if (array_diff_key($authenticationData, array_flip($requiredProperties))) { + if (array_diff_key(array_flip($requiredProperties), $authenticationData)) { $authenticationClass = NullRequestAuthentication::class; } } From 34f279a7a5c94430fa4d4f361124b2e84840d682 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Sun, 17 Aug 2025 22:30:09 -0500 Subject: [PATCH 22/77] Ensure models returned from provider registry are properly hooked up with their necessary dependencies. --- src/Providers/ProviderRegistry.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Providers/ProviderRegistry.php b/src/Providers/ProviderRegistry.php index 02a98a25..db44b687 100644 --- a/src/Providers/ProviderRegistry.php +++ b/src/Providers/ProviderRegistry.php @@ -238,7 +238,19 @@ public function getProviderModel( // Use static method from ProviderInterface /** @var class-string $className */ - return $className::model($modelId, $modelConfig); + $modelInstance = $className::model($modelId, $modelConfig); + + if ($modelInstance instanceof WithHttpTransporterInterface) { + $modelInstance->setHttpTransporter($this->getHttpTransporter()); + } + + if ($modelInstance instanceof WithRequestAuthenticationInterface) { + $modelInstance->setRequestAuthentication( + $this->getProviderRequestAuthentication($className) + ); + } + + return $modelInstance; } /** From f75289a164ae4497dc33e2fe63c18c0c64e0ef35 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Sun, 17 Aug 2025 22:31:28 -0500 Subject: [PATCH 23/77] Implement simple ResponseUtil class for an easy way to handle unsuccessful responses with common error data formats. --- ...OpenAiCompatibleModelMetadataDirectory.php | 16 +++++ .../Http/Exception/ResponseException.php | 16 +++++ src/Providers/Http/Util/ResponseUtil.php | 67 +++++++++++++++++++ ...actOpenAiCompatibleTextGenerationModel.php | 16 +++++ 4 files changed, 115 insertions(+) create mode 100644 src/Providers/Http/Exception/ResponseException.php create mode 100644 src/Providers/Http/Util/ResponseUtil.php diff --git a/src/Providers/AbstractOpenAiCompatibleModelMetadataDirectory.php b/src/Providers/AbstractOpenAiCompatibleModelMetadataDirectory.php index afba1f1c..86f58176 100644 --- a/src/Providers/AbstractOpenAiCompatibleModelMetadataDirectory.php +++ b/src/Providers/AbstractOpenAiCompatibleModelMetadataDirectory.php @@ -7,6 +7,8 @@ use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; +use WordPress\AiClient\Providers\Http\Exception\ResponseException; +use WordPress\AiClient\Providers\Http\Util\ResponseUtil; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; /** @@ -27,6 +29,7 @@ protected function sendListModelsRequest(): array $request = $this->getRequestAuthentication()->authenticateRequest($request); $response = $httpTransporter->send($request); + $this->throwIfNotSuccessful($response); $modelsMetadataList = $this->parseResponseToModelMetadataList($response); // Parse list to map. @@ -55,6 +58,19 @@ abstract protected function createRequest( $data = null ): Request; + /** + * Throws an exception if the response is not successful. + * + * @since n.e.x.t + * + * @param Response $response The HTTP response to check. + * @throws ResponseException If the response is not successful. + */ + protected function throwIfNotSuccessful(Response $response): void + { + ResponseUtil::throwIfNotSuccessful($response); + } + /** * Parses the response from the API endpoint to list models into a list of model metadata objects. * diff --git a/src/Providers/Http/Exception/ResponseException.php b/src/Providers/Http/Exception/ResponseException.php new file mode 100644 index 00000000..a835995a --- /dev/null +++ b/src/Providers/Http/Exception/ResponseException.php @@ -0,0 +1,16 @@ +isSuccessful()) { + return; + } + + $errorMessage = sprintf( + 'Bad status code: %d.', + $response->getStatusCode() + ); + + // Handle common error formats in API responses. + $data = $response->getData(); + if ( + is_array($data) && + isset($data['error']) && + is_array($data['error']) && + isset($data['error']['message']) && + is_string($data['error']['message']) + ) { + $errorMessage .= ' ' . $data['error']['message']; + } elseif ( + is_array($data) && + isset($data['error']) && + is_string($data['error']) + ) { + $errorMessage .= ' ' . $data['error']; + } elseif ( + is_array($data) && + isset($data['message']) && + is_string($data['message']) + ) { + $errorMessage .= ' ' . $data['message']; + } + + throw new ResponseException($errorMessage, $response->getStatusCode()); + } +} diff --git a/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php index 437dfb9a..6c1d47a7 100644 --- a/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php @@ -16,6 +16,8 @@ use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; +use WordPress\AiClient\Providers\Http\Exception\ResponseException; +use WordPress\AiClient\Providers\Http\Util\ResponseUtil; use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface; use WordPress\AiClient\Results\DTO\Candidate; use WordPress\AiClient\Results\DTO\GenerativeAiResult; @@ -45,6 +47,7 @@ final public function generateTextResult(array $prompt): GenerativeAiResult $request = $this->getRequestAuthentication()->authenticateRequest($request); $response = $httpTransporter->send($request); + $this->throwIfNotSuccessful($response); return $this->parseResponseToGenerativeAiResult($response); } @@ -498,6 +501,19 @@ abstract protected function createRequest( $data = null ): Request; + /** + * Throws an exception if the response is not successful. + * + * @since n.e.x.t + * + * @param Response $response The HTTP response to check. + * @throws ResponseException If the response is not successful. + */ + protected function throwIfNotSuccessful(Response $response): void + { + ResponseUtil::throwIfNotSuccessful($response); + } + /** * Parses the response from the API endpoint to a generative AI result. * From dc0b26d5eb7bf417251d5f867556445345cb99a3 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Mon, 18 Aug 2025 14:20:23 -0500 Subject: [PATCH 24/77] Fix OpenAI compatible POST request by setting correct Content-Type header. --- .../AbstractOpenAiCompatibleTextGenerationModel.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php index 6c1d47a7..dd291512 100644 --- a/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php @@ -43,10 +43,18 @@ final public function generateTextResult(array $prompt): GenerativeAiResult $params = $this->prepareGenerateTextParams($prompt); - $request = $this->createRequest(HttpMethodEnum::POST(), 'chat/completions', [], $params); + $request = $this->createRequest( + HttpMethodEnum::POST(), + 'chat/completions', + ['Content-Type' => 'application/json'], + $params + ); + + // Add authentication credentials to the request. $request = $this->getRequestAuthentication()->authenticateRequest($request); - $response = $httpTransporter->send($request); + // Send and process the request. + $response = $httpTransporter->send($request); $this->throwIfNotSuccessful($response); return $this->parseResponseToGenerativeAiResult($response); } From b04c6374ec3b910e75b9c466fccae47a0d269470 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Mon, 18 Aug 2025 14:26:21 -0500 Subject: [PATCH 25/77] Implement MessageUtil class to make it easy to parse messages from various input shapes. --- src/Messages/Util/MessageUtil.php | 119 ++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 src/Messages/Util/MessageUtil.php diff --git a/src/Messages/Util/MessageUtil.php b/src/Messages/Util/MessageUtil.php new file mode 100644 index 00000000..fdbab124 --- /dev/null +++ b/src/Messages/Util/MessageUtil.php @@ -0,0 +1,119 @@ + Date: Mon, 18 Aug 2025 15:16:15 -0500 Subject: [PATCH 26/77] Remove non-functional demo code in favor of TODO comment to implement it later. --- src/ProviderImplementations/OpenAi/OpenAiProvider.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/ProviderImplementations/OpenAi/OpenAiProvider.php b/src/ProviderImplementations/OpenAi/OpenAiProvider.php index ee30c446..e03a1f4a 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiProvider.php +++ b/src/ProviderImplementations/OpenAi/OpenAiProvider.php @@ -37,11 +37,15 @@ protected static function createModel( } if ($capability->isImageGeneration()) { // TODO: Implement OpenAiImageGenerationModel. - return new OpenAiImageGenerationModel($modelMetadata, $providerMetadata); + throw new RuntimeException( + 'OpenAI image generation model class is not yet implemented.' + ); } if ($capability->isTextToSpeechConversion()) { // TODO: Implement OpenAiTextToSpeechConversionModel. - return new OpenAiTextToSpeechConversionModel($modelMetadata, $providerMetadata); + throw new RuntimeException( + 'OpenAI text to speech conversion model class is not yet implemented.' + ); } } From ffdccd373040888c3223ada76d0e256567e010b5 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Mon, 18 Aug 2025 16:04:39 -0500 Subject: [PATCH 27/77] Implement provider classes for Google. --- .../GoogleApiKeyRequestAuthentication.php | 28 +++ .../Google/GoogleModelMetadataDirectory.php | 203 ++++++++++++++++++ .../Google/GoogleProvider.php | 81 +++++++ .../Google/GoogleTextGenerationModel.php | 30 +++ 4 files changed, 342 insertions(+) create mode 100644 src/ProviderImplementations/Google/GoogleApiKeyRequestAuthentication.php create mode 100644 src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php create mode 100644 src/ProviderImplementations/Google/GoogleProvider.php create mode 100644 src/ProviderImplementations/Google/GoogleTextGenerationModel.php diff --git a/src/ProviderImplementations/Google/GoogleApiKeyRequestAuthentication.php b/src/ProviderImplementations/Google/GoogleApiKeyRequestAuthentication.php new file mode 100644 index 00000000..f2b2e13b --- /dev/null +++ b/src/ProviderImplementations/Google/GoogleApiKeyRequestAuthentication.php @@ -0,0 +1,28 @@ +withHeader('X-Goog-Api-Key', $this->apiKey); + } +} diff --git a/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php b/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php new file mode 100644 index 00000000..193dd917 --- /dev/null +++ b/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php @@ -0,0 +1,203 @@ +getApiKey()); + } + + /** + * @inheritDoc + */ + protected function createRequest(HttpMethodEnum $method, string $path, array $headers = [], $data = null): Request + { + /* + * We don't call Google's OpenAI compatible models endpoint here because it provides fewer details about the + * models than the primary models endpoint. + * For Google's models endpoint, set pageSize=1000 which is the maximum page size. + * This allows us to retrieve all models in one go. + */ + if ($path === 'models' && $data === null) { + $data = ['pageSize' => 1000]; + } + return new Request( + $method, + GoogleProvider::BASE_URI . '/' . ltrim($path, '/'), + $headers, + $data + ); + } + + /** + * @inheritDoc + */ + protected function parseResponseToModelMetadataList(Response $response): array + { + $responseData = $response->getData(); + if (!isset($responseData['models']) || !$responseData['models']) { + throw new RuntimeException( + 'Unexpected API response: Missing the models key.' + ); + } + + $geminiCapabilities = [ + CapabilityEnum::textGeneration(), + CapabilityEnum::chatHistory(), + ]; + $geminiLegacyOptions = [ + new SupportedOption(ModelConfig::KEY_SYSTEM_INSTRUCTION), + new SupportedOption(ModelConfig::KEY_CANDIDATE_COUNT), + new SupportedOption(ModelConfig::KEY_MAX_TOKENS), + new SupportedOption(ModelConfig::KEY_TEMPERATURE), + new SupportedOption(ModelConfig::KEY_TOP_P), + new SupportedOption(ModelConfig::KEY_STOP_SEQUENCES), + new SupportedOption(ModelConfig::KEY_PRESENCE_PENALTY), + new SupportedOption(ModelConfig::KEY_FREQUENCY_PENALTY), + new SupportedOption(ModelConfig::KEY_LOGPROBS), + new SupportedOption(ModelConfig::KEY_TOP_LOGPROBS), + new SupportedOption(ModelConfig::KEY_OUTPUT_MIME_TYPE, ['text/plain', 'application/json']), + new SupportedOption(ModelConfig::KEY_OUTPUT_SCHEMA), + new SupportedOption(ModelConfig::KEY_FUNCTION_DECLARATIONS), + ]; + $geminiOptions = $geminiLegacyOptions + [ + new SupportedOption( + ModelConfig::KEY_INPUT_MODALITIES, + [ + [ModalityEnum::text()], + [ModalityEnum::text(), ModalityEnum::image()], + [ModalityEnum::text(), ModalityEnum::image(), ModalityEnum::audio()], + ] + ), + ]; + $geminiWebSearchOptions = $geminiOptions + [ + new SupportedOption(ModelConfig::KEY_WEB_SEARCH), + ]; + $geminiMultimodalImageOutputOptions = $geminiOptions + [ + new SupportedOption( + ModelConfig::KEY_OUTPUT_MODALITIES, + [ + [ModalityEnum::text()], + [ModalityEnum::text(), ModalityEnum::image()], + ] + ), + ]; + $imagenCapabilities = [ + CapabilityEnum::imageGeneration(), + ]; + $imagenOptions = [ + new SupportedOption(ModelConfig::KEY_CANDIDATE_COUNT), + new SupportedOption(ModelConfig::KEY_OUTPUT_MIME_TYPE, ['image/png', 'image/jpeg', 'image/webp']), + new SupportedOption(ModelConfig::KEY_OUTPUT_FILE_TYPE, [FileTypeEnum::inline()]), + new SupportedOption(ModelConfig::KEY_OUTPUT_MEDIA_ORIENTATION, [ + MediaOrientationEnum::square(), + MediaOrientationEnum::landscape(), + MediaOrientationEnum::portrait(), + ]), + new SupportedOption(ModelConfig::KEY_OUTPUT_MEDIA_ASPECT_RATIO, ['1:1', '16:9', '4:3', '9:16', '3:4']), + ]; + + /** @var array> $modelsData */ + $modelsData = (array) $responseData['models']; + + return array_values( + array_map( + static function (array $modelData) use ( + $geminiCapabilities, + $geminiLegacyOptions, + $geminiOptions, + $geminiWebSearchOptions, + $geminiMultimodalImageOutputOptions, + $imagenCapabilities, + $imagenOptions, + ): ModelMetadata { + /** @var string $modelId */ + $modelId = $modelData['baseModelId'] ?? $modelData['name']; + if (str_starts_with($modelId, 'models/')) { + $modelId = substr($modelId, 7); + } + if ( + isset($modelData['supportedGenerationMethods']) && + in_array('generateContent', $modelData['supportedGenerationMethods'], true) + ) { + $modelCaps = $geminiCapabilities; + if ( + str_starts_with($modelId, 'gemini-1.0') || + str_starts_with($modelId, 'gemini-pro') // 'gemini-pro' without version refers to 1.0. + ) { + $modelOptions = $geminiLegacyOptions; + } else { + if ( + // Web search is supported by Gemini 2.0 and newer. + str_starts_with($modelId, 'gemini-') && + ! str_starts_with($modelId, 'gemini-1.5-') + ) { + $modelOptions = $geminiWebSearchOptions; + } elseif ( + // New multimodal output model for image generation. + str_contains($modelId, 'image-generation') || + str_starts_with($modelId, 'gemini-2.0-flash-exp') + ) { + $modelOptions = $geminiMultimodalImageOutputOptions; + } else { + $modelOptions = $geminiOptions; + } + } + } elseif ( + isset($modelData['supportedGenerationMethods']) && + in_array('predict', $modelData['supportedGenerationMethods'], true) + ) { + $modelCaps = $imagenCapabilities; + $modelOptions = $imagenOptions; + } else { + $modelCaps = []; + $modelOptions = []; + } + + return new ModelMetadata( + $modelId, + $modelData['displayName'] ?? $modelId, + $modelCaps, + $modelOptions + ); + }, + $modelsData + ) + ); + } +} diff --git a/src/ProviderImplementations/Google/GoogleProvider.php b/src/ProviderImplementations/Google/GoogleProvider.php new file mode 100644 index 00000000..60d21ee9 --- /dev/null +++ b/src/ProviderImplementations/Google/GoogleProvider.php @@ -0,0 +1,81 @@ +getSupportedCapabilities(); + foreach ($capabilities as $capability) { + if ($capability->isTextGeneration()) { + return new GoogleTextGenerationModel($modelMetadata, $providerMetadata); + } + if ($capability->isImageGeneration()) { + // TODO: Implement GoogleImageGenerationModel. + throw new RuntimeException( + 'Google image generation model class is not yet implemented.' + ); + } + } + + throw new RuntimeException( + 'Unsupported model capabilities: ' . implode(', ', $capabilities) + ); + } + + /** + * @inheritDoc + */ + protected static function createProviderMetadata(): ProviderMetadata + { + return new ProviderMetadata( + 'google', + 'Google', + ProviderTypeEnum::cloud() + ); + } + + /** + * @inheritDoc + */ + protected static function createProviderAvailability(): ProviderAvailabilityInterface + { + // Check valid API access by attempting to list models. + return new ListModelsApiBasedProviderAvailability( + static::modelMetadataDirectory() + ); + } + + /** + * @inheritDoc + */ + protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface + { + return new GoogleModelMetadataDirectory(); + } +} diff --git a/src/ProviderImplementations/Google/GoogleTextGenerationModel.php b/src/ProviderImplementations/Google/GoogleTextGenerationModel.php new file mode 100644 index 00000000..b7c7b81f --- /dev/null +++ b/src/ProviderImplementations/Google/GoogleTextGenerationModel.php @@ -0,0 +1,30 @@ + Date: Mon, 18 Aug 2025 16:24:38 -0500 Subject: [PATCH 28/77] Implement provider classes for Anthropic. --- .../AnthropicApiKeyRequestAuthentication.php | 30 ++++ .../AnthropicModelMetadataDirectory.php | 133 ++++++++++++++++++ .../Anthropic/AnthropicProvider.php | 75 ++++++++++ .../AnthropicTextGenerationModel.php | 30 ++++ .../Google/GoogleModelMetadataDirectory.php | 7 +- 5 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 src/ProviderImplementations/Anthropic/AnthropicApiKeyRequestAuthentication.php create mode 100644 src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php create mode 100644 src/ProviderImplementations/Anthropic/AnthropicProvider.php create mode 100644 src/ProviderImplementations/Anthropic/AnthropicTextGenerationModel.php diff --git a/src/ProviderImplementations/Anthropic/AnthropicApiKeyRequestAuthentication.php b/src/ProviderImplementations/Anthropic/AnthropicApiKeyRequestAuthentication.php new file mode 100644 index 00000000..fd9b87ce --- /dev/null +++ b/src/ProviderImplementations/Anthropic/AnthropicApiKeyRequestAuthentication.php @@ -0,0 +1,30 @@ +withHeader('anthropic-version', self::ANTHROPIC_API_VERSION); + + // Add the API key to the request headers. + return $request->withHeader('x-api-key', $this->apiKey); + } +} diff --git a/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php b/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php new file mode 100644 index 00000000..428fb097 --- /dev/null +++ b/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php @@ -0,0 +1,133 @@ +getApiKey()); + } + + /** + * @inheritDoc + */ + protected function createRequest(HttpMethodEnum $method, string $path, array $headers = [], $data = null): Request + { + return new Request( + $method, + AnthropicProvider::BASE_URI . '/' . ltrim($path, '/'), + $headers, + $data + ); + } + + /** + * @inheritDoc + */ + protected function parseResponseToModelMetadataList(Response $response): array + { + $responseData = $response->getData(); + if (!isset($responseData['data']) || !$responseData['data']) { + throw new RuntimeException( + 'Unexpected API response: Missing the data key.' + ); + } + + // Unfortunately, the Anthropic API does not return model capabilities, so we have to hardcode them here. + $anthropicCapabilities = [ + CapabilityEnum::textGeneration(), + CapabilityEnum::chatHistory(), + ]; + $anthropicOptions = [ + new SupportedOption(ModelConfig::KEY_SYSTEM_INSTRUCTION), + new SupportedOption(ModelConfig::KEY_CANDIDATE_COUNT), + new SupportedOption(ModelConfig::KEY_MAX_TOKENS), + new SupportedOption(ModelConfig::KEY_TEMPERATURE), + new SupportedOption(ModelConfig::KEY_TOP_P), + new SupportedOption(ModelConfig::KEY_STOP_SEQUENCES), + new SupportedOption(ModelConfig::KEY_PRESENCE_PENALTY), + new SupportedOption(ModelConfig::KEY_FREQUENCY_PENALTY), + new SupportedOption(ModelConfig::KEY_LOGPROBS), + new SupportedOption(ModelConfig::KEY_TOP_LOGPROBS), + new SupportedOption(ModelConfig::KEY_OUTPUT_MIME_TYPE, ['text/plain', 'application/json']), + new SupportedOption(ModelConfig::KEY_OUTPUT_SCHEMA), + new SupportedOption(ModelConfig::KEY_FUNCTION_DECLARATIONS), + new SupportedOption( + ModelConfig::KEY_INPUT_MODALITIES, + [ + [ModalityEnum::text()], + [ModalityEnum::text(), ModalityEnum::image()], + ] + ), + ]; + $anthropicWebSearchOptions = $anthropicOptions + [ + new SupportedOption(ModelConfig::KEY_WEB_SEARCH), + ]; + + /** @var array> $modelsData */ + $modelsData = (array) $responseData['data']; + + return array_values( + array_map( + static function (array $modelData) use ( + $anthropicCapabilities, + $anthropicOptions, + $anthropicWebSearchOptions, + ): ModelMetadata { + /** @var string $modelId */ + $modelId = $modelData['id']; + $modelCaps = $anthropicCapabilities; + if (!preg_match('/^claude-3-[a-z]+/', $modelId)) { + // Only models newer than Claude 3 support web search. + $modelOptions = $anthropicWebSearchOptions; + } else { + $modelOptions = $anthropicOptions; + } + + /** @var string $modelName */ + $modelName = $modelData['display_name'] ?? $modelId; + + return new ModelMetadata( + $modelId, + $modelName, + $modelCaps, + $modelOptions + ); + }, + $modelsData + ) + ); + } +} diff --git a/src/ProviderImplementations/Anthropic/AnthropicProvider.php b/src/ProviderImplementations/Anthropic/AnthropicProvider.php new file mode 100644 index 00000000..caf56a6e --- /dev/null +++ b/src/ProviderImplementations/Anthropic/AnthropicProvider.php @@ -0,0 +1,75 @@ +getSupportedCapabilities(); + foreach ($capabilities as $capability) { + if ($capability->isTextGeneration()) { + return new AnthropicTextGenerationModel($modelMetadata, $providerMetadata); + } + } + + throw new RuntimeException( + 'Unsupported model capabilities: ' . implode(', ', $capabilities) + ); + } + + /** + * @inheritDoc + */ + protected static function createProviderMetadata(): ProviderMetadata + { + return new ProviderMetadata( + 'anthropic', + 'Anthropic', + ProviderTypeEnum::cloud() + ); + } + + /** + * @inheritDoc + */ + protected static function createProviderAvailability(): ProviderAvailabilityInterface + { + // Check valid API access by attempting to list models. + return new ListModelsApiBasedProviderAvailability( + static::modelMetadataDirectory() + ); + } + + /** + * @inheritDoc + */ + protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface + { + return new AnthropicModelMetadataDirectory(); + } +} diff --git a/src/ProviderImplementations/Anthropic/AnthropicTextGenerationModel.php b/src/ProviderImplementations/Anthropic/AnthropicTextGenerationModel.php new file mode 100644 index 00000000..280795a4 --- /dev/null +++ b/src/ProviderImplementations/Anthropic/AnthropicTextGenerationModel.php @@ -0,0 +1,30 @@ + Date: Mon, 18 Aug 2025 16:50:18 -0500 Subject: [PATCH 29/77] Implement very basic CLI tool to test the SDK (experimental, not part of the actual SDK). --- .gitattributes | 1 + cli.php | 184 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100755 cli.php diff --git a/.gitattributes b/.gitattributes index 9936f69d..7d098507 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,6 +2,7 @@ .gitattributes export-ignore /.github/ export-ignore .gitignore export-ignore +/cli.php export-ignore /*.md export-ignore /LICENSE.md -export-ignore /README.md -export-ignore diff --git a/cli.php b/cli.php new file mode 100755 index 00000000..53212920 --- /dev/null +++ b/cli.php @@ -0,0 +1,184 @@ +setHttpTransporter(HttpTransporterFactory::createTransporter()); +$providerRegistry->registerProvider(AnthropicProvider::class); +$providerRegistry->registerProvider(GoogleProvider::class); +$providerRegistry->registerProvider(OpenAiProvider::class); + +// --- Main logic --- + +// Allow complex input to be passed as a JSON string. +if (strpos($promptInput, '{') === 0 || strpos($promptInput, '[') === 0) { + $decodedInput = json_decode($promptInput, true); + if ($decodedInput) { + $promptInput = $decodedInput; + } +} + +$messages = MessageUtil::parseMessagesFromInput($promptInput); + +$modelConfig = new ModelConfig(); +$modelConfig->setTemperature(0.1); + +$modelRequirements = new ModelRequirements( + [ + CapabilityEnum::textGeneration(), + ], + [ + new RequiredOption(ModelConfig::KEY_TEMPERATURE, 0.1), + ], +); + +try { + if (!$providerId && !$modelId) { + $providerModelsMetadata = $providerRegistry->findModelsMetadataForSupport($modelRequirements); + $providerId = $providerModelsMetadata[0]->getProvider()->getId(); + $modelId = $providerModelsMetadata[0]->getModels()[0]->getId(); + } elseif (!$modelId) { + $modelsMetadata = $providerRegistry->findProviderModelsMetadataForSupport($providerId, $modelRequirements); + $modelId = $modelsMetadata[0]->getId(); + } + $modelInstance = $providerRegistry->getProviderModel($providerId, $modelId); +} catch (InvalidArgumentException $e) { + logError('Invalid arguments while trying to set up model instance: ' . $e->getMessage()); +} catch (ResponseException $e) { + logError('Request failed while trying to set up model instance: ' . $e->getMessage()); +} + +logInfo("Using provider ID: \"{$modelInstance->providerMetadata()->getId()}\""); +logInfo("Using model ID: \"{$modelInstance->metadata()->getId()}\""); + +if (!($modelInstance instanceof TextGenerationModelInterface)) { + logError('The model class ' . get_class($modelInstance) . ' does not support text generation.'); +} + +$modelInstance->setConfig($modelConfig); + +try { + $result = $modelInstance->generateTextResult($messages); +} catch (InvalidArgumentException $e) { + logError('Invalid arguments while trying to generate text result: ' . $e->getMessage()); +} catch (ResponseException $e) { + logError('Request failed while trying to generate text result: ' . $e->getMessage()); +} + +switch ($outputFormat) { + case 'result-json': + $output = json_encode($result, JSON_PRETTY_PRINT); + break; + case 'candidates-json': + $output = json_encode($result->getCandidates(), JSON_PRETTY_PRINT); + break; + case 'message-text': + default: + $output = $result->toText(); +} + +printOutput($output); From 599201505e6479f28d1ef9a60b85384b3963bfef Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Wed, 20 Aug 2025 09:26:45 -0500 Subject: [PATCH 30/77] Ensure ModelConfig::KEY_CUSTOM_OPTIONS is marked as supported by the OpenAI compatible providers. --- .../Anthropic/AnthropicModelMetadataDirectory.php | 1 + .../Google/GoogleModelMetadataDirectory.php | 1 + .../OpenAi/OpenAiModelMetadataDirectory.php | 1 + 3 files changed, 3 insertions(+) diff --git a/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php b/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php index 428fb097..be9d5607 100644 --- a/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php +++ b/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php @@ -84,6 +84,7 @@ protected function parseResponseToModelMetadataList(Response $response): array new SupportedOption(ModelConfig::KEY_OUTPUT_MIME_TYPE, ['text/plain', 'application/json']), new SupportedOption(ModelConfig::KEY_OUTPUT_SCHEMA), new SupportedOption(ModelConfig::KEY_FUNCTION_DECLARATIONS), + new SupportedOption(ModelConfig::KEY_CUSTOM_OPTIONS), new SupportedOption( ModelConfig::KEY_INPUT_MODALITIES, [ diff --git a/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php b/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php index 78bed2fc..00897480 100644 --- a/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php +++ b/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php @@ -94,6 +94,7 @@ protected function parseResponseToModelMetadataList(Response $response): array new SupportedOption(ModelConfig::KEY_OUTPUT_MIME_TYPE, ['text/plain', 'application/json']), new SupportedOption(ModelConfig::KEY_OUTPUT_SCHEMA), new SupportedOption(ModelConfig::KEY_FUNCTION_DECLARATIONS), + new SupportedOption(ModelConfig::KEY_CUSTOM_OPTIONS), ]; $geminiOptions = $geminiLegacyOptions + [ new SupportedOption( diff --git a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php index b49525aa..176e5b41 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php +++ b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php @@ -68,6 +68,7 @@ protected function parseResponseToModelMetadataList(Response $response): array new SupportedOption(ModelConfig::KEY_OUTPUT_MIME_TYPE, ['text/plain', 'application/json']), new SupportedOption(ModelConfig::KEY_OUTPUT_SCHEMA), new SupportedOption(ModelConfig::KEY_FUNCTION_DECLARATIONS), + new SupportedOption(ModelConfig::KEY_CUSTOM_OPTIONS), ]; $gptMultimodalInputOptions = $gptOptions + [ new SupportedOption( From 2bf695129ff8def48822f69771d5a267229d8801 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Wed, 20 Aug 2025 09:31:35 -0500 Subject: [PATCH 31/77] Enhance CLI tool to allow passing any model config parameters. --- cli.php | 61 +++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 48 insertions(+), 13 deletions(-) diff --git a/cli.php b/cli.php index 53212920..386cd1a9 100755 --- a/cli.php +++ b/cli.php @@ -97,12 +97,54 @@ function logError(string $message, int $exit_code = 1): void logError('Missing required positional argument "prompt input".'); } +// Prompt input. Allow complex input as a JSON string. $promptInput = $positional_args[0]; +if (strpos($promptInput, '{') === 0 || strpos($promptInput, '[') === 0) { + $decodedInput = json_decode($promptInput, true); + if ($decodedInput) { + $promptInput = $decodedInput; + } +} +// Provider ID, model ID, and output format. $providerId = $named_args['providerId'] ?? null; $modelId = $named_args['modelId'] ?? null; $outputFormat = $named_args['outputFormat'] ?? 'message-text'; +// Any model configuration options. +$schema = ModelConfig::getJsonSchema()['properties']; +$model_config_data = []; +foreach ($named_args as $key => $value) { + if (!isset($schema[$key])) { + continue; + } + + $property_schema = $schema[$key]; + $type = $property_schema['type'] ?? null; + + $processed_value = $value; + if ($type === 'array' || $type === 'object') { + $decoded = json_decode((string) $value, true); + if (json_last_error() !== JSON_ERROR_NONE) { + logWarning("Invalid JSON for argument --{$key}: " . json_last_error_msg()); + continue; + } + $processed_value = $decoded; + } elseif ($type === 'integer') { + $processed_value = (int) $value; + } elseif ($type === 'number') { + $processed_value = (float) $value; + } elseif ($type === 'boolean') { + $processed_value = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if (null === $processed_value) { + logWarning("Invalid boolean for argument --{$key}: {$value}"); + continue; + } + } + + $model_config_data[$key] = $processed_value; +} + // --- SDK setup --- // This will eventually be obsolete, as the AiClient class will handle it. @@ -114,26 +156,19 @@ function logError(string $message, int $exit_code = 1): void // --- Main logic --- -// Allow complex input to be passed as a JSON string. -if (strpos($promptInput, '{') === 0 || strpos($promptInput, '[') === 0) { - $decodedInput = json_decode($promptInput, true); - if ($decodedInput) { - $promptInput = $decodedInput; - } -} - $messages = MessageUtil::parseMessagesFromInput($promptInput); -$modelConfig = new ModelConfig(); -$modelConfig->setTemperature(0.1); +$modelConfig = ModelConfig::fromArray($model_config_data); +$requiredOptions = []; +foreach ($modelConfig->toArray() as $option => $value) { + $requiredOptions[] = new RequiredOption($option, $value); +} $modelRequirements = new ModelRequirements( [ CapabilityEnum::textGeneration(), ], - [ - new RequiredOption(ModelConfig::KEY_TEMPERATURE, 0.1), - ], + $requiredOptions ); try { From 9d307f6776e2c8b39b44cac302a6d815bfccfacb Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Wed, 20 Aug 2025 09:33:50 -0500 Subject: [PATCH 32/77] Handle missing model for required options scenario gracefully in CLI tool. --- cli.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cli.php b/cli.php index 386cd1a9..65b335f3 100755 --- a/cli.php +++ b/cli.php @@ -174,10 +174,16 @@ function logError(string $message, int $exit_code = 1): void try { if (!$providerId && !$modelId) { $providerModelsMetadata = $providerRegistry->findModelsMetadataForSupport($modelRequirements); + if (!isset($providerModelsMetadata[0])) { + logError('No provider model supports the necessary model requirements.'); + } $providerId = $providerModelsMetadata[0]->getProvider()->getId(); $modelId = $providerModelsMetadata[0]->getModels()[0]->getId(); } elseif (!$modelId) { $modelsMetadata = $providerRegistry->findProviderModelsMetadataForSupport($providerId, $modelRequirements); + if (!isset($modelsMetadata[0])) { + logError('No "' . $providerId . '" model supports the necessary model requirements.'); + } $modelId = $modelsMetadata[0]->getId(); } $modelInstance = $providerRegistry->getProviderModel($providerId, $modelId); From 6c53d89df85f823d2e8055348e8b6bc031dee2d1 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Wed, 20 Aug 2025 09:52:49 -0500 Subject: [PATCH 33/77] Add test coverage for class changes. --- src/Tools/DTO/FunctionResponse.php | 3 +- tests/mocks/MockHttpTransporter.php | 54 +++++ tests/mocks/MockModel.php | 18 +- tests/mocks/MockModelMetadataDirectory.php | 12 +- tests/mocks/MockProviderAvailability.php | 12 +- tests/mocks/MockRequestAuthentication.php | 50 ++++ tests/unit/Messages/Util/MessageUtilTest.php | 179 +++++++++++++++ .../DTO/ApiKeyRequestAuthenticationTest.php | 116 ++++++++++ .../DTO/NullRequestAuthenticationTest.php | 73 ++++++ .../Providers/Http/Util/ResponseUtilTest.php | 115 ++++++++++ .../Models/DTO/SupportedOptionTest.php | 19 ++ tests/unit/Providers/ProviderRegistryTest.php | 213 +++++++++++++++++- 12 files changed, 859 insertions(+), 5 deletions(-) create mode 100644 tests/mocks/MockHttpTransporter.php create mode 100644 tests/mocks/MockRequestAuthentication.php create mode 100644 tests/unit/Messages/Util/MessageUtilTest.php create mode 100644 tests/unit/Providers/Http/DTO/ApiKeyRequestAuthenticationTest.php create mode 100644 tests/unit/Providers/Http/DTO/NullRequestAuthenticationTest.php create mode 100644 tests/unit/Providers/Http/Util/ResponseUtilTest.php diff --git a/src/Tools/DTO/FunctionResponse.php b/src/Tools/DTO/FunctionResponse.php index 1078ddc1..4d6d4143 100644 --- a/src/Tools/DTO/FunctionResponse.php +++ b/src/Tools/DTO/FunctionResponse.php @@ -4,6 +4,7 @@ namespace WordPress\AiClient\Tools\DTO; +use InvalidArgumentException; use WordPress\AiClient\Common\AbstractDataTransferObject; /** @@ -151,7 +152,7 @@ public static function fromArray(array $array): self // Validate that at least one of id or name is provided if (!array_key_exists(self::KEY_ID, $array) && !array_key_exists(self::KEY_NAME, $array)) { - throw new \InvalidArgumentException('At least one of id or name must be provided.'); + throw new InvalidArgumentException('At least one of id or name must be provided.'); } return new self( diff --git a/tests/mocks/MockHttpTransporter.php b/tests/mocks/MockHttpTransporter.php new file mode 100644 index 00000000..01f7c0ea --- /dev/null +++ b/tests/mocks/MockHttpTransporter.php @@ -0,0 +1,54 @@ +lastRequest = $request; + return $this->responseToReturn ?? new Response(200, [], '{"status":"success"}'); + } + + /** + * Gets the last request that was sent. + * + * @return Request|null + */ + public function getLastRequest(): ?Request + { + return $this->lastRequest; + } + + /** + * Sets the response to return for subsequent requests. + * + * @param Response $response + */ + public function setResponseToReturn(Response $response): void + { + $this->responseToReturn = $response; + } +} diff --git a/tests/mocks/MockModel.php b/tests/mocks/MockModel.php index 57541368..c79b5ca0 100644 --- a/tests/mocks/MockModel.php +++ b/tests/mocks/MockModel.php @@ -4,6 +4,10 @@ namespace WordPress\AiClient\Tests\mocks; +use WordPress\AiClient\Providers\Http\Contracts\WithHttpTransporterInterface; +use WordPress\AiClient\Providers\Http\Contracts\WithRequestAuthenticationInterface; +use WordPress\AiClient\Providers\Http\Traits\WithHttpTransporterTrait; +use WordPress\AiClient\Providers\Http\Traits\WithRequestAuthenticationTrait; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; @@ -13,8 +17,11 @@ * * @since n.e.x.t */ -class MockModel implements ModelInterface +class MockModel implements ModelInterface, WithHttpTransporterInterface, WithRequestAuthenticationInterface { + use WithHttpTransporterTrait; + use WithRequestAuthenticationTrait; + /** * @var ModelMetadata The model metadata. */ @@ -45,6 +52,15 @@ public function metadata(): ModelMetadata return $this->metadata; } + /** + * {@inheritDoc} + */ + public function providerMetadata(): \WordPress\AiClient\Providers\DTO\ProviderMetadata + { + // This mock doesn't need to return actual provider metadata for its tests. + return $this->createMock(\WordPress\AiClient\Providers\DTO\ProviderMetadata::class); + } + /** * {@inheritDoc} */ diff --git a/tests/mocks/MockModelMetadataDirectory.php b/tests/mocks/MockModelMetadataDirectory.php index e7e19e3a..f2b21603 100644 --- a/tests/mocks/MockModelMetadataDirectory.php +++ b/tests/mocks/MockModelMetadataDirectory.php @@ -6,6 +6,10 @@ use InvalidArgumentException; use WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface; +use WordPress\AiClient\Providers\Http\Contracts\WithHttpTransporterInterface; +use WordPress\AiClient\Providers\Http\Contracts\WithRequestAuthenticationInterface; +use WordPress\AiClient\Providers\Http\Traits\WithHttpTransporterTrait; +use WordPress\AiClient\Providers\Http\Traits\WithRequestAuthenticationTrait; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; /** @@ -13,8 +17,14 @@ * * @since n.e.x.t */ -class MockModelMetadataDirectory implements ModelMetadataDirectoryInterface +class MockModelMetadataDirectory implements + ModelMetadataDirectoryInterface, + WithHttpTransporterInterface, + WithRequestAuthenticationInterface { + use WithHttpTransporterTrait; + use WithRequestAuthenticationTrait; + /** * @var array Available models. */ diff --git a/tests/mocks/MockProviderAvailability.php b/tests/mocks/MockProviderAvailability.php index 805aa3df..0e4a8f0b 100644 --- a/tests/mocks/MockProviderAvailability.php +++ b/tests/mocks/MockProviderAvailability.php @@ -5,14 +5,24 @@ namespace WordPress\AiClient\Tests\mocks; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; +use WordPress\AiClient\Providers\Http\Contracts\WithHttpTransporterInterface; +use WordPress\AiClient\Providers\Http\Contracts\WithRequestAuthenticationInterface; +use WordPress\AiClient\Providers\Http\Traits\WithHttpTransporterTrait; +use WordPress\AiClient\Providers\Http\Traits\WithRequestAuthenticationTrait; /** * Mock provider availability for testing. * * @since n.e.x.t */ -class MockProviderAvailability implements ProviderAvailabilityInterface +class MockProviderAvailability implements + ProviderAvailabilityInterface, + WithHttpTransporterInterface, + WithRequestAuthenticationInterface { + use WithHttpTransporterTrait; + use WithRequestAuthenticationTrait; + /** * @var bool Whether the provider is configured. */ diff --git a/tests/mocks/MockRequestAuthentication.php b/tests/mocks/MockRequestAuthentication.php new file mode 100644 index 00000000..4406b9b1 --- /dev/null +++ b/tests/mocks/MockRequestAuthentication.php @@ -0,0 +1,50 @@ +token = $token; + } + + /** + * @inheritDoc + */ + public function authenticateRequest(Request $request): Request + { + return $request->withHeader('X-Mock-Auth', $this->token); + } + + /** + * @inheritDoc + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [], + 'required' => [], + ]; + } +} diff --git a/tests/unit/Messages/Util/MessageUtilTest.php b/tests/unit/Messages/Util/MessageUtilTest.php new file mode 100644 index 00000000..6397cc2a --- /dev/null +++ b/tests/unit/Messages/Util/MessageUtilTest.php @@ -0,0 +1,179 @@ +assertSame($message, $result); + } + + /** + * Tests that parseMessageFromInput correctly parses a message from an array. + * + * @return void + */ + public function testParseMessageFromInputWithMessageArray(): void + { + $input = [ + 'role' => 'user', + 'parts' => [ + ['text' => 'Hello from array'] + ], + ]; + $result = MessageUtil::parseMessageFromInput($input); + $this->assertInstanceOf(Message::class, $result); + $this->assertEquals(MessageRoleEnum::user(), $result->getRole()); + $this->assertCount(1, $result->getParts()); + $this->assertEquals('Hello from array', $result->getParts()[0]->getText()); + } + + /** + * Tests that parseMessageFromInput correctly parses a message from various single part inputs. + * + * @dataProvider singlePartInputProvider + * @param mixed $input The input to test. + * @param string $expectedText The expected text in the message part. + * @return void + */ + public function testParseMessageFromInputWithSinglePartInput($input, string $expectedText): void + { + $result = MessageUtil::parseMessageFromInput($input); + $this->assertInstanceOf(Message::class, $result); + $this->assertEquals(MessageRoleEnum::user(), $result->getRole()); + $this->assertCount(1, $result->getParts()); + $this->assertEquals($expectedText, $result->getParts()[0]->getText()); + } + + /** + * Provides various single part inputs for testing. + * + * @return array + */ + public function singlePartInputProvider(): array + { + return [ + 'string' => ['Just a string', 'Just a string'], + 'MessagePart instance' => [new MessagePart('A message part'), 'A message part'], + 'MessagePart array' => [['text' => 'Part from array'], 'Part from array'], + ]; + } + + /** + * Tests that parseMessageFromInput correctly parses a message from multiple part inputs. + * + * @return void + */ + public function testParseMessageFromInputWithMultiplePartInputs(): void + { + $part = new MessagePart('A message part'); + $input = [ + 'First part', + $part, + ['text' => 'Third part'], + ]; + $result = MessageUtil::parseMessageFromInput($input); + $this->assertInstanceOf(Message::class, $result); + $this->assertEquals(MessageRoleEnum::user(), $result->getRole()); + $this->assertCount(3, $result->getParts()); + $this->assertEquals('First part', $result->getParts()[0]->getText()); + $this->assertSame($part, $result->getParts()[1]); + $this->assertEquals('Third part', $result->getParts()[2]->getText()); + } + + /** + * Tests that parseMessagesFromInput correctly parses an array of Message instances. + * + * @return void + */ + public function testParseMessagesFromInputWithArrayOfMessageInstances(): void + { + $messages = [ + new Message(MessageRoleEnum::user(), [new MessagePart('Hello')]), + new Message(MessageRoleEnum::model(), [new MessagePart('Hi there')]), + ]; + $result = MessageUtil::parseMessagesFromInput($messages); + $this->assertCount(2, $result); + $this->assertSame($messages[0], $result[0]); + $this->assertSame($messages[1], $result[1]); + } + + /** + * Tests that parseMessagesFromInput correctly parses an array of message arrays. + * + * @return void + */ + public function testParseMessagesFromInputWithArrayOfMessageArrays(): void + { + $input = [ + [ + 'role' => 'user', + 'parts' => [['text' => 'Message 1']], + ], + [ + 'role' => 'model', + 'parts' => [['text' => 'Message 2']], + ], + ]; + $result = MessageUtil::parseMessagesFromInput($input); + $this->assertCount(2, $result); + $this->assertInstanceOf(Message::class, $result[0]); + $this->assertEquals(MessageRoleEnum::user(), $result[0]->getRole()); + $this->assertEquals('Message 1', $result[0]->getParts()[0]->getText()); + $this->assertInstanceOf(Message::class, $result[1]); + $this->assertEquals(MessageRoleEnum::model(), $result[1]->getRole()); + $this->assertEquals('Message 2', $result[1]->getParts()[0]->getText()); + } + + /** + * Tests that parseMessagesFromInput correctly handles a single message input. + * + * @return void + */ + public function testParseMessagesFromInputWithSingleMessageInput(): void + { + $input = 'A single message'; + $result = MessageUtil::parseMessagesFromInput($input); + $this->assertCount(1, $result); + $this->assertInstanceOf(Message::class, $result[0]); + $this->assertEquals(MessageRoleEnum::user(), $result[0]->getRole()); + $this->assertEquals('A single message', $result[0]->getParts()[0]->getText()); + } + + /** + * Tests that parseMessagesFromInput correctly handles a single message array input. + * + * @return void + */ + public function testParseMessagesFromInputWithSingleMessageArrayInput(): void + { + $input = [ + 'role' => 'system', + 'parts' => [['text' => 'System prompt']], + ]; + $result = MessageUtil::parseMessagesFromInput($input); + $this->assertCount(1, $result); + $this->assertInstanceOf(Message::class, $result[0]); + $this->assertEquals(MessageRoleEnum::system(), $result[0]->getRole()); + $this->assertEquals('System prompt', $result[0]->getParts()[0]->getText()); + } +} diff --git a/tests/unit/Providers/Http/DTO/ApiKeyRequestAuthenticationTest.php b/tests/unit/Providers/Http/DTO/ApiKeyRequestAuthenticationTest.php new file mode 100644 index 00000000..7f2c82b9 --- /dev/null +++ b/tests/unit/Providers/Http/DTO/ApiKeyRequestAuthenticationTest.php @@ -0,0 +1,116 @@ +assertEquals($apiKey, $auth->getApiKey()); + } + + /** + * Tests authenticateRequest method. + * + * @return void + */ + public function testAuthenticateRequest(): void + { + $apiKey = 'test_api_key_456'; + $auth = new ApiKeyRequestAuthentication($apiKey); + + $request = new Request(HttpMethodEnum::get(), 'https://example.com/api'); + $authenticatedRequest = $auth->authenticateRequest($request); + + $this->assertNotSame($request, $authenticatedRequest); // Ensure immutability + $this->assertTrue($authenticatedRequest->hasHeader('Authorization')); + $this->assertEquals('Bearer ' . $apiKey, $authenticatedRequest->getHeaderAsString('Authorization')); + } + + /** + * Tests toArray method. + * + * @return void + */ + public function testToArray(): void + { + $apiKey = 'test_api_key_789'; + $auth = new ApiKeyRequestAuthentication($apiKey); + + $array = $auth->toArray(); + + $this->assertIsArray($array); + $this->assertArrayHasKey(ApiKeyRequestAuthentication::KEY_API_KEY, $array); + $this->assertEquals($apiKey, $array[ApiKeyRequestAuthentication::KEY_API_KEY]); + } + + /** + * Tests fromArray method. + * + * @return void + */ + public function testFromArray(): void + { + $apiKey = 'test_api_key_abc'; + $array = [ + ApiKeyRequestAuthentication::KEY_API_KEY => $apiKey, + ]; + + $auth = ApiKeyRequestAuthentication::fromArray($array); + + $this->assertInstanceOf(ApiKeyRequestAuthentication::class, $auth); + $this->assertEquals($apiKey, $auth->getApiKey()); + } + + /** + * Tests fromArray method with missing API key. + * + * @return void + */ + public function testFromArrayWithMissingApiKey(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + ApiKeyRequestAuthentication::class . '::fromArray() missing required keys: apiKey' + ); + + ApiKeyRequestAuthentication::fromArray([]); + } + + /** + * Tests getJsonSchema method. + * + * @return void + */ + public function testGetJsonSchema(): void + { + $schema = ApiKeyRequestAuthentication::getJsonSchema(); + + $this->assertIsArray($schema); + $this->assertEquals('object', $schema['type']); + $this->assertArrayHasKey('properties', $schema); + $this->assertArrayHasKey(ApiKeyRequestAuthentication::KEY_API_KEY, $schema['properties']); + $this->assertEquals('string', $schema['properties'][ApiKeyRequestAuthentication::KEY_API_KEY]['type']); + $this->assertArrayHasKey('required', $schema); + $this->assertEquals([ApiKeyRequestAuthentication::KEY_API_KEY], $schema['required']); + } +} diff --git a/tests/unit/Providers/Http/DTO/NullRequestAuthenticationTest.php b/tests/unit/Providers/Http/DTO/NullRequestAuthenticationTest.php new file mode 100644 index 00000000..87ac15ef --- /dev/null +++ b/tests/unit/Providers/Http/DTO/NullRequestAuthenticationTest.php @@ -0,0 +1,73 @@ +authenticateRequest($request); + + $this->assertSame($request, $authenticatedRequest); + } + + /** + * Tests toArray method. + * + * @return void + */ + public function testToArray(): void + { + $auth = new NullRequestAuthentication(); + $array = $auth->toArray(); + + $this->assertIsArray($array); + $this->assertEmpty($array); + } + + /** + * Tests fromArray method. + * + * @return void + */ + public function testFromArray(): void + { + $auth = NullRequestAuthentication::fromArray([]); + + $this->assertInstanceOf(NullRequestAuthentication::class, $auth); + } + + /** + * Tests getJsonSchema method. + * + * @return void + */ + public function testGetJsonSchema(): void + { + $schema = NullRequestAuthentication::getJsonSchema(); + + $this->assertIsArray($schema); + $this->assertEquals('object', $schema['type']); + $this->assertArrayHasKey('properties', $schema); + $this->assertEmpty($schema['properties']); + $this->assertArrayHasKey('required', $schema); + $this->assertEmpty($schema['required']); + } +} diff --git a/tests/unit/Providers/Http/Util/ResponseUtilTest.php b/tests/unit/Providers/Http/Util/ResponseUtilTest.php new file mode 100644 index 00000000..68b81ea3 --- /dev/null +++ b/tests/unit/Providers/Http/Util/ResponseUtilTest.php @@ -0,0 +1,115 @@ +createMock(Response::class); + $response->method('isSuccessful')->willReturn(true); + $response->method('getStatusCode')->willReturn($statusCode); + + // Expect no exception to be thrown + $this->expectNotToPerformAssertions(); + ResponseUtil::throwIfNotSuccessful($response); + } + + /** + * Provides successful HTTP status codes. + * + * @return array + */ + public function successfulResponseStatusCodeProvider(): array + { + return [ + '200 OK' => [200], + '201 Created' => [201], + '204 No Content' => [204], + ]; + } + + /** + * Tests that throwIfNotSuccessful throws an exception for unsuccessful responses. + * + * @dataProvider unsuccessfulResponseStatusCodeProvider + * @param int $statusCode The unsuccessful HTTP status code. + * @param array $data The response data. + * @param string $expectedMessagePart The expected part of the exception message. + * @return void + */ + public function testThrowIfNotSuccessfulThrowsForUnsuccessfulResponses( + int $statusCode, + array $data, + string $expectedMessagePart + ): void { + $response = $this->createMock(Response::class); + $response->method('isSuccessful')->willReturn(false); + $response->method('getStatusCode')->willReturn($statusCode); + $response->method('getData')->willReturn($data); + + $this->expectException(ResponseException::class); + $this->expectExceptionCode($statusCode); + $this->expectExceptionMessageMatches("/^Bad status code: {$statusCode}\.($| {$expectedMessagePart})$/"); + + ResponseUtil::throwIfNotSuccessful($response); + } + + /** + * Provides unsuccessful HTTP status codes and corresponding data for testing. + * + * @return array + */ + public function unsuccessfulResponseStatusCodeProvider(): array + { + return [ + '400 Bad Request (no extra message)' => [ + 400, + [], + '', + ], + '401 Unauthorized (error.message)' => [ + 401, + ['error' => ['message' => 'Invalid API key.']], + 'Invalid API key\.', + ], + '403 Forbidden (error string)' => [ + 403, + ['error' => 'Access denied.'], + 'Access denied\.', + ], + '404 Not Found (message string)' => [ + 404, + ['message' => 'Resource not found.'], + 'Resource not found\.', + ], + '500 Internal Server Error (no extra message)' => [ + 500, + [], + '', + ], + '503 Service Unavailable (error.message with special chars)' => [ + 503, + ['error' => ['message' => 'Service is temporarily unavailable. Please try again later.']], + 'Service is temporarily unavailable\. Please try again later\.', + ], + ]; + } +} diff --git a/tests/unit/Providers/Models/DTO/SupportedOptionTest.php b/tests/unit/Providers/Models/DTO/SupportedOptionTest.php index 49e313a2..27c3f6be 100644 --- a/tests/unit/Providers/Models/DTO/SupportedOptionTest.php +++ b/tests/unit/Providers/Models/DTO/SupportedOptionTest.php @@ -137,6 +137,25 @@ public function testWithObjectValues(): void $this->assertFalse($option->isSupportedValue(['type' => 'json_object', 'extra' => 'field'])); } + /** + * Tests that isSupportedValue correctly handles unordered array values. + * + * @return void + */ + public function testIsSupportedValueWithUnorderedArray(): void + { + $option = new SupportedOption('colors', [['red', 'green', 'blue'], ['yellow', 'orange']]); + + // Test with an array that has the same elements but in a different order + $this->assertTrue($option->isSupportedValue(['blue', 'red', 'green'])); + $this->assertTrue($option->isSupportedValue(['orange', 'yellow'])); + + // Test with an array that has different elements or missing elements + $this->assertFalse($option->isSupportedValue(['red', 'green'])); + $this->assertFalse($option->isSupportedValue(['red', 'green', 'blue', 'purple'])); + $this->assertFalse($option->isSupportedValue(['red', 'yellow', 'blue'])); + } + /** * Tests JSON schema generation. * diff --git a/tests/unit/Providers/ProviderRegistryTest.php b/tests/unit/Providers/ProviderRegistryTest.php index 832980d4..4a47ec72 100644 --- a/tests/unit/Providers/ProviderRegistryTest.php +++ b/tests/unit/Providers/ProviderRegistryTest.php @@ -6,10 +6,19 @@ use InvalidArgumentException; use PHPUnit\Framework\TestCase; +use WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication; +use WordPress\AiClient\Providers\Http\DTO\NullRequestAuthentication; +use WordPress\AiClient\Providers\Models\DTO\ModelConfig; +use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; use WordPress\AiClient\Providers\Models\DTO\ModelRequirements; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; use WordPress\AiClient\Providers\ProviderRegistry; +use WordPress\AiClient\Tests\mocks\MockHttpTransporter; +use WordPress\AiClient\Tests\mocks\MockModel; +use WordPress\AiClient\Tests\mocks\MockModelMetadataDirectory; use WordPress\AiClient\Tests\mocks\MockProvider; +use WordPress\AiClient\Tests\mocks\MockProviderAvailability; +use WordPress\AiClient\Tests\mocks\MockRequestAuthentication; /** * @covers \WordPress\AiClient\Providers\ProviderRegistry @@ -20,7 +29,15 @@ class ProviderRegistryTest extends TestCase protected function setUp(): void { + parent::setUp(); $this->registry = new ProviderRegistry(); + MockProvider::reset(); // Reset static state of mock provider before each test. + } + + protected function tearDown(): void + { + MockProvider::reset(); // Reset static state of mock provider after each test. + parent::tearDown(); } /** @@ -173,7 +190,7 @@ public function testGetProviderModelThrowsException(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Model not found: test-model'); - $modelConfig = new \WordPress\AiClient\Providers\Models\DTO\ModelConfig([]); + $modelConfig = new ModelConfig([]); $this->registry->getProviderModel('mock', 'test-model', $modelConfig); } @@ -209,4 +226,198 @@ public function testProviderInstanceCaching(): void // Should not throw any errors and should reuse cached instance $this->addToAssertionCount(1); } + + /** + * Tests that setHttpTransporter hooks up the transporter to registered providers. + * + * @return void + */ + public function testSetHttpTransporterHooksUpToProviders(): void + { + $mockTransporter = new MockHttpTransporter(); + $mockAvailability = new MockProviderAvailability(); + $mockModelMetadataDirectory = new MockModelMetadataDirectory([ + 'mock-text-model' => new ModelMetadata( + 'mock-text-model', + 'Mock Text Model', + [CapabilityEnum::textGeneration()], + [] + ) + ]); + $mockModel = new MockModel( + new ModelMetadata('mock-model', 'Mock Model', [], []), + new ModelConfig([]) + ); + + MockProvider::setAvailability($mockAvailability); + MockProvider::setModelMetadataDirectory($mockModelMetadataDirectory); + + // Register the provider AFTER setting up mocks, so it uses these mocks. + $this->registry->registerProvider(MockProvider::class); + + // Set the transporter on the registry. + $this->registry->setHttpTransporter($mockTransporter); + + // Get a model instance from the provider. + $modelConfig = new ModelConfig([]); + $retrievedModel = $this->registry->getProviderModel('mock', 'mock-text-model', $modelConfig); + + // Verify that the transporter was set on the relevant instances. + $this->assertSame($mockTransporter, $mockAvailability->getHttpTransporter()); + $this->assertSame($mockTransporter, $mockModelMetadataDirectory->getHttpTransporter()); + $this->assertSame($mockTransporter, $retrievedModel->getHttpTransporter()); + } + + /** + * Tests that setProviderRequestAuthentication hooks up the authentication to registered providers. + * + * @return void + */ + public function testSetProviderRequestAuthenticationHooksUpToProviders(): void + { + $mockTransporter = new MockHttpTransporter(); // Add this line + $this->registry->setHttpTransporter($mockTransporter); // Add this line + + $mockAuth = new MockRequestAuthentication('custom_token'); + $mockAvailability = new MockProviderAvailability(); + $mockModelMetadataDirectory = new MockModelMetadataDirectory([ + 'mock-text-model' => new ModelMetadata( + 'mock-text-model', + 'Mock Text Model', + [CapabilityEnum::textGeneration()], + [] + ) + ]); + $mockModel = new MockModel( + new ModelMetadata('mock-model', 'Mock Model', [], []), + new ModelConfig([]) + ); + + MockProvider::setAvailability($mockAvailability); + MockProvider::setModelMetadataDirectory($mockModelMetadataDirectory); + + // Register the provider AFTER setting up mocks, so it uses these mocks. + $this->registry->registerProvider(MockProvider::class); + + // Set the authentication on the specific provider. + $this->registry->setProviderRequestAuthentication('mock', $mockAuth); + + // Get a model instance from the provider. + $modelConfig = new ModelConfig([]); + $retrievedModel = $this->registry->getProviderModel('mock', 'mock-text-model', $modelConfig); + + // Verify that the authentication was set on the relevant instances. + $this->assertSame($mockAuth, $mockAvailability->getRequestAuthentication()); + $this->assertSame($mockAuth, $mockModelMetadataDirectory->getRequestAuthentication()); + $this->assertSame($mockAuth, $retrievedModel->getRequestAuthentication()); + } + + /** + * Tests that getProviderRequestAuthentication returns the correct instance. + * + * @return void + */ + public function testGetProviderRequestAuthentication(): void + { + $this->registry->registerProvider(MockProvider::class); + $mockAuth = new MockRequestAuthentication('another_token'); + $this->registry->setProviderRequestAuthentication('mock', $mockAuth); + + $retrievedAuth = $this->registry->getProviderRequestAuthentication('mock'); + $this->assertSame($mockAuth, $retrievedAuth); + } + + /** + * Tests that getProviderRequestAuthentication returns a default instance if not explicitly set. + * + * @return void + */ + public function testGetProviderRequestAuthenticationReturnsDefault(): void + { + $this->registry->registerProvider(MockProvider::class); + $retrievedAuth = $this->registry->getProviderRequestAuthentication('mock'); + + // By default, it should create an ApiKeyRequestAuthentication if environment variables are set. + // Since no env vars are set in tests, it should fall back to NullRequestAuthentication. + $this->assertInstanceOf(NullRequestAuthentication::class, $retrievedAuth); + } + + /** + * Tests the internal getEnvVarName method using reflection. + * + * @dataProvider envVarNameProvider + * @param string $providerId The provider ID. + * @param string $field The field name. + * @param string $expected The expected environment variable name. + * @return void + */ + public function testGetEnvVarName(string $providerId, string $field, string $expected): void + { + $method = new \ReflectionMethod(ProviderRegistry::class, 'getEnvVarName'); + $method->setAccessible(true); + + $result = $method->invoke($this->registry, $providerId, $field); // Invoke on instance + + $this->assertEquals($expected, $result); + } + + /** + * Provides data for testing getEnvVarName. + * + * @return array + */ + public function envVarNameProvider(): array + { + return [ + 'camelCase provider and field' => ['myProvider', 'apiKey', 'MY_PROVIDER_API_KEY'], + 'kebab-case provider and field' => ['my-provider', 'api-key', 'MY_PROVIDER_API_KEY'], + 'snake_case provider and field' => ['my_provider', 'api_key', 'MY_PROVIDER_API_KEY'], + 'mixed case' => ['AnotherProvider', 'someOtherField', 'ANOTHER_PROVIDER_SOME_OTHER_FIELD'], + 'simple names' => ['openai', 'key', 'OPENAI_KEY'], + ]; + } + + /** + * Tests that createDefaultProviderRequestAuthentication creates ApiKeyRequestAuthentication when env var is set. + * + * @return void + */ + public function testCreateDefaultProviderRequestAuthenticationWithEnvVar(): void + { + // Temporarily set an environment variable. + putenv('MOCK_API_KEY=test_env_api_key'); + + $this->registry->registerProvider(MockProvider::class); + + $method = new \ReflectionMethod(ProviderRegistry::class, 'createDefaultProviderRequestAuthentication'); + $method->setAccessible(true); + + $auth = $method->invoke($this->registry, MockProvider::class); + + $this->assertInstanceOf(ApiKeyRequestAuthentication::class, $auth); + $this->assertEquals('test_env_api_key', $auth->getApiKey()); + + // Clean up environment variable. + putenv('MOCK_API_KEY'); + } + + /** + * Tests that createDefaultProviderRequestAuthentication creates NullRequestAuthentication when env var is not set. + * + * @return void + */ + public function testCreateDefaultProviderRequestAuthenticationWithoutEnvVar(): void + { + // Ensure environment variable is not set. + putenv('MOCK_API_KEY'); + + $this->registry->registerProvider(MockProvider::class); + + $method = new \ReflectionMethod(ProviderRegistry::class, 'createDefaultProviderRequestAuthentication'); + $method->setAccessible(true); + + $auth = $method->invoke($this->registry, MockProvider::class); + + $this->assertInstanceOf(NullRequestAuthentication::class, $auth); + } } From 56c9178804dd0f8a22be256c953d082b1c710bc4 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Wed, 20 Aug 2025 10:37:29 -0500 Subject: [PATCH 34/77] Add more test coverage and fix fully specified imports in test code. --- tests/mocks/MockModel.php | 5 +- tests/mocks/MockProvider.php | 3 +- tests/traits/ArrayTransformationTestTrait.php | 4 +- .../Common/AbstractDataTransferObjectTest.php | 6 +- tests/unit/Files/DTO/FileTest.php | 14 +- tests/unit/Messages/DTO/MessagePartTest.php | 3 +- tests/unit/Messages/DTO/MessageTest.php | 3 +- tests/unit/Messages/DTO/ModelMessageTest.php | 7 +- tests/unit/Messages/DTO/SystemMessageTest.php | 7 +- tests/unit/Messages/DTO/UserMessageTest.php | 7 +- .../DTO/GenerativeAiOperationTest.php | 3 +- ...ractApiBasedModelMetadataDirectoryTest.php | 86 ++ ...AiCompatibleModelMetadataDirectoryTest.php | 116 ++ .../Providers/DTO/ProviderMetadataTest.php | 6 +- .../DTO/ProviderModelsMetadataTest.php | 6 +- ...ModelsApiBasedProviderAvailabilityTest.php | 62 + .../MockApiBasedModelMetadataDirectory.php | 37 + ...OpenAiCompatibleModelMetadataDirectory.php | 106 ++ .../Models/AbstractApiBasedModelTest.php | 89 ++ ...penAiCompatibleTextGenerationModelTest.php | 1295 +++++++++++++++++ .../Providers/Models/DTO/ModelConfigTest.php | 6 +- .../Models/DTO/ModelMetadataTest.php | 6 +- .../Models/DTO/ModelRequirementsTest.php | 6 +- .../Models/DTO/RequiredOptionTest.php | 6 +- .../Models/DTO/SupportedOptionTest.php | 6 +- .../Providers/Models/MockApiBasedModel.php | 21 + ...ockOpenAiCompatibleTextGenerationModel.php | 180 +++ .../Results/DTO/GenerativeAiResultTest.php | 3 +- tests/unit/Results/DTO/TokenUsageTest.php | 6 +- tests/unit/Tools/DTO/WebSearchTest.php | 3 +- 30 files changed, 2066 insertions(+), 42 deletions(-) create mode 100644 tests/unit/Providers/AbstractApiBasedModelMetadataDirectoryTest.php create mode 100644 tests/unit/Providers/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php create mode 100644 tests/unit/Providers/ListModelsApiBasedProviderAvailabilityTest.php create mode 100644 tests/unit/Providers/MockApiBasedModelMetadataDirectory.php create mode 100644 tests/unit/Providers/MockOpenAiCompatibleModelMetadataDirectory.php create mode 100644 tests/unit/Providers/Models/AbstractApiBasedModelTest.php create mode 100644 tests/unit/Providers/Models/AbstractOpenAiCompatibleTextGenerationModelTest.php create mode 100644 tests/unit/Providers/Models/MockApiBasedModel.php create mode 100644 tests/unit/Providers/Models/MockOpenAiCompatibleTextGenerationModel.php diff --git a/tests/mocks/MockModel.php b/tests/mocks/MockModel.php index c79b5ca0..19027aac 100644 --- a/tests/mocks/MockModel.php +++ b/tests/mocks/MockModel.php @@ -4,6 +4,7 @@ namespace WordPress\AiClient\Tests\mocks; +use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Http\Contracts\WithHttpTransporterInterface; use WordPress\AiClient\Providers\Http\Contracts\WithRequestAuthenticationInterface; use WordPress\AiClient\Providers\Http\Traits\WithHttpTransporterTrait; @@ -55,10 +56,10 @@ public function metadata(): ModelMetadata /** * {@inheritDoc} */ - public function providerMetadata(): \WordPress\AiClient\Providers\DTO\ProviderMetadata + public function providerMetadata(): ProviderMetadata { // This mock doesn't need to return actual provider metadata for its tests. - return $this->createMock(\WordPress\AiClient\Providers\DTO\ProviderMetadata::class); + return $this->createMock(ProviderMetadata::class); } /** diff --git a/tests/mocks/MockProvider.php b/tests/mocks/MockProvider.php index 7b0a07cc..7ad38a3c 100644 --- a/tests/mocks/MockProvider.php +++ b/tests/mocks/MockProvider.php @@ -11,6 +11,7 @@ use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; +use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; /** @@ -74,7 +75,7 @@ public static function modelMetadataDirectory(): ModelMetadataDirectoryInterface if (static::$modelMetadataDirectory === null) { // Create some mock models for testing $mockModels = [ - 'mock-text-model' => new \WordPress\AiClient\Providers\Models\DTO\ModelMetadata( + 'mock-text-model' => new ModelMetadata( 'mock-text-model', 'Mock Text Model', [CapabilityEnum::textGeneration()], diff --git a/tests/traits/ArrayTransformationTestTrait.php b/tests/traits/ArrayTransformationTestTrait.php index 09f175ba..84cee350 100644 --- a/tests/traits/ArrayTransformationTestTrait.php +++ b/tests/traits/ArrayTransformationTestTrait.php @@ -4,6 +4,8 @@ namespace WordPress\AiClient\Tests\traits; +use WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface; + /** * Trait for testing array transformation functionality. * @@ -20,7 +22,7 @@ trait ArrayTransformationTestTrait protected function assertImplementsArrayTransformation($object): void { $this->assertInstanceOf( - \WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface::class, + WithArrayTransformationInterface::class, $object, 'Object should implement WithArrayTransformationInterface interface' ); diff --git a/tests/unit/Common/AbstractDataTransferObjectTest.php b/tests/unit/Common/AbstractDataTransferObjectTest.php index fa7c954d..f26292d4 100644 --- a/tests/unit/Common/AbstractDataTransferObjectTest.php +++ b/tests/unit/Common/AbstractDataTransferObjectTest.php @@ -8,6 +8,8 @@ use PHPUnit\Framework\TestCase; use stdClass; use WordPress\AiClient\Common\AbstractDataTransferObject; +use WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface; +use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; /** * Tests for the AbstractDataTransferObject class. @@ -546,10 +548,10 @@ public static function getJsonSchema(): array // Verify interface implementations $this->assertInstanceOf( - \WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface::class, + WithArrayTransformationInterface::class, $testObject ); - $this->assertInstanceOf(\WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface::class, $testObject); + $this->assertInstanceOf(WithJsonSchemaInterface::class, $testObject); $this->assertInstanceOf(JsonSerializable::class, $testObject); // Verify methods exist and work diff --git a/tests/unit/Files/DTO/FileTest.php b/tests/unit/Files/DTO/FileTest.php index 9ed4e6d7..8f28040e 100644 --- a/tests/unit/Files/DTO/FileTest.php +++ b/tests/unit/Files/DTO/FileTest.php @@ -6,8 +6,10 @@ use InvalidArgumentException; use PHPUnit\Framework\TestCase; +use WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Files\Enums\FileTypeEnum; +use WordPress\AiClient\Files\ValueObjects\MimeType; /** * @covers \WordPress\AiClient\Files\DTO\File @@ -206,7 +208,7 @@ public function testMimeTypeMethods(): void $file = new File('https://example.com/video.mp4'); $this->assertEquals('video/mp4', $file->getMimeType()); - $this->assertInstanceOf(\WordPress\AiClient\Files\ValueObjects\MimeType::class, $file->getMimeTypeObject()); + $this->assertInstanceOf(MimeType::class, $file->getMimeTypeObject()); $this->assertTrue($file->isVideo()); $this->assertFalse($file->isImage()); $this->assertFalse($file->isAudio()); @@ -294,7 +296,7 @@ public function testToArrayRemoteFile(): void $json = $file->toArray(); $this->assertIsArray($json); - $this->assertEquals(\WordPress\AiClient\Files\Enums\FileTypeEnum::remote()->value, $json[File::KEY_FILE_TYPE]); + $this->assertEquals(FileTypeEnum::remote()->value, $json[File::KEY_FILE_TYPE]); $this->assertEquals('image/jpeg', $json[File::KEY_MIME_TYPE]); $this->assertEquals('https://example.com/image.jpg', $json[File::KEY_URL]); $this->assertArrayNotHasKey(File::KEY_BASE64_DATA, $json); @@ -313,7 +315,7 @@ public function testToArrayInlineFile(): void $json = $file->toArray(); $this->assertIsArray($json); - $this->assertEquals(\WordPress\AiClient\Files\Enums\FileTypeEnum::inline()->value, $json[File::KEY_FILE_TYPE]); + $this->assertEquals(FileTypeEnum::inline()->value, $json[File::KEY_FILE_TYPE]); $this->assertEquals('text/plain', $json[File::KEY_MIME_TYPE]); $this->assertEquals($base64Data, $json[File::KEY_BASE64_DATA]); $this->assertArrayNotHasKey(File::KEY_URL, $json); @@ -327,7 +329,7 @@ public function testToArrayInlineFile(): void public function testFromArrayRemoteFile(): void { $json = [ - File::KEY_FILE_TYPE => \WordPress\AiClient\Files\Enums\FileTypeEnum::remote()->value, + File::KEY_FILE_TYPE => FileTypeEnum::remote()->value, File::KEY_MIME_TYPE => 'image/png', File::KEY_URL => 'https://example.com/test.png' ]; @@ -350,7 +352,7 @@ public function testFromArrayInlineFile(): void { $base64Data = 'SGVsbG8gV29ybGQ='; $json = [ - File::KEY_FILE_TYPE => \WordPress\AiClient\Files\Enums\FileTypeEnum::inline()->value, + File::KEY_FILE_TYPE => FileTypeEnum::inline()->value, File::KEY_MIME_TYPE => 'text/plain', File::KEY_BASE64_DATA => $base64Data ]; @@ -401,7 +403,7 @@ public function testImplementsWithArrayTransformationInterface(): void $file = new File('https://example.com/test.jpg'); $this->assertInstanceOf( - \WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface::class, + WithArrayTransformationInterface::class, $file ); } diff --git a/tests/unit/Messages/DTO/MessagePartTest.php b/tests/unit/Messages/DTO/MessagePartTest.php index 971ffcd2..063d2a85 100644 --- a/tests/unit/Messages/DTO/MessagePartTest.php +++ b/tests/unit/Messages/DTO/MessagePartTest.php @@ -7,6 +7,7 @@ use InvalidArgumentException; use PHPUnit\Framework\TestCase; use stdClass; +use WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Messages\DTO\MessagePart; @@ -388,7 +389,7 @@ public function testImplementsWithArrayTransformationInterface(): void $part = new MessagePart('test'); $this->assertInstanceOf( - \WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface::class, + WithArrayTransformationInterface::class, $part ); } diff --git a/tests/unit/Messages/DTO/MessageTest.php b/tests/unit/Messages/DTO/MessageTest.php index ac8edaf4..c28cfa29 100644 --- a/tests/unit/Messages/DTO/MessageTest.php +++ b/tests/unit/Messages/DTO/MessageTest.php @@ -5,6 +5,7 @@ namespace WordPress\AiClient\Tests\unit\Messages\DTO; use PHPUnit\Framework\TestCase; +use WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; @@ -317,7 +318,7 @@ public function testImplementsWithArrayTransformationInterface(): void $message = new Message(MessageRoleEnum::user(), [new MessagePart('test')]); $this->assertInstanceOf( - \WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface::class, + WithArrayTransformationInterface::class, $message ); } diff --git a/tests/unit/Messages/DTO/ModelMessageTest.php b/tests/unit/Messages/DTO/ModelMessageTest.php index f315defe..5ee9537d 100644 --- a/tests/unit/Messages/DTO/ModelMessageTest.php +++ b/tests/unit/Messages/DTO/ModelMessageTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use WordPress\AiClient\Files\DTO\File; +use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\ModelMessage; use WordPress\AiClient\Messages\Enums\MessagePartTypeEnum; @@ -15,7 +16,7 @@ use WordPress\AiClient\Tools\DTO\FunctionResponse; /** - * @covers \WordPress\AiClient\Messages\DTO\ModelMessage + * @covers ModelMessage */ class ModelMessageTest extends TestCase { @@ -80,7 +81,7 @@ public function testInheritsFromMessage(): void { $message = new ModelMessage([]); - $this->assertInstanceOf(\WordPress\AiClient\Messages\DTO\Message::class, $message); + $this->assertInstanceOf(Message::class, $message); } /** @@ -117,7 +118,7 @@ public function testWithVariousContentTypes(): void public function testJsonSchemaInheritance(): void { $schema = ModelMessage::getJsonSchema(); - $parentSchema = \WordPress\AiClient\Messages\DTO\Message::getJsonSchema(); + $parentSchema = Message::getJsonSchema(); $this->assertEquals($parentSchema, $schema); } diff --git a/tests/unit/Messages/DTO/SystemMessageTest.php b/tests/unit/Messages/DTO/SystemMessageTest.php index 3ba589d7..1a802c7a 100644 --- a/tests/unit/Messages/DTO/SystemMessageTest.php +++ b/tests/unit/Messages/DTO/SystemMessageTest.php @@ -5,6 +5,7 @@ namespace WordPress\AiClient\Tests\unit\Messages\DTO; use PHPUnit\Framework\TestCase; +use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\SystemMessage; use WordPress\AiClient\Messages\Enums\MessagePartTypeEnum; @@ -12,7 +13,7 @@ use WordPress\AiClient\Tests\traits\ArrayTransformationTestTrait; /** - * @covers \WordPress\AiClient\Messages\DTO\SystemMessage + * @covers SystemMessage */ class SystemMessageTest extends TestCase { @@ -77,7 +78,7 @@ public function testInheritsFromMessage(): void { $message = new SystemMessage([]); - $this->assertInstanceOf(\WordPress\AiClient\Messages\DTO\Message::class, $message); + $this->assertInstanceOf(Message::class, $message); } /** @@ -124,7 +125,7 @@ public function testWithComplexInstructions(): void public function testJsonSchemaInheritance(): void { $schema = SystemMessage::getJsonSchema(); - $parentSchema = \WordPress\AiClient\Messages\DTO\Message::getJsonSchema(); + $parentSchema = Message::getJsonSchema(); $this->assertEquals($parentSchema, $schema); } diff --git a/tests/unit/Messages/DTO/UserMessageTest.php b/tests/unit/Messages/DTO/UserMessageTest.php index cc811243..074e62cf 100644 --- a/tests/unit/Messages/DTO/UserMessageTest.php +++ b/tests/unit/Messages/DTO/UserMessageTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use WordPress\AiClient\Files\DTO\File; +use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\UserMessage; use WordPress\AiClient\Messages\Enums\MessagePartTypeEnum; @@ -13,7 +14,7 @@ use WordPress\AiClient\Tests\traits\ArrayTransformationTestTrait; /** - * @covers \WordPress\AiClient\Messages\DTO\UserMessage + * @covers UserMessage */ class UserMessageTest extends TestCase { @@ -81,7 +82,7 @@ public function testInheritsFromMessage(): void { $message = new UserMessage([]); - $this->assertInstanceOf(\WordPress\AiClient\Messages\DTO\Message::class, $message); + $this->assertInstanceOf(Message::class, $message); } /** @@ -137,7 +138,7 @@ public function testWithImageAndText(): void public function testJsonSchemaInheritance(): void { $schema = UserMessage::getJsonSchema(); - $parentSchema = \WordPress\AiClient\Messages\DTO\Message::getJsonSchema(); + $parentSchema = Message::getJsonSchema(); $this->assertEquals($parentSchema, $schema); } diff --git a/tests/unit/Operations/DTO/GenerativeAiOperationTest.php b/tests/unit/Operations/DTO/GenerativeAiOperationTest.php index 31485305..dabaf912 100644 --- a/tests/unit/Operations/DTO/GenerativeAiOperationTest.php +++ b/tests/unit/Operations/DTO/GenerativeAiOperationTest.php @@ -9,6 +9,7 @@ use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\ModelMessage; use WordPress\AiClient\Messages\Enums\MessageRoleEnum; +use WordPress\AiClient\Operations\Contracts\OperationInterface; use WordPress\AiClient\Operations\DTO\GenerativeAiOperation; use WordPress\AiClient\Operations\Enums\OperationStateEnum; use WordPress\AiClient\Results\DTO\Candidate; @@ -139,7 +140,7 @@ public function testImplementsOperationInterface(): void ); $this->assertInstanceOf( - \WordPress\AiClient\Operations\Contracts\OperationInterface::class, + OperationInterface::class, $operation ); } diff --git a/tests/unit/Providers/AbstractApiBasedModelMetadataDirectoryTest.php b/tests/unit/Providers/AbstractApiBasedModelMetadataDirectoryTest.php new file mode 100644 index 00000000..6b6b1c07 --- /dev/null +++ b/tests/unit/Providers/AbstractApiBasedModelMetadataDirectoryTest.php @@ -0,0 +1,86 @@ +mockModels = [ + 'model-1' => $this->createStub(ModelMetadata::class), + 'model-2' => $this->createStub(ModelMetadata::class), + ]; + } + + /** + * Tests listModelMetadata() method. + * + * @return void + */ + public function testListModelMetadata(): void + { + $directory = new MockApiBasedModelMetadataDirectory($this->mockModels); + $models = $directory->listModelMetadata(); + + $this->assertIsArray($models); + $this->assertCount(2, $models); + $this->assertContains($this->mockModels['model-1'], $models); + $this->assertContains($this->mockModels['model-2'], $models); + } + + /** + * Tests hasModelMetadata() method. + * + * @return void + */ + public function testHasModelMetadata(): void + { + $directory = new MockApiBasedModelMetadataDirectory($this->mockModels); + + $this->assertTrue($directory->hasModelMetadata('model-1')); + $this->assertFalse($directory->hasModelMetadata('non-existent-model')); + } + + /** + * Tests getModelMetadata() method. + * + * @return void + */ + public function testGetModelMetadata(): void + { + $directory = new MockApiBasedModelMetadataDirectory($this->mockModels); + + $this->assertSame($this->mockModels['model-1'], $directory->getModelMetadata('model-1')); + } + + /** + * Tests getModelMetadata() method with non-existent model. + * + * @return void + */ + public function testGetModelMetadataThrowsExceptionForNonExistentModel(): void + { + $directory = new MockApiBasedModelMetadataDirectory($this->mockModels); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No model with ID non-existent-model was found in the provider'); + + $directory->getModelMetadata('non-existent-model'); + } +} diff --git a/tests/unit/Providers/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php b/tests/unit/Providers/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php new file mode 100644 index 00000000..48a6c576 --- /dev/null +++ b/tests/unit/Providers/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php @@ -0,0 +1,116 @@ +mockHttpTransporter = $this->createMock(HttpTransporterInterface::class); + $this->mockRequestAuthentication = $this->createMock(RequestAuthenticationInterface::class); + } + + /** + * Tests sendListModelsRequest() method on success. + * + * @return void + */ + public function testSendListModelsRequestSuccess(): void + { + $response = new Response(200, [], '{"data": [{"id": "model-a"}, {"id": "model-b"}]}'); + + $this->mockRequestAuthentication + ->expects($this->once()) + ->method('authenticateRequest') + ->willReturnArgument(0); // Return the request as is. + + $this->mockHttpTransporter + ->expects($this->once()) + ->method('send') + ->willReturn($response); + + $directory = new MockOpenAiCompatibleModelMetadataDirectory( + $this->mockHttpTransporter, + $this->mockRequestAuthentication, + function (string $modelId) { + return $this->createModelMetadataStub($modelId); + } + ); + + $modelsMetadata = $directory->listModelMetadata(); // Calls sendListModelsRequest internally. + + $this->assertCount(2, $modelsMetadata); + $this->assertEquals('model-a', $modelsMetadata[0]->getId()); + $this->assertEquals('model-b', $modelsMetadata[1]->getId()); + } + + /** + * Creates a ModelMetadata stub with the given ID. + * + * @param string $modelId + * @return ModelMetadata&\PHPUnit\Framework\MockObject\Stub + */ + public function createModelMetadataStub(string $modelId) + { + $modelMetadata = $this->createStub(ModelMetadata::class); + $modelMetadata->method('getId')->willReturn($modelId); + return $modelMetadata; + } + + /** + * Tests sendListModelsRequest() method on failure. + * + * @return void + */ + public function testSendListModelsRequestFailure(): void + { + $response = new Response(400, [], '{"error": "Bad Request"}'); + + $this->mockRequestAuthentication + ->expects($this->once()) + ->method('authenticateRequest') + ->willReturnArgument(0); + + $this->mockHttpTransporter + ->expects($this->once()) + ->method('send') + ->willReturn($response); + + $directory = new MockOpenAiCompatibleModelMetadataDirectory( + $this->mockHttpTransporter, + $this->mockRequestAuthentication, + function (string $modelId) { + return $this->createModelMetadataStub($modelId); + } + ); + + $this->expectException(ResponseException::class); + $this->expectExceptionMessage('Bad status code: 400. Bad Request'); + + $directory->listModelMetadata(); + } +} diff --git a/tests/unit/Providers/DTO/ProviderMetadataTest.php b/tests/unit/Providers/DTO/ProviderMetadataTest.php index 2118b2bc..bf54cfd4 100644 --- a/tests/unit/Providers/DTO/ProviderMetadataTest.php +++ b/tests/unit/Providers/DTO/ProviderMetadataTest.php @@ -6,6 +6,8 @@ use JsonSerializable; use PHPUnit\Framework\TestCase; +use WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface; +use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; @@ -213,11 +215,11 @@ public function testImplementsCorrectInterfaces(): void $metadata = new ProviderMetadata('test', 'Test', ProviderTypeEnum::cloud()); $this->assertInstanceOf( - \WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface::class, + WithArrayTransformationInterface::class, $metadata ); $this->assertInstanceOf( - \WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface::class, + WithJsonSchemaInterface::class, $metadata ); $this->assertInstanceOf( diff --git a/tests/unit/Providers/DTO/ProviderModelsMetadataTest.php b/tests/unit/Providers/DTO/ProviderModelsMetadataTest.php index 0141cc60..8244f488 100644 --- a/tests/unit/Providers/DTO/ProviderModelsMetadataTest.php +++ b/tests/unit/Providers/DTO/ProviderModelsMetadataTest.php @@ -6,6 +6,8 @@ use JsonSerializable; use PHPUnit\Framework\TestCase; +use WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface; +use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\DTO\ProviderModelsMetadata; use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; @@ -356,11 +358,11 @@ public function testImplementsCorrectInterfaces(): void $metadata = new ProviderModelsMetadata($this->createProviderMetadata(), []); $this->assertInstanceOf( - \WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface::class, + WithArrayTransformationInterface::class, $metadata ); $this->assertInstanceOf( - \WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface::class, + WithJsonSchemaInterface::class, $metadata ); $this->assertInstanceOf( diff --git a/tests/unit/Providers/ListModelsApiBasedProviderAvailabilityTest.php b/tests/unit/Providers/ListModelsApiBasedProviderAvailabilityTest.php new file mode 100644 index 00000000..e3e86bea --- /dev/null +++ b/tests/unit/Providers/ListModelsApiBasedProviderAvailabilityTest.php @@ -0,0 +1,62 @@ +modelMetadataDirectory = $this->createMock(ModelMetadataDirectoryInterface::class); + } + + /** + * Tests isConfigured() method when listing models succeeds. + * + * @return void + */ + public function testIsConfiguredReturnsTrueOnSuccess(): void + { + $this->modelMetadataDirectory + ->expects($this->once()) + ->method('listModelMetadata') + ->willReturn([]); + + $availability = new ListModelsApiBasedProviderAvailability($this->modelMetadataDirectory); + + $this->assertTrue($availability->isConfigured()); + } + + /** + * Tests isConfigured() method when listing models throws an exception. + * + * @return void + */ + public function testIsConfiguredReturnsFalseOnException(): void + { + $this->modelMetadataDirectory + ->expects($this->once()) + ->method('listModelMetadata') + ->willThrowException(new Exception('API error')); + + $availability = new ListModelsApiBasedProviderAvailability($this->modelMetadataDirectory); + + $this->assertFalse($availability->isConfigured()); + } +} diff --git a/tests/unit/Providers/MockApiBasedModelMetadataDirectory.php b/tests/unit/Providers/MockApiBasedModelMetadataDirectory.php new file mode 100644 index 00000000..4d56943e --- /dev/null +++ b/tests/unit/Providers/MockApiBasedModelMetadataDirectory.php @@ -0,0 +1,37 @@ + + */ + private array $mockModels; + + /** + * Constructor. + * + * @param array $mockModels + */ + public function __construct(array $mockModels = []) + { + $this->mockModels = $mockModels; + } + + /** + * @inheritdoc + */ + protected function sendListModelsRequest(): array + { + return $this->mockModels; + } +} diff --git a/tests/unit/Providers/MockOpenAiCompatibleModelMetadataDirectory.php b/tests/unit/Providers/MockOpenAiCompatibleModelMetadataDirectory.php new file mode 100644 index 00000000..d39893ca --- /dev/null +++ b/tests/unit/Providers/MockOpenAiCompatibleModelMetadataDirectory.php @@ -0,0 +1,106 @@ + + */ + private array $mockModels; + + /** + * @var callable + */ + private $modelMetadataStubFactory; + + /** + * Constructor. + * + * @param HttpTransporterInterface&\PHPUnit\Framework\MockObject\MockObject $mockHttpTransporter + * @param RequestAuthenticationInterface&\PHPUnit\Framework\MockObject\MockObject $mockRequestAuthentication + * @param callable $modelMetadataStubFactory + * @param array $mockModels + */ + public function __construct( + $mockHttpTransporter, + $mockRequestAuthentication, + callable $modelMetadataStubFactory, + array $mockModels = [] + ) { + $this->mockHttpTransporter = $mockHttpTransporter; + $this->mockRequestAuthentication = $mockRequestAuthentication; + $this->modelMetadataStubFactory = $modelMetadataStubFactory; + $this->mockModels = $mockModels; + } + + /** + * @inheritdoc + */ + public function getHttpTransporter(): HttpTransporterInterface + { + return $this->mockHttpTransporter; + } + + /** + * @inheritdoc + */ + public function getRequestAuthentication(): RequestAuthenticationInterface + { + return $this->mockRequestAuthentication; + } + + /** + * @inheritdoc + */ + protected function createRequest( + HttpMethodEnum $method, + string $path, + array $headers = [], + $data = null + ): Request { + return new Request($method, 'https://example.com/' . $path, $headers, $data); + } + + /** + * @inheritdoc + */ + protected function parseResponseToModelMetadataList(Response $response): array + { + $data = $response->getData(); + $modelsMetadata = []; + if (isset($data['data']) && is_array($data['data'])) { + foreach ($data['data'] as $modelData) { + if (isset($modelData['id']) && is_string($modelData['id'])) { + $factory = $this->modelMetadataStubFactory; + $modelMetadata = $factory($modelData['id']); + $modelsMetadata[] = $modelMetadata; + } + } + } + return $modelsMetadata; + } +} diff --git a/tests/unit/Providers/Models/AbstractApiBasedModelTest.php b/tests/unit/Providers/Models/AbstractApiBasedModelTest.php new file mode 100644 index 00000000..535d485d --- /dev/null +++ b/tests/unit/Providers/Models/AbstractApiBasedModelTest.php @@ -0,0 +1,89 @@ +modelMetadata = $this->createStub(ModelMetadata::class); + $this->providerMetadata = $this->createStub(ProviderMetadata::class); + } + + /** + * Tests the constructor and initial state. + * + * @return void + */ + public function testConstructorAndInitialState(): void + { + $model = new MockApiBasedModel($this->modelMetadata, $this->providerMetadata); + + $this->assertSame($this->modelMetadata, $model->metadata()); + $this->assertSame($this->providerMetadata, $model->providerMetadata()); + $this->assertInstanceOf(ModelConfig::class, $model->getConfig()); + $this->assertEquals(['customOptions' => []], $model->getConfig()->toArray()); + } + + /** + * Tests the metadata() method. + * + * @return void + */ + public function testMetadata(): void + { + $model = new MockApiBasedModel($this->modelMetadata, $this->providerMetadata); + $this->assertSame($this->modelMetadata, $model->metadata()); + } + + /** + * Tests the providerMetadata() method. + * + * @return void + */ + public function testProviderMetadata(): void + { + $model = new MockApiBasedModel($this->modelMetadata, $this->providerMetadata); + $this->assertSame($this->providerMetadata, $model->providerMetadata()); + } + + /** + * Tests the setConfig() and getConfig() methods. + * + * @return void + */ + public function testSetConfigAndGetConfig(): void + { + $model = new MockApiBasedModel($this->modelMetadata, $this->providerMetadata); + $initialConfig = $model->getConfig(); + + $newConfig = ModelConfig::fromArray(['temperature' => 0.7]); + $model->setConfig($newConfig); + + $this->assertSame($newConfig, $model->getConfig()); + $this->assertNotSame($initialConfig, $model->getConfig()); + $this->assertEquals(['temperature' => 0.7, 'customOptions' => []], $model->getConfig()->toArray()); + } +} diff --git a/tests/unit/Providers/Models/AbstractOpenAiCompatibleTextGenerationModelTest.php b/tests/unit/Providers/Models/AbstractOpenAiCompatibleTextGenerationModelTest.php new file mode 100644 index 00000000..6567ed4c --- /dev/null +++ b/tests/unit/Providers/Models/AbstractOpenAiCompatibleTextGenerationModelTest.php @@ -0,0 +1,1295 @@ +modelMetadata = $this->createStub(ModelMetadata::class); + $this->modelMetadata->method('getId')->willReturn('test-model'); + $this->providerMetadata = $this->createStub(ProviderMetadata::class); + $this->mockHttpTransporter = $this->createMock(HttpTransporterInterface::class); + $this->mockRequestAuthentication = $this->createMock(RequestAuthenticationInterface::class); + } + + /** + * Creates a mock instance of AbstractOpenAiCompatibleTextGenerationModel. + * + * @param ModelConfig|null $modelConfig + * @return MockOpenAiCompatibleTextGenerationModel + */ + private function createModel(?ModelConfig $modelConfig = null): MockOpenAiCompatibleTextGenerationModel + { + $model = new MockOpenAiCompatibleTextGenerationModel( + $this->modelMetadata, + $this->providerMetadata, + $this->mockHttpTransporter, + $this->mockRequestAuthentication + ); + if ($modelConfig) { + $model->setConfig($modelConfig); + } + return $model; + } + + /** + * Tests generateTextResult() method on success. + * + * @return void + */ + public function testGenerateTextResultSuccess(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Hello')])]; + $response = new Response( + 200, + [], + json_encode([ + 'id' => 'chatcmpl-123', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hi there!', + ], + 'finish_reason' => 'stop', + ], + ], + 'usage' => [ + 'prompt_tokens' => 10, + 'completion_tokens' => 5, + 'total_tokens' => 15, + ], + ]) + ); + + $this->mockRequestAuthentication + ->expects($this->once()) + ->method('authenticateRequest') + ->willReturnArgument(0); + + $this->mockHttpTransporter + ->expects($this->once()) + ->method('send') + ->willReturn($response); + + $model = $this->createModel(); + $result = $model->generateTextResult($prompt); + + $this->assertInstanceOf(GenerativeAiResult::class, $result); + $this->assertEquals('chatcmpl-123', $result->getId()); + $this->assertCount(1, $result->getCandidates()); + $this->assertEquals('Hi there!', $result->getCandidates()[0]->getMessage()->getParts()[0]->getText()); + $this->assertEquals(FinishReasonEnum::stop(), $result->getCandidates()[0]->getFinishReason()); + $this->assertEquals(10, $result->getTokenUsage()->getPromptTokens()); + $this->assertEquals(5, $result->getTokenUsage()->getCompletionTokens()); + $this->assertEquals(15, $result->getTokenUsage()->getTotalTokens()); + } + + /** + * Tests generateTextResult() method on API failure. + * + * @return void + */ + public function testGenerateTextResultApiFailure(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Hello')])]; + $response = new Response(400, [], '{"error": "Bad Request"}'); + + $this->mockRequestAuthentication + ->expects($this->once()) + ->method('authenticateRequest') + ->willReturnArgument(0); + + $this->mockHttpTransporter + ->expects($this->once()) + ->method('send') + ->willReturn($response); + + $model = $this->createModel(); + + $this->expectException(ResponseException::class); + $this->expectExceptionMessage('Bad status code: 400. Bad Request'); + + $model->generateTextResult($prompt); + } + + /** + * Tests streamGenerateTextResult() method. + * + * @return void + */ + public function testStreamGenerateTextResult(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Hello')])]; + $model = $this->createModel(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Streaming is not yet implemented.'); + + $generator = $model->streamGenerateTextResult($prompt); + $generator->current(); // Attempt to get the first value to trigger the exception. + } + + /** + * Tests prepareGenerateTextParams() with basic text prompt. + * + * @return void + */ + public function testPrepareGenerateTextParamsBasicText(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Test message')])]; + $model = $this->createModel(); + + $params = $model->exposePrepareGenerateTextParams($prompt); + + $this->assertArrayHasKey('model', $params); + $this->assertEquals('test-model', $params['model']); + $this->assertArrayHasKey('messages', $params); + $this->assertCount(1, $params['messages']); + $this->assertEquals('user', $params['messages'][0]['role']); + $this->assertCount(1, $params['messages'][0]['content']); + $this->assertEquals('text', $params['messages'][0]['content'][0]['type']); + $this->assertEquals('Test message', $params['messages'][0]['content'][0]['text']); + $this->assertArrayNotHasKey('customOptions', $params); // customOptions should not be present if empty + } + + /** + * Tests prepareGenerateTextParams() with system instruction. + * + * @return void + */ + public function testPrepareGenerateTextParamsWithSystemInstruction(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('User message')])]; + $modelConfig = ModelConfig::fromArray(['systemInstruction' => 'You are a helpful assistant.']); + $model = $this->createModel($modelConfig); + + $params = $model->exposePrepareGenerateTextParams($prompt); + + $this->assertCount(2, $params['messages']); + $this->assertEquals('system', $params['messages'][0]['role']); + $this->assertEquals('You are a helpful assistant.', $params['messages'][0]['content'][0]['text']); + $this->assertEquals('user', $params['messages'][1]['role']); + } + + /** + * Tests prepareGenerateTextParams() with candidate count. + * + * @return void + */ + public function testPrepareGenerateTextParamsWithCandidateCount(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Test')])]; + $modelConfig = ModelConfig::fromArray(['candidateCount' => 2]); + $model = $this->createModel($modelConfig); + + $params = $model->exposePrepareGenerateTextParams($prompt); + + $this->assertArrayHasKey('n', $params); + $this->assertEquals(2, $params['n']); + } + + /** + * Tests prepareGenerateTextParams() with max tokens. + * + * @return void + */ + public function testPrepareGenerateTextParamsWithMaxTokens(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Test')])]; + $modelConfig = ModelConfig::fromArray(['maxTokens' => 100]); + $model = $this->createModel($modelConfig); + + $params = $model->exposePrepareGenerateTextParams($prompt); + + $this->assertArrayHasKey('max_tokens', $params); + $this->assertEquals(100, $params['max_tokens']); + } + + /** + * Tests prepareGenerateTextParams() with temperature. + * + * @return void + */ + public function testPrepareGenerateTextParamsWithTemperature(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Test')])]; + $modelConfig = ModelConfig::fromArray(['temperature' => 0.5]); + $model = $this->createModel($modelConfig); + + $params = $model->exposePrepareGenerateTextParams($prompt); + + $this->assertArrayHasKey('temperature', $params); + $this->assertEquals(0.5, $params['temperature']); + } + + /** + * Tests prepareGenerateTextParams() with topP. + * + * @return void + */ + public function testPrepareGenerateTextParamsWithTopP(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Test')])]; + $modelConfig = ModelConfig::fromArray(['topP' => 0.9]); + $model = $this->createModel($modelConfig); + + $params = $model->exposePrepareGenerateTextParams($prompt); + + $this->assertArrayHasKey('top_p', $params); + $this->assertEquals(0.9, $params['top_p']); + } + + /** + * Tests prepareGenerateTextParams() with stop sequences. + * + * @return void + */ + public function testPrepareGenerateTextParamsWithStopSequences(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Test')])]; + $modelConfig = ModelConfig::fromArray(['stopSequences' => ['stop1', 'stop2']]); + $model = $this->createModel($modelConfig); + + $params = $model->exposePrepareGenerateTextParams($prompt); + + $this->assertArrayHasKey('stop', $params); + $this->assertEquals(['stop1', 'stop2'], $params['stop']); + } + + /** + * Tests prepareGenerateTextParams() with presence penalty. + * + * @return void + */ + public function testPrepareGenerateTextParamsWithPresencePenalty(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Test')])]; + $modelConfig = ModelConfig::fromArray(['presencePenalty' => 0.1]); + $model = $this->createModel($modelConfig); + + $params = $model->exposePrepareGenerateTextParams($prompt); + + $this->assertArrayHasKey('presence_penalty', $params); + $this->assertEquals(0.1, $params['presence_penalty']); + } + + /** + * Tests prepareGenerateTextParams() with frequency penalty. + * + * @return void + */ + public function testPrepareGenerateTextParamsWithFrequencyPenalty(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Test')])]; + $modelConfig = ModelConfig::fromArray(['frequencyPenalty' => 0.2]); + $model = $this->createModel($modelConfig); + + $params = $model->exposePrepareGenerateTextParams($prompt); + + $this->assertArrayHasKey('frequency_penalty', $params); + $this->assertEquals(0.2, $params['frequency_penalty']); + } + + /** + * Tests prepareGenerateTextParams() with logprobs. + * + * @return void + */ + public function testPrepareGenerateTextParamsWithLogprobs(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Test')])]; + $modelConfig = ModelConfig::fromArray(['logprobs' => true]); + $model = $this->createModel($modelConfig); + + $params = $model->exposePrepareGenerateTextParams($prompt); + + $this->assertArrayHasKey('logprobs', $params); + $this->assertTrue($params['logprobs']); + } + + /** + * Tests prepareGenerateTextParams() with top logprobs. + * + * @return void + */ + public function testPrepareGenerateTextParamsWithTopLogprobs(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Test')])]; + $modelConfig = ModelConfig::fromArray(['topLogprobs' => 5]); + $model = $this->createModel($modelConfig); + + $params = $model->exposePrepareGenerateTextParams($prompt); + + $this->assertArrayHasKey('top_logprobs', $params); + $this->assertEquals(5, $params['top_logprobs']); + } + + /** + * Tests prepareGenerateTextParams() with function declarations. + * + * @return void + */ + public function testPrepareGenerateTextParamsWithFunctionDeclarations(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Test')])]; + $functionDeclaration = new FunctionDeclaration( + 'my_function', + 'My function', + ['type' => 'object'] + ); + $modelConfig = ModelConfig::fromArray( + ['functionDeclarations' => [$functionDeclaration->toArray()]] + ); + $model = $this->createModel($modelConfig); + + $params = $model->exposePrepareGenerateTextParams($prompt); + + $this->assertArrayHasKey('tools', $params); + $this->assertCount(1, $params['tools']); + $this->assertEquals('function', $params['tools'][0]['type']); + $this->assertEquals($functionDeclaration->toArray(), $params['tools'][0]['function']); + } + + /** + * Tests prepareGenerateTextParams() with JSON output. + * + * @return void + */ + public function testPrepareGenerateTextParamsWithJsonOutput(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Test')])]; + $modelConfig = ModelConfig::fromArray(['outputMimeType' => 'application/json']); + $model = $this->createModel($modelConfig); + + $params = $model->exposePrepareGenerateTextParams($prompt); + + $this->assertArrayHasKey('response_format', $params); + $this->assertEquals(['type' => 'json_object'], $params['response_format']); + } + + /** + * Tests prepareGenerateTextParams() with JSON output schema. + * + * @return void + */ + public function testPrepareGenerateTextParamsWithJsonOutputSchema(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Test')])]; + $schema = ['type' => 'object', 'properties' => ['name' => ['type' => 'string']]]; + $modelConfig = ModelConfig::fromArray(['outputMimeType' => 'application/json', 'outputSchema' => $schema]); + $model = $this->createModel($modelConfig); + + $params = $model->exposePrepareGenerateTextParams($prompt); + + $this->assertArrayHasKey('response_format', $params); + $this->assertEquals(['type' => 'json_schema', 'json_schema' => $schema], $params['response_format']); + } + + /** + * Tests prepareGenerateTextParams() with custom options. + * + * @return void + */ + public function testPrepareGenerateTextParamsWithCustomOptions(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Test')])]; + $modelConfig = ModelConfig::fromArray(['customOptions' => ['my_custom_key' => 'my_custom_value']]); + $model = $this->createModel($modelConfig); + + $params = $model->exposePrepareGenerateTextParams($prompt); + + $this->assertArrayHasKey('my_custom_key', $params); + $this->assertEquals('my_custom_value', $params['my_custom_key']); + } + + /** + * Tests prepareGenerateTextParams() with conflicting custom option. + * + * @return void + */ + public function testPrepareGenerateTextParamsWithConflictingCustomOption(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Test')])]; + $modelConfig = ModelConfig::fromArray(['customOptions' => ['model' => 'conflicting-model']]); + $model = $this->createModel($modelConfig); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The custom option "model" conflicts with an existing parameter.'); + + $model->exposePrepareGenerateTextParams($prompt); + } + + /** + * Tests mergeSystemInstruction() method. + * + * @return void + */ + public function testMergeSystemInstruction(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('User message')])]; + $systemInstruction = 'You are a helpful assistant.'; + $model = $this->createModel(); + + $updatedPrompt = $model->exposeMergeSystemInstruction($prompt, $systemInstruction); + + $this->assertCount(2, $updatedPrompt); + $this->assertInstanceOf(SystemMessage::class, $updatedPrompt[0]); + $this->assertEquals($systemInstruction, $updatedPrompt[0]->getParts()[0]->getText()); + $this->assertEquals(MessageRoleEnum::user(), $updatedPrompt[1]->getRole()); + } + + /** + * Tests mergeSystemInstruction() method with existing system message. + * + * @return void + */ + public function testMergeSystemInstructionWithExistingSystemMessage(): void + { + $prompt = [ + new SystemMessage([new MessagePart('Existing system message')]), + new Message(MessageRoleEnum::user(), [new MessagePart('User message')]), + ]; + $systemInstruction = 'You are a helpful assistant.'; + $model = $this->createModel(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The first message in the prompt cannot be a system message when using a system instruction.' + ); + + $model->exposeMergeSystemInstruction($prompt, $systemInstruction); + } + + /** + * Tests prepareMessagesParam() with text message. + * + * @return void + */ + public function testPrepareMessagesParamTextMessage(): void + { + $message = new Message(MessageRoleEnum::user(), [new MessagePart('Hello')]); + $model = $this->createModel(); + + $prepared = $model->exposePrepareMessagesParam([$message]); + + $this->assertCount(1, $prepared); + $this->assertEquals('user', $prepared[0]['role']); + $this->assertCount(1, $prepared[0]['content']); + $this->assertEquals('text', $prepared[0]['content'][0]['type']); + $this->assertEquals('Hello', $prepared[0]['content'][0]['text']); + } + + /** + * Tests prepareMessagesParam() with model message and function call. + * + * @return void + */ + public function testPrepareMessagesParamModelMessageWithFunctionCall(): void + { + $functionCall = new FunctionCall('call_1', 'my_function', ['arg1' => 'value1']); + $message = new Message( + MessageRoleEnum::model(), + [new MessagePart($functionCall)] + ); + $model = $this->createModel(); + + $prepared = $model->exposePrepareMessagesParam([$message]); + + $this->assertCount(1, $prepared); + $this->assertEquals('assistant', $prepared[0]['role']); + $this->assertCount(1, $prepared[0]['tool_calls']); + $this->assertEquals('function', $prepared[0]['tool_calls'][0]['type']); + $this->assertEquals('call_1', $prepared[0]['tool_calls'][0]['id']); + $this->assertEquals('my_function', $prepared[0]['tool_calls'][0]['function']['name']); + $this->assertEquals( + json_encode(['arg1' => 'value1']), + $prepared[0]['tool_calls'][0]['function']['arguments'] + ); + } + + /** + * Tests prepareMessagesParam() with function response. + * + * @return void + */ + public function testPrepareMessagesParamFunctionResponse(): void + { + $functionResponse = new FunctionResponse( + 'call_1', + 'my_function', + ['result' => 'success'] + ); + $message = new Message( + MessageRoleEnum::user(), + [new MessagePart($functionResponse)] + ); // Changed to user role + $model = $this->createModel(); + + $prepared = $model->exposePrepareMessagesParam([$message]); + + $this->assertCount(1, $prepared); + $this->assertEquals('tool', $prepared[0]['role']); + $this->assertEquals(json_encode(['result' => 'success']), $prepared[0]['content']); + $this->assertEquals('call_1', $prepared[0]['tool_call_id']); + } + + /** + * Tests getMessageRoleString() method. + * + * @dataProvider messageRoleProvider + * @param MessageRoleEnum $role + * @param string $expected + * @return void + */ + public function testGetMessageRoleString(MessageRoleEnum $role, string $expected): void + { + $model = $this->createModel(); + $this->assertEquals($expected, $model->exposeGetMessageRoleString($role)); + } + + /** + * Provides message roles and their expected string representations. + * + * @return array> + */ + public function messageRoleProvider(): array + { + return [ + 'user' => [MessageRoleEnum::user(), 'user'], + 'model' => [MessageRoleEnum::model(), 'assistant'], + 'system' => [MessageRoleEnum::system(), 'system'], + ]; + } + + /** + * Tests getMessagePartContentData() with text part. + * + * @return void + */ + public function testGetMessagePartContentDataTextPart(): void + { + $part = new MessagePart('Hello'); + $model = $this->createModel(); + $data = $model->exposeGetMessagePartContentData($part); + + $this->assertEquals(['type' => 'text', 'text' => 'Hello'], $data); + } + + /** + * Tests getMessagePartContentData() with remote image file. + * + * @return void + */ + public function testGetMessagePartContentDataRemoteImageFile(): void + { + $file = new File('https://example.com/image.png', 'image/png'); + $part = new MessagePart($file); + $model = $this->createModel(); + $data = $model->exposeGetMessagePartContentData($part); + + $this->assertEquals(['type' => 'image_url', 'image_url' => ['url' => 'https://example.com/image.png']], $data); + } + + /** + * Tests getMessagePartContentData() with inline image file. + * + * @return void + */ + public function testGetMessagePartContentDataInlineImageFile(): void + { + $base64Image = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; + $file = new File( + $base64Image, + 'image/png' + ); + $part = new MessagePart($file); + $model = $this->createModel(); + $data = $model->exposeGetMessagePartContentData($part); + + $this->assertEquals( + [ + 'type' => 'image_url', + 'image_url' => [ + 'url' => $base64Image + ] + ], + $data + ); + } + + /** + * Tests getMessagePartContentData() with inline audio file. + * + * @return void + */ + public function testGetMessagePartContentDataInlineAudioFile(): void + { + $file = new File( + 'data:audio/mpeg;base64,SUQzBAAAAAAA', + 'audio/mpeg' + ); + $part = new MessagePart($file); + $model = $this->createModel(); + $data = $model->exposeGetMessagePartContentData($part); + + $this->assertEquals([ + 'type' => 'input_audio', + 'input_audio' => ['data' => 'SUQzBAAAAAAA', 'format' => 'mp3'] + ], $data); + } + + /** + * Tests getMessagePartContentData() with unsupported remote file type. + * + * @return void + */ + public function testGetMessagePartContentDataUnsupportedRemoteFile(): void + { + $file = new File('https://example.com/doc.pdf', 'application/pdf'); + $part = new MessagePart($file); + $model = $this->createModel(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported MIME type "application/pdf" for remote file message part.'); + + $model->exposeGetMessagePartContentData($part); + } + + /** + * Tests getMessagePartContentData() with unsupported inline file type. + * + * @return void + */ + public function testGetMessagePartContentDataUnsupportedInlineFile(): void + { + $file = new File('data:text/plain;base64,SGVsbG8=', 'text/plain'); + $part = new MessagePart($file); + $model = $this->createModel(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported MIME type "text/plain" for inline file message part.'); + + $model->exposeGetMessagePartContentData($part); + } + + /** + * Tests getMessagePartContentData() with function call part (should return null). + * + * @return void + */ + public function testGetMessagePartContentDataFunctionCallPart(): void + { + $functionCall = new FunctionCall('call_1', 'my_function', []); + $part = new MessagePart($functionCall); + $model = $this->createModel(); + $data = $model->exposeGetMessagePartContentData($part); + + $this->assertNull($data); + } + + /** + * Tests getMessagePartContentData() with function response part (should throw exception). + * + * @return void + */ + public function testGetMessagePartContentDataFunctionResponsePart(): void + { + $functionResponse = new FunctionResponse('call_1', 'my_function', []); + $part = new MessagePart($functionResponse); + $model = $this->createModel(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The API only allows a single function response, as the only content of the message.' + ); + + $model->exposeGetMessagePartContentData($part); + } + + /** + * Tests getMessagePartToolCallData() with function call part. + * + * @return void + */ + public function testGetMessagePartToolCallDataFunctionCallPart(): void + { + $functionCall = new FunctionCall( + 'call_1', + 'my_function', + ['arg1' => 'value1'] + ); + $part = new MessagePart($functionCall); + $model = $this->createModel(); + $data = $model->exposeGetMessagePartToolCallData($part); + + $this->assertEquals([ + 'type' => 'function', + 'id' => 'call_1', + 'function' => [ + 'name' => 'my_function', + 'arguments' => json_encode(['arg1' => 'value1']), + ], + ], $data); + } + + /** + * Tests getMessagePartToolCallData() with text part (should return null). + * + * @return void + */ + public function testGetMessagePartToolCallDataTextPart(): void + { + $part = new MessagePart('Hello'); + $model = $this->createModel(); + $data = $model->exposeGetMessagePartToolCallData($part); + + $this->assertNull($data); + } + + /** + * Tests validateOutputModalities() with text modality. + * + * @return void + */ + public function testValidateOutputModalitiesWithText(): void + { + $model = $this->createModel(); + $model->exposeValidateOutputModalities([ModalityEnum::text()]); + $this->assertTrue(true); // No exception means success. + } + + /** + * Tests validateOutputModalities() with multiple modalities including text. + * + * @return void + */ + public function testValidateOutputModalitiesWithMultipleIncludingText(): void + { + $model = $this->createModel(); + $model->exposeValidateOutputModalities([ModalityEnum::text(), ModalityEnum::image()]); + $this->assertTrue(true); // No exception means success. + } + + /** + * Tests validateOutputModalities() with no modalities. + * + * @return void + */ + public function testValidateOutputModalitiesWithNoModalities(): void + { + $model = $this->createModel(); + $model->exposeValidateOutputModalities([]); + $this->assertTrue(true); // No exception means success. + } + + /** + * Tests validateOutputModalities() without text modality. + * + * @return void + */ + public function testValidateOutputModalitiesWithoutText(): void + { + $model = $this->createModel(); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('A text output modality must be present when generating text.'); + $model->exposeValidateOutputModalities([ModalityEnum::image()]); + } + + /** + * Tests prepareOutputModalitiesParam() method. + * + * @dataProvider outputModalitiesProvider + * @param array $modalities + * @param array $expected + * @return void + */ + public function testPrepareOutputModalitiesParam( + array $modalities, + array $expected + ): void { + $model = $this->createModel(); + $this->assertEquals($expected, $model->exposePrepareOutputModalitiesParam($modalities)); + } + + /** + * Provides output modalities and their expected API parameter representations. + * + * @return array> + */ + public function outputModalitiesProvider(): array + { + return [ + 'text only' => [ + [ModalityEnum::text()], ['text'] + ], + 'image only' => [ + [ModalityEnum::image()], ['image'] + ], + 'audio only' => [ + [ModalityEnum::audio()], ['audio'] + ], + 'text and image' => [ + [ModalityEnum::text(), ModalityEnum::image()], ['text', 'image'] + ], + 'all modalities' => [ + [ModalityEnum::text(), ModalityEnum::image(), ModalityEnum::audio()], ['text', 'image', 'audio'] + ], + ]; + } + + + /** + * Tests prepareToolsParam() method. + * + * @return void + */ + public function testPrepareToolsParam(): void + { + $functionDeclaration1 = new FunctionDeclaration('func1', 'Description 1', ['type' => 'object']); + $functionDeclaration2 = new FunctionDeclaration('func2', 'Description 2', ['type' => 'object']); + $functionDeclarations = [$functionDeclaration1, $functionDeclaration2]; + $model = $this->createModel(); + + $prepared = $model->exposePrepareToolsParam($functionDeclarations); + + $this->assertCount(2, $prepared); + $this->assertEquals('function', $prepared[0]['type']); + $this->assertEquals($functionDeclaration1->toArray(), $prepared[0]['function']); + $this->assertEquals('function', $prepared[1]['type']); + $this->assertEquals($functionDeclaration2->toArray(), $prepared[1]['function']); + } + + /** + * Tests prepareResponseFormatParam() with null schema. + * + * @return void + */ + public function testPrepareResponseFormatParamNullSchema(): void + { + $model = $this->createModel(); + $format = $model->exposePrepareResponseFormatParam(null); + + $this->assertEquals(['type' => 'json_object'], $format); + } + + /** + * Tests prepareResponseFormatParam() with schema. + * + * @return void + */ + public function testPrepareResponseFormatParamWithSchema(): void + { + $schema = ['type' => 'object', 'properties' => ['key' => ['type' => 'string']]]; + $model = $this->createModel(); + $format = $model->exposePrepareResponseFormatParam($schema); + + $this->assertEquals(['type' => 'json_schema', 'json_schema' => $schema], $format); + } + + /** + * Tests parseResponseToGenerativeAiResult() with valid response. + * + * @return void + */ + public function testParseResponseToGenerativeAiResultValidResponse(): void + { + $response = new Response( + 200, + [], + json_encode([ + 'id' => 'test-id', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Test content', + ], + 'finish_reason' => 'stop', + ], + ], + 'usage' => [ + 'prompt_tokens' => 10, + 'completion_tokens' => 20, + 'total_tokens' => 30, + ], + 'model' => 'test-model', + ]) + ); + $model = $this->createModel(); + $result = $model->parseResponseToGenerativeAiResult($response); + + $this->assertInstanceOf(GenerativeAiResult::class, $result); + $this->assertEquals('test-id', $result->getId()); + $this->assertCount(1, $result->getCandidates()); + $this->assertEquals('Test content', $result->getCandidates()[0]->getMessage()->getParts()[0]->getText()); + $this->assertEquals(FinishReasonEnum::stop(), $result->getCandidates()[0]->getFinishReason()); + $this->assertEquals(10, $result->getTokenUsage()->getPromptTokens()); + $this->assertEquals(20, $result->getTokenUsage()->getCompletionTokens()); + $this->assertEquals(30, $result->getTokenUsage()->getTotalTokens()); + $this->assertEquals(['model' => 'test-model'], $result->getProviderMetadata()); + } + + /** + * Tests parseResponseToGenerativeAiResult() with missing choices. + * + * @return void + */ + public function testParseResponseToGenerativeAiResultMissingChoices(): void + { + $response = new Response(200, [], json_encode(['id' => 'test-id'])); + $model = $this->createModel(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unexpected API response: Missing the choices key.'); + + $model->parseResponseToGenerativeAiResult($response); + } + + /** + * Tests parseResponseToGenerativeAiResult() with invalid choices type. + * + * @return void + */ + public function testParseResponseToGenerativeAiResultInvalidChoicesType(): void + { + $response = new Response( + 200, + [], + json_encode(['choices' => 'invalid']) + ); + $model = $this->createModel(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unexpected API response: The choices key must contain an array.'); + + $model->parseResponseToGenerativeAiResult($response); + } + + /** + * Tests parseResponseToGenerativeAiResult() with invalid choice element type. + * + * @return void + */ + public function testParseResponseToGenerativeAiResultInvalidChoiceElementType(): void + { + $response = new Response(200, [], json_encode(['choices' => ['invalid']])); + $model = $this->createModel(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'Unexpected API response: Each element in the choices key must be an associative array.' + ); + + $model->parseResponseToGenerativeAiResult($response); + } + + /** + * Tests parseResponseChoiceToCandidate() with valid data. + * + * @return void + */ + public function testParseResponseChoiceToCandidateValidData(): void + { + $choiceData = [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello from AI', + ], + 'finish_reason' => 'stop', + ]; + $model = $this->createModel(); + $candidate = $model->exposeParseResponseChoiceToCandidate($choiceData); + + $this->assertInstanceOf(Candidate::class, $candidate); + $this->assertEquals('Hello from AI', $candidate->getMessage()->getParts()[0]->getText()); + $this->assertEquals(FinishReasonEnum::stop(), $candidate->getFinishReason()); + } + + /** + * Tests parseResponseChoiceToCandidate() with missing message. + * + * @return void + */ + public function testParseResponseChoiceToCandidateMissingMessage(): void + { + $choiceData = [ + 'finish_reason' => 'stop', + ]; + $model = $this->createModel(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'Unexpected API response: Each choice must contain a message key with an associative array.' + ); + + $model->exposeParseResponseChoiceToCandidate($choiceData); + } + + /** + * Tests parseResponseChoiceToCandidate() with invalid message type. + * + * @return void + */ + public function testParseResponseChoiceToCandidateInvalidMessageType(): void + { + $choiceData = [ + 'message' => 'invalid', + 'finish_reason' => 'stop', + ]; + $model = $this->createModel(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'Unexpected API response: Each choice must contain a message key with an associative array.' + ); + + $model->exposeParseResponseChoiceToCandidate($choiceData); + } + + /** + * Tests parseResponseChoiceToCandidate() with missing finish reason. + * + * @return void + */ + public function testParseResponseChoiceToCandidateMissingFinishReason(): void + { + $choiceData = [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello from AI', + ], + ]; + $model = $this->createModel(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'Unexpected API response: Each choice must contain a finish_reason key with a string value.' + ); + + $model->exposeParseResponseChoiceToCandidate($choiceData); + } + + /** + * Tests parseResponseChoiceToCandidate() with invalid finish reason. + * + * @return void + */ + public function testParseResponseChoiceToCandidateInvalidFinishReason(): void + { + $choiceData = [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello from AI', + ], + 'finish_reason' => 'unknown', + ]; + $model = $this->createModel(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unexpected API response: Invalid finish reason "unknown".'); + + $model->exposeParseResponseChoiceToCandidate($choiceData); + } + + /** + * Tests parseResponseChoiceMessage() with assistant message. + * + * @return void + */ + public function testParseResponseChoiceMessageAssistant(): void + { + $messageData = [ + 'role' => 'assistant', + 'content' => 'Assistant response', + ]; + $model = $this->createModel(); + $message = $model->exposeParseResponseChoiceMessage($messageData); + + $this->assertEquals(MessageRoleEnum::model(), $message->getRole()); + $this->assertCount(1, $message->getParts()); + $this->assertEquals('Assistant response', $message->getParts()[0]->getText()); + } + + /** + * Tests parseResponseChoiceMessage() with user message. + * + * @return void + */ + public function testParseResponseChoiceMessageUser(): void + { + $messageData = [ + 'role' => 'user', + 'content' => 'User response', + ]; + $model = $this->createModel(); + $message = $model->exposeParseResponseChoiceMessage($messageData); + + $this->assertEquals(MessageRoleEnum::user(), $message->getRole()); + $this->assertCount(1, $message->getParts()); + $this->assertEquals('User response', $message->getParts()[0]->getText()); + } + + /** + * Tests parseResponseChoiceMessageParts() with content and reasoning. + * + * @return void + */ + public function testParseResponseChoiceMessagePartsContentAndReasoning(): void + { + $messageData = [ + 'reasoning_content' => 'Thinking process', + 'content' => 'Final answer', + ]; + $model = $this->createModel(); + $parts = $model->exposeParseResponseChoiceMessageParts($messageData); + + $this->assertCount(2, $parts); + $this->assertEquals('Thinking process', $parts[0]->getText()); + $this->assertEquals(MessagePartChannelEnum::thought(), $parts[0]->getChannel()); + $this->assertEquals('Final answer', $parts[1]->getText()); + $this->assertEquals(MessagePartChannelEnum::content(), $parts[1]->getChannel()); + } + + /** + * Tests parseResponseChoiceMessageParts() with tool calls. + * + * @return void + */ + public function testParseResponseChoiceMessagePartsToolCalls(): void + { + $messageData = [ + 'tool_calls' => [ + [ + 'id' => 'call_1', + 'type' => 'function', + 'function' => [ + 'name' => 'my_function', + 'arguments' => '{"param":"value"}', + ], + ], + ], + ]; + $model = $this->createModel(); + $parts = $model->exposeParseResponseChoiceMessageParts($messageData); + + $this->assertCount(1, $parts); + $this->assertInstanceOf(FunctionCall::class, $parts[0]->getFunctionCall()); + $this->assertEquals('call_1', $parts[0]->getFunctionCall()->getId()); + } + + /** + * Tests parseResponseChoiceMessageToolCallPart() with valid function call. + * + * @return void + */ + public function testParseResponseChoiceMessageToolCallPartValidFunctionCall(): void + { + $toolCallData = [ + 'id' => 'call_123', + 'type' => 'function', + 'function' => [ + 'name' => 'test_function', + 'arguments' => '{"key":"value"}', + ], + ]; + $model = $this->createModel(); + $part = $model->exposeParseResponseChoiceMessageToolCallPart($toolCallData); + + $this->assertInstanceOf(MessagePart::class, $part); + $this->assertInstanceOf(FunctionCall::class, $part->getFunctionCall()); + $this->assertEquals('call_123', $part->getFunctionCall()->getId()); + $this->assertEquals('test_function', $part->getFunctionCall()->getName()); + $this->assertEquals(['key' => 'value'], $part->getFunctionCall()->getArgs()); + } + + /** + * Tests parseResponseChoiceMessageToolCallPart() with missing function data. + * + * @return void + */ + public function testParseResponseChoiceMessageToolCallPartMissingFunctionData(): void + { + $toolCallData = [ + 'id' => 'call_123', + 'type' => 'function', + ]; + $model = $this->createModel(); + $part = $model->exposeParseResponseChoiceMessageToolCallPart($toolCallData); + + $this->assertNull($part); + } + + /** + * Tests parseResponseChoiceMessageToolCallPart() with non-function type. + * + * @return void + */ + public function testParseResponseChoiceMessageToolCallPartNonFunctionType(): void + { + $toolCallData = [ + 'id' => 'call_123', + 'type' => 'unknown', + 'function' => [ + 'name' => 'test_function', + 'arguments' => '{"key":"value"}', + ], + ]; + $model = $this->createModel(); + $part = $model->exposeParseResponseChoiceMessageToolCallPart($toolCallData); + + $this->assertNull($part); + } +} diff --git a/tests/unit/Providers/Models/DTO/ModelConfigTest.php b/tests/unit/Providers/Models/DTO/ModelConfigTest.php index bac9b5ad..b9f9add3 100644 --- a/tests/unit/Providers/Models/DTO/ModelConfigTest.php +++ b/tests/unit/Providers/Models/DTO/ModelConfigTest.php @@ -6,6 +6,8 @@ use JsonSerializable; use PHPUnit\Framework\TestCase; +use WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface; +use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Files\Enums\MediaOrientationEnum; use WordPress\AiClient\Messages\Enums\ModalityEnum; @@ -580,11 +582,11 @@ public function testImplementsCorrectInterfaces(): void $config = new ModelConfig(); $this->assertInstanceOf( - \WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface::class, + WithArrayTransformationInterface::class, $config ); $this->assertInstanceOf( - \WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface::class, + WithJsonSchemaInterface::class, $config ); $this->assertInstanceOf( diff --git a/tests/unit/Providers/Models/DTO/ModelMetadataTest.php b/tests/unit/Providers/Models/DTO/ModelMetadataTest.php index 7954d6f6..5de88edf 100644 --- a/tests/unit/Providers/Models/DTO/ModelMetadataTest.php +++ b/tests/unit/Providers/Models/DTO/ModelMetadataTest.php @@ -6,6 +6,8 @@ use JsonSerializable; use PHPUnit\Framework\TestCase; +use WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface; +use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; use WordPress\AiClient\Providers\Models\DTO\SupportedOption; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; @@ -426,11 +428,11 @@ public function testImplementsCorrectInterfaces(): void $metadata = new ModelMetadata('test', 'Test', [], []); $this->assertInstanceOf( - \WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface::class, + WithArrayTransformationInterface::class, $metadata ); $this->assertInstanceOf( - \WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface::class, + WithJsonSchemaInterface::class, $metadata ); $this->assertInstanceOf( diff --git a/tests/unit/Providers/Models/DTO/ModelRequirementsTest.php b/tests/unit/Providers/Models/DTO/ModelRequirementsTest.php index 706859a5..d4f44cdb 100644 --- a/tests/unit/Providers/Models/DTO/ModelRequirementsTest.php +++ b/tests/unit/Providers/Models/DTO/ModelRequirementsTest.php @@ -6,6 +6,8 @@ use JsonSerializable; use PHPUnit\Framework\TestCase; +use WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface; +use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; use WordPress\AiClient\Providers\Models\DTO\ModelRequirements; use WordPress\AiClient\Providers\Models\DTO\RequiredOption; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; @@ -371,11 +373,11 @@ public function testImplementsCorrectInterfaces(): void $requirements = new ModelRequirements([], []); $this->assertInstanceOf( - \WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface::class, + WithArrayTransformationInterface::class, $requirements ); $this->assertInstanceOf( - \WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface::class, + WithJsonSchemaInterface::class, $requirements ); $this->assertInstanceOf( diff --git a/tests/unit/Providers/Models/DTO/RequiredOptionTest.php b/tests/unit/Providers/Models/DTO/RequiredOptionTest.php index b636fd7e..7af22102 100644 --- a/tests/unit/Providers/Models/DTO/RequiredOptionTest.php +++ b/tests/unit/Providers/Models/DTO/RequiredOptionTest.php @@ -6,6 +6,8 @@ use JsonSerializable; use PHPUnit\Framework\TestCase; +use WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface; +use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; use WordPress\AiClient\Providers\Models\DTO\RequiredOption; /** @@ -426,11 +428,11 @@ public function testImplementsCorrectInterfaces(): void $option = new RequiredOption('test', 'value'); $this->assertInstanceOf( - \WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface::class, + WithArrayTransformationInterface::class, $option ); $this->assertInstanceOf( - \WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface::class, + WithJsonSchemaInterface::class, $option ); $this->assertInstanceOf( diff --git a/tests/unit/Providers/Models/DTO/SupportedOptionTest.php b/tests/unit/Providers/Models/DTO/SupportedOptionTest.php index 27c3f6be..aab332bc 100644 --- a/tests/unit/Providers/Models/DTO/SupportedOptionTest.php +++ b/tests/unit/Providers/Models/DTO/SupportedOptionTest.php @@ -6,6 +6,8 @@ use JsonSerializable; use PHPUnit\Framework\TestCase; +use WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface; +use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; use WordPress\AiClient\Providers\Models\DTO\SupportedOption; /** @@ -418,11 +420,11 @@ public function testImplementsCorrectInterfaces(): void $option = new SupportedOption('test', ['value']); $this->assertInstanceOf( - \WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface::class, + WithArrayTransformationInterface::class, $option ); $this->assertInstanceOf( - \WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface::class, + WithJsonSchemaInterface::class, $option ); $this->assertInstanceOf( diff --git a/tests/unit/Providers/Models/MockApiBasedModel.php b/tests/unit/Providers/Models/MockApiBasedModel.php new file mode 100644 index 00000000..d7913860 --- /dev/null +++ b/tests/unit/Providers/Models/MockApiBasedModel.php @@ -0,0 +1,21 @@ +mockHttpTransporter = $mockHttpTransporter; + $this->mockRequestAuthentication = $mockRequestAuthentication; + } + + /** + * @inheritdoc + */ + public function getHttpTransporter(): HttpTransporterInterface + { + return $this->mockHttpTransporter; + } + + /** + * @inheritdoc + */ + public function getRequestAuthentication(): RequestAuthenticationInterface + { + return $this->mockRequestAuthentication; + } + + /** + * @inheritdoc + */ + protected function createRequest( + HttpMethodEnum $method, + string $path, + array $headers = [], + $data = null + ): Request { + return new Request($method, 'https://example.com/' . $path, $headers, $data); + } + + /** + * Sets a mock generative AI result to be returned by parseResponseToGenerativeAiResult. + * + * @param GenerativeAiResult $result + */ + public function setMockGenerativeAiResult(GenerativeAiResult $result): void + { + $this->mockGenerativeAiResult = $result; + } + + /** + * @inheritdoc + */ + public function parseResponseToGenerativeAiResult(Response $response): GenerativeAiResult + { + if ($this->mockGenerativeAiResult) { + return $this->mockGenerativeAiResult; + } + // Fallback to parent if no mock is set, or implement a basic parsing for testing. + return parent::parseResponseToGenerativeAiResult($response); + } + + // Expose protected methods for testing. + public function exposePrepareGenerateTextParams(array $prompt): array + { + return $this->prepareGenerateTextParams($prompt); + } + + public function exposeMergeSystemInstruction(array $prompt, string $systemInstruction): array + { + return $this->mergeSystemInstruction($prompt, $systemInstruction); + } + + public function exposePrepareMessagesParam(array $messages): array + { + return $this->prepareMessagesParam($messages); + } + + public function exposeGetMessageRoleString(MessageRoleEnum $role): string + { + return $this->getMessageRoleString($role); + } + + public function exposeGetMessagePartContentData(MessagePart $part): ?array + { + return $this->getMessagePartContentData($part); + } + + public function exposeGetMessagePartToolCallData(MessagePart $part): ?array + { + return $this->getMessagePartToolCallData($part); + } + + public function exposeValidateOutputModalities(array $outputModalities): void + { + $this->validateOutputModalities($outputModalities); + } + + public function exposePrepareOutputModalitiesParam(array $modalities): array + { + return $this->prepareOutputModalitiesParam($modalities); + } + + public function exposePrepareToolsParam(array $functionDeclarations): array + { + return $this->prepareToolsParam($functionDeclarations); + } + + public function exposePrepareResponseFormatParam(?array $outputSchema): array + { + return $this->prepareResponseFormatParam($outputSchema); + } + + public function exposeParseResponseChoiceToCandidate(array $choiceData): Candidate + { + return $this->parseResponseChoiceToCandidate($choiceData); + } + + public function exposeParseResponseChoiceMessage(array $messageData): Message + { + return $this->parseResponseChoiceMessage($messageData); + } + + public function exposeParseResponseChoiceMessageParts(array $messageData): array + { + return $this->parseResponseChoiceMessageParts($messageData); + } + + public function exposeParseResponseChoiceMessageToolCallPart(array $toolCallData): ?MessagePart + { + return $this->parseResponseChoiceMessageToolCallPart($toolCallData); + } +} diff --git a/tests/unit/Results/DTO/GenerativeAiResultTest.php b/tests/unit/Results/DTO/GenerativeAiResultTest.php index e58f43a5..d0e6b2c0 100644 --- a/tests/unit/Results/DTO/GenerativeAiResultTest.php +++ b/tests/unit/Results/DTO/GenerativeAiResultTest.php @@ -13,6 +13,7 @@ use WordPress\AiClient\Messages\DTO\ModelMessage; use WordPress\AiClient\Messages\Enums\MessagePartTypeEnum; use WordPress\AiClient\Messages\Enums\MessageRoleEnum; +use WordPress\AiClient\Results\Contracts\ResultInterface; use WordPress\AiClient\Results\DTO\Candidate; use WordPress\AiClient\Results\DTO\GenerativeAiResult; use WordPress\AiClient\Results\DTO\TokenUsage; @@ -582,7 +583,7 @@ public function testImplementsResultInterface(): void ); $this->assertInstanceOf( - \WordPress\AiClient\Results\Contracts\ResultInterface::class, + ResultInterface::class, $result ); } diff --git a/tests/unit/Results/DTO/TokenUsageTest.php b/tests/unit/Results/DTO/TokenUsageTest.php index d65f8974..8cff5033 100644 --- a/tests/unit/Results/DTO/TokenUsageTest.php +++ b/tests/unit/Results/DTO/TokenUsageTest.php @@ -5,6 +5,8 @@ namespace WordPress\AiClient\Tests\unit\Results\DTO; use PHPUnit\Framework\TestCase; +use WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface; +use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; use WordPress\AiClient\Results\DTO\TokenUsage; /** @@ -184,7 +186,7 @@ public function testImplementsWithJsonSchemaInterface(): void $tokenUsage = new TokenUsage(10, 20, 30); $this->assertInstanceOf( - \WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface::class, + WithJsonSchemaInterface::class, $tokenUsage ); } @@ -278,7 +280,7 @@ public function testImplementsWithArrayTransformationInterface(): void $tokenUsage = new TokenUsage(10, 20, 30); $this->assertInstanceOf( - \WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface::class, + WithArrayTransformationInterface::class, $tokenUsage ); } diff --git a/tests/unit/Tools/DTO/WebSearchTest.php b/tests/unit/Tools/DTO/WebSearchTest.php index 93dc6962..465478c9 100644 --- a/tests/unit/Tools/DTO/WebSearchTest.php +++ b/tests/unit/Tools/DTO/WebSearchTest.php @@ -5,6 +5,7 @@ namespace WordPress\AiClient\Tests\unit\Tools\DTO; use PHPUnit\Framework\TestCase; +use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; use WordPress\AiClient\Tests\traits\ArrayTransformationTestTrait; use WordPress\AiClient\Tools\DTO\WebSearch; @@ -222,7 +223,7 @@ public function testImplementsWithJsonSchemaInterface(): void $webSearch = new WebSearch(); $this->assertInstanceOf( - \WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface::class, + WithJsonSchemaInterface::class, $webSearch ); } From 073a767342f2678969bb9ed31781671bac439aa7 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Wed, 20 Aug 2025 13:42:03 -0500 Subject: [PATCH 35/77] Remove NullRequestAuthentication in favor of only considering models from providers that are configured. --- cli.php | 6 +- .../Http/DTO/NullRequestAuthentication.php | 56 -------------- src/Providers/ProviderRegistry.php | 44 +++++++---- .../DTO/NullRequestAuthenticationTest.php | 73 ------------------- tests/unit/Providers/ProviderRegistryTest.php | 9 +-- 5 files changed, 38 insertions(+), 150 deletions(-) delete mode 100644 src/Providers/Http/DTO/NullRequestAuthentication.php delete mode 100644 tests/unit/Providers/Http/DTO/NullRequestAuthenticationTest.php diff --git a/cli.php b/cli.php index 65b335f3..58f56bab 100755 --- a/cli.php +++ b/cli.php @@ -182,7 +182,11 @@ function logError(string $message, int $exit_code = 1): void } elseif (!$modelId) { $modelsMetadata = $providerRegistry->findProviderModelsMetadataForSupport($providerId, $modelRequirements); if (!isset($modelsMetadata[0])) { - logError('No "' . $providerId . '" model supports the necessary model requirements.'); + if (!$providerRegistry->isProviderConfigured($providerId)) { + logError('The provider "' . $providerId . '" is not configured.'); + } else { + logError('No "' . $providerId . '" model supports the necessary model requirements.'); + } } $modelId = $modelsMetadata[0]->getId(); } diff --git a/src/Providers/Http/DTO/NullRequestAuthentication.php b/src/Providers/Http/DTO/NullRequestAuthentication.php deleted file mode 100644 index b10ff194..00000000 --- a/src/Providers/Http/DTO/NullRequestAuthentication.php +++ /dev/null @@ -1,56 +0,0 @@ - - */ -class NullRequestAuthentication extends AbstractDataTransferObject implements RequestAuthenticationInterface -{ - /** - * @inheritDoc - */ - public function authenticateRequest(Request $request): Request - { - return $request; - } - - /** - * @inheritDoc - */ - public function toArray(): array - { - return []; - } - - /** - * @inheritDoc - */ - public static function fromArray(array $array): self - { - return new self(); - } - - /** - * @inheritDoc - */ - public static function getJsonSchema(): array - { - return [ - 'type' => 'object', - 'properties' => [], - 'required' => [], - ]; - } -} diff --git a/src/Providers/ProviderRegistry.php b/src/Providers/ProviderRegistry.php index db44b687..528c1143 100644 --- a/src/Providers/ProviderRegistry.php +++ b/src/Providers/ProviderRegistry.php @@ -15,7 +15,6 @@ use WordPress\AiClient\Providers\Http\Contracts\WithHttpTransporterInterface; use WordPress\AiClient\Providers\Http\Contracts\WithRequestAuthenticationInterface; use WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication; -use WordPress\AiClient\Providers\Http\DTO\NullRequestAuthentication; use WordPress\AiClient\Providers\Http\Traits\WithHttpTransporterTrait; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; @@ -94,11 +93,16 @@ public function registerProvider(string $className): void // Hook up the request authentication instance, using a default if not set. if (!isset($this->providerAuthenticationInstances[$className])) { - $this->providerAuthenticationInstances[$className] = $this->createDefaultProviderRequestAuthentication( + $defaultProviderAuthentication = $this->createDefaultProviderRequestAuthentication( $className ); + if ($defaultProviderAuthentication !== null) { + $this->providerAuthenticationInstances[$className] = $defaultProviderAuthentication; + } + } + if (isset($this->providerAuthenticationInstances[$className])) { + $this->setRequestAuthenticationForProvider($className, $this->providerAuthenticationInstances[$className]); } - $this->setRequestAuthenticationForProvider($className, $this->providerAuthenticationInstances[$className]); $this->providerClassNames[$metadata->getId()] = $className; $this->registeredClassNames[$className] = true; @@ -162,7 +166,7 @@ public function isProviderConfigured(string $idOrClassName): bool } /** - * Finds models across all providers that support the given requirements. + * Finds models across all available providers that support the given requirements. * * @since n.e.x.t * @@ -191,7 +195,7 @@ public function findModelsMetadataForSupport(ModelRequirements $modelRequirement } /** - * Finds models within a specific provider that support the given requirements. + * Finds models within a specific available provider that support the given requirements. * * @since n.e.x.t * @@ -205,6 +209,11 @@ public function findProviderModelsMetadataForSupport( ): array { $className = $this->resolveProviderClassName($idOrClassName); + // If the provider is not configured, there is no way to use it, so it is considered unavailable. + if (!$this->isProviderConfigured($className)) { + return []; + } + $modelMetadataDirectory = $className::modelMetadataDirectory(); // Filter models that meet requirements @@ -245,9 +254,10 @@ public function getProviderModel( } if ($modelInstance instanceof WithRequestAuthenticationInterface) { - $modelInstance->setRequestAuthentication( - $this->getProviderRequestAuthentication($className) - ); + $requestAuthentication = $this->getProviderRequestAuthentication($className); + if ($requestAuthentication !== null) { + $modelInstance->setRequestAuthentication($requestAuthentication); + } } return $modelInstance; @@ -308,16 +318,19 @@ public function setProviderRequestAuthentication( } /** - * Gets the request authentication instance for the given provider. + * Gets the request authentication instance for the given provider, if set. * * @since n.e.x.t * * @param string|class-string $idOrClassName The provider ID or class name. - * @return RequestAuthenticationInterface The request authentication instance. + * @return ?RequestAuthenticationInterface The request authentication instance, or null if not set. */ - public function getProviderRequestAuthentication(string $idOrClassName): RequestAuthenticationInterface + public function getProviderRequestAuthentication(string $idOrClassName): ?RequestAuthenticationInterface { $className = $this->resolveProviderClassName($idOrClassName); + if (!isset($this->providerAuthenticationInstances[$className])) { + return null; + } return $this->providerAuthenticationInstances[$className]; } @@ -387,11 +400,12 @@ private function setRequestAuthenticationForProvider( * @since n.e.x.t * * @param class-string $className The provider class name. - * @return RequestAuthenticationInterface The default request authentication instance. + * @return ?RequestAuthenticationInterface The default request authentication instance, or null if not required or + * if no credential data can be found. */ private function createDefaultProviderRequestAuthentication( string $className - ): RequestAuthenticationInterface { + ): ?RequestAuthenticationInterface { $providerId = $className::metadata()->getId(); /* @@ -439,12 +453,12 @@ private function createDefaultProviderRequestAuthentication( } } - // If any required fields are missing, use an empty authentication instance to avoid errors. + // If any required fields are missing, return null to avoid immediate errors. if (isset($authenticationSchema['required']) && is_array($authenticationSchema['required'])) { /** @var list $requiredProperties */ $requiredProperties = $authenticationSchema['required']; if (array_diff_key(array_flip($requiredProperties), $authenticationData)) { - $authenticationClass = NullRequestAuthentication::class; + return null; } } } diff --git a/tests/unit/Providers/Http/DTO/NullRequestAuthenticationTest.php b/tests/unit/Providers/Http/DTO/NullRequestAuthenticationTest.php deleted file mode 100644 index 87ac15ef..00000000 --- a/tests/unit/Providers/Http/DTO/NullRequestAuthenticationTest.php +++ /dev/null @@ -1,73 +0,0 @@ -authenticateRequest($request); - - $this->assertSame($request, $authenticatedRequest); - } - - /** - * Tests toArray method. - * - * @return void - */ - public function testToArray(): void - { - $auth = new NullRequestAuthentication(); - $array = $auth->toArray(); - - $this->assertIsArray($array); - $this->assertEmpty($array); - } - - /** - * Tests fromArray method. - * - * @return void - */ - public function testFromArray(): void - { - $auth = NullRequestAuthentication::fromArray([]); - - $this->assertInstanceOf(NullRequestAuthentication::class, $auth); - } - - /** - * Tests getJsonSchema method. - * - * @return void - */ - public function testGetJsonSchema(): void - { - $schema = NullRequestAuthentication::getJsonSchema(); - - $this->assertIsArray($schema); - $this->assertEquals('object', $schema['type']); - $this->assertArrayHasKey('properties', $schema); - $this->assertEmpty($schema['properties']); - $this->assertArrayHasKey('required', $schema); - $this->assertEmpty($schema['required']); - } -} diff --git a/tests/unit/Providers/ProviderRegistryTest.php b/tests/unit/Providers/ProviderRegistryTest.php index 4a47ec72..4dd57fa6 100644 --- a/tests/unit/Providers/ProviderRegistryTest.php +++ b/tests/unit/Providers/ProviderRegistryTest.php @@ -7,7 +7,6 @@ use InvalidArgumentException; use PHPUnit\Framework\TestCase; use WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication; -use WordPress\AiClient\Providers\Http\DTO\NullRequestAuthentication; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; use WordPress\AiClient\Providers\Models\DTO\ModelRequirements; @@ -338,8 +337,8 @@ public function testGetProviderRequestAuthenticationReturnsDefault(): void $retrievedAuth = $this->registry->getProviderRequestAuthentication('mock'); // By default, it should create an ApiKeyRequestAuthentication if environment variables are set. - // Since no env vars are set in tests, it should fall back to NullRequestAuthentication. - $this->assertInstanceOf(NullRequestAuthentication::class, $retrievedAuth); + // Since no env vars are set in tests, it should fall back to null. + $this->assertNull($retrievedAuth); } /** @@ -402,7 +401,7 @@ public function testCreateDefaultProviderRequestAuthenticationWithEnvVar(): void } /** - * Tests that createDefaultProviderRequestAuthentication creates NullRequestAuthentication when env var is not set. + * Tests that createDefaultProviderRequestAuthentication returns null when env var is not set. * * @return void */ @@ -418,6 +417,6 @@ public function testCreateDefaultProviderRequestAuthenticationWithoutEnvVar(): v $auth = $method->invoke($this->registry, MockProvider::class); - $this->assertInstanceOf(NullRequestAuthentication::class, $auth); + $this->assertNull($auth); } } From 7757d33db3ba21a8ac3aaad61fc5a9076905ad2f Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Wed, 20 Aug 2025 13:47:05 -0500 Subject: [PATCH 36/77] Add tests for AbstractProvider. --- tests/mocks/MockAbstractProvider.php | 82 +++++++ tests/unit/Providers/AbstractProviderTest.php | 221 ++++++++++++++++++ 2 files changed, 303 insertions(+) create mode 100644 tests/mocks/MockAbstractProvider.php create mode 100644 tests/unit/Providers/AbstractProviderTest.php diff --git a/tests/mocks/MockAbstractProvider.php b/tests/mocks/MockAbstractProvider.php new file mode 100644 index 00000000..9a800f4c --- /dev/null +++ b/tests/mocks/MockAbstractProvider.php @@ -0,0 +1,82 @@ +getProperty('metadataCache'); + $metadataCacheProperty->setAccessible(true); + $metadataCacheProperty->setValue(null, []); + + $availabilityCacheProperty = $reflectionClass->getProperty('availabilityCache'); + $availabilityCacheProperty->setAccessible(true); + $availabilityCacheProperty->setValue(null, []); + + $modelMetadataDirectoryCacheProperty = $reflectionClass->getProperty('modelMetadataDirectoryCache'); + $modelMetadataDirectoryCacheProperty->setAccessible(true); + $modelMetadataDirectoryCacheProperty->setValue(null, []); + } + + /** + * Tests the metadata() method. + * + * @return void + */ + public function testMetadata(): void + { + $providerMetadata = $this->createMock(ProviderMetadata::class); + MockAbstractProvider::$mockProviderMetadata = $providerMetadata; + + // Call metadata twice to ensure caching works + $result1 = MockAbstractProvider::metadata(); + $result2 = MockAbstractProvider::metadata(); + + $this->assertSame($providerMetadata, $result1); + $this->assertSame($providerMetadata, $result2); + } + + /** + * Tests the model() method without ModelConfig. + * + * @return void + */ + public function testModelWithoutModelConfig(): void + { + $modelId = 'test-model'; + $modelMetadata = $this->createMock(ModelMetadata::class); + $providerMetadata = $this->createMock(ProviderMetadata::class); + $model = $this->createMock(ModelInterface::class); // Use ModelInterface for the mock + $mockModelMetadataDirectory = $this->createMock(ModelMetadataDirectoryInterface::class); + + // Set expectations on the mock that will be used by MockAbstractProvider + $mockModelMetadataDirectory->expects($this->once()) + ->method('getModelMetadata') + ->with($modelId) + ->willReturn($modelMetadata); + + MockAbstractProvider::$mockProviderMetadata = $providerMetadata; + MockAbstractProvider::$mockModelMetadataDirectory = $mockModelMetadataDirectory; + MockAbstractProvider::$mockModel = $model; + + $model->expects($this->never())->method('setConfig'); + + $result = MockAbstractProvider::model($modelId); + + $this->assertSame($model, $result); + } + + /** + * Tests the model() method with ModelConfig. + * + * @return void + */ + public function testModelWithModelConfig(): void + { + $modelId = 'test-model'; + $modelConfig = $this->createMock(ModelConfig::class); + $modelMetadata = $this->createMock(ModelMetadata::class); + $providerMetadata = $this->createMock(ProviderMetadata::class); + $model = $this->createMock(ModelInterface::class); // Use ModelInterface for the mock + $mockModelMetadataDirectory = $this->createMock(ModelMetadataDirectoryInterface::class); + + // Set expectations on the mock that will be used by MockAbstractProvider + $mockModelMetadataDirectory->expects($this->once()) + ->method('getModelMetadata') + ->with($modelId) + ->willReturn($modelMetadata); + + MockAbstractProvider::$mockProviderMetadata = $providerMetadata; + MockAbstractProvider::$mockModelMetadataDirectory = $mockModelMetadataDirectory; + MockAbstractProvider::$mockModel = $model; + + $model->expects($this->once())->method('setConfig')->with($modelConfig); + + $result = MockAbstractProvider::model($modelId, $modelConfig); + + $this->assertSame($model, $result); + } + + /** + * Tests the availability() method. + * + * @return void + */ + public function testAvailability(): void + { + $providerAvailability = $this->createMock(ProviderAvailabilityInterface::class); + MockAbstractProvider::$mockProviderAvailability = $providerAvailability; + + // Call availability twice to ensure caching works + $result1 = MockAbstractProvider::availability(); + $result2 = MockAbstractProvider::availability(); + + $this->assertSame($providerAvailability, $result1); + $this->assertSame($providerAvailability, $result2); + } + + /** + * Tests the modelMetadataDirectory() method. + * + * @return void + */ + public function testModelMetadataDirectory(): void + { + $modelMetadataDirectory = $this->createMock(ModelMetadataDirectoryInterface::class); + MockAbstractProvider::$mockModelMetadataDirectory = $modelMetadataDirectory; + + // Call modelMetadataDirectory twice to ensure caching works + $result1 = MockAbstractProvider::modelMetadataDirectory(); + $result2 = MockAbstractProvider::modelMetadataDirectory(); + + $this->assertSame($modelMetadataDirectory, $result1); + $this->assertSame($modelMetadataDirectory, $result2); + } + + /** + * Tests that the caches are reset between tests for different concrete provider classes. + * + * @return void + */ + public function testCachesArePerConcreteClass(): void + { + // Create two distinct anonymous classes extending AbstractProvider + $mockProviderClass1 = new class extends AbstractProvider { + protected static function createModel(ModelMetadata $modelMetadata, ProviderMetadata $providerMetadata): ModelInterface { return new MockModel(); } + protected static function createProviderMetadata(): ProviderMetadata { return new ProviderMetadata('mock-provider-1', 'Mock Provider 1', ProviderTypeEnum::cloud()); } + protected static function createProviderAvailability(): ProviderAvailabilityInterface { return new MockProviderAvailability(); } + protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface { return new MockModelMetadataDirectory(); } + }; + + $mockProviderClass2 = new class extends AbstractProvider { + protected static function createModel(ModelMetadata $modelMetadata, ProviderMetadata $providerMetadata): ModelInterface { return new MockModel(); } + protected static function createProviderMetadata(): ProviderMetadata { return new ProviderMetadata('mock-provider-2', 'Mock Provider 2', ProviderTypeEnum::cloud()); } + protected static function createProviderAvailability(): ProviderAvailabilityInterface { return new MockProviderAvailability(); } + protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface { return new MockModelMetadataDirectory(); } + }; + + // Get metadata for the first provider + $metadata1_1 = $mockProviderClass1::metadata(); + $metadata1_2 = $mockProviderClass1::metadata(); // Should be cached + + // Get metadata for the second provider + $metadata2_1 = $mockProviderClass2::metadata(); + $metadata2_2 = $mockProviderClass2::metadata(); // Should be cached + + // Assert that the first provider's metadata is consistent and distinct from the second + $this->assertSame($metadata1_1, $metadata1_2); + $this->assertEquals('mock-provider-1', $metadata1_1->getId()); + $this->assertNotSame($metadata1_1, $metadata2_1); // Ensure they are different instances + + // Assert that the second provider's metadata is consistent + $this->assertSame($metadata2_1, $metadata2_2); + $this->assertEquals('mock-provider-2', $metadata2_1->getId()); + + // Repeat for availability + $availability1_1 = $mockProviderClass1::availability(); + $availability1_2 = $mockProviderClass1::availability(); + $availability2_1 = $mockProviderClass2::availability(); + $availability2_2 = $mockProviderClass2::availability(); + + $this->assertSame($availability1_1, $availability1_2); + $this->assertNotSame($availability1_1, $availability2_1); + $this->assertSame($availability2_1, $availability2_2); + + // Repeat for modelMetadataDirectory + $modelMetadataDirectory1_1 = $mockProviderClass1::modelMetadataDirectory(); + $modelMetadataDirectory1_2 = $mockProviderClass1::modelMetadataDirectory(); + $modelMetadataDirectory2_1 = $mockProviderClass2::modelMetadataDirectory(); + $modelMetadataDirectory2_2 = $mockProviderClass2::modelMetadataDirectory(); + + $this->assertSame($modelMetadataDirectory1_1, $modelMetadataDirectory1_2); + $this->assertNotSame($modelMetadataDirectory1_1, $modelMetadataDirectory2_1); + $this->assertSame($modelMetadataDirectory2_1, $modelMetadataDirectory2_2); + } +} From 878828013198af68752478e02c76228b65f22d9a Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Wed, 20 Aug 2025 19:35:19 -0500 Subject: [PATCH 37/77] Fix PHPCS violations. --- tests/unit/Providers/AbstractProviderTest.php | 46 +++++++++++++++---- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/tests/unit/Providers/AbstractProviderTest.php b/tests/unit/Providers/AbstractProviderTest.php index beb905c2..10bbaf6a 100644 --- a/tests/unit/Providers/AbstractProviderTest.php +++ b/tests/unit/Providers/AbstractProviderTest.php @@ -9,10 +9,10 @@ use WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\DTO\ProviderMetadata; +use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; -use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; use WordPress\AiClient\Tests\mocks\MockAbstractProvider; use WordPress\AiClient\Tests\mocks\MockModel; use WordPress\AiClient\Tests\mocks\MockModelMetadataDirectory; @@ -168,17 +168,45 @@ public function testCachesArePerConcreteClass(): void { // Create two distinct anonymous classes extending AbstractProvider $mockProviderClass1 = new class extends AbstractProvider { - protected static function createModel(ModelMetadata $modelMetadata, ProviderMetadata $providerMetadata): ModelInterface { return new MockModel(); } - protected static function createProviderMetadata(): ProviderMetadata { return new ProviderMetadata('mock-provider-1', 'Mock Provider 1', ProviderTypeEnum::cloud()); } - protected static function createProviderAvailability(): ProviderAvailabilityInterface { return new MockProviderAvailability(); } - protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface { return new MockModelMetadataDirectory(); } + protected static function createModel( + ModelMetadata $modelMetadata, + ProviderMetadata $providerMetadata + ): ModelInterface { + return new MockModel(); + } + protected static function createProviderMetadata(): ProviderMetadata + { + return new ProviderMetadata('mock-provider-1', 'Mock Provider 1', ProviderTypeEnum::cloud()); + } + protected static function createProviderAvailability(): ProviderAvailabilityInterface + { + return new MockProviderAvailability(); + } + protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface + { + return new MockModelMetadataDirectory(); + } }; $mockProviderClass2 = new class extends AbstractProvider { - protected static function createModel(ModelMetadata $modelMetadata, ProviderMetadata $providerMetadata): ModelInterface { return new MockModel(); } - protected static function createProviderMetadata(): ProviderMetadata { return new ProviderMetadata('mock-provider-2', 'Mock Provider 2', ProviderTypeEnum::cloud()); } - protected static function createProviderAvailability(): ProviderAvailabilityInterface { return new MockProviderAvailability(); } - protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface { return new MockModelMetadataDirectory(); } + protected static function createModel( + ModelMetadata $modelMetadata, + ProviderMetadata $providerMetadata + ): ModelInterface { + return new MockModel(); + } + protected static function createProviderMetadata(): ProviderMetadata + { + return new ProviderMetadata('mock-provider-2', 'Mock Provider 2', ProviderTypeEnum::cloud()); + } + protected static function createProviderAvailability(): ProviderAvailabilityInterface + { + return new MockProviderAvailability(); + } + protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface + { + return new MockModelMetadataDirectory(); + } }; // Get metadata for the first provider From e6848b6a87915b96f6e2d20785bd969f6c3e54fa Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Fri, 22 Aug 2025 19:44:05 -0700 Subject: [PATCH 38/77] Update provider code after removal of system message. --- ...actOpenAiCompatibleTextGenerationModel.php | 59 +++++++------------ tests/unit/Messages/Util/MessageUtilTest.php | 8 +-- ...penAiCompatibleTextGenerationModelTest.php | 43 -------------- 3 files changed, 25 insertions(+), 85 deletions(-) diff --git a/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php index dd291512..02e3ad85 100644 --- a/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php @@ -9,7 +9,6 @@ use RuntimeException; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; -use WordPress\AiClient\Messages\DTO\SystemMessage; use WordPress\AiClient\Messages\Enums\MessagePartChannelEnum; use WordPress\AiClient\Messages\Enums\MessageRoleEnum; use WordPress\AiClient\Messages\Enums\ModalityEnum; @@ -85,14 +84,9 @@ protected function prepareGenerateTextParams(array $prompt): array { $config = $this->getConfig(); - $systemInstruction = $config->getSystemInstruction(); - if ($systemInstruction) { - $prompt = $this->mergeSystemInstruction($prompt, $systemInstruction); - } - $params = [ 'model' => $this->metadata()->getId(), - 'messages' => $this->prepareMessagesParam($prompt), + 'messages' => $this->prepareMessagesParam($prompt, $config->getSystemInstruction()), ]; $outputModalities = $config->getOutputModalities(); @@ -179,43 +173,18 @@ protected function prepareGenerateTextParams(array $prompt): array return $params; } - /** - * Merges the system instruction into the prompt, ensuring that it is the first message. - * - * @since n.e.x.t - * - * @param list $prompt The prompt to merge the system instruction into. - * @param string $systemInstruction The system instruction to merge. - * @return list The updated prompt with the system instruction as the first message. - * @throws InvalidArgumentException If the first message in the prompt is already a system message. - */ - protected function mergeSystemInstruction(array $prompt, string $systemInstruction): array - { - // If the first message is a system message, throw an exception due to a conflict. - if (isset($prompt[0]) && $prompt[0]->getRole() === MessageRoleEnum::system()) { - throw new InvalidArgumentException( - 'The first message in the prompt cannot be a system message when using a system instruction.' - ); - } - - $systemMessage = new SystemMessage([ - new MessagePart($systemInstruction), - ]); - array_unshift($prompt, $systemMessage); - return $prompt; - } - /** * Prepares the messages parameter for the API request. * * @since n.e.x.t * * @param list $messages The messages to prepare. + * @param string|null $systemInstruction An optional system instruction to prepend to the messages. * @return list> The prepared messages parameter. */ - protected function prepareMessagesParam(array $messages): array + protected function prepareMessagesParam(array $messages, ?string $systemInstruction = null): array { - return array_map( + $messagesParam = array_map( function (Message $message): array { // Special case: Function response. $messageParts = $message->getParts(); @@ -251,6 +220,23 @@ function (MessagePart $part): ?array { }, $messages ); + + if ($systemInstruction) { + array_unshift( + $messagesParam, + [ + 'role' => 'system', + 'content' => [ + [ + 'type' => 'text', + 'text' => $systemInstruction, + ], + ], + ] + ); + } + + return $messagesParam; } /** @@ -266,9 +252,6 @@ protected function getMessageRoleString(MessageRoleEnum $role): string if ($role === MessageRoleEnum::model()) { return 'assistant'; } - if ($role === MessageRoleEnum::system()) { - return 'system'; - } return 'user'; } diff --git a/tests/unit/Messages/Util/MessageUtilTest.php b/tests/unit/Messages/Util/MessageUtilTest.php index 6397cc2a..29f93cfe 100644 --- a/tests/unit/Messages/Util/MessageUtilTest.php +++ b/tests/unit/Messages/Util/MessageUtilTest.php @@ -167,13 +167,13 @@ public function testParseMessagesFromInputWithSingleMessageInput(): void public function testParseMessagesFromInputWithSingleMessageArrayInput(): void { $input = [ - 'role' => 'system', - 'parts' => [['text' => 'System prompt']], + 'role' => 'user', + 'parts' => [['text' => 'Test prompt']], ]; $result = MessageUtil::parseMessagesFromInput($input); $this->assertCount(1, $result); $this->assertInstanceOf(Message::class, $result[0]); - $this->assertEquals(MessageRoleEnum::system(), $result[0]->getRole()); - $this->assertEquals('System prompt', $result[0]->getParts()[0]->getText()); + $this->assertEquals(MessageRoleEnum::user(), $result[0]->getRole()); + $this->assertEquals('Test prompt', $result[0]->getParts()[0]->getText()); } } diff --git a/tests/unit/Providers/Models/AbstractOpenAiCompatibleTextGenerationModelTest.php b/tests/unit/Providers/Models/AbstractOpenAiCompatibleTextGenerationModelTest.php index 6567ed4c..5ba64da1 100644 --- a/tests/unit/Providers/Models/AbstractOpenAiCompatibleTextGenerationModelTest.php +++ b/tests/unit/Providers/Models/AbstractOpenAiCompatibleTextGenerationModelTest.php @@ -10,7 +10,6 @@ use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; -use WordPress\AiClient\Messages\DTO\SystemMessage; use WordPress\AiClient\Messages\Enums\MessagePartChannelEnum; use WordPress\AiClient\Messages\Enums\MessageRoleEnum; use WordPress\AiClient\Messages\Enums\ModalityEnum; @@ -472,47 +471,6 @@ public function testPrepareGenerateTextParamsWithConflictingCustomOption(): void $model->exposePrepareGenerateTextParams($prompt); } - /** - * Tests mergeSystemInstruction() method. - * - * @return void - */ - public function testMergeSystemInstruction(): void - { - $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('User message')])]; - $systemInstruction = 'You are a helpful assistant.'; - $model = $this->createModel(); - - $updatedPrompt = $model->exposeMergeSystemInstruction($prompt, $systemInstruction); - - $this->assertCount(2, $updatedPrompt); - $this->assertInstanceOf(SystemMessage::class, $updatedPrompt[0]); - $this->assertEquals($systemInstruction, $updatedPrompt[0]->getParts()[0]->getText()); - $this->assertEquals(MessageRoleEnum::user(), $updatedPrompt[1]->getRole()); - } - - /** - * Tests mergeSystemInstruction() method with existing system message. - * - * @return void - */ - public function testMergeSystemInstructionWithExistingSystemMessage(): void - { - $prompt = [ - new SystemMessage([new MessagePart('Existing system message')]), - new Message(MessageRoleEnum::user(), [new MessagePart('User message')]), - ]; - $systemInstruction = 'You are a helpful assistant.'; - $model = $this->createModel(); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage( - 'The first message in the prompt cannot be a system message when using a system instruction.' - ); - - $model->exposeMergeSystemInstruction($prompt, $systemInstruction); - } - /** * Tests prepareMessagesParam() with text message. * @@ -610,7 +568,6 @@ public function messageRoleProvider(): array return [ 'user' => [MessageRoleEnum::user(), 'user'], 'model' => [MessageRoleEnum::model(), 'assistant'], - 'system' => [MessageRoleEnum::system(), 'system'], ]; } From 45a8d50bc37e64ae0835db52e47fae4715c86a10 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Sun, 24 Aug 2025 19:51:23 -0700 Subject: [PATCH 39/77] Fix data formatting bug for OpenAI compatible APIs. --- .../AbstractOpenAiCompatibleTextGenerationModel.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php index 02e3ad85..c9b0744f 100644 --- a/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php @@ -204,18 +204,18 @@ function (Message $message): array { } return [ 'role' => $this->getMessageRoleString($message->getRole()), - 'content' => array_filter(array_map( + 'content' => array_values(array_filter(array_map( function (MessagePart $part): ?array { return $this->getMessagePartContentData($part); }, $messageParts - )), - 'tool_calls' => array_filter(array_map( + ))), + 'tool_calls' => array_values(array_filter(array_map( function (MessagePart $part): ?array { return $this->getMessagePartToolCallData($part); }, $messageParts - )), + ))), ]; }, $messages From ecc3a5385f3b27e43ea93dca9481c5b42ff0c2bf Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Tue, 26 Aug 2025 15:06:53 -0700 Subject: [PATCH 40/77] Add missing value to OptionEnum. --- src/Providers/Models/Enums/OptionEnum.php | 2 ++ tests/unit/Providers/Models/Enums/OptionEnumTest.php | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Providers/Models/Enums/OptionEnum.php b/src/Providers/Models/Enums/OptionEnum.php index 508cc67d..7c28f593 100644 --- a/src/Providers/Models/Enums/OptionEnum.php +++ b/src/Providers/Models/Enums/OptionEnum.php @@ -31,6 +31,7 @@ * @method static self outputMimeType() Creates an instance for OUTPUT_MIME_TYPE option. * @method static self outputModalities() Creates an instance for OUTPUT_MODALITIES option. * @method static self outputSchema() Creates an instance for OUTPUT_SCHEMA option. + * @method static self outputSpeechVoice() Creates an instance for OUTPUT_SPEECH_VOICE option. * @method static self presencePenalty() Creates an instance for PRESENCE_PENALTY option. * @method static self stopSequences() Creates an instance for STOP_SEQUENCES option. * @method static self systemInstruction() Creates an instance for SYSTEM_INSTRUCTION option. @@ -51,6 +52,7 @@ * @method bool isOutputMimeType() Checks if the option is OUTPUT_MIME_TYPE. * @method bool isOutputModalities() Checks if the option is OUTPUT_MODALITIES. * @method bool isOutputSchema() Checks if the option is OUTPUT_SCHEMA. + * @method bool isOutputSpeechVoice() Checks if the option is OUTPUT_SPEECH_VOICE. * @method bool isPresencePenalty() Checks if the option is PRESENCE_PENALTY. * @method bool isStopSequences() Checks if the option is STOP_SEQUENCES. * @method bool isSystemInstruction() Checks if the option is SYSTEM_INSTRUCTION. diff --git a/tests/unit/Providers/Models/Enums/OptionEnumTest.php b/tests/unit/Providers/Models/Enums/OptionEnumTest.php index dd82b746..9248a9ad 100644 --- a/tests/unit/Providers/Models/Enums/OptionEnumTest.php +++ b/tests/unit/Providers/Models/Enums/OptionEnumTest.php @@ -34,7 +34,7 @@ protected function getExpectedValues(): array { return [ // Explicitly defined constant (not in ModelConfig) - 'INPUT_MODALITIES' => 'input_modalities', + 'INPUT_MODALITIES' => 'inputModalities', // Dynamically added from ModelConfig KEY_* constants 'OUTPUT_MODALITIES' => 'outputModalities', @@ -56,6 +56,7 @@ protected function getExpectedValues(): array 'OUTPUT_SCHEMA' => 'outputSchema', 'OUTPUT_MEDIA_ORIENTATION' => 'outputMediaOrientation', 'OUTPUT_MEDIA_ASPECT_RATIO' => 'outputMediaAspectRatio', + 'OUTPUT_SPEECH_VOICE' => 'outputSpeechVoice', 'CUSTOM_OPTIONS' => 'customOptions', ]; } From 21691723369052252e66e055a43c08a3d33fcf47 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Tue, 26 Aug 2025 15:07:38 -0700 Subject: [PATCH 41/77] Fix failing test for SupportedOption. --- tests/unit/Providers/Models/DTO/SupportedOptionTest.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/unit/Providers/Models/DTO/SupportedOptionTest.php b/tests/unit/Providers/Models/DTO/SupportedOptionTest.php index a64958f2..a66a6ed8 100644 --- a/tests/unit/Providers/Models/DTO/SupportedOptionTest.php +++ b/tests/unit/Providers/Models/DTO/SupportedOptionTest.php @@ -147,7 +147,11 @@ public function testWithObjectValues(): void */ public function testIsSupportedValueWithUnorderedArray(): void { - $option = new SupportedOption('colors', [['red', 'green', 'blue'], ['yellow', 'orange']]); + // Just use any option enum value for the name. + $option = new SupportedOption( + OptionEnum::outputSpeechVoice(), + [['red', 'green', 'blue'], ['yellow', 'orange']] + ); // Test with an array that has the same elements but in a different order $this->assertTrue($option->isSupportedValue(['blue', 'red', 'green'])); From 9b4f09b341ce4d687cd51951aeb35a0ef43b9d2f Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Tue, 26 Aug 2025 15:08:04 -0700 Subject: [PATCH 42/77] Allow passing ModelConfig to PromptBuilder via constructor. --- src/Builders/PromptBuilder.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Builders/PromptBuilder.php b/src/Builders/PromptBuilder.php index 83721788..c8a7dc56 100644 --- a/src/Builders/PromptBuilder.php +++ b/src/Builders/PromptBuilder.php @@ -69,14 +69,14 @@ class PromptBuilder * @since n.e.x.t * * @param ProviderRegistry $registry The provider registry for finding suitable models. - * @param Prompt $prompt - * Optional initial prompt content. + * @param Prompt $prompt Optional initial prompt content. + * @param ModelConfig|null $modelConfig Optional initial model configuration. */ // phpcs:enable Generic.Files.LineLength.TooLong - public function __construct(ProviderRegistry $registry, $prompt = null) + public function __construct(ProviderRegistry $registry, $prompt = null, ?ModelConfig $modelConfig = null) { $this->registry = $registry; - $this->modelConfig = new ModelConfig(); + $this->modelConfig = $modelConfig ?? new ModelConfig(); if ($prompt === null) { return; From f5fbe896348f3f3d3f56f17324c080c9ee40b0ff Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Tue, 26 Aug 2025 15:08:41 -0700 Subject: [PATCH 43/77] Update PromptBuilder tests to include ProviderMetadata in mock model classes. --- tests/unit/Builders/PromptBuilderTest.php | 160 +++++++++++++++++++--- 1 file changed, 140 insertions(+), 20 deletions(-) diff --git a/tests/unit/Builders/PromptBuilderTest.php b/tests/unit/Builders/PromptBuilderTest.php index 0740c2ac..e1996089 100644 --- a/tests/unit/Builders/PromptBuilderTest.php +++ b/tests/unit/Builders/PromptBuilderTest.php @@ -16,6 +16,8 @@ use WordPress\AiClient\Messages\DTO\UserMessage; use WordPress\AiClient\Messages\Enums\MessageRoleEnum; use WordPress\AiClient\Messages\Enums\ModalityEnum; +use WordPress\AiClient\Providers\DTO\ProviderMetadata; +use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; @@ -47,16 +49,33 @@ class PromptBuilderTest extends TestCase * @param GenerativeAiResult $result The result to return from generation. * @return ModelInterface&TextGenerationModelInterface The mock model. */ - private function createTextGenerationModel(ModelMetadata $metadata, GenerativeAiResult $result): ModelInterface - { - return new class ($metadata, $result) implements ModelInterface, TextGenerationModelInterface { + private function createTextGenerationModel( + ModelMetadata $metadata, + GenerativeAiResult $result + ): ModelInterface { + $providerMetadata = new ProviderMetadata( + 'mock-provider', + 'Mock Provider', + ProviderTypeEnum::cloud() + ); + + return new class ( + $metadata, + $providerMetadata, + $result + ) implements ModelInterface, TextGenerationModelInterface { private ModelMetadata $metadata; + private ProviderMetadata $providerMetadata; private GenerativeAiResult $result; private ModelConfig $config; - public function __construct(ModelMetadata $metadata, GenerativeAiResult $result) - { + public function __construct( + ModelMetadata $metadata, + ProviderMetadata $providerMetadata, + GenerativeAiResult $result + ) { $this->metadata = $metadata; + $this->providerMetadata = $providerMetadata; $this->result = $result; $this->config = new ModelConfig(); } @@ -66,6 +85,11 @@ public function metadata(): ModelMetadata return $this->metadata; } + public function providerMetadata(): ProviderMetadata + { + return $this->providerMetadata; + } + public function setConfig(ModelConfig $config): void { $this->config = $config; @@ -97,14 +121,29 @@ public function streamGenerateTextResult(array $prompt): Generator */ private function createImageGenerationModel(ModelMetadata $metadata, GenerativeAiResult $result): ModelInterface { - return new class ($metadata, $result) implements ModelInterface, ImageGenerationModelInterface { + $providerMetadata = new ProviderMetadata( + 'mock-provider', + 'Mock Provider', + ProviderTypeEnum::cloud() + ); + + return new class ( + $metadata, + $providerMetadata, + $result + ) implements ModelInterface, ImageGenerationModelInterface { private ModelMetadata $metadata; + private ProviderMetadata $providerMetadata; private GenerativeAiResult $result; private ModelConfig $config; - public function __construct(ModelMetadata $metadata, GenerativeAiResult $result) - { + public function __construct( + ModelMetadata $metadata, + ProviderMetadata $providerMetadata, + GenerativeAiResult $result + ) { $this->metadata = $metadata; + $this->providerMetadata = $providerMetadata; $this->result = $result; $this->config = new ModelConfig(); } @@ -114,6 +153,11 @@ public function metadata(): ModelMetadata return $this->metadata; } + public function providerMetadata(): ProviderMetadata + { + return $this->providerMetadata; + } + public function setConfig(ModelConfig $config): void { $this->config = $config; @@ -140,14 +184,29 @@ public function generateImageResult(array $prompt): GenerativeAiResult */ private function createSpeechGenerationModel(ModelMetadata $metadata, GenerativeAiResult $result): ModelInterface { - return new class ($metadata, $result) implements ModelInterface, SpeechGenerationModelInterface { + $providerMetadata = new ProviderMetadata( + 'mock-provider', + 'Mock Provider', + ProviderTypeEnum::cloud() + ); + + return new class ( + $metadata, + $providerMetadata, + $result + ) implements ModelInterface, SpeechGenerationModelInterface { private ModelMetadata $metadata; + private ProviderMetadata $providerMetadata; private GenerativeAiResult $result; private ModelConfig $config; - public function __construct(ModelMetadata $metadata, GenerativeAiResult $result) - { + public function __construct( + ModelMetadata $metadata, + ProviderMetadata $providerMetadata, + GenerativeAiResult $result + ) { $this->metadata = $metadata; + $this->providerMetadata = $providerMetadata; $this->result = $result; $this->config = new ModelConfig(); } @@ -157,6 +216,11 @@ public function metadata(): ModelMetadata return $this->metadata; } + public function providerMetadata(): ProviderMetadata + { + return $this->providerMetadata; + } + public function setConfig(ModelConfig $config): void { $this->config = $config; @@ -183,14 +247,29 @@ public function generateSpeechResult(array $prompt): GenerativeAiResult */ private function createTextToSpeechModel(ModelMetadata $metadata, GenerativeAiResult $result): ModelInterface { - return new class ($metadata, $result) implements ModelInterface, TextToSpeechConversionModelInterface { + $providerMetadata = new ProviderMetadata( + 'mock-provider', + 'Mock Provider', + ProviderTypeEnum::cloud() + ); + + return new class ( + $metadata, + $providerMetadata, + $result + ) implements ModelInterface, TextToSpeechConversionModelInterface { private ModelMetadata $metadata; + private ProviderMetadata $providerMetadata; private GenerativeAiResult $result; private ModelConfig $config; - public function __construct(ModelMetadata $metadata, GenerativeAiResult $result) - { + public function __construct( + ModelMetadata $metadata, + ProviderMetadata $providerMetadata, + GenerativeAiResult $result + ) { $this->metadata = $metadata; + $this->providerMetadata = $providerMetadata; $this->result = $result; $this->config = new ModelConfig(); } @@ -200,6 +279,11 @@ public function metadata(): ModelMetadata return $this->metadata; } + public function providerMetadata(): ProviderMetadata + { + return $this->providerMetadata; + } + public function setConfig(ModelConfig $config): void { $this->config = $config; @@ -1416,13 +1500,26 @@ public function testGenerateTextThrowsExceptionWhenNoCandidates(): void $metadata->method('getId')->willReturn('test-model'); $metadata->method('meetsRequirements')->willReturn(true); - $model = new class ($metadata) implements ModelInterface, TextGenerationModelInterface { + $providerMetadata = new ProviderMetadata( + 'mock-provider', + 'Mock Provider', + ProviderTypeEnum::cloud() + ); + + $model = new class ( + $metadata, + $providerMetadata + ) implements ModelInterface, TextGenerationModelInterface { private ModelMetadata $metadata; + private ProviderMetadata $providerMetadata; private ModelConfig $config; - public function __construct(ModelMetadata $metadata) - { + public function __construct( + ModelMetadata $metadata, + ProviderMetadata $providerMetadata + ) { $this->metadata = $metadata; + $this->providerMetadata = $providerMetadata; $this->config = new ModelConfig(); } @@ -1431,6 +1528,11 @@ public function metadata(): ModelMetadata return $this->metadata; } + public function providerMetadata(): ProviderMetadata + { + return $this->providerMetadata; + } + public function setConfig(ModelConfig $config): void { $this->config = $config; @@ -1578,13 +1680,26 @@ public function testGenerateTextsThrowsExceptionWhenNoTextGenerated(): void $metadata->method('getId')->willReturn('test-model'); $metadata->method('meetsRequirements')->willReturn(true); - $model = new class ($metadata) implements ModelInterface, TextGenerationModelInterface { + $providerMetadata = new ProviderMetadata( + 'mock-provider', + 'Mock Provider', + ProviderTypeEnum::cloud() + ); + + $model = new class ( + $metadata, + $providerMetadata + ) implements ModelInterface, TextGenerationModelInterface { private ModelMetadata $metadata; + private ProviderMetadata $providerMetadata; private ModelConfig $config; - public function __construct(ModelMetadata $metadata) - { + public function __construct( + ModelMetadata $metadata, + ProviderMetadata $providerMetadata + ) { $this->metadata = $metadata; + $this->providerMetadata = $providerMetadata; $this->config = new ModelConfig(); } @@ -1593,6 +1708,11 @@ public function metadata(): ModelMetadata return $this->metadata; } + public function providerMetadata(): ProviderMetadata + { + return $this->providerMetadata; + } + public function setConfig(ModelConfig $config): void { $this->config = $config; From 4c58f96c749483fe8f0603b307c44090f1798c3f Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Tue, 26 Aug 2025 15:09:28 -0700 Subject: [PATCH 44/77] Fix provider model metadata to use OptionEnum values instead of ModelConfig keys. --- .../AnthropicModelMetadataDirectory.php | 34 +++++------ .../Google/GoogleModelMetadataDirectory.php | 46 +++++++-------- .../OpenAi/OpenAiModelMetadataDirectory.php | 58 +++++++++---------- 3 files changed, 69 insertions(+), 69 deletions(-) diff --git a/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php b/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php index be9d5607..d3ddf1fc 100644 --- a/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php +++ b/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php @@ -12,10 +12,10 @@ use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; -use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; use WordPress\AiClient\Providers\Models\DTO\SupportedOption; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; +use WordPress\AiClient\Providers\Models\Enums\OptionEnum; /** * Class for the Anthropic model metadata directory. @@ -71,22 +71,22 @@ protected function parseResponseToModelMetadataList(Response $response): array CapabilityEnum::chatHistory(), ]; $anthropicOptions = [ - new SupportedOption(ModelConfig::KEY_SYSTEM_INSTRUCTION), - new SupportedOption(ModelConfig::KEY_CANDIDATE_COUNT), - new SupportedOption(ModelConfig::KEY_MAX_TOKENS), - new SupportedOption(ModelConfig::KEY_TEMPERATURE), - new SupportedOption(ModelConfig::KEY_TOP_P), - new SupportedOption(ModelConfig::KEY_STOP_SEQUENCES), - new SupportedOption(ModelConfig::KEY_PRESENCE_PENALTY), - new SupportedOption(ModelConfig::KEY_FREQUENCY_PENALTY), - new SupportedOption(ModelConfig::KEY_LOGPROBS), - new SupportedOption(ModelConfig::KEY_TOP_LOGPROBS), - new SupportedOption(ModelConfig::KEY_OUTPUT_MIME_TYPE, ['text/plain', 'application/json']), - new SupportedOption(ModelConfig::KEY_OUTPUT_SCHEMA), - new SupportedOption(ModelConfig::KEY_FUNCTION_DECLARATIONS), - new SupportedOption(ModelConfig::KEY_CUSTOM_OPTIONS), + new SupportedOption(OptionEnum::systemInstruction()), + new SupportedOption(OptionEnum::candidateCount()), + new SupportedOption(OptionEnum::maxTokens()), + new SupportedOption(OptionEnum::temperature()), + new SupportedOption(OptionEnum::topP()), + new SupportedOption(OptionEnum::stopSequences()), + new SupportedOption(OptionEnum::presencePenalty()), + new SupportedOption(OptionEnum::frequencyPenalty()), + new SupportedOption(OptionEnum::logprobs()), + new SupportedOption(OptionEnum::topLogprobs()), + new SupportedOption(OptionEnum::outputMimeType(), ['text/plain', 'application/json']), + new SupportedOption(OptionEnum::outputSchema()), + new SupportedOption(OptionEnum::functionDeclarations()), + new SupportedOption(OptionEnum::customOptions()), new SupportedOption( - ModelConfig::KEY_INPUT_MODALITIES, + OptionEnum::inputModalities(), [ [ModalityEnum::text()], [ModalityEnum::text(), ModalityEnum::image()], @@ -94,7 +94,7 @@ protected function parseResponseToModelMetadataList(Response $response): array ), ]; $anthropicWebSearchOptions = $anthropicOptions + [ - new SupportedOption(ModelConfig::KEY_WEB_SEARCH), + new SupportedOption(OptionEnum::webSearch()), ]; /** @var array> $modelsData */ diff --git a/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php b/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php index 00897480..0862443e 100644 --- a/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php +++ b/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php @@ -14,10 +14,10 @@ use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; -use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; use WordPress\AiClient\Providers\Models\DTO\SupportedOption; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; +use WordPress\AiClient\Providers\Models\Enums\OptionEnum; /** * Class for the Google model metadata directory. @@ -81,24 +81,24 @@ protected function parseResponseToModelMetadataList(Response $response): array CapabilityEnum::chatHistory(), ]; $geminiLegacyOptions = [ - new SupportedOption(ModelConfig::KEY_SYSTEM_INSTRUCTION), - new SupportedOption(ModelConfig::KEY_CANDIDATE_COUNT), - new SupportedOption(ModelConfig::KEY_MAX_TOKENS), - new SupportedOption(ModelConfig::KEY_TEMPERATURE), - new SupportedOption(ModelConfig::KEY_TOP_P), - new SupportedOption(ModelConfig::KEY_STOP_SEQUENCES), - new SupportedOption(ModelConfig::KEY_PRESENCE_PENALTY), - new SupportedOption(ModelConfig::KEY_FREQUENCY_PENALTY), - new SupportedOption(ModelConfig::KEY_LOGPROBS), - new SupportedOption(ModelConfig::KEY_TOP_LOGPROBS), - new SupportedOption(ModelConfig::KEY_OUTPUT_MIME_TYPE, ['text/plain', 'application/json']), - new SupportedOption(ModelConfig::KEY_OUTPUT_SCHEMA), - new SupportedOption(ModelConfig::KEY_FUNCTION_DECLARATIONS), - new SupportedOption(ModelConfig::KEY_CUSTOM_OPTIONS), + new SupportedOption(OptionEnum::systemInstruction()), + new SupportedOption(OptionEnum::candidateCount()), + new SupportedOption(OptionEnum::maxTokens()), + new SupportedOption(OptionEnum::temperature()), + new SupportedOption(OptionEnum::topP()), + new SupportedOption(OptionEnum::stopSequences()), + new SupportedOption(OptionEnum::presencePenalty()), + new SupportedOption(OptionEnum::frequencyPenalty()), + new SupportedOption(OptionEnum::logprobs()), + new SupportedOption(OptionEnum::topLogprobs()), + new SupportedOption(OptionEnum::outputMimeType(), ['text/plain', 'application/json']), + new SupportedOption(OptionEnum::outputSchema()), + new SupportedOption(OptionEnum::functionDeclarations()), + new SupportedOption(OptionEnum::customOptions()), ]; $geminiOptions = $geminiLegacyOptions + [ new SupportedOption( - ModelConfig::KEY_INPUT_MODALITIES, + OptionEnum::inputModalities(), [ [ModalityEnum::text()], [ModalityEnum::text(), ModalityEnum::image()], @@ -107,11 +107,11 @@ protected function parseResponseToModelMetadataList(Response $response): array ), ]; $geminiWebSearchOptions = $geminiOptions + [ - new SupportedOption(ModelConfig::KEY_WEB_SEARCH), + new SupportedOption(OptionEnum::webSearch()), ]; $geminiMultimodalImageOutputOptions = $geminiOptions + [ new SupportedOption( - ModelConfig::KEY_OUTPUT_MODALITIES, + OptionEnum::outputModalities(), [ [ModalityEnum::text()], [ModalityEnum::text(), ModalityEnum::image()], @@ -122,15 +122,15 @@ protected function parseResponseToModelMetadataList(Response $response): array CapabilityEnum::imageGeneration(), ]; $imagenOptions = [ - new SupportedOption(ModelConfig::KEY_CANDIDATE_COUNT), - new SupportedOption(ModelConfig::KEY_OUTPUT_MIME_TYPE, ['image/png', 'image/jpeg', 'image/webp']), - new SupportedOption(ModelConfig::KEY_OUTPUT_FILE_TYPE, [FileTypeEnum::inline()]), - new SupportedOption(ModelConfig::KEY_OUTPUT_MEDIA_ORIENTATION, [ + new SupportedOption(OptionEnum::candidateCount()), + new SupportedOption(OptionEnum::outputMimeType(), ['image/png', 'image/jpeg', 'image/webp']), + new SupportedOption(OptionEnum::outputFileType(), [FileTypeEnum::inline()]), + new SupportedOption(OptionEnum::outputMediaOrientation(), [ MediaOrientationEnum::square(), MediaOrientationEnum::landscape(), MediaOrientationEnum::portrait(), ]), - new SupportedOption(ModelConfig::KEY_OUTPUT_MEDIA_ASPECT_RATIO, ['1:1', '16:9', '4:3', '9:16', '3:4']), + new SupportedOption(OptionEnum::outputMediaAspectRatio(), ['1:1', '16:9', '4:3', '9:16', '3:4']), ]; /** @var array> $modelsData */ diff --git a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php index 176e5b41..12febf92 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php +++ b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php @@ -12,10 +12,10 @@ use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; -use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; use WordPress\AiClient\Providers\Models\DTO\SupportedOption; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; +use WordPress\AiClient\Providers\Models\Enums\OptionEnum; /** * Class for the OpenAI model metadata directory. @@ -55,24 +55,24 @@ protected function parseResponseToModelMetadataList(Response $response): array CapabilityEnum::chatHistory(), ]; $gptOptions = [ - new SupportedOption(ModelConfig::KEY_SYSTEM_INSTRUCTION), - new SupportedOption(ModelConfig::KEY_CANDIDATE_COUNT), - new SupportedOption(ModelConfig::KEY_MAX_TOKENS), - new SupportedOption(ModelConfig::KEY_TEMPERATURE), - new SupportedOption(ModelConfig::KEY_TOP_P), - new SupportedOption(ModelConfig::KEY_STOP_SEQUENCES), - new SupportedOption(ModelConfig::KEY_PRESENCE_PENALTY), - new SupportedOption(ModelConfig::KEY_FREQUENCY_PENALTY), - new SupportedOption(ModelConfig::KEY_LOGPROBS), - new SupportedOption(ModelConfig::KEY_TOP_LOGPROBS), - new SupportedOption(ModelConfig::KEY_OUTPUT_MIME_TYPE, ['text/plain', 'application/json']), - new SupportedOption(ModelConfig::KEY_OUTPUT_SCHEMA), - new SupportedOption(ModelConfig::KEY_FUNCTION_DECLARATIONS), - new SupportedOption(ModelConfig::KEY_CUSTOM_OPTIONS), + new SupportedOption(OptionEnum::systemInstruction()), + new SupportedOption(OptionEnum::candidateCount()), + new SupportedOption(OptionEnum::maxTokens()), + new SupportedOption(OptionEnum::temperature()), + new SupportedOption(OptionEnum::topP()), + new SupportedOption(OptionEnum::stopSequences()), + new SupportedOption(OptionEnum::presencePenalty()), + new SupportedOption(OptionEnum::frequencyPenalty()), + new SupportedOption(OptionEnum::logprobs()), + new SupportedOption(OptionEnum::topLogprobs()), + new SupportedOption(OptionEnum::outputMimeType(), ['text/plain', 'application/json']), + new SupportedOption(OptionEnum::outputSchema()), + new SupportedOption(OptionEnum::functionDeclarations()), + new SupportedOption(OptionEnum::customOptions()), ]; $gptMultimodalInputOptions = $gptOptions + [ new SupportedOption( - ModelConfig::KEY_INPUT_MODALITIES, + OptionEnum::inputModalities(), [ [ModalityEnum::text()], [ModalityEnum::text(), ModalityEnum::image()], @@ -82,7 +82,7 @@ protected function parseResponseToModelMetadataList(Response $response): array ]; $gptMultimodalSpeechOutputOptions = $gptMultimodalInputOptions + [ new SupportedOption( - ModelConfig::KEY_OUTPUT_MODALITIES, + OptionEnum::outputModalities(), [ [ModalityEnum::text()], [ModalityEnum::text(), ModalityEnum::audio()], @@ -93,33 +93,33 @@ protected function parseResponseToModelMetadataList(Response $response): array CapabilityEnum::imageGeneration(), ]; $dalleImageOptions = [ - new SupportedOption(ModelConfig::KEY_CANDIDATE_COUNT), - new SupportedOption(ModelConfig::KEY_OUTPUT_MIME_TYPE, ['image/png']), - new SupportedOption(ModelConfig::KEY_OUTPUT_FILE_TYPE, [FileTypeEnum::inline(), FileTypeEnum::remote()]), - new SupportedOption(ModelConfig::KEY_OUTPUT_MEDIA_ORIENTATION, [ + new SupportedOption(OptionEnum::candidateCount()), + new SupportedOption(OptionEnum::outputMimeType(), ['image/png']), + new SupportedOption(OptionEnum::outputFileType(), [FileTypeEnum::inline(), FileTypeEnum::remote()]), + new SupportedOption(OptionEnum::outputMediaOrientation(), [ MediaOrientationEnum::square(), MediaOrientationEnum::landscape(), MediaOrientationEnum::portrait(), ]), - new SupportedOption(ModelConfig::KEY_OUTPUT_MEDIA_ASPECT_RATIO, ['1:1', '7:4', '4:7']), + new SupportedOption(OptionEnum::outputMediaAspectRatio(), ['1:1', '7:4', '4:7']), ]; $gptImageOptions = [ - new SupportedOption(ModelConfig::KEY_CANDIDATE_COUNT), - new SupportedOption(ModelConfig::KEY_OUTPUT_MIME_TYPE, ['image/png', 'image/jpeg', 'image/webp']), - new SupportedOption(ModelConfig::KEY_OUTPUT_FILE_TYPE, [FileTypeEnum::inline()]), - new SupportedOption(ModelConfig::KEY_OUTPUT_MEDIA_ORIENTATION, [ + new SupportedOption(OptionEnum::candidateCount()), + new SupportedOption(OptionEnum::outputMimeType(), ['image/png', 'image/jpeg', 'image/webp']), + new SupportedOption(OptionEnum::outputFileType(), [FileTypeEnum::inline()]), + new SupportedOption(OptionEnum::outputMediaOrientation(), [ MediaOrientationEnum::square(), MediaOrientationEnum::landscape(), MediaOrientationEnum::portrait(), ]), - new SupportedOption(ModelConfig::KEY_OUTPUT_MEDIA_ASPECT_RATIO, ['1:1', '3:2', '2:3']), + new SupportedOption(OptionEnum::outputMediaAspectRatio(), ['1:1', '3:2', '2:3']), ]; $ttsCapabilities = [ CapabilityEnum::textToSpeechConversion(), ]; $ttsOptions = [ - new SupportedOption(ModelConfig::KEY_OUTPUT_MIME_TYPE, ['audio/mpeg', 'audio/ogg', 'audio/wav']), - new SupportedOption(ModelConfig::KEY_OUTPUT_SPEECH_VOICE), + new SupportedOption(OptionEnum::outputMimeType(), ['audio/mpeg', 'audio/ogg', 'audio/wav']), + new SupportedOption(OptionEnum::outputSpeechVoice()), ]; /** @var array> $modelsData */ From 6322f96511f54e99ad4895a16b8a08719c39e0b6 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Tue, 26 Aug 2025 15:27:00 -0700 Subject: [PATCH 45/77] Fix enum base implementation to allow JSON encoding. --- src/Common/AbstractEnum.php | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/Common/AbstractEnum.php b/src/Common/AbstractEnum.php index 6b4a2cf4..587a77a4 100644 --- a/src/Common/AbstractEnum.php +++ b/src/Common/AbstractEnum.php @@ -6,6 +6,7 @@ use BadMethodCallException; use InvalidArgumentException; +use JsonSerializable; use ReflectionClass; use RuntimeException; @@ -36,7 +37,7 @@ * * @since n.e.x.t */ -abstract class AbstractEnum +abstract class AbstractEnum implements JsonSerializable { /** * @var string The value of the enum instance. @@ -393,4 +394,17 @@ final public function __toString(): string { return $this->value; } + + /** + * Converts the enum to a JSON-serializable format. + * + * @since n.e.x.t + * + * @return mixed The JSON-serializable representation. + */ + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return $this->value; + } } From 2a0e5f0e5d35c3379d7776059bd057ff9451286d Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Tue, 26 Aug 2025 15:52:19 -0700 Subject: [PATCH 46/77] Fix provider model metadata directory implementations to always express supported input and output modalities. --- .../AnthropicModelMetadataDirectory.php | 5 ++-- .../Google/GoogleModelMetadataDirectory.php | 29 ++++++++++++++----- .../OpenAi/OpenAiModelMetadataDirectory.php | 29 +++++++++++++++---- 3 files changed, 49 insertions(+), 14 deletions(-) diff --git a/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php b/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php index d3ddf1fc..5e6d058c 100644 --- a/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php +++ b/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php @@ -92,10 +92,11 @@ protected function parseResponseToModelMetadataList(Response $response): array [ModalityEnum::text(), ModalityEnum::image()], ] ), + new SupportedOption(OptionEnum::outputModalities(), [[ModalityEnum::text()]]), ]; - $anthropicWebSearchOptions = $anthropicOptions + [ + $anthropicWebSearchOptions = array_merge($anthropicOptions, [ new SupportedOption(OptionEnum::webSearch()), - ]; + ]); /** @var array> $modelsData */ $modelsData = (array) $responseData['data']; diff --git a/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php b/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php index 0862443e..d8ad8162 100644 --- a/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php +++ b/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php @@ -80,7 +80,7 @@ protected function parseResponseToModelMetadataList(Response $response): array CapabilityEnum::textGeneration(), CapabilityEnum::chatHistory(), ]; - $geminiLegacyOptions = [ + $geminiBaseOptions = [ new SupportedOption(OptionEnum::systemInstruction()), new SupportedOption(OptionEnum::candidateCount()), new SupportedOption(OptionEnum::maxTokens()), @@ -96,7 +96,11 @@ protected function parseResponseToModelMetadataList(Response $response): array new SupportedOption(OptionEnum::functionDeclarations()), new SupportedOption(OptionEnum::customOptions()), ]; - $geminiOptions = $geminiLegacyOptions + [ + $geminiLegacyOptions = array_merge($geminiBaseOptions, [ + new SupportedOption(OptionEnum::inputModalities(), [[ModalityEnum::text()]]), + new SupportedOption(OptionEnum::outputModalities(), [[ModalityEnum::text()]]), + ]); + $geminiOptions = array_merge($geminiBaseOptions, [ new SupportedOption( OptionEnum::inputModalities(), [ @@ -105,11 +109,20 @@ protected function parseResponseToModelMetadataList(Response $response): array [ModalityEnum::text(), ModalityEnum::image(), ModalityEnum::audio()], ] ), - ]; - $geminiWebSearchOptions = $geminiOptions + [ + new SupportedOption(OptionEnum::outputModalities(), [[ModalityEnum::text()]]), + ]); + $geminiWebSearchOptions = array_merge($geminiOptions, [ new SupportedOption(OptionEnum::webSearch()), - ]; - $geminiMultimodalImageOutputOptions = $geminiOptions + [ + ]); + $geminiMultimodalImageOutputOptions = array_merge($geminiBaseOptions, [ + new SupportedOption( + OptionEnum::inputModalities(), + [ + [ModalityEnum::text()], + [ModalityEnum::text(), ModalityEnum::image()], + [ModalityEnum::text(), ModalityEnum::image(), ModalityEnum::audio()], + ] + ), new SupportedOption( OptionEnum::outputModalities(), [ @@ -117,11 +130,13 @@ protected function parseResponseToModelMetadataList(Response $response): array [ModalityEnum::text(), ModalityEnum::image()], ] ), - ]; + ]); $imagenCapabilities = [ CapabilityEnum::imageGeneration(), ]; $imagenOptions = [ + new SupportedOption(OptionEnum::inputModalities(), [[ModalityEnum::text()]]), + new SupportedOption(OptionEnum::outputModalities(), [[ModalityEnum::image()]]), new SupportedOption(OptionEnum::candidateCount()), new SupportedOption(OptionEnum::outputMimeType(), ['image/png', 'image/jpeg', 'image/webp']), new SupportedOption(OptionEnum::outputFileType(), [FileTypeEnum::inline()]), diff --git a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php index 12febf92..edd70109 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php +++ b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php @@ -54,7 +54,7 @@ protected function parseResponseToModelMetadataList(Response $response): array CapabilityEnum::textGeneration(), CapabilityEnum::chatHistory(), ]; - $gptOptions = [ + $gptBaseOptions = [ new SupportedOption(OptionEnum::systemInstruction()), new SupportedOption(OptionEnum::candidateCount()), new SupportedOption(OptionEnum::maxTokens()), @@ -70,7 +70,22 @@ protected function parseResponseToModelMetadataList(Response $response): array new SupportedOption(OptionEnum::functionDeclarations()), new SupportedOption(OptionEnum::customOptions()), ]; - $gptMultimodalInputOptions = $gptOptions + [ + $gptOptions = array_merge($gptBaseOptions, [ + new SupportedOption(OptionEnum::inputModalities(), [[ModalityEnum::text()]]), + new SupportedOption(OptionEnum::outputModalities(), [[ModalityEnum::text()]]), + ]); + $gptMultimodalInputOptions = array_merge($gptBaseOptions, [ + new SupportedOption( + OptionEnum::inputModalities(), + [ + [ModalityEnum::text()], + [ModalityEnum::text(), ModalityEnum::image()], + [ModalityEnum::text(), ModalityEnum::image(), ModalityEnum::audio()], + ] + ), + new SupportedOption(OptionEnum::outputModalities(), [[ModalityEnum::text()]]), + ]); + $gptMultimodalSpeechOutputOptions = array_merge($gptBaseOptions, [ new SupportedOption( OptionEnum::inputModalities(), [ @@ -79,8 +94,6 @@ protected function parseResponseToModelMetadataList(Response $response): array [ModalityEnum::text(), ModalityEnum::image(), ModalityEnum::audio()], ] ), - ]; - $gptMultimodalSpeechOutputOptions = $gptMultimodalInputOptions + [ new SupportedOption( OptionEnum::outputModalities(), [ @@ -88,11 +101,13 @@ protected function parseResponseToModelMetadataList(Response $response): array [ModalityEnum::text(), ModalityEnum::audio()], ] ), - ]; + ]); $imageCapabilities = [ CapabilityEnum::imageGeneration(), ]; $dalleImageOptions = [ + new SupportedOption(OptionEnum::inputModalities(), [[ModalityEnum::text()]]), + new SupportedOption(OptionEnum::outputModalities(), [[ModalityEnum::image()]]), new SupportedOption(OptionEnum::candidateCount()), new SupportedOption(OptionEnum::outputMimeType(), ['image/png']), new SupportedOption(OptionEnum::outputFileType(), [FileTypeEnum::inline(), FileTypeEnum::remote()]), @@ -104,6 +119,8 @@ protected function parseResponseToModelMetadataList(Response $response): array new SupportedOption(OptionEnum::outputMediaAspectRatio(), ['1:1', '7:4', '4:7']), ]; $gptImageOptions = [ + new SupportedOption(OptionEnum::inputModalities(), [[ModalityEnum::text()]]), + new SupportedOption(OptionEnum::outputModalities(), [[ModalityEnum::image()]]), new SupportedOption(OptionEnum::candidateCount()), new SupportedOption(OptionEnum::outputMimeType(), ['image/png', 'image/jpeg', 'image/webp']), new SupportedOption(OptionEnum::outputFileType(), [FileTypeEnum::inline()]), @@ -118,6 +135,8 @@ protected function parseResponseToModelMetadataList(Response $response): array CapabilityEnum::textToSpeechConversion(), ]; $ttsOptions = [ + new SupportedOption(OptionEnum::inputModalities(), [[ModalityEnum::text()]]), + new SupportedOption(OptionEnum::outputModalities(), [[ModalityEnum::audio()]]), new SupportedOption(OptionEnum::outputMimeType(), ['audio/mpeg', 'audio/ogg', 'audio/wav']), new SupportedOption(OptionEnum::outputSpeechVoice()), ]; From f192b631e689836cba68600cbb3164cb81802ff5 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Tue, 26 Aug 2025 15:55:45 -0700 Subject: [PATCH 47/77] Update the CLI test tool to use PromptBuilder. --- cli.php | 59 +++++++++++++++------------------------------------------ 1 file changed, 15 insertions(+), 44 deletions(-) diff --git a/cli.php b/cli.php index 58f56bab..a84b17d4 100755 --- a/cli.php +++ b/cli.php @@ -13,6 +13,7 @@ declare(strict_types=1); +use WordPress\AiClient\Builders\PromptBuilder; use WordPress\AiClient\Messages\Util\MessageUtil; use WordPress\AiClient\ProviderImplementations\Anthropic\AnthropicProvider; use WordPress\AiClient\ProviderImplementations\Google\GoogleProvider; @@ -156,58 +157,28 @@ function logError(string $message, int $exit_code = 1): void // --- Main logic --- -$messages = MessageUtil::parseMessagesFromInput($promptInput); - -$modelConfig = ModelConfig::fromArray($model_config_data); - -$requiredOptions = []; -foreach ($modelConfig->toArray() as $option => $value) { - $requiredOptions[] = new RequiredOption($option, $value); -} -$modelRequirements = new ModelRequirements( - [ - CapabilityEnum::textGeneration(), - ], - $requiredOptions -); - try { - if (!$providerId && !$modelId) { - $providerModelsMetadata = $providerRegistry->findModelsMetadataForSupport($modelRequirements); - if (!isset($providerModelsMetadata[0])) { - logError('No provider model supports the necessary model requirements.'); - } - $providerId = $providerModelsMetadata[0]->getProvider()->getId(); - $modelId = $providerModelsMetadata[0]->getModels()[0]->getId(); - } elseif (!$modelId) { - $modelsMetadata = $providerRegistry->findProviderModelsMetadataForSupport($providerId, $modelRequirements); - if (!isset($modelsMetadata[0])) { - if (!$providerRegistry->isProviderConfigured($providerId)) { - logError('The provider "' . $providerId . '" is not configured.'); - } else { - logError('No "' . $providerId . '" model supports the necessary model requirements.'); - } - } - $modelId = $modelsMetadata[0]->getId(); + $modelConfig = ModelConfig::fromArray($model_config_data); + + $promptBuilder = new PromptBuilder($providerRegistry, $promptInput, $modelConfig); + if ($providerId && $modelId) { + $providerClassName = $providerRegistry->getProviderClassName($providerId); + $promptBuilder = $promptBuilder->usingModel($providerClassName::model($modelId)); + } elseif ($providerId) { + $promptBuilder = $promptBuilder->usingProvider($providerId); } - $modelInstance = $providerRegistry->getProviderModel($providerId, $modelId); } catch (InvalidArgumentException $e) { - logError('Invalid arguments while trying to set up model instance: ' . $e->getMessage()); + logError('Invalid arguments while trying to set up prompt builder: ' . $e->getMessage()); } catch (ResponseException $e) { - logError('Request failed while trying to set up model instance: ' . $e->getMessage()); -} - -logInfo("Using provider ID: \"{$modelInstance->providerMetadata()->getId()}\""); -logInfo("Using model ID: \"{$modelInstance->metadata()->getId()}\""); - -if (!($modelInstance instanceof TextGenerationModelInterface)) { - logError('The model class ' . get_class($modelInstance) . ' does not support text generation.'); + logError('Request failed while trying to set up prompt builder: ' . $e->getMessage()); } -$modelInstance->setConfig($modelConfig); +// TODO: Reinstate this once the generative AI result includes model and provider metadata. +//logInfo("Using provider ID: \"{$modelInstance->providerMetadata()->getId()}\""); +//logInfo("Using model ID: \"{$modelInstance->metadata()->getId()}\""); try { - $result = $modelInstance->generateTextResult($messages); + $result = $promptBuilder->generateTextResult(); } catch (InvalidArgumentException $e) { logError('Invalid arguments while trying to generate text result: ' . $e->getMessage()); } catch (ResponseException $e) { From 657d754ead13ede1706498c92ab053ad3bb2eb26 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Tue, 26 Aug 2025 16:02:03 -0700 Subject: [PATCH 48/77] Fix code after GenerativeAiResult update. --- .../AbstractOpenAiCompatibleTextGenerationModel.php | 8 +++++--- .../AbstractOpenAiCompatibleTextGenerationModelTest.php | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php index c9b0744f..7a44db8e 100644 --- a/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php @@ -555,14 +555,16 @@ protected function parseResponseToGenerativeAiResult(Response $response): Genera } // Use any other data from the response as provider metadata. - $providerMetadata = $responseData; - unset($providerMetadata['id'], $providerMetadata['choices'], $providerMetadata['usage']); + $additionalData = $responseData; + unset($additionalData['id'], $additionalData['choices'], $additionalData['usage']); return new GenerativeAiResult( $id, $candidates, $tokenUsage, - $providerMetadata + $this->providerMetadata(), + $this->metadata(), + $additionalData ); } diff --git a/tests/unit/Providers/Models/AbstractOpenAiCompatibleTextGenerationModelTest.php b/tests/unit/Providers/Models/AbstractOpenAiCompatibleTextGenerationModelTest.php index 5ba64da1..8225d329 100644 --- a/tests/unit/Providers/Models/AbstractOpenAiCompatibleTextGenerationModelTest.php +++ b/tests/unit/Providers/Models/AbstractOpenAiCompatibleTextGenerationModelTest.php @@ -936,7 +936,7 @@ public function testParseResponseToGenerativeAiResultValidResponse(): void $this->assertEquals(10, $result->getTokenUsage()->getPromptTokens()); $this->assertEquals(20, $result->getTokenUsage()->getCompletionTokens()); $this->assertEquals(30, $result->getTokenUsage()->getTotalTokens()); - $this->assertEquals(['model' => 'test-model'], $result->getProviderMetadata()); + $this->assertEquals(['model' => 'test-model'], $result->getAdditionalData()); } /** From e4bc2d7acf8f670cf5064ba8a53970b535f8bce4 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Tue, 26 Aug 2025 16:03:36 -0700 Subject: [PATCH 49/77] Reinstate CLI tool code to show provider and model used for the query. --- cli.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/cli.php b/cli.php index a84b17d4..2a45bbdd 100755 --- a/cli.php +++ b/cli.php @@ -173,10 +173,6 @@ function logError(string $message, int $exit_code = 1): void logError('Request failed while trying to set up prompt builder: ' . $e->getMessage()); } -// TODO: Reinstate this once the generative AI result includes model and provider metadata. -//logInfo("Using provider ID: \"{$modelInstance->providerMetadata()->getId()}\""); -//logInfo("Using model ID: \"{$modelInstance->metadata()->getId()}\""); - try { $result = $promptBuilder->generateTextResult(); } catch (InvalidArgumentException $e) { @@ -185,6 +181,9 @@ function logError(string $message, int $exit_code = 1): void logError('Request failed while trying to generate text result: ' . $e->getMessage()); } +logInfo("Using provider ID: \"{$result->getProviderMetadata()->getId()}\""); +logInfo("Using model ID: \"{$result->getModelMetadata()->getId()}\""); + switch ($outputFormat) { case 'result-json': $output = json_encode($result, JSON_PRETTY_PRINT); From 4499d456c2bf4f019b5c6a3522488c22320bab49 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Tue, 26 Aug 2025 16:45:19 -0700 Subject: [PATCH 50/77] Moved abstract classes and other provider/model implementations to their domain specific directories. --- .../Anthropic/AnthropicModelMetadataDirectory.php | 2 +- src/ProviderImplementations/Anthropic/AnthropicProvider.php | 2 +- .../Anthropic/AnthropicTextGenerationModel.php | 2 +- .../Google/GoogleModelMetadataDirectory.php | 2 +- src/ProviderImplementations/Google/GoogleProvider.php | 2 +- .../Google/GoogleTextGenerationModel.php | 2 +- .../OpenAi/OpenAiModelMetadataDirectory.php | 2 +- src/ProviderImplementations/OpenAi/OpenAiProvider.php | 2 +- .../OpenAi/OpenAiTextGenerationModel.php | 2 +- .../AbstractApiBasedModel.php | 2 +- .../AbstractApiBasedModelMetadataDirectory.php | 2 +- .../GenerateTextApiBasedProviderAvailability.php | 2 +- .../ListModelsApiBasedProviderAvailability.php | 2 +- .../AbstractOpenAiCompatibleModelMetadataDirectory.php | 3 ++- .../AbstractOpenAiCompatibleTextGenerationModel.php | 3 ++- .../AbstractApiBasedModelMetadataDirectoryTest.php | 2 +- .../AbstractApiBasedModelTest.php | 2 +- .../ListModelsApiBasedProviderAvailabilityTest.php | 4 ++-- .../{Models => ApiBasedImplementation}/MockApiBasedModel.php | 4 ++-- .../MockApiBasedModelMetadataDirectory.php | 4 ++-- .../AbstractOpenAiCompatibleModelMetadataDirectoryTest.php | 2 +- .../AbstractOpenAiCompatibleTextGenerationModelTest.php | 2 +- .../MockOpenAiCompatibleModelMetadataDirectory.php | 4 ++-- .../MockOpenAiCompatibleTextGenerationModel.php | 4 ++-- 24 files changed, 31 insertions(+), 29 deletions(-) rename src/Providers/{Models => ApiBasedImplementation}/AbstractApiBasedModel.php (97%) rename src/Providers/{ => ApiBasedImplementation}/AbstractApiBasedModelMetadataDirectory.php (97%) rename src/Providers/{ => ApiBasedImplementation}/GenerateTextApiBasedProviderAvailability.php (97%) rename src/Providers/{ => ApiBasedImplementation}/ListModelsApiBasedProviderAvailability.php (95%) rename src/Providers/{ => OpenAiCompatibleImplementation}/AbstractOpenAiCompatibleModelMetadataDirectory.php (94%) rename src/Providers/{Models => OpenAiCompatibleImplementation}/AbstractOpenAiCompatibleTextGenerationModel.php (99%) rename tests/unit/Providers/{ => ApiBasedImplementation}/AbstractApiBasedModelMetadataDirectoryTest.php (96%) rename tests/unit/Providers/{Models => ApiBasedImplementation}/AbstractApiBasedModelTest.php (97%) rename tests/unit/Providers/{ => ApiBasedImplementation}/ListModelsApiBasedProviderAvailabilityTest.php (90%) rename tests/unit/Providers/{Models => ApiBasedImplementation}/MockApiBasedModel.php (79%) rename tests/unit/Providers/{ => ApiBasedImplementation}/MockApiBasedModelMetadataDirectory.php (80%) rename tests/unit/Providers/{ => OpenAiCompatibleImplementation}/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php (97%) rename tests/unit/Providers/{Models => OpenAiCompatibleImplementation}/AbstractOpenAiCompatibleTextGenerationModelTest.php (99%) rename tests/unit/Providers/{ => OpenAiCompatibleImplementation}/MockOpenAiCompatibleModelMetadataDirectory.php (94%) rename tests/unit/Providers/{Models => OpenAiCompatibleImplementation}/MockOpenAiCompatibleTextGenerationModel.php (96%) diff --git a/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php b/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php index 5e6d058c..92e55448 100644 --- a/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php +++ b/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php @@ -6,7 +6,6 @@ use RuntimeException; use WordPress\AiClient\Messages\Enums\ModalityEnum; -use WordPress\AiClient\Providers\AbstractOpenAiCompatibleModelMetadataDirectory; use WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface; use WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication; use WordPress\AiClient\Providers\Http\DTO\Request; @@ -16,6 +15,7 @@ use WordPress\AiClient\Providers\Models\DTO\SupportedOption; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; use WordPress\AiClient\Providers\Models\Enums\OptionEnum; +use WordPress\AiClient\Providers\OpenAiCompatibleImplementation\AbstractOpenAiCompatibleModelMetadataDirectory; /** * Class for the Anthropic model metadata directory. diff --git a/src/ProviderImplementations/Anthropic/AnthropicProvider.php b/src/ProviderImplementations/Anthropic/AnthropicProvider.php index caf56a6e..61579cfb 100644 --- a/src/ProviderImplementations/Anthropic/AnthropicProvider.php +++ b/src/ProviderImplementations/Anthropic/AnthropicProvider.php @@ -6,11 +6,11 @@ use RuntimeException; use WordPress\AiClient\Providers\AbstractProvider; +use WordPress\AiClient\Providers\ApiBasedImplementation\ListModelsApiBasedProviderAvailability; use WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; -use WordPress\AiClient\Providers\ListModelsApiBasedProviderAvailability; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; diff --git a/src/ProviderImplementations/Anthropic/AnthropicTextGenerationModel.php b/src/ProviderImplementations/Anthropic/AnthropicTextGenerationModel.php index 280795a4..fc6f9368 100644 --- a/src/ProviderImplementations/Anthropic/AnthropicTextGenerationModel.php +++ b/src/ProviderImplementations/Anthropic/AnthropicTextGenerationModel.php @@ -6,7 +6,7 @@ use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; -use WordPress\AiClient\Providers\Models\AbstractOpenAiCompatibleTextGenerationModel; +use WordPress\AiClient\Providers\OpenAiCompatibleImplementation\AbstractOpenAiCompatibleTextGenerationModel; /** * Class for an Anthropic text generation model. diff --git a/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php b/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php index d8ad8162..33cab506 100644 --- a/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php +++ b/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php @@ -8,7 +8,6 @@ use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Files\Enums\MediaOrientationEnum; use WordPress\AiClient\Messages\Enums\ModalityEnum; -use WordPress\AiClient\Providers\AbstractOpenAiCompatibleModelMetadataDirectory; use WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface; use WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication; use WordPress\AiClient\Providers\Http\DTO\Request; @@ -18,6 +17,7 @@ use WordPress\AiClient\Providers\Models\DTO\SupportedOption; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; use WordPress\AiClient\Providers\Models\Enums\OptionEnum; +use WordPress\AiClient\Providers\OpenAiCompatibleImplementation\AbstractOpenAiCompatibleModelMetadataDirectory; /** * Class for the Google model metadata directory. diff --git a/src/ProviderImplementations/Google/GoogleProvider.php b/src/ProviderImplementations/Google/GoogleProvider.php index 60d21ee9..a490da97 100644 --- a/src/ProviderImplementations/Google/GoogleProvider.php +++ b/src/ProviderImplementations/Google/GoogleProvider.php @@ -6,11 +6,11 @@ use RuntimeException; use WordPress\AiClient\Providers\AbstractProvider; +use WordPress\AiClient\Providers\ApiBasedImplementation\ListModelsApiBasedProviderAvailability; use WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; -use WordPress\AiClient\Providers\ListModelsApiBasedProviderAvailability; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; diff --git a/src/ProviderImplementations/Google/GoogleTextGenerationModel.php b/src/ProviderImplementations/Google/GoogleTextGenerationModel.php index b7c7b81f..f6aa2ddb 100644 --- a/src/ProviderImplementations/Google/GoogleTextGenerationModel.php +++ b/src/ProviderImplementations/Google/GoogleTextGenerationModel.php @@ -6,7 +6,7 @@ use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; -use WordPress\AiClient\Providers\Models\AbstractOpenAiCompatibleTextGenerationModel; +use WordPress\AiClient\Providers\OpenAiCompatibleImplementation\AbstractOpenAiCompatibleTextGenerationModel; /** * Class for a Google text generation model. diff --git a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php index edd70109..359912bb 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php +++ b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php @@ -8,7 +8,6 @@ use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Files\Enums\MediaOrientationEnum; use WordPress\AiClient\Messages\Enums\ModalityEnum; -use WordPress\AiClient\Providers\AbstractOpenAiCompatibleModelMetadataDirectory; use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; @@ -16,6 +15,7 @@ use WordPress\AiClient\Providers\Models\DTO\SupportedOption; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; use WordPress\AiClient\Providers\Models\Enums\OptionEnum; +use WordPress\AiClient\Providers\OpenAiCompatibleImplementation\AbstractOpenAiCompatibleModelMetadataDirectory; /** * Class for the OpenAI model metadata directory. diff --git a/src/ProviderImplementations/OpenAi/OpenAiProvider.php b/src/ProviderImplementations/OpenAi/OpenAiProvider.php index e03a1f4a..4c5a4922 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiProvider.php +++ b/src/ProviderImplementations/OpenAi/OpenAiProvider.php @@ -6,11 +6,11 @@ use RuntimeException; use WordPress\AiClient\Providers\AbstractProvider; +use WordPress\AiClient\Providers\ApiBasedImplementation\ListModelsApiBasedProviderAvailability; use WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; -use WordPress\AiClient\Providers\ListModelsApiBasedProviderAvailability; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; diff --git a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php index 5c6acdc4..0cd86f7d 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php +++ b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php @@ -6,7 +6,7 @@ use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; -use WordPress\AiClient\Providers\Models\AbstractOpenAiCompatibleTextGenerationModel; +use WordPress\AiClient\Providers\OpenAiCompatibleImplementation\AbstractOpenAiCompatibleTextGenerationModel; /** * Class for an OpenAI text generation model. diff --git a/src/Providers/Models/AbstractApiBasedModel.php b/src/Providers/ApiBasedImplementation/AbstractApiBasedModel.php similarity index 97% rename from src/Providers/Models/AbstractApiBasedModel.php rename to src/Providers/ApiBasedImplementation/AbstractApiBasedModel.php index 82f922bb..ee9932f7 100644 --- a/src/Providers/Models/AbstractApiBasedModel.php +++ b/src/Providers/ApiBasedImplementation/AbstractApiBasedModel.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace WordPress\AiClient\Providers\Models; +namespace WordPress\AiClient\Providers\ApiBasedImplementation; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Http\Contracts\WithHttpTransporterInterface; diff --git a/src/Providers/AbstractApiBasedModelMetadataDirectory.php b/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php similarity index 97% rename from src/Providers/AbstractApiBasedModelMetadataDirectory.php rename to src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php index 5b2b0f69..c09fab50 100644 --- a/src/Providers/AbstractApiBasedModelMetadataDirectory.php +++ b/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace WordPress\AiClient\Providers; +namespace WordPress\AiClient\Providers\ApiBasedImplementation; use InvalidArgumentException; use WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface; diff --git a/src/Providers/GenerateTextApiBasedProviderAvailability.php b/src/Providers/ApiBasedImplementation/GenerateTextApiBasedProviderAvailability.php similarity index 97% rename from src/Providers/GenerateTextApiBasedProviderAvailability.php rename to src/Providers/ApiBasedImplementation/GenerateTextApiBasedProviderAvailability.php index 5c2fe8f4..ef4f4acd 100644 --- a/src/Providers/GenerateTextApiBasedProviderAvailability.php +++ b/src/Providers/ApiBasedImplementation/GenerateTextApiBasedProviderAvailability.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace WordPress\AiClient\Providers; +namespace WordPress\AiClient\Providers\ApiBasedImplementation; use Exception; use WordPress\AiClient\Messages\DTO\Message; diff --git a/src/Providers/ListModelsApiBasedProviderAvailability.php b/src/Providers/ApiBasedImplementation/ListModelsApiBasedProviderAvailability.php similarity index 95% rename from src/Providers/ListModelsApiBasedProviderAvailability.php rename to src/Providers/ApiBasedImplementation/ListModelsApiBasedProviderAvailability.php index 491fa64d..a9165be7 100644 --- a/src/Providers/ListModelsApiBasedProviderAvailability.php +++ b/src/Providers/ApiBasedImplementation/ListModelsApiBasedProviderAvailability.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace WordPress\AiClient\Providers; +namespace WordPress\AiClient\Providers\ApiBasedImplementation; use Exception; use WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface; diff --git a/src/Providers/AbstractOpenAiCompatibleModelMetadataDirectory.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectory.php similarity index 94% rename from src/Providers/AbstractOpenAiCompatibleModelMetadataDirectory.php rename to src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectory.php index 86f58176..e58f35f9 100644 --- a/src/Providers/AbstractOpenAiCompatibleModelMetadataDirectory.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectory.php @@ -2,8 +2,9 @@ declare(strict_types=1); -namespace WordPress\AiClient\Providers; +namespace WordPress\AiClient\Providers\OpenAiCompatibleImplementation; +use WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiBasedModelMetadataDirectory; use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; diff --git a/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php similarity index 99% rename from src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php rename to src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php index 7a44db8e..835cca4f 100644 --- a/src/Providers/Models/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace WordPress\AiClient\Providers\Models; +namespace WordPress\AiClient\Providers\OpenAiCompatibleImplementation; use Generator; use InvalidArgumentException; @@ -12,6 +12,7 @@ use WordPress\AiClient\Messages\Enums\MessagePartChannelEnum; use WordPress\AiClient\Messages\Enums\MessageRoleEnum; use WordPress\AiClient\Messages\Enums\ModalityEnum; +use WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiBasedModel; use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; diff --git a/tests/unit/Providers/AbstractApiBasedModelMetadataDirectoryTest.php b/tests/unit/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectoryTest.php similarity index 96% rename from tests/unit/Providers/AbstractApiBasedModelMetadataDirectoryTest.php rename to tests/unit/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectoryTest.php index 6b6b1c07..140be0ce 100644 --- a/tests/unit/Providers/AbstractApiBasedModelMetadataDirectoryTest.php +++ b/tests/unit/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectoryTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace WordPress\AiClient\Tests\unit\Providers; +namespace WordPress\AiClient\Tests\unit\Providers\ApiBasedImplementation; use InvalidArgumentException; use PHPUnit\Framework\TestCase; diff --git a/tests/unit/Providers/Models/AbstractApiBasedModelTest.php b/tests/unit/Providers/ApiBasedImplementation/AbstractApiBasedModelTest.php similarity index 97% rename from tests/unit/Providers/Models/AbstractApiBasedModelTest.php rename to tests/unit/Providers/ApiBasedImplementation/AbstractApiBasedModelTest.php index 535d485d..390085cf 100644 --- a/tests/unit/Providers/Models/AbstractApiBasedModelTest.php +++ b/tests/unit/Providers/ApiBasedImplementation/AbstractApiBasedModelTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace WordPress\AiClient\Tests\unit\Providers\Models; +namespace WordPress\AiClient\Tests\unit\Providers\ApiBasedImplementation; use PHPUnit\Framework\TestCase; use WordPress\AiClient\Providers\DTO\ProviderMetadata; diff --git a/tests/unit/Providers/ListModelsApiBasedProviderAvailabilityTest.php b/tests/unit/Providers/ApiBasedImplementation/ListModelsApiBasedProviderAvailabilityTest.php similarity index 90% rename from tests/unit/Providers/ListModelsApiBasedProviderAvailabilityTest.php rename to tests/unit/Providers/ApiBasedImplementation/ListModelsApiBasedProviderAvailabilityTest.php index e3e86bea..549879a5 100644 --- a/tests/unit/Providers/ListModelsApiBasedProviderAvailabilityTest.php +++ b/tests/unit/Providers/ApiBasedImplementation/ListModelsApiBasedProviderAvailabilityTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace WordPress\AiClient\Tests\unit\Providers; +namespace WordPress\AiClient\Tests\unit\Providers\ApiBasedImplementation; use Exception; use PHPUnit\Framework\TestCase; +use WordPress\AiClient\Providers\ApiBasedImplementation\ListModelsApiBasedProviderAvailability; use WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface; -use WordPress\AiClient\Providers\ListModelsApiBasedProviderAvailability; /** * @covers \WordPress\AiClient\Providers\ListModelsApiBasedProviderAvailability diff --git a/tests/unit/Providers/Models/MockApiBasedModel.php b/tests/unit/Providers/ApiBasedImplementation/MockApiBasedModel.php similarity index 79% rename from tests/unit/Providers/Models/MockApiBasedModel.php rename to tests/unit/Providers/ApiBasedImplementation/MockApiBasedModel.php index d7913860..70c9b624 100644 --- a/tests/unit/Providers/Models/MockApiBasedModel.php +++ b/tests/unit/Providers/ApiBasedImplementation/MockApiBasedModel.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace WordPress\AiClient\Tests\unit\Providers\Models; +namespace WordPress\AiClient\Tests\unit\Providers\ApiBasedImplementation; +use WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiBasedModel; use WordPress\AiClient\Providers\Http\Contracts\WithHttpTransporterInterface; use WordPress\AiClient\Providers\Http\Contracts\WithRequestAuthenticationInterface; -use WordPress\AiClient\Providers\Models\AbstractApiBasedModel; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; /** diff --git a/tests/unit/Providers/MockApiBasedModelMetadataDirectory.php b/tests/unit/Providers/ApiBasedImplementation/MockApiBasedModelMetadataDirectory.php similarity index 80% rename from tests/unit/Providers/MockApiBasedModelMetadataDirectory.php rename to tests/unit/Providers/ApiBasedImplementation/MockApiBasedModelMetadataDirectory.php index 4d56943e..3635c069 100644 --- a/tests/unit/Providers/MockApiBasedModelMetadataDirectory.php +++ b/tests/unit/Providers/ApiBasedImplementation/MockApiBasedModelMetadataDirectory.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace WordPress\AiClient\Tests\unit\Providers; +namespace WordPress\AiClient\Tests\unit\Providers\ApiBasedImplementation; -use WordPress\AiClient\Providers\AbstractApiBasedModelMetadataDirectory; +use WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiBasedModelMetadataDirectory; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; /** diff --git a/tests/unit/Providers/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php similarity index 97% rename from tests/unit/Providers/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php rename to tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php index 48a6c576..691c82ab 100644 --- a/tests/unit/Providers/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace WordPress\AiClient\Tests\unit\Providers; +namespace WordPress\AiClient\Tests\unit\Providers\OpenAiCompatibleImplementation; use PHPUnit\Framework\TestCase; use WordPress\AiClient\Providers\Http\Contracts\HttpTransporterInterface; diff --git a/tests/unit/Providers/Models/AbstractOpenAiCompatibleTextGenerationModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php similarity index 99% rename from tests/unit/Providers/Models/AbstractOpenAiCompatibleTextGenerationModelTest.php rename to tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php index 8225d329..4aacac25 100644 --- a/tests/unit/Providers/Models/AbstractOpenAiCompatibleTextGenerationModelTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace WordPress\AiClient\Tests\unit\Providers\Models; +namespace WordPress\AiClient\Tests\unit\Providers\OpenAiCompatibleImplementation; use InvalidArgumentException; use PHPUnit\Framework\TestCase; diff --git a/tests/unit/Providers/MockOpenAiCompatibleModelMetadataDirectory.php b/tests/unit/Providers/OpenAiCompatibleImplementation/MockOpenAiCompatibleModelMetadataDirectory.php similarity index 94% rename from tests/unit/Providers/MockOpenAiCompatibleModelMetadataDirectory.php rename to tests/unit/Providers/OpenAiCompatibleImplementation/MockOpenAiCompatibleModelMetadataDirectory.php index d39893ca..97f36787 100644 --- a/tests/unit/Providers/MockOpenAiCompatibleModelMetadataDirectory.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/MockOpenAiCompatibleModelMetadataDirectory.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace WordPress\AiClient\Tests\unit\Providers; +namespace WordPress\AiClient\Tests\unit\Providers\OpenAiCompatibleImplementation; -use WordPress\AiClient\Providers\AbstractOpenAiCompatibleModelMetadataDirectory; use WordPress\AiClient\Providers\Http\Contracts\HttpTransporterInterface; use WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface; use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; +use WordPress\AiClient\Providers\OpenAiCompatibleImplementation\AbstractOpenAiCompatibleModelMetadataDirectory; /** * Mock class for testing AbstractOpenAiCompatibleModelMetadataDirectory. diff --git a/tests/unit/Providers/Models/MockOpenAiCompatibleTextGenerationModel.php b/tests/unit/Providers/OpenAiCompatibleImplementation/MockOpenAiCompatibleTextGenerationModel.php similarity index 96% rename from tests/unit/Providers/Models/MockOpenAiCompatibleTextGenerationModel.php rename to tests/unit/Providers/OpenAiCompatibleImplementation/MockOpenAiCompatibleTextGenerationModel.php index 8eb17022..0b7e8549 100644 --- a/tests/unit/Providers/Models/MockOpenAiCompatibleTextGenerationModel.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/MockOpenAiCompatibleTextGenerationModel.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace WordPress\AiClient\Tests\unit\Providers\Models; +namespace WordPress\AiClient\Tests\unit\Providers\OpenAiCompatibleImplementation; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; @@ -13,8 +13,8 @@ use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; -use WordPress\AiClient\Providers\Models\AbstractOpenAiCompatibleTextGenerationModel; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; +use WordPress\AiClient\Providers\OpenAiCompatibleImplementation\AbstractOpenAiCompatibleTextGenerationModel; use WordPress\AiClient\Results\DTO\Candidate; use WordPress\AiClient\Results\DTO\GenerativeAiResult; From bbffb4a198e5a2a04d3ed6f621aed644e6c489bf Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Tue, 26 Aug 2025 16:52:51 -0700 Subject: [PATCH 51/77] Update outdated references. --- .../AbstractApiBasedModelMetadataDirectoryTest.php | 2 +- .../ApiBasedImplementation/AbstractApiBasedModelTest.php | 2 +- .../AbstractOpenAiCompatibleModelMetadataDirectoryTest.php | 2 +- .../AbstractOpenAiCompatibleTextGenerationModelTest.php | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectoryTest.php b/tests/unit/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectoryTest.php index 140be0ce..664127f8 100644 --- a/tests/unit/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectoryTest.php +++ b/tests/unit/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectoryTest.php @@ -9,7 +9,7 @@ use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; /** - * @covers \WordPress\AiClient\Providers\AbstractApiBasedModelMetadataDirectory + * @covers \WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiBasedModelMetadataDirectory */ class AbstractApiBasedModelMetadataDirectoryTest extends TestCase { diff --git a/tests/unit/Providers/ApiBasedImplementation/AbstractApiBasedModelTest.php b/tests/unit/Providers/ApiBasedImplementation/AbstractApiBasedModelTest.php index 390085cf..25f05056 100644 --- a/tests/unit/Providers/ApiBasedImplementation/AbstractApiBasedModelTest.php +++ b/tests/unit/Providers/ApiBasedImplementation/AbstractApiBasedModelTest.php @@ -10,7 +10,7 @@ use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; /** - * @covers \WordPress\AiClient\Providers\Models\AbstractApiBasedModel + * @covers \WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiBasedModel */ class AbstractApiBasedModelTest extends TestCase { diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php index 691c82ab..4924fbeb 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php @@ -12,7 +12,7 @@ use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; /** - * @covers \WordPress\AiClient\Providers\AbstractOpenAiCompatibleModelMetadataDirectory + * @covers \WordPress\AiClient\Providers\ApiBasedImplementation\AbstractOpenAiCompatibleModelMetadataDirectory */ class AbstractOpenAiCompatibleModelMetadataDirectoryTest extends TestCase { diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php index 4aacac25..cae0b6a9 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php @@ -28,7 +28,7 @@ use WordPress\AiClient\Tools\DTO\FunctionResponse; /** - * @covers \WordPress\AiClient\Providers\Models\AbstractOpenAiCompatibleTextGenerationModel + * @covers \WordPress\AiClient\Providers\OpenAiCompatibleImplementation\AbstractOpenAiCompatibleTextGenerationModel */ class AbstractOpenAiCompatibleTextGenerationModelTest extends TestCase { From 0f035980f0905d7fc881844a137324f58dfcb025 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Tue, 26 Aug 2025 23:57:41 -0700 Subject: [PATCH 52/77] Remove now unused MessageUtil. --- cli.php | 1 - src/Messages/Util/MessageUtil.php | 119 ------------ tests/unit/Messages/Util/MessageUtilTest.php | 179 ------------------- 3 files changed, 299 deletions(-) delete mode 100644 src/Messages/Util/MessageUtil.php delete mode 100644 tests/unit/Messages/Util/MessageUtilTest.php diff --git a/cli.php b/cli.php index 2a45bbdd..38f0717d 100755 --- a/cli.php +++ b/cli.php @@ -14,7 +14,6 @@ declare(strict_types=1); use WordPress\AiClient\Builders\PromptBuilder; -use WordPress\AiClient\Messages\Util\MessageUtil; use WordPress\AiClient\ProviderImplementations\Anthropic\AnthropicProvider; use WordPress\AiClient\ProviderImplementations\Google\GoogleProvider; use WordPress\AiClient\ProviderImplementations\OpenAi\OpenAiProvider; diff --git a/src/Messages/Util/MessageUtil.php b/src/Messages/Util/MessageUtil.php deleted file mode 100644 index fdbab124..00000000 --- a/src/Messages/Util/MessageUtil.php +++ /dev/null @@ -1,119 +0,0 @@ -assertSame($message, $result); - } - - /** - * Tests that parseMessageFromInput correctly parses a message from an array. - * - * @return void - */ - public function testParseMessageFromInputWithMessageArray(): void - { - $input = [ - 'role' => 'user', - 'parts' => [ - ['text' => 'Hello from array'] - ], - ]; - $result = MessageUtil::parseMessageFromInput($input); - $this->assertInstanceOf(Message::class, $result); - $this->assertEquals(MessageRoleEnum::user(), $result->getRole()); - $this->assertCount(1, $result->getParts()); - $this->assertEquals('Hello from array', $result->getParts()[0]->getText()); - } - - /** - * Tests that parseMessageFromInput correctly parses a message from various single part inputs. - * - * @dataProvider singlePartInputProvider - * @param mixed $input The input to test. - * @param string $expectedText The expected text in the message part. - * @return void - */ - public function testParseMessageFromInputWithSinglePartInput($input, string $expectedText): void - { - $result = MessageUtil::parseMessageFromInput($input); - $this->assertInstanceOf(Message::class, $result); - $this->assertEquals(MessageRoleEnum::user(), $result->getRole()); - $this->assertCount(1, $result->getParts()); - $this->assertEquals($expectedText, $result->getParts()[0]->getText()); - } - - /** - * Provides various single part inputs for testing. - * - * @return array - */ - public function singlePartInputProvider(): array - { - return [ - 'string' => ['Just a string', 'Just a string'], - 'MessagePart instance' => [new MessagePart('A message part'), 'A message part'], - 'MessagePart array' => [['text' => 'Part from array'], 'Part from array'], - ]; - } - - /** - * Tests that parseMessageFromInput correctly parses a message from multiple part inputs. - * - * @return void - */ - public function testParseMessageFromInputWithMultiplePartInputs(): void - { - $part = new MessagePart('A message part'); - $input = [ - 'First part', - $part, - ['text' => 'Third part'], - ]; - $result = MessageUtil::parseMessageFromInput($input); - $this->assertInstanceOf(Message::class, $result); - $this->assertEquals(MessageRoleEnum::user(), $result->getRole()); - $this->assertCount(3, $result->getParts()); - $this->assertEquals('First part', $result->getParts()[0]->getText()); - $this->assertSame($part, $result->getParts()[1]); - $this->assertEquals('Third part', $result->getParts()[2]->getText()); - } - - /** - * Tests that parseMessagesFromInput correctly parses an array of Message instances. - * - * @return void - */ - public function testParseMessagesFromInputWithArrayOfMessageInstances(): void - { - $messages = [ - new Message(MessageRoleEnum::user(), [new MessagePart('Hello')]), - new Message(MessageRoleEnum::model(), [new MessagePart('Hi there')]), - ]; - $result = MessageUtil::parseMessagesFromInput($messages); - $this->assertCount(2, $result); - $this->assertSame($messages[0], $result[0]); - $this->assertSame($messages[1], $result[1]); - } - - /** - * Tests that parseMessagesFromInput correctly parses an array of message arrays. - * - * @return void - */ - public function testParseMessagesFromInputWithArrayOfMessageArrays(): void - { - $input = [ - [ - 'role' => 'user', - 'parts' => [['text' => 'Message 1']], - ], - [ - 'role' => 'model', - 'parts' => [['text' => 'Message 2']], - ], - ]; - $result = MessageUtil::parseMessagesFromInput($input); - $this->assertCount(2, $result); - $this->assertInstanceOf(Message::class, $result[0]); - $this->assertEquals(MessageRoleEnum::user(), $result[0]->getRole()); - $this->assertEquals('Message 1', $result[0]->getParts()[0]->getText()); - $this->assertInstanceOf(Message::class, $result[1]); - $this->assertEquals(MessageRoleEnum::model(), $result[1]->getRole()); - $this->assertEquals('Message 2', $result[1]->getParts()[0]->getText()); - } - - /** - * Tests that parseMessagesFromInput correctly handles a single message input. - * - * @return void - */ - public function testParseMessagesFromInputWithSingleMessageInput(): void - { - $input = 'A single message'; - $result = MessageUtil::parseMessagesFromInput($input); - $this->assertCount(1, $result); - $this->assertInstanceOf(Message::class, $result[0]); - $this->assertEquals(MessageRoleEnum::user(), $result[0]->getRole()); - $this->assertEquals('A single message', $result[0]->getParts()[0]->getText()); - } - - /** - * Tests that parseMessagesFromInput correctly handles a single message array input. - * - * @return void - */ - public function testParseMessagesFromInputWithSingleMessageArrayInput(): void - { - $input = [ - 'role' => 'user', - 'parts' => [['text' => 'Test prompt']], - ]; - $result = MessageUtil::parseMessagesFromInput($input); - $this->assertCount(1, $result); - $this->assertInstanceOf(Message::class, $result[0]); - $this->assertEquals(MessageRoleEnum::user(), $result[0]->getRole()); - $this->assertEquals('Test prompt', $result[0]->getParts()[0]->getText()); - } -} From 39edb57aaed82cb20e91c3df49820beefec3bb03 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Wed, 27 Aug 2025 00:01:33 -0700 Subject: [PATCH 53/77] Simplify hasModelMetadata method. --- .../AbstractApiBasedModelMetadataDirectory.php | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php b/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php index c09fab50..d531cb99 100644 --- a/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php +++ b/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php @@ -44,12 +44,8 @@ final public function listModelMetadata(): array */ final public function hasModelMetadata(string $modelId): bool { - try { - $this->getModelMetadata($modelId); - } catch (InvalidArgumentException $e) { - return false; - } - return true; + $modelsMetadata = $this->getModelMetadataMap(); + return isset($modelsMetadata[$modelId]); } /** From c6575f5803640b08f6840e547754114f28e70b1f Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Wed, 27 Aug 2025 00:08:07 -0700 Subject: [PATCH 54/77] Update inheritdoc blocks to still include since annotations. --- .../AnthropicApiKeyRequestAuthentication.php | 4 +++- .../AnthropicModelMetadataDirectory.php | 12 +++++++++--- .../Anthropic/AnthropicProvider.php | 16 ++++++++++++---- .../Anthropic/AnthropicTextGenerationModel.php | 4 +++- .../Google/GoogleApiKeyRequestAuthentication.php | 4 +++- .../Google/GoogleModelMetadataDirectory.php | 12 +++++++++--- .../Google/GoogleProvider.php | 16 ++++++++++++---- .../Google/GoogleTextGenerationModel.php | 4 +++- .../OpenAi/OpenAiModelMetadataDirectory.php | 8 ++++++-- .../OpenAi/OpenAiProvider.php | 16 ++++++++++++---- .../OpenAi/OpenAiTextGenerationModel.php | 4 +++- .../Http/DTO/ApiKeyRequestAuthentication.php | 16 ++++++++++++---- .../Http/Traits/WithHttpTransporterTrait.php | 8 ++++++-- .../Traits/WithRequestAuthenticationTrait.php | 8 ++++++-- ...stractOpenAiCompatibleTextGenerationModel.php | 8 ++++++-- src/Providers/ProviderRegistry.php | 4 +++- tests/mocks/MockHttpTransporter.php | 4 +++- tests/mocks/MockRequestAuthentication.php | 8 ++++++-- 18 files changed, 117 insertions(+), 39 deletions(-) diff --git a/src/ProviderImplementations/Anthropic/AnthropicApiKeyRequestAuthentication.php b/src/ProviderImplementations/Anthropic/AnthropicApiKeyRequestAuthentication.php index fd9b87ce..468cefc5 100644 --- a/src/ProviderImplementations/Anthropic/AnthropicApiKeyRequestAuthentication.php +++ b/src/ProviderImplementations/Anthropic/AnthropicApiKeyRequestAuthentication.php @@ -17,7 +17,9 @@ class AnthropicApiKeyRequestAuthentication extends ApiKeyRequestAuthentication public const ANTHROPIC_API_VERSION = '2023-06-01'; /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ public function authenticateRequest(Request $request): Request { diff --git a/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php b/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php index 92e55448..2924f45a 100644 --- a/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php +++ b/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php @@ -25,7 +25,9 @@ class AnthropicModelMetadataDirectory extends AbstractOpenAiCompatibleModelMetadataDirectory { /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ public function getRequestAuthentication(): RequestAuthenticationInterface { @@ -41,7 +43,9 @@ public function getRequestAuthentication(): RequestAuthenticationInterface } /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ protected function createRequest(HttpMethodEnum $method, string $path, array $headers = [], $data = null): Request { @@ -54,7 +58,9 @@ protected function createRequest(HttpMethodEnum $method, string $path, array $he } /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ protected function parseResponseToModelMetadataList(Response $response): array { diff --git a/src/ProviderImplementations/Anthropic/AnthropicProvider.php b/src/ProviderImplementations/Anthropic/AnthropicProvider.php index 61579cfb..af598bfe 100644 --- a/src/ProviderImplementations/Anthropic/AnthropicProvider.php +++ b/src/ProviderImplementations/Anthropic/AnthropicProvider.php @@ -24,7 +24,9 @@ class AnthropicProvider extends AbstractProvider public const BASE_URI = 'https://api.anthropic.com/v1'; /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ protected static function createModel( ModelMetadata $modelMetadata, @@ -43,7 +45,9 @@ protected static function createModel( } /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ protected static function createProviderMetadata(): ProviderMetadata { @@ -55,7 +59,9 @@ protected static function createProviderMetadata(): ProviderMetadata } /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ protected static function createProviderAvailability(): ProviderAvailabilityInterface { @@ -66,7 +72,9 @@ protected static function createProviderAvailability(): ProviderAvailabilityInte } /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface { diff --git a/src/ProviderImplementations/Anthropic/AnthropicTextGenerationModel.php b/src/ProviderImplementations/Anthropic/AnthropicTextGenerationModel.php index fc6f9368..e7f48fc7 100644 --- a/src/ProviderImplementations/Anthropic/AnthropicTextGenerationModel.php +++ b/src/ProviderImplementations/Anthropic/AnthropicTextGenerationModel.php @@ -16,7 +16,9 @@ class AnthropicTextGenerationModel extends AbstractOpenAiCompatibleTextGenerationModel { /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ protected function createRequest(HttpMethodEnum $method, string $path, array $headers = [], $data = null): Request { diff --git a/src/ProviderImplementations/Google/GoogleApiKeyRequestAuthentication.php b/src/ProviderImplementations/Google/GoogleApiKeyRequestAuthentication.php index f2b2e13b..9059458a 100644 --- a/src/ProviderImplementations/Google/GoogleApiKeyRequestAuthentication.php +++ b/src/ProviderImplementations/Google/GoogleApiKeyRequestAuthentication.php @@ -18,7 +18,9 @@ class GoogleApiKeyRequestAuthentication extends ApiKeyRequestAuthentication { /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ public function authenticateRequest(Request $request): Request { diff --git a/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php b/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php index 33cab506..02b4e6d1 100644 --- a/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php +++ b/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php @@ -27,7 +27,9 @@ class GoogleModelMetadataDirectory extends AbstractOpenAiCompatibleModelMetadataDirectory { /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ public function getRequestAuthentication(): RequestAuthenticationInterface { @@ -43,7 +45,9 @@ public function getRequestAuthentication(): RequestAuthenticationInterface } /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ protected function createRequest(HttpMethodEnum $method, string $path, array $headers = [], $data = null): Request { @@ -65,7 +69,9 @@ protected function createRequest(HttpMethodEnum $method, string $path, array $he } /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ protected function parseResponseToModelMetadataList(Response $response): array { diff --git a/src/ProviderImplementations/Google/GoogleProvider.php b/src/ProviderImplementations/Google/GoogleProvider.php index a490da97..2aa7bc79 100644 --- a/src/ProviderImplementations/Google/GoogleProvider.php +++ b/src/ProviderImplementations/Google/GoogleProvider.php @@ -24,7 +24,9 @@ class GoogleProvider extends AbstractProvider public const BASE_URI = 'https://generativelanguage.googleapis.com/v1beta'; /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ protected static function createModel( ModelMetadata $modelMetadata, @@ -49,7 +51,9 @@ protected static function createModel( } /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ protected static function createProviderMetadata(): ProviderMetadata { @@ -61,7 +65,9 @@ protected static function createProviderMetadata(): ProviderMetadata } /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ protected static function createProviderAvailability(): ProviderAvailabilityInterface { @@ -72,7 +78,9 @@ protected static function createProviderAvailability(): ProviderAvailabilityInte } /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface { diff --git a/src/ProviderImplementations/Google/GoogleTextGenerationModel.php b/src/ProviderImplementations/Google/GoogleTextGenerationModel.php index f6aa2ddb..e8066681 100644 --- a/src/ProviderImplementations/Google/GoogleTextGenerationModel.php +++ b/src/ProviderImplementations/Google/GoogleTextGenerationModel.php @@ -16,7 +16,9 @@ class GoogleTextGenerationModel extends AbstractOpenAiCompatibleTextGenerationModel { /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ protected function createRequest(HttpMethodEnum $method, string $path, array $headers = [], $data = null): Request { diff --git a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php index 359912bb..7b1c3714 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php +++ b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php @@ -25,7 +25,9 @@ class OpenAiModelMetadataDirectory extends AbstractOpenAiCompatibleModelMetadataDirectory { /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ protected function createRequest(HttpMethodEnum $method, string $path, array $headers = [], $data = null): Request { @@ -38,7 +40,9 @@ protected function createRequest(HttpMethodEnum $method, string $path, array $he } /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ protected function parseResponseToModelMetadataList(Response $response): array { diff --git a/src/ProviderImplementations/OpenAi/OpenAiProvider.php b/src/ProviderImplementations/OpenAi/OpenAiProvider.php index 4c5a4922..1021664a 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiProvider.php +++ b/src/ProviderImplementations/OpenAi/OpenAiProvider.php @@ -24,7 +24,9 @@ class OpenAiProvider extends AbstractProvider public const BASE_URI = 'https://api.openai.com/v1'; /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ protected static function createModel( ModelMetadata $modelMetadata, @@ -55,7 +57,9 @@ protected static function createModel( } /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ protected static function createProviderMetadata(): ProviderMetadata { @@ -67,7 +71,9 @@ protected static function createProviderMetadata(): ProviderMetadata } /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ protected static function createProviderAvailability(): ProviderAvailabilityInterface { @@ -78,7 +84,9 @@ protected static function createProviderAvailability(): ProviderAvailabilityInte } /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface { diff --git a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php index 0cd86f7d..66823ad7 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php +++ b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php @@ -16,7 +16,9 @@ class OpenAiTextGenerationModel extends AbstractOpenAiCompatibleTextGenerationModel { /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ protected function createRequest(HttpMethodEnum $method, string $path, array $headers = [], $data = null): Request { diff --git a/src/Providers/Http/DTO/ApiKeyRequestAuthentication.php b/src/Providers/Http/DTO/ApiKeyRequestAuthentication.php index 6ef3d877..7a726ff2 100644 --- a/src/Providers/Http/DTO/ApiKeyRequestAuthentication.php +++ b/src/Providers/Http/DTO/ApiKeyRequestAuthentication.php @@ -40,7 +40,9 @@ public function __construct(string $apiKey) } /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ public function authenticateRequest(Request $request): Request { @@ -61,7 +63,9 @@ public function getApiKey(): string } /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t * * @since n.e.x.t * @@ -75,7 +79,9 @@ public function toArray(): array } /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t * * @since n.e.x.t */ @@ -87,7 +93,9 @@ public static function fromArray(array $array): self } /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ public static function getJsonSchema(): array { diff --git a/src/Providers/Http/Traits/WithHttpTransporterTrait.php b/src/Providers/Http/Traits/WithHttpTransporterTrait.php index 212df386..12131f77 100644 --- a/src/Providers/Http/Traits/WithHttpTransporterTrait.php +++ b/src/Providers/Http/Traits/WithHttpTransporterTrait.php @@ -20,7 +20,9 @@ trait WithHttpTransporterTrait private ?HttpTransporterInterface $httpTransporter = null; /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ public function setHttpTransporter(HttpTransporterInterface $httpTransporter): void { @@ -28,7 +30,9 @@ public function setHttpTransporter(HttpTransporterInterface $httpTransporter): v } /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ public function getHttpTransporter(): HttpTransporterInterface { diff --git a/src/Providers/Http/Traits/WithRequestAuthenticationTrait.php b/src/Providers/Http/Traits/WithRequestAuthenticationTrait.php index b8fca6a2..3f5dc2d5 100644 --- a/src/Providers/Http/Traits/WithRequestAuthenticationTrait.php +++ b/src/Providers/Http/Traits/WithRequestAuthenticationTrait.php @@ -20,7 +20,9 @@ trait WithRequestAuthenticationTrait private ?RequestAuthenticationInterface $requestAuthentication = null; /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ public function setRequestAuthentication(RequestAuthenticationInterface $requestAuthentication): void { @@ -28,7 +30,9 @@ public function setRequestAuthentication(RequestAuthenticationInterface $request } /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ public function getRequestAuthentication(): RequestAuthenticationInterface { diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php index 835cca4f..0aabd77d 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php @@ -35,7 +35,9 @@ abstract class AbstractOpenAiCompatibleTextGenerationModel extends AbstractApiBa TextGenerationModelInterface { /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ final public function generateTextResult(array $prompt): GenerativeAiResult { @@ -60,7 +62,9 @@ final public function generateTextResult(array $prompt): GenerativeAiResult } /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ final public function streamGenerateTextResult(array $prompt): Generator { diff --git a/src/Providers/ProviderRegistry.php b/src/Providers/ProviderRegistry.php index 528c1143..6d38197d 100644 --- a/src/Providers/ProviderRegistry.php +++ b/src/Providers/ProviderRegistry.php @@ -286,7 +286,9 @@ private function resolveProviderClassName(string $idOrClassName): string } /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ public function setHttpTransporter(HttpTransporterInterface $httpTransporter): void { diff --git a/tests/mocks/MockHttpTransporter.php b/tests/mocks/MockHttpTransporter.php index 01f7c0ea..711a912b 100644 --- a/tests/mocks/MockHttpTransporter.php +++ b/tests/mocks/MockHttpTransporter.php @@ -24,7 +24,9 @@ class MockHttpTransporter implements HttpTransporterInterface private ?Response $responseToReturn = null; /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ public function send(Request $request): Response { diff --git a/tests/mocks/MockRequestAuthentication.php b/tests/mocks/MockRequestAuthentication.php index 4406b9b1..8c1eafb1 100644 --- a/tests/mocks/MockRequestAuthentication.php +++ b/tests/mocks/MockRequestAuthentication.php @@ -29,7 +29,9 @@ public function __construct(string $token = 'mock_token') } /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ public function authenticateRequest(Request $request): Request { @@ -37,7 +39,9 @@ public function authenticateRequest(Request $request): Request } /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ public static function getJsonSchema(): array { From 7fb92677c4031133d32ac6a0654626944852db88 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Wed, 27 Aug 2025 00:08:59 -0700 Subject: [PATCH 55/77] Update return type doc. --- src/Common/AbstractEnum.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Common/AbstractEnum.php b/src/Common/AbstractEnum.php index 587a77a4..685002b9 100644 --- a/src/Common/AbstractEnum.php +++ b/src/Common/AbstractEnum.php @@ -400,7 +400,7 @@ final public function __toString(): string * * @since n.e.x.t * - * @return mixed The JSON-serializable representation. + * @return string The enum value. */ #[\ReturnTypeWillChange] public function jsonSerialize() From 1c53f39bbbb6e5a64c50f681daa7606362bc269a Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Wed, 27 Aug 2025 00:20:23 -0700 Subject: [PATCH 56/77] Centrally defined ModelsResponseData PHPStan types. --- .../Anthropic/AnthropicModelMetadataDirectory.php | 8 +++++--- .../Google/GoogleModelMetadataDirectory.php | 13 ++++++++++--- .../OpenAi/OpenAiModelMetadataDirectory.php | 7 +++++-- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php b/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php index 2924f45a..dcc0796c 100644 --- a/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php +++ b/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php @@ -21,6 +21,10 @@ * Class for the Anthropic model metadata directory. * * @since n.e.x.t + * + * @phpstan-type ModelsResponseData array{ + * data: list + * } */ class AnthropicModelMetadataDirectory extends AbstractOpenAiCompatibleModelMetadataDirectory { @@ -64,6 +68,7 @@ protected function createRequest(HttpMethodEnum $method, string $path, array $he */ protected function parseResponseToModelMetadataList(Response $response): array { + /** @var ModelsResponseData $responseData */ $responseData = $response->getData(); if (!isset($responseData['data']) || !$responseData['data']) { throw new RuntimeException( @@ -104,7 +109,6 @@ protected function parseResponseToModelMetadataList(Response $response): array new SupportedOption(OptionEnum::webSearch()), ]); - /** @var array> $modelsData */ $modelsData = (array) $responseData['data']; return array_values( @@ -114,7 +118,6 @@ static function (array $modelData) use ( $anthropicOptions, $anthropicWebSearchOptions, ): ModelMetadata { - /** @var string $modelId */ $modelId = $modelData['id']; $modelCaps = $anthropicCapabilities; if (!preg_match('/^claude-3-[a-z]+/', $modelId)) { @@ -124,7 +127,6 @@ static function (array $modelData) use ( $modelOptions = $anthropicOptions; } - /** @var string $modelName */ $modelName = $modelData['display_name'] ?? $modelId; return new ModelMetadata( diff --git a/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php b/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php index 02b4e6d1..8f639f24 100644 --- a/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php +++ b/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php @@ -23,6 +23,15 @@ * Class for the Google model metadata directory. * * @since n.e.x.t + * + * @phpstan-type ModelsResponseData array{ + * models: list, + * displayName?: string + * }> + * } */ class GoogleModelMetadataDirectory extends AbstractOpenAiCompatibleModelMetadataDirectory { @@ -75,6 +84,7 @@ protected function createRequest(HttpMethodEnum $method, string $path, array $he */ protected function parseResponseToModelMetadataList(Response $response): array { + /** @var ModelsResponseData $responseData */ $responseData = $response->getData(); if (!isset($responseData['models']) || !$responseData['models']) { throw new RuntimeException( @@ -154,7 +164,6 @@ protected function parseResponseToModelMetadataList(Response $response): array new SupportedOption(OptionEnum::outputMediaAspectRatio(), ['1:1', '16:9', '4:3', '9:16', '3:4']), ]; - /** @var array> $modelsData */ $modelsData = (array) $responseData['models']; return array_values( @@ -168,7 +177,6 @@ static function (array $modelData) use ( $imagenCapabilities, $imagenOptions, ): ModelMetadata { - /** @var string $modelId */ $modelId = $modelData['baseModelId'] ?? $modelData['name']; if (str_starts_with($modelId, 'models/')) { $modelId = substr($modelId, 7); @@ -213,7 +221,6 @@ static function (array $modelData) use ( $modelOptions = []; } - /** @var string $modelName */ $modelName = $modelData['displayName'] ?? $modelId; return new ModelMetadata( diff --git a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php index 7b1c3714..d312fd32 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php +++ b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php @@ -21,6 +21,10 @@ * Class for the OpenAI model metadata directory. * * @since n.e.x.t + * + * @phpstan-type ModelsResponseData array{ + * data: list + * } */ class OpenAiModelMetadataDirectory extends AbstractOpenAiCompatibleModelMetadataDirectory { @@ -46,6 +50,7 @@ protected function createRequest(HttpMethodEnum $method, string $path, array $he */ protected function parseResponseToModelMetadataList(Response $response): array { + /** @var ModelsResponseData $responseData */ $responseData = $response->getData(); if (!isset($responseData['data']) || !$responseData['data']) { throw new RuntimeException( @@ -145,7 +150,6 @@ protected function parseResponseToModelMetadataList(Response $response): array new SupportedOption(OptionEnum::outputSpeechVoice()), ]; - /** @var array> $modelsData */ $modelsData = (array) $responseData['data']; return array_values( @@ -161,7 +165,6 @@ static function (array $modelData) use ( $ttsCapabilities, $ttsOptions, ): ModelMetadata { - /** @var string $modelId */ $modelId = $modelData['id']; if ( str_starts_with($modelId, 'dall-e-') || From a8dfd8a15439d461d99cbe8e4fd1783119efa709 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Wed, 27 Aug 2025 15:07:39 -0700 Subject: [PATCH 57/77] feat: adds bindModelDependencies public method --- src/Providers/ProviderRegistry.php | 24 ++- tests/mocks/MockModel.php | 4 +- tests/unit/Providers/ProviderRegistryTest.php | 138 ++++++++++++++++++ 3 files changed, 160 insertions(+), 6 deletions(-) diff --git a/src/Providers/ProviderRegistry.php b/src/Providers/ProviderRegistry.php index 6d38197d..af17b10a 100644 --- a/src/Providers/ProviderRegistry.php +++ b/src/Providers/ProviderRegistry.php @@ -245,10 +245,28 @@ public function getProviderModel( ): ModelInterface { $className = $this->resolveProviderClassName($idOrClassName); - // Use static method from ProviderInterface - /** @var class-string $className */ $modelInstance = $className::model($modelId, $modelConfig); + $this->bindModelDependencies($modelInstance); + + return $modelInstance; + } + + /** + * Binds dependencies to a model instance. + * + * This method injects required dependencies such as HTTP transporter + * and authentication into model instances that need them. + * + * @since n.e.x.t + * + * @param ModelInterface $modelInstance The model instance to bind dependencies to. + * @return void + */ + public function bindModelDependencies(ModelInterface $modelInstance): void + { + $className = $this->resolveProviderClassName($modelInstance->providerMetadata()->getId()); + if ($modelInstance instanceof WithHttpTransporterInterface) { $modelInstance->setHttpTransporter($this->getHttpTransporter()); } @@ -259,8 +277,6 @@ public function getProviderModel( $modelInstance->setRequestAuthentication($requestAuthentication); } } - - return $modelInstance; } /** diff --git a/tests/mocks/MockModel.php b/tests/mocks/MockModel.php index 19027aac..7623b122 100644 --- a/tests/mocks/MockModel.php +++ b/tests/mocks/MockModel.php @@ -58,8 +58,8 @@ public function metadata(): ModelMetadata */ public function providerMetadata(): ProviderMetadata { - // This mock doesn't need to return actual provider metadata for its tests. - return $this->createMock(ProviderMetadata::class); + // Return the MockProvider's metadata + return MockProvider::metadata(); } /** diff --git a/tests/unit/Providers/ProviderRegistryTest.php b/tests/unit/Providers/ProviderRegistryTest.php index 4dd57fa6..91126997 100644 --- a/tests/unit/Providers/ProviderRegistryTest.php +++ b/tests/unit/Providers/ProviderRegistryTest.php @@ -419,4 +419,142 @@ public function testCreateDefaultProviderRequestAuthenticationWithoutEnvVar(): v $this->assertNull($auth); } + + /** + * Tests bindModelDependencies with HTTP transporter. + * + * @return void + */ + public function testBindModelDependenciesWithHttpTransporter(): void + { + // Register provider and set HTTP transporter + $this->registry->registerProvider(MockProvider::class); + $httpTransporter = new MockHttpTransporter(); + $this->registry->setHttpTransporter($httpTransporter); + + // Create a mock model + $modelMetadata = new ModelMetadata( + 'test-model', + 'Test Model', + [CapabilityEnum::textGeneration()], + [] + ); + $modelConfig = new ModelConfig(); + + // Create a mock model instance that implements WithHttpTransporterInterface + $modelInstance = $this->createMock(MockModel::class); + $modelInstance->expects($this->once()) + ->method('providerMetadata') + ->willReturn(MockProvider::metadata()); + + $modelInstance->expects($this->once()) + ->method('setHttpTransporter') + ->with($httpTransporter); + + // Call bindModelDependencies + $this->registry->bindModelDependencies($modelInstance); + } + + /** + * Tests bindModelDependencies with request authentication. + * + * @return void + */ + public function testBindModelDependenciesWithRequestAuthentication(): void + { + // Register provider and set authentication + $this->registry->registerProvider(MockProvider::class); + $authentication = new MockRequestAuthentication('test-api-key'); + $this->registry->setProviderRequestAuthentication('mock', $authentication); + + // Set HTTP transporter (required by registry) + $httpTransporter = new MockHttpTransporter(); + $this->registry->setHttpTransporter($httpTransporter); + + // Create a mock model instance that implements WithRequestAuthenticationInterface + $modelInstance = $this->createMock(MockModel::class); + $modelInstance->expects($this->once()) + ->method('providerMetadata') + ->willReturn(MockProvider::metadata()); + + $modelInstance->expects($this->once()) + ->method('setHttpTransporter') + ->with($httpTransporter); + + $modelInstance->expects($this->once()) + ->method('setRequestAuthentication') + ->with($authentication); + + // Call bindModelDependencies + $this->registry->bindModelDependencies($modelInstance); + } + + /** + * Tests bindModelDependencies with model that doesn't need dependencies. + * + * @return void + */ + public function testBindModelDependenciesWithSimpleModel(): void + { + // Register provider + $this->registry->registerProvider(MockProvider::class); + + // Create a mock model that doesn't implement dependency interfaces + $modelInstance = $this->createMock(\WordPress\AiClient\Providers\Models\Contracts\ModelInterface::class); + $modelInstance->expects($this->once()) + ->method('providerMetadata') + ->willReturn(MockProvider::metadata()); + + // Call bindModelDependencies - should not throw any errors + $this->registry->bindModelDependencies($modelInstance); + + // Test passes if no exceptions are thrown + $this->assertTrue(true); + } + + /** + * Tests bindModelDependencies with unregistered provider. + * + * @return void + */ + public function testBindModelDependenciesWithUnregisteredProvider(): void + { + // Create a mock model with a provider that's not registered + $providerMetadata = $this->createMock(\WordPress\AiClient\Providers\DTO\ProviderMetadata::class); + $providerMetadata->method('getId')->willReturn('unregistered-provider'); + + $modelInstance = $this->createMock(\WordPress\AiClient\Providers\Models\Contracts\ModelInterface::class); + $modelInstance->expects($this->once()) + ->method('providerMetadata') + ->willReturn($providerMetadata); + + // Expect exception when trying to bind dependencies for unregistered provider + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Provider not registered: unregistered-provider'); + + $this->registry->bindModelDependencies($modelInstance); + } + + /** + * Tests bindModelDependencies without HTTP transporter when model needs it. + * + * @return void + */ + public function testBindModelDependenciesWithoutHttpTransporter(): void + { + // Register provider but don't set HTTP transporter + $this->registry->registerProvider(MockProvider::class); + + // Create a mock model instance that implements WithHttpTransporterInterface + $modelInstance = $this->createMock(MockModel::class); + $modelInstance->expects($this->once()) + ->method('providerMetadata') + ->willReturn(MockProvider::metadata()); + + // Expect runtime exception when trying to get HTTP transporter that isn't set + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('HttpTransporterInterface instance not set'); + + $this->registry->bindModelDependencies($modelInstance); + } } From aa892531cf297f7d9051375fd7226b74c25346a2 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Wed, 27 Aug 2025 15:19:19 -0700 Subject: [PATCH 58/77] feat: merges the configs in usingModel --- src/Builders/PromptBuilder.php | 11 ++ tests/unit/Builders/PromptBuilderTest.php | 116 ++++++++++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/src/Builders/PromptBuilder.php b/src/Builders/PromptBuilder.php index 096915c3..f4061419 100644 --- a/src/Builders/PromptBuilder.php +++ b/src/Builders/PromptBuilder.php @@ -192,6 +192,9 @@ public function withHistory(Message ...$messages): self /** * Sets the model to use for generation. * + * The model's configuration will be merged with the builder's configuration, + * with the builder's configuration taking precedence for any overlapping settings. + * * @since n.e.x.t * * @param ModelInterface $model The model to use. @@ -200,6 +203,14 @@ public function withHistory(Message ...$messages): self public function usingModel(ModelInterface $model): self { $this->model = $model; + + // Merge model's config with builder's config, with builder's config taking precedence + $modelConfigArray = $model->getConfig()->toArray(); + $builderConfigArray = $this->modelConfig->toArray(); + $mergedConfigArray = array_merge($modelConfigArray, $builderConfigArray); + + $this->modelConfig = ModelConfig::fromArray($mergedConfigArray); + return $this; } diff --git a/tests/unit/Builders/PromptBuilderTest.php b/tests/unit/Builders/PromptBuilderTest.php index 7397edc4..f48afba3 100644 --- a/tests/unit/Builders/PromptBuilderTest.php +++ b/tests/unit/Builders/PromptBuilderTest.php @@ -709,7 +709,11 @@ public function testWithHistory(): void */ public function testUsingModel(): void { + // Create a model with empty config + $modelConfig = new ModelConfig(); $model = $this->createMock(ModelInterface::class); + $model->method('getConfig')->willReturn($modelConfig); + $builder = new PromptBuilder($this->registry); $result = $builder->usingModel($model); @@ -724,6 +728,118 @@ public function testUsingModel(): void $this->assertSame($model, $actualModel); } + /** + * Tests usingModel merges model config with builder config. + * + * @return void + */ + public function testUsingModelMergesConfigs(): void + { + // Create model config with some settings + $modelConfig = ModelConfig::fromArray([ + ModelConfig::KEY_TEMPERATURE => 0.5, + ModelConfig::KEY_MAX_TOKENS => 100, + ModelConfig::KEY_TOP_P => 0.8, + ]); + + // Create builder config with overlapping and different settings + $builderConfig = ModelConfig::fromArray([ + ModelConfig::KEY_TEMPERATURE => 0.7, // Should override model's value + ModelConfig::KEY_TOP_K => 40, // New setting + ]); + + $model = $this->createMock(ModelInterface::class); + $model->method('getConfig')->willReturn($modelConfig); + + $builder = new PromptBuilder($this->registry, null, $builderConfig); + $builder->usingModel($model); + + // Get the merged config + $reflection = new \ReflectionClass($builder); + $configProperty = $reflection->getProperty('modelConfig'); + $configProperty->setAccessible(true); + + /** @var ModelConfig $mergedConfig */ + $mergedConfig = $configProperty->getValue($builder); + + // Check that builder's config took precedence + $this->assertEquals(0.7, $mergedConfig->getTemperature()); + + // Check that model's config was preserved where not overridden + $this->assertEquals(100, $mergedConfig->getMaxTokens()); + $this->assertEquals(0.8, $mergedConfig->getTopP()); + + // Check that builder's additional config was included + $this->assertEquals(40, $mergedConfig->getTopK()); + } + + /** + * Tests usingModel with model having empty config. + * + * @return void + */ + public function testUsingModelWithEmptyModelConfig(): void + { + // Create builder config with settings + $builderConfig = ModelConfig::fromArray([ + ModelConfig::KEY_TEMPERATURE => 0.9, + ModelConfig::KEY_MAX_TOKENS => 200, + ]); + + // Model with empty config + $modelConfig = new ModelConfig(); + $model = $this->createMock(ModelInterface::class); + $model->method('getConfig')->willReturn($modelConfig); + + $builder = new PromptBuilder($this->registry, null, $builderConfig); + $builder->usingModel($model); + + // Get the config + $reflection = new \ReflectionClass($builder); + $configProperty = $reflection->getProperty('modelConfig'); + $configProperty->setAccessible(true); + + /** @var ModelConfig $mergedConfig */ + $mergedConfig = $configProperty->getValue($builder); + + // Check that builder's config is preserved + $this->assertEquals(0.9, $mergedConfig->getTemperature()); + $this->assertEquals(200, $mergedConfig->getMaxTokens()); + } + + /** + * Tests usingModel with builder having empty config. + * + * @return void + */ + public function testUsingModelWithEmptyBuilderConfig(): void + { + // Model config with settings + $modelConfig = ModelConfig::fromArray([ + ModelConfig::KEY_TEMPERATURE => 0.6, + ModelConfig::KEY_MAX_TOKENS => 150, + ]); + + $model = $this->createMock(ModelInterface::class); + $model->method('getConfig')->willReturn($modelConfig); + + // Builder with empty/default config + $builder = new PromptBuilder($this->registry); + $builder->usingModel($model); + + // Get the config + $reflection = new \ReflectionClass($builder); + $configProperty = $reflection->getProperty('modelConfig'); + $configProperty->setAccessible(true); + + /** @var ModelConfig $mergedConfig */ + $mergedConfig = $configProperty->getValue($builder); + + // Check that model's config is adopted + $this->assertEquals(0.6, $mergedConfig->getTemperature()); + $this->assertEquals(150, $mergedConfig->getMaxTokens()); + } + /** * Tests usingProvider method. * From 504479618c2256533288fa1e5e65f2d2bbdbb327 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Wed, 27 Aug 2025 15:42:12 -0700 Subject: [PATCH 59/77] Add since annotations for methods with inheritDoc. --- src/Providers/AbstractProvider.php | 16 ++++++++++++---- .../AbstractApiBasedModel.php | 16 ++++++++++++---- .../AbstractApiBasedModelMetadataDirectory.php | 12 +++++++++--- .../GenerateTextApiBasedProviderAvailability.php | 4 +++- .../ListModelsApiBasedProviderAvailability.php | 4 +++- ...actOpenAiCompatibleModelMetadataDirectory.php | 4 +++- 6 files changed, 42 insertions(+), 14 deletions(-) diff --git a/src/Providers/AbstractProvider.php b/src/Providers/AbstractProvider.php index 5d8e2ef9..802ce8cc 100644 --- a/src/Providers/AbstractProvider.php +++ b/src/Providers/AbstractProvider.php @@ -35,7 +35,9 @@ abstract class AbstractProvider implements ProviderInterface private static array $modelMetadataDirectoryCache = []; /** - * @inheritdoc + * {@inheritDoc} + * + * @since n.e.x.t */ final public static function metadata(): ProviderMetadata { @@ -47,7 +49,9 @@ final public static function metadata(): ProviderMetadata } /** - * @inheritdoc + * {@inheritDoc} + * + * @since n.e.x.t */ final public static function model(string $modelId, ?ModelConfig $modelConfig = null): ModelInterface { @@ -62,7 +66,9 @@ final public static function model(string $modelId, ?ModelConfig $modelConfig = } /** - * @inheritdoc + * {@inheritDoc} + * + * @since n.e.x.t */ final public static function availability(): ProviderAvailabilityInterface { @@ -74,7 +80,9 @@ final public static function availability(): ProviderAvailabilityInterface } /** - * @inheritdoc + * {@inheritDoc} + * + * @since n.e.x.t */ final public static function modelMetadataDirectory(): ModelMetadataDirectoryInterface { diff --git a/src/Providers/ApiBasedImplementation/AbstractApiBasedModel.php b/src/Providers/ApiBasedImplementation/AbstractApiBasedModel.php index ee9932f7..7d6a34ef 100644 --- a/src/Providers/ApiBasedImplementation/AbstractApiBasedModel.php +++ b/src/Providers/ApiBasedImplementation/AbstractApiBasedModel.php @@ -60,7 +60,9 @@ public function __construct(ModelMetadata $metadata, ProviderMetadata $providerM } /** - * @inheritdoc + * {@inheritDoc} + * + * @since n.e.x.t */ final public function metadata(): ModelMetadata { @@ -68,7 +70,9 @@ final public function metadata(): ModelMetadata } /** - * @inheritdoc + * {@inheritDoc} + * + * @since n.e.x.t */ final public function providerMetadata(): ProviderMetadata { @@ -76,7 +80,9 @@ final public function providerMetadata(): ProviderMetadata } /** - * @inheritdoc + * {@inheritDoc} + * + * @since n.e.x.t */ final public function setConfig(ModelConfig $config): void { @@ -84,7 +90,9 @@ final public function setConfig(ModelConfig $config): void } /** - * @inheritdoc + * {@inheritDoc} + * + * @since n.e.x.t */ final public function getConfig(): ModelConfig { diff --git a/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php b/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php index d531cb99..aa27265f 100644 --- a/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php +++ b/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php @@ -31,7 +31,9 @@ abstract class AbstractApiBasedModelMetadataDirectory implements private ?array $modelMetadataMap = null; /** - * @inheritdoc + * {@inheritDoc} + * + * @since n.e.x.t */ final public function listModelMetadata(): array { @@ -40,7 +42,9 @@ final public function listModelMetadata(): array } /** - * @inheritdoc + * {@inheritDoc} + * + * @since n.e.x.t */ final public function hasModelMetadata(string $modelId): bool { @@ -49,7 +53,9 @@ final public function hasModelMetadata(string $modelId): bool } /** - * @inheritdoc + * {@inheritDoc} + * + * @since n.e.x.t */ final public function getModelMetadata(string $modelId): ModelMetadata { diff --git a/src/Providers/ApiBasedImplementation/GenerateTextApiBasedProviderAvailability.php b/src/Providers/ApiBasedImplementation/GenerateTextApiBasedProviderAvailability.php index ef4f4acd..5b00ee72 100644 --- a/src/Providers/ApiBasedImplementation/GenerateTextApiBasedProviderAvailability.php +++ b/src/Providers/ApiBasedImplementation/GenerateTextApiBasedProviderAvailability.php @@ -43,7 +43,9 @@ public function __construct(ModelInterface $model) } /** - * @inheritdoc + * {@inheritDoc} + * + * @since n.e.x.t */ public function isConfigured(): bool { diff --git a/src/Providers/ApiBasedImplementation/ListModelsApiBasedProviderAvailability.php b/src/Providers/ApiBasedImplementation/ListModelsApiBasedProviderAvailability.php index a9165be7..a5cf50db 100644 --- a/src/Providers/ApiBasedImplementation/ListModelsApiBasedProviderAvailability.php +++ b/src/Providers/ApiBasedImplementation/ListModelsApiBasedProviderAvailability.php @@ -34,7 +34,9 @@ public function __construct(ModelMetadataDirectoryInterface $modelMetadataDirect } /** - * @inheritdoc + * {@inheritDoc} + * + * @since n.e.x.t */ public function isConfigured(): bool { diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectory.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectory.php index e58f35f9..2c0c7ae0 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectory.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectory.php @@ -20,7 +20,9 @@ abstract class AbstractOpenAiCompatibleModelMetadataDirectory extends AbstractApiBasedModelMetadataDirectory { /** - * @inheritdoc + * {@inheritDoc} + * + * @since n.e.x.t */ protected function sendListModelsRequest(): array { From e87d4f3675fd80d0001cd2bfda8fdf4684604c55 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Wed, 27 Aug 2025 15:45:10 -0700 Subject: [PATCH 60/77] refactor: add array data types --- ...actOpenAiCompatibleTextGenerationModel.php | 43 +++++++++++++++---- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php index 0aabd77d..24876225 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php @@ -30,6 +30,35 @@ * Base class for a text generation model for an OpenAI compatible provider. * * @since n.e.x.t + * + * @phpstan-type ToolCallData array{ + * type?: string, + * id?: string, + * function?: array{ + * name?: string, + * arguments: string|array + * } + * } + * @phpstan-type MessageData array{ + * role?: string, + * reasoning_content?: string, + * content?: string, + * tool_calls?: list + * } + * @phpstan-type ChoiceData array{ + * message?: MessageData, + * finish_reason?: string + * } + * @phpstan-type UsageData array{ + * prompt_tokens?: int, + * completion_tokens?: int, + * total_tokens?: int + * } + * @phpstan-type ResponseData array{ + * id?: string, + * choices?: list, + * usage?: UsageData + * } */ abstract class AbstractOpenAiCompatibleTextGenerationModel extends AbstractApiBasedModel implements TextGenerationModelInterface @@ -520,6 +549,7 @@ protected function throwIfNotSuccessful(Response $response): void */ protected function parseResponseToGenerativeAiResult(Response $response): GenerativeAiResult { + /** @var ResponseData $responseData */ $responseData = $response->getData(); if (!isset($responseData['choices']) || !$responseData['choices']) { throw new RuntimeException( @@ -540,14 +570,12 @@ protected function parseResponseToGenerativeAiResult(Response $response): Genera ); } - /** @var array $choiceData */ $candidates[] = $this->parseResponseChoiceToCandidate($choiceData); } $id = isset($responseData['id']) && is_string($responseData['id']) ? $responseData['id'] : ''; if (isset($responseData['usage']) && is_array($responseData['usage'])) { - /** @var array $usage */ $usage = $responseData['usage']; $tokenUsage = new TokenUsage( @@ -578,7 +606,7 @@ protected function parseResponseToGenerativeAiResult(Response $response): Genera * * @since n.e.x.t * - * @param array $choiceData The choice data from the API response. + * @param ChoiceData $choiceData The choice data from the API response. * @return Candidate The parsed candidate. * @throws RuntimeException If the choice data is invalid. */ @@ -600,7 +628,6 @@ protected function parseResponseChoiceToCandidate(array $choiceData): Candidate ); } - /** @var array $messageData */ $messageData = $choiceData['message']; $message = $this->parseResponseChoiceMessage($messageData); @@ -634,7 +661,7 @@ protected function parseResponseChoiceToCandidate(array $choiceData): Candidate * * @since n.e.x.t * - * @param array $messageData The message data from the API response. + * @param MessageData $messageData The message data from the API response. * @return Message The parsed message. */ protected function parseResponseChoiceMessage(array $messageData): Message @@ -653,7 +680,7 @@ protected function parseResponseChoiceMessage(array $messageData): Message * * @since n.e.x.t * - * @param array $messageData The message data from the API response. + * @param MessageData $messageData The message data from the API response. * @return MessagePart[] The parsed message parts. */ protected function parseResponseChoiceMessageParts(array $messageData): array @@ -670,7 +697,6 @@ protected function parseResponseChoiceMessageParts(array $messageData): array if (isset($messageData['tool_calls']) && is_array($messageData['tool_calls'])) { foreach ($messageData['tool_calls'] as $toolCallData) { - /** @var array $toolCallData */ $toolCallPart = $this->parseResponseChoiceMessageToolCallPart($toolCallData); if (!$toolCallPart) { throw new RuntimeException( @@ -689,7 +715,7 @@ protected function parseResponseChoiceMessageParts(array $messageData): array * * @since n.e.x.t * - * @param array $toolCallData The tool call data from the API response. + * @param ToolCallData $toolCallData The tool call data from the API response. * @return MessagePart|null The parsed message part for the tool call, or null if not applicable. */ protected function parseResponseChoiceMessageToolCallPart(array $toolCallData): ?MessagePart @@ -707,7 +733,6 @@ protected function parseResponseChoiceMessageToolCallPart(array $toolCallData): return null; } - /** @var array $functionArguments */ $functionArguments = is_string($toolCallData['function']['arguments']) ? json_decode($toolCallData['function']['arguments'], true) : $toolCallData['function']['arguments']; From 50850b6f3beef1a6fcd8ab35969716ad5330e89b Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Wed, 27 Aug 2025 15:45:24 -0700 Subject: [PATCH 61/77] Clarify in comment why we ignore exception. --- src/Providers/ProviderRegistry.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Providers/ProviderRegistry.php b/src/Providers/ProviderRegistry.php index af17b10a..ff352714 100644 --- a/src/Providers/ProviderRegistry.php +++ b/src/Providers/ProviderRegistry.php @@ -88,7 +88,12 @@ public function registerProvider(string $className): void $httpTransporter = $this->getHttpTransporter(); $this->setHttpTransporterForProvider($className, $httpTransporter); } catch (RuntimeException $e) { - // Ignore. + /* + * If this fails, it's okay. There is no defined sequence between setting the HTTP transporter in the + * registry and registering providers in it, so it might be that the transporter is set later. It will be + * hooked up then. + * Therefore we can simply ignore this exception. + */ } // Hook up the request authentication instance, using a default if not set. From 36df670e2706f76fce10103b2f11ffa3dfdb42ec Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Wed, 27 Aug 2025 15:48:03 -0700 Subject: [PATCH 62/77] Avoid unnecessary middleman functions. Co-authored-by: Jason Adams --- .../AbstractOpenAiCompatibleTextGenerationModel.php | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php index 24876225..99b63582 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php @@ -239,15 +239,11 @@ function (Message $message): array { return [ 'role' => $this->getMessageRoleString($message->getRole()), 'content' => array_values(array_filter(array_map( - function (MessagePart $part): ?array { - return $this->getMessagePartContentData($part); - }, + [$this, 'getMessagePartContentData], $messageParts ))), 'tool_calls' => array_values(array_filter(array_map( - function (MessagePart $part): ?array { - return $this->getMessagePartToolCallData($part); - }, + [$this, 'getMessagePartToolCallData], $messageParts ))), ]; From 005fed1575aa1bf0d27564d416df2326b4149ac3 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Wed, 27 Aug 2025 16:03:20 -0700 Subject: [PATCH 63/77] Fix fatal error and clarify throwIfNotSuccessful method presence. --- .../AbstractOpenAiCompatibleModelMetadataDirectory.php | 4 ++++ .../AbstractOpenAiCompatibleTextGenerationModel.php | 8 ++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectory.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectory.php index 2c0c7ae0..a267eeeb 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectory.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectory.php @@ -71,6 +71,10 @@ abstract protected function createRequest( */ protected function throwIfNotSuccessful(Response $response): void { + /* + * While this method only calls the utility method, it's important to have it here as a protected method so + * that child classes can override it if needed. + */ ResponseUtil::throwIfNotSuccessful($response); } diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php index 99b63582..f64af886 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php @@ -239,11 +239,11 @@ function (Message $message): array { return [ 'role' => $this->getMessageRoleString($message->getRole()), 'content' => array_values(array_filter(array_map( - [$this, 'getMessagePartContentData], + [$this, 'getMessagePartContentData'], $messageParts ))), 'tool_calls' => array_values(array_filter(array_map( - [$this, 'getMessagePartToolCallData], + [$this, 'getMessagePartToolCallData'], $messageParts ))), ]; @@ -532,6 +532,10 @@ abstract protected function createRequest( */ protected function throwIfNotSuccessful(Response $response): void { + /* + * While this method only calls the utility method, it's important to have it here as a protected method so + * that child classes can override it if needed. + */ ResponseUtil::throwIfNotSuccessful($response); } From a23d42d73c07f88e31cea303093a87ad81f0b9e5 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Wed, 27 Aug 2025 16:22:24 -0700 Subject: [PATCH 64/77] Remove ModelConfig from PromptBuilder constructor again in favor of usingModelConfig method. --- cli.php | 3 ++- src/Builders/PromptBuilder.php | 5 ++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cli.php b/cli.php index 38f0717d..54728e3e 100755 --- a/cli.php +++ b/cli.php @@ -159,7 +159,8 @@ function logError(string $message, int $exit_code = 1): void try { $modelConfig = ModelConfig::fromArray($model_config_data); - $promptBuilder = new PromptBuilder($providerRegistry, $promptInput, $modelConfig); + $promptBuilder = new PromptBuilder($providerRegistry, $promptInput); + $promptBuilder = $promptBuilder->usingModelConfig($modelConfig); if ($providerId && $modelId) { $providerClassName = $providerRegistry->getProviderClassName($providerId); $promptBuilder = $promptBuilder->usingModel($providerClassName::model($modelId)); diff --git a/src/Builders/PromptBuilder.php b/src/Builders/PromptBuilder.php index 119605e7..8fcaaaa7 100644 --- a/src/Builders/PromptBuilder.php +++ b/src/Builders/PromptBuilder.php @@ -75,13 +75,12 @@ class PromptBuilder * * @param ProviderRegistry $registry The provider registry for finding suitable models. * @param Prompt $prompt Optional initial prompt content. - * @param ModelConfig|null $modelConfig Optional initial model configuration. */ // phpcs:enable Generic.Files.LineLength.TooLong - public function __construct(ProviderRegistry $registry, $prompt = null, ?ModelConfig $modelConfig = null) + public function __construct(ProviderRegistry $registry, $prompt = null) { $this->registry = $registry; - $this->modelConfig = $modelConfig ?? new ModelConfig(); + $this->modelConfig = new ModelConfig(); if ($prompt === null) { return; From 11ccd68f81fd5e0e8fb9ecf758f3b286c9b6c58e Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 28 Aug 2025 15:12:03 -0700 Subject: [PATCH 65/77] refactor: uses passthrough isRemote method --- .../AbstractOpenAiCompatibleTextGenerationModel.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php index f64af886..a912bc5b 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php @@ -311,7 +311,7 @@ protected function getMessagePartContentData(MessagePart $part): ?array 'The file typed message part must contain a file.' ); } - if ($file->getFileType()->isRemote()) { + if ($file->isRemote()) { if ($file->isImage()) { return [ 'type' => 'image_url', From 702f4fbeabb33c1b9d2e1e09f3d14b7f96863520 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Thu, 28 Aug 2025 15:46:50 -0700 Subject: [PATCH 66/77] Properly wire up default registry in AiClient. --- src/AiClient.php | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/AiClient.php b/src/AiClient.php index dbafd5d3..80ffbd62 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -5,7 +5,11 @@ namespace WordPress\AiClient; use WordPress\AiClient\Builders\PromptBuilder; +use WordPress\AiClient\ProviderImplementations\Anthropic\AnthropicProvider; +use WordPress\AiClient\ProviderImplementations\Google\GoogleProvider; +use WordPress\AiClient\ProviderImplementations\OpenAi\OpenAiProvider; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; +use WordPress\AiClient\Providers\Http\HttpTransporterFactory; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\ProviderRegistry; @@ -95,12 +99,11 @@ public static function defaultRegistry(): ProviderRegistry if (self::$defaultRegistry === null) { $registry = new ProviderRegistry(); - // Provider registration will be enabled once concrete provider implementations are available. - // This follows the pattern established in the provider registry architecture. - //$registry->setHttpTransporter(HttpTransporterFactory::createTransporter()); - //$registry->registerProvider(AnthropicProvider::class); - //$registry->registerProvider(GoogleProvider::class); - //$registry->registerProvider(OpenAiProvider::class); + // Set up default HTTP transporter and register built-in providers. + $registry->setHttpTransporter(HttpTransporterFactory::createTransporter()); + $registry->registerProvider(AnthropicProvider::class); + $registry->registerProvider(GoogleProvider::class); + $registry->registerProvider(OpenAiProvider::class); self::$defaultRegistry = $registry; } From e92b8ee71f69c33ba0d919ea95086418a56bba49 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Thu, 28 Aug 2025 15:47:19 -0700 Subject: [PATCH 67/77] Use AiClient in CLI test tool. --- cli.php | 24 +++--------------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/cli.php b/cli.php index 54728e3e..17f913f9 100755 --- a/cli.php +++ b/cli.php @@ -13,18 +13,9 @@ declare(strict_types=1); -use WordPress\AiClient\Builders\PromptBuilder; -use WordPress\AiClient\ProviderImplementations\Anthropic\AnthropicProvider; -use WordPress\AiClient\ProviderImplementations\Google\GoogleProvider; -use WordPress\AiClient\ProviderImplementations\OpenAi\OpenAiProvider; +use WordPress\AiClient\AiClient; use WordPress\AiClient\Providers\Http\Exception\ResponseException; -use WordPress\AiClient\Providers\Http\HttpTransporterFactory; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; -use WordPress\AiClient\Providers\Models\DTO\ModelRequirements; -use WordPress\AiClient\Providers\Models\DTO\RequiredOption; -use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; -use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface; -use WordPress\AiClient\Providers\ProviderRegistry; require_once __DIR__ . '/vendor/autoload.php'; @@ -145,24 +136,15 @@ function logError(string $message, int $exit_code = 1): void $model_config_data[$key] = $processed_value; } -// --- SDK setup --- - -// This will eventually be obsolete, as the AiClient class will handle it. -$providerRegistry = new ProviderRegistry(); -$providerRegistry->setHttpTransporter(HttpTransporterFactory::createTransporter()); -$providerRegistry->registerProvider(AnthropicProvider::class); -$providerRegistry->registerProvider(GoogleProvider::class); -$providerRegistry->registerProvider(OpenAiProvider::class); - // --- Main logic --- try { $modelConfig = ModelConfig::fromArray($model_config_data); - $promptBuilder = new PromptBuilder($providerRegistry, $promptInput); + $promptBuilder = AiClient::prompt($promptInput); $promptBuilder = $promptBuilder->usingModelConfig($modelConfig); if ($providerId && $modelId) { - $providerClassName = $providerRegistry->getProviderClassName($providerId); + $providerClassName = AiClient::defaultRegistry()->getProviderClassName($providerId); $promptBuilder = $promptBuilder->usingModel($providerClassName::model($modelId)); } elseif ($providerId) { $promptBuilder = $promptBuilder->usingProvider($providerId); From 0845206aabf9fb16463b5e6cf5d3a9c3779b2e53 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Thu, 28 Aug 2025 15:47:39 -0700 Subject: [PATCH 68/77] Fix errors in unit tests. --- tests/traits/MockModelCreationTrait.php | 52 ++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/tests/traits/MockModelCreationTrait.php b/tests/traits/MockModelCreationTrait.php index 0f4517fe..358ef0c1 100644 --- a/tests/traits/MockModelCreationTrait.php +++ b/tests/traits/MockModelCreationTrait.php @@ -116,14 +116,29 @@ protected function createMockTextGenerationModel( ): ModelInterface { $metadata = $metadata ?? $this->createTestTextModelMetadata(); - return new class ($metadata, $result) implements ModelInterface, TextGenerationModelInterface { + $providerMetadata = new ProviderMetadata( + 'mock-provider', + 'Mock Provider', + ProviderTypeEnum::cloud() + ); + + return new class ( + $metadata, + $providerMetadata, + $result + ) implements ModelInterface, TextGenerationModelInterface { private ModelMetadata $metadata; + private ProviderMetadata $providerMetadata; private GenerativeAiResult $result; private ModelConfig $config; - public function __construct(ModelMetadata $metadata, GenerativeAiResult $result) - { + public function __construct( + ModelMetadata $metadata, + ProviderMetadata $providerMetadata, + GenerativeAiResult $result + ) { $this->metadata = $metadata; + $this->providerMetadata = $providerMetadata; $this->result = $result; $this->config = new ModelConfig(); } @@ -133,6 +148,11 @@ public function metadata(): ModelMetadata return $this->metadata; } + public function providerMetadata(): ProviderMetadata + { + return $this->providerMetadata; + } + public function setConfig(ModelConfig $config): void { $this->config = $config; @@ -168,14 +188,29 @@ protected function createMockImageGenerationModel( ): ModelInterface { $metadata = $metadata ?? $this->createTestImageModelMetadata(); - return new class ($metadata, $result) implements ModelInterface, ImageGenerationModelInterface { + $providerMetadata = new ProviderMetadata( + 'mock-provider', + 'Mock Provider', + ProviderTypeEnum::cloud() + ); + + return new class ( + $metadata, + $providerMetadata, + $result + ) implements ModelInterface, ImageGenerationModelInterface { private ModelMetadata $metadata; + private ProviderMetadata $providerMetadata; private GenerativeAiResult $result; private ModelConfig $config; - public function __construct(ModelMetadata $metadata, GenerativeAiResult $result) - { + public function __construct( + ModelMetadata $metadata, + ProviderMetadata $providerMetadata, + GenerativeAiResult $result + ) { $this->metadata = $metadata; + $this->providerMetadata = $providerMetadata; $this->result = $result; $this->config = new ModelConfig(); } @@ -185,6 +220,11 @@ public function metadata(): ModelMetadata return $this->metadata; } + public function providerMetadata(): ProviderMetadata + { + return $this->providerMetadata; + } + public function setConfig(ModelConfig $config): void { $this->config = $config; From f802aeb9788d52d5663b2947dcac90c6b6ff8e1c Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Thu, 28 Aug 2025 15:55:15 -0700 Subject: [PATCH 69/77] Reuse MockModelCreationTrait instead of duplicating methods. --- tests/unit/Builders/PromptBuilderTest.php | 241 +++++----------------- 1 file changed, 47 insertions(+), 194 deletions(-) diff --git a/tests/unit/Builders/PromptBuilderTest.php b/tests/unit/Builders/PromptBuilderTest.php index c77b9088..a17db192 100644 --- a/tests/unit/Builders/PromptBuilderTest.php +++ b/tests/unit/Builders/PromptBuilderTest.php @@ -22,8 +22,6 @@ use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; use WordPress\AiClient\Providers\Models\DTO\ModelRequirements; -use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; -use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationModelInterface; use WordPress\AiClient\Providers\Models\SpeechGeneration\Contracts\SpeechGenerationModelInterface; use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface; use WordPress\AiClient\Providers\Models\TextToSpeechConversion\Contracts\TextToSpeechConversionModelInterface; @@ -32,6 +30,7 @@ use WordPress\AiClient\Results\DTO\GenerativeAiResult; use WordPress\AiClient\Results\DTO\TokenUsage; use WordPress\AiClient\Results\Enums\FinishReasonEnum; +use WordPress\AiClient\Tests\traits\MockModelCreationTrait; use WordPress\AiClient\Tools\DTO\FunctionResponse; /** @@ -39,6 +38,8 @@ */ class PromptBuilderTest extends TestCase { + use MockModelCreationTrait; + /** * @var ProviderRegistry */ @@ -54,154 +55,6 @@ private function createTestProviderMetadata(): ProviderMetadata return new ProviderMetadata('test-provider', 'Test Provider', ProviderTypeEnum::cloud()); } - /** - * Creates a test model metadata instance. - * - * @return ModelMetadata - */ - private function createTestModelMetadata(): ModelMetadata - { - return new ModelMetadata( - 'test-model', - 'Test Model', - [CapabilityEnum::textGeneration()], - [] - ); - } - - /** - * Creates a mock model that implements both ModelInterface and TextGenerationModelInterface. - * - * @param ModelMetadata $metadata The metadata for the model. - * @param GenerativeAiResult $result The result to return from generation. - * @return ModelInterface&TextGenerationModelInterface The mock model. - */ - private function createTextGenerationModel( - ModelMetadata $metadata, - GenerativeAiResult $result - ): ModelInterface { - $providerMetadata = new ProviderMetadata( - 'mock-provider', - 'Mock Provider', - ProviderTypeEnum::cloud() - ); - - return new class ( - $metadata, - $providerMetadata, - $result - ) implements ModelInterface, TextGenerationModelInterface { - private ModelMetadata $metadata; - private ProviderMetadata $providerMetadata; - private GenerativeAiResult $result; - private ModelConfig $config; - - public function __construct( - ModelMetadata $metadata, - ProviderMetadata $providerMetadata, - GenerativeAiResult $result - ) { - $this->metadata = $metadata; - $this->providerMetadata = $providerMetadata; - $this->result = $result; - $this->config = new ModelConfig(); - } - - public function metadata(): ModelMetadata - { - return $this->metadata; - } - - public function providerMetadata(): ProviderMetadata - { - return $this->providerMetadata; - } - - public function setConfig(ModelConfig $config): void - { - $this->config = $config; - } - - public function getConfig(): ModelConfig - { - return $this->config; - } - - public function generateTextResult(array $prompt): GenerativeAiResult - { - return $this->result; - } - - public function streamGenerateTextResult(array $prompt): Generator - { - yield $this->result; - } - }; - } - - /** - * Creates a mock model that implements both ModelInterface and ImageGenerationModelInterface. - * - * @param ModelMetadata $metadata The metadata for the model. - * @param GenerativeAiResult $result The result to return from generation. - * @return ModelInterface&ImageGenerationModelInterface The mock model. - */ - private function createImageGenerationModel(ModelMetadata $metadata, GenerativeAiResult $result): ModelInterface - { - $providerMetadata = new ProviderMetadata( - 'mock-provider', - 'Mock Provider', - ProviderTypeEnum::cloud() - ); - - return new class ( - $metadata, - $providerMetadata, - $result - ) implements ModelInterface, ImageGenerationModelInterface { - private ModelMetadata $metadata; - private ProviderMetadata $providerMetadata; - private GenerativeAiResult $result; - private ModelConfig $config; - - public function __construct( - ModelMetadata $metadata, - ProviderMetadata $providerMetadata, - GenerativeAiResult $result - ) { - $this->metadata = $metadata; - $this->providerMetadata = $providerMetadata; - $this->result = $result; - $this->config = new ModelConfig(); - } - - public function metadata(): ModelMetadata - { - return $this->metadata; - } - - public function providerMetadata(): ProviderMetadata - { - return $this->providerMetadata; - } - - public function setConfig(ModelConfig $config): void - { - $this->config = $config; - } - - public function getConfig(): ModelConfig - { - return $this->config; - } - - public function generateImageResult(array $prompt): GenerativeAiResult - { - return $this->result; - } - }; - } - /** * Creates a mock model that implements both ModelInterface and SpeechGenerationModelInterface. * @@ -1281,7 +1134,7 @@ public function testGenerateResultWithTextModality(): void $metadata->method('getId')->willReturn('test-model'); $metadata->method('meetsRequirements')->willReturn(true); - $model = $this->createTextGenerationModel($metadata, $result); + $model = $this->createMockTextGenerationModel($result, $metadata); $builder = new PromptBuilder($this->registry, 'Test prompt'); $builder->usingModel($model); @@ -1305,14 +1158,14 @@ public function testGenerateResultWithImageModality(): void )], new TokenUsage(100, 50, 150), $this->createTestProviderMetadata(), - $this->createTestModelMetadata() + $this->createTestTextModelMetadata() ); $metadata = $this->createMock(ModelMetadata::class); $metadata->method('getId')->willReturn('test-model'); $metadata->method('meetsRequirements')->willReturn(true); - $model = $this->createImageGenerationModel($metadata, $result); + $model = $this->createMockImageGenerationModel($result, $metadata); $builder = new PromptBuilder($this->registry, 'Generate an image'); $builder->usingModel($model); @@ -1337,7 +1190,7 @@ public function testGenerateResultWithAudioModality(): void )], new TokenUsage(100, 50, 150), $this->createTestProviderMetadata(), - $this->createTestModelMetadata() + $this->createTestTextModelMetadata() ); $metadata = $this->createMock(ModelMetadata::class); @@ -1366,14 +1219,14 @@ public function testGenerateResultWithMultimodalOutput(): void [new Candidate(new ModelMessage([new MessagePart('Generated text')]), FinishReasonEnum::stop())], new TokenUsage(100, 50, 150), $this->createTestProviderMetadata(), - $this->createTestModelMetadata() + $this->createTestTextModelMetadata() ); $metadata = $this->createMock(ModelMetadata::class); $metadata->method('getId')->willReturn('test-model'); $metadata->method('meetsRequirements')->willReturn(true); - $model = $this->createTextGenerationModel($metadata, $result); + $model = $this->createMockTextGenerationModel($result, $metadata); $builder = new PromptBuilder($this->registry, 'Generate multimodal'); $builder->usingModel($model); @@ -1443,14 +1296,14 @@ public function testGenerateTextResult(): void [new Candidate(new ModelMessage([new MessagePart('Generated text')]), FinishReasonEnum::stop())], new TokenUsage(100, 50, 150), $this->createTestProviderMetadata(), - $this->createTestModelMetadata() + $this->createTestTextModelMetadata() ); $metadata = $this->createMock(ModelMetadata::class); $metadata->method('getId')->willReturn('test-model'); $metadata->method('meetsRequirements')->willReturn(true); - $model = $this->createTextGenerationModel($metadata, $result); + $model = $this->createMockTextGenerationModel($result, $metadata); $builder = new PromptBuilder($this->registry, 'Test prompt'); $builder->usingModel($model); @@ -1485,14 +1338,14 @@ public function testGenerateImageResult(): void )], new TokenUsage(100, 50, 150), $this->createTestProviderMetadata(), - $this->createTestModelMetadata() + $this->createTestTextModelMetadata() ); $metadata = $this->createMock(ModelMetadata::class); $metadata->method('getId')->willReturn('test-model'); $metadata->method('meetsRequirements')->willReturn(true); - $model = $this->createImageGenerationModel($metadata, $result); + $model = $this->createMockImageGenerationModel($result, $metadata); $builder = new PromptBuilder($this->registry, 'Generate image'); $builder->usingModel($model); @@ -1527,7 +1380,7 @@ public function testGenerateSpeechResult(): void )], new TokenUsage(100, 50, 150), $this->createTestProviderMetadata(), - $this->createTestModelMetadata() + $this->createTestTextModelMetadata() ); $metadata = $this->createMock(ModelMetadata::class); @@ -1569,7 +1422,7 @@ public function testConvertTextToSpeechResult(): void )], new TokenUsage(100, 50, 150), $this->createTestProviderMetadata(), - $this->createTestModelMetadata() + $this->createTestTextModelMetadata() ); $metadata = $this->createMock(ModelMetadata::class); @@ -1636,14 +1489,14 @@ public function testGenerateText(): void [$candidate], new TokenUsage(100, 50, 150), $this->createTestProviderMetadata(), - $this->createTestModelMetadata() + $this->createTestTextModelMetadata() ); $metadata = $this->createMock(ModelMetadata::class); $metadata->method('getId')->willReturn('test-model'); $metadata->method('meetsRequirements')->willReturn(true); - $model = $this->createTextGenerationModel($metadata, $result); + $model = $this->createMockTextGenerationModel($result, $metadata); $builder = new PromptBuilder($this->registry, 'Generate text'); $builder->usingModel($model); @@ -1743,14 +1596,14 @@ public function testGenerateTextThrowsExceptionWhenNoParts(): void [$candidate], new TokenUsage(100, 50, 150), $this->createTestProviderMetadata(), - $this->createTestModelMetadata() + $this->createTestTextModelMetadata() ); $metadata = $this->createMock(ModelMetadata::class); $metadata->method('getId')->willReturn('test-model'); $metadata->method('meetsRequirements')->willReturn(true); - $model = $this->createTextGenerationModel($metadata, $result); + $model = $this->createMockTextGenerationModel($result, $metadata); $builder = new PromptBuilder($this->registry, 'Generate text'); $builder->usingModel($model); @@ -1778,14 +1631,14 @@ public function testGenerateTextThrowsExceptionWhenPartHasNoText(): void [$candidate], new TokenUsage(100, 50, 150), $this->createTestProviderMetadata(), - $this->createTestModelMetadata() + $this->createTestTextModelMetadata() ); $metadata = $this->createMock(ModelMetadata::class); $metadata->method('getId')->willReturn('test-model'); $metadata->method('meetsRequirements')->willReturn(true); - $model = $this->createTextGenerationModel($metadata, $result); + $model = $this->createMockTextGenerationModel($result, $metadata); $builder = new PromptBuilder($this->registry, 'Generate text'); $builder->usingModel($model); @@ -1823,14 +1676,14 @@ public function testGenerateTexts(): void $candidates, new TokenUsage(100, 50, 150), $this->createTestProviderMetadata(), - $this->createTestModelMetadata() + $this->createTestTextModelMetadata() ); $metadata = $this->createMock(ModelMetadata::class); $metadata->method('getId')->willReturn('test-model'); $metadata->method('meetsRequirements')->willReturn(true); - $model = $this->createTextGenerationModel($metadata, $result); + $model = $this->createMockTextGenerationModel($result, $metadata); $builder = new PromptBuilder($this->registry, 'Generate texts'); $builder->usingModel($model); @@ -1943,14 +1796,14 @@ public function testGenerateImage(): void [$candidate], new TokenUsage(100, 50, 150), $this->createTestProviderMetadata(), - $this->createTestModelMetadata() + $this->createTestTextModelMetadata() ); $metadata = $this->createMock(ModelMetadata::class); $metadata->method('getId')->willReturn('test-model'); $metadata->method('meetsRequirements')->willReturn(true); - $model = $this->createImageGenerationModel($metadata, $result); + $model = $this->createMockImageGenerationModel($result, $metadata); $builder = new PromptBuilder($this->registry, 'Generate image'); $builder->usingModel($model); @@ -1975,14 +1828,14 @@ public function testGenerateImageThrowsExceptionWhenNoFile(): void [$candidate], new TokenUsage(100, 50, 150), $this->createTestProviderMetadata(), - $this->createTestModelMetadata() + $this->createTestTextModelMetadata() ); $metadata = $this->createMock(ModelMetadata::class); $metadata->method('getId')->willReturn('test-model'); $metadata->method('meetsRequirements')->willReturn(true); - $model = $this->createImageGenerationModel($metadata, $result); + $model = $this->createMockImageGenerationModel($result, $metadata); $builder = new PromptBuilder($this->registry, 'Generate image'); $builder->usingModel($model); @@ -2018,14 +1871,14 @@ public function testGenerateImages(): void $candidates, new TokenUsage(100, 50, 150), $this->createTestProviderMetadata(), - $this->createTestModelMetadata() + $this->createTestTextModelMetadata() ); $metadata = $this->createMock(ModelMetadata::class); $metadata->method('getId')->willReturn('test-model'); $metadata->method('meetsRequirements')->willReturn(true); - $model = $this->createImageGenerationModel($metadata, $result); + $model = $this->createMockImageGenerationModel($result, $metadata); $builder = new PromptBuilder($this->registry, 'Generate images'); $builder->usingModel($model); @@ -2054,7 +1907,7 @@ public function testConvertTextToSpeech(): void [$candidate], new TokenUsage(100, 50, 150), $this->createTestProviderMetadata(), - $this->createTestModelMetadata() + $this->createTestTextModelMetadata() ); $metadata = $this->createMock(ModelMetadata::class); @@ -2095,7 +1948,7 @@ public function testConvertTextToSpeeches(): void $candidates, new TokenUsage(100, 50, 150), $this->createTestProviderMetadata(), - $this->createTestModelMetadata() + $this->createTestTextModelMetadata() ); $metadata = $this->createMock(ModelMetadata::class); @@ -2131,7 +1984,7 @@ public function testGenerateSpeech(): void [$candidate], new TokenUsage(100, 50, 150), $this->createTestProviderMetadata(), - $this->createTestModelMetadata() + $this->createTestTextModelMetadata() ); $metadata = $this->createMock(ModelMetadata::class); @@ -2174,7 +2027,7 @@ public function testGenerateSpeeches(): void $candidates, new TokenUsage(100, 50, 150), $this->createTestProviderMetadata(), - $this->createTestModelMetadata() + $this->createTestTextModelMetadata() ); $metadata = $this->createMock(ModelMetadata::class); @@ -2382,14 +2235,14 @@ public function testIncludeOutputModalityPreservesExisting(): void [new Candidate(new ModelMessage([new MessagePart('Generated text')]), FinishReasonEnum::stop())], new TokenUsage(100, 50, 150), $this->createTestProviderMetadata(), - $this->createTestModelMetadata() + $this->createTestTextModelMetadata() ); $metadata = $this->createMock(ModelMetadata::class); $metadata->method('getId')->willReturn('test-model'); $metadata->method('meetsRequirements')->willReturn(true); - $model = $this->createTextGenerationModel($metadata, $result); + $model = $this->createMockTextGenerationModel($result, $metadata); $builder = new PromptBuilder($this->registry, 'Test'); $builder->usingModel($model); @@ -2526,14 +2379,14 @@ public function testGenerateImageResultCreatesProperOperation(): void )], new TokenUsage(100, 50, 150), $this->createTestProviderMetadata(), - $this->createTestModelMetadata() + $this->createTestTextModelMetadata() ); $metadata = $this->createMock(ModelMetadata::class); $metadata->method('getId')->willReturn('test-model'); $metadata->method('meetsRequirements')->willReturn(true); - $model = $this->createImageGenerationModel($metadata, $result); + $model = $this->createMockImageGenerationModel($result, $metadata); $builder = new PromptBuilder($this->registry, 'Generate an image'); $builder->usingModel($model); @@ -2591,14 +2444,14 @@ public function testGenerateImageReturnsFileDirectly(): void [$candidate], new TokenUsage(100, 50, 150), $this->createTestProviderMetadata(), - $this->createTestModelMetadata() + $this->createTestTextModelMetadata() ); $metadata = $this->createMock(ModelMetadata::class); $metadata->method('getId')->willReturn('test-model'); $metadata->method('meetsRequirements')->willReturn(true); - $model = $this->createImageGenerationModel($metadata, $result); + $model = $this->createMockImageGenerationModel($result, $metadata); $builder = new PromptBuilder($this->registry, 'Generate an image'); $builder->usingModel($model); @@ -2662,7 +2515,7 @@ public function testGenerateTextWithNoCandidatesThrowsException(): void $metadata->method('getId')->willReturn('test-model'); $metadata->method('meetsRequirements')->willReturn(true); - $model = $this->createTextGenerationModel($metadata, $result); + $model = $this->createMockTextGenerationModel($result, $metadata); $builder = new PromptBuilder($this->registry, 'Generate text'); $builder->usingModel($model); @@ -2691,14 +2544,14 @@ public function testGenerateTextWithNonStringPartThrowsException(): void [$candidate], new TokenUsage(100, 50, 150), $this->createTestProviderMetadata(), - $this->createTestModelMetadata() + $this->createTestTextModelMetadata() ); $metadata = $this->createMock(ModelMetadata::class); $metadata->method('getId')->willReturn('test-model'); $metadata->method('meetsRequirements')->willReturn(true); - $model = $this->createTextGenerationModel($metadata, $result); + $model = $this->createMockTextGenerationModel($result, $metadata); $builder = new PromptBuilder($this->registry, 'Generate text'); $builder->usingModel($model); @@ -2736,9 +2589,9 @@ public function testIsSupportedForText(): void new ModelMessage([new MessagePart('Test')]), FinishReasonEnum::stop() ) - ], new TokenUsage(10, 5, 15), $this->createTestProviderMetadata(), $this->createTestModelMetadata()); + ], new TokenUsage(10, 5, 15), $this->createTestProviderMetadata(), $this->createTestTextModelMetadata()); - $model = $this->createTextGenerationModel($metadata, $result); + $model = $this->createMockTextGenerationModel($result, $metadata); $builder = new PromptBuilder($this->registry, 'Test prompt'); $builder->usingModel($model); @@ -2810,7 +2663,7 @@ public function testIsSupportedForSpeechGeneration(): void new ModelMessage([new MessagePart(new File('https://example.com/speech.mp3', 'audio/mp3'))]), FinishReasonEnum::stop() ) - ], new TokenUsage(10, 5, 15), $this->createTestProviderMetadata(), $this->createTestModelMetadata()); + ], new TokenUsage(10, 5, 15), $this->createTestProviderMetadata(), $this->createTestTextModelMetadata()); $model = $this->createSpeechGenerationModel($metadata, $result); @@ -2833,7 +2686,7 @@ public function testGenerateResultWithProvider(): void $modelMetadata->method('getId')->willReturn('provider-model'); $modelMetadata->method('meetsRequirements')->willReturn(true); - $model = $this->createTextGenerationModel($modelMetadata, $result); + $model = $this->createMockTextGenerationModel($result, $modelMetadata); // Mock the registry to return the model when provider is specified $this->registry->expects($this->once()) @@ -2888,7 +2741,7 @@ public function testModelTakesPrecedenceOverProvider(): void $metadata->method('getId')->willReturn('explicit-model'); $metadata->method('meetsRequirements')->willReturn(true); - $model = $this->createTextGenerationModel($metadata, $result); + $model = $this->createMockTextGenerationModel($result, $metadata); // Registry should not be called when model is explicitly set $this->registry->expects($this->never()) From 61ac6d301627f3a0a5eff9e6caf63f695440e918 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Thu, 28 Aug 2025 15:56:42 -0700 Subject: [PATCH 70/77] Fix incorrect provider reference in comment. --- .../Anthropic/AnthropicModelMetadataDirectory.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php b/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php index dcc0796c..1770da68 100644 --- a/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php +++ b/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php @@ -36,8 +36,8 @@ class AnthropicModelMetadataDirectory extends AbstractOpenAiCompatibleModelMetad public function getRequestAuthentication(): RequestAuthenticationInterface { /* - * Since we're calling the primary Google API models endpoint here, we need to use the Google specific API key - * authentication class. + * Since we're calling the primary Anthropic API models endpoint here, we need to use the Anthropic specific + * API key authentication class. */ $requestAuthentication = parent::getRequestAuthentication(); if (!$requestAuthentication instanceof ApiKeyRequestAuthentication) { From 31f3e05636096927ae78586dd4a06616e8edab60 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Thu, 28 Aug 2025 16:15:42 -0700 Subject: [PATCH 71/77] Make sure manually provided model is bound. --- src/Builders/PromptBuilder.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Builders/PromptBuilder.php b/src/Builders/PromptBuilder.php index 24f23177..08de3d1e 100644 --- a/src/Builders/PromptBuilder.php +++ b/src/Builders/PromptBuilder.php @@ -1020,6 +1020,7 @@ private function getConfiguredModel(CapabilityEnum $capability): ModelInterface // If a model has been explicitly set, return it if ($this->model !== null) { $this->model->setConfig($this->modelConfig); + $this->registry->bindModelDependencies($this->model); return $this->model; } From d6c3ab2eb2a81891d6f2c8b8f6aa292970c38f19 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Fri, 29 Aug 2025 10:09:44 -0700 Subject: [PATCH 72/77] Include additional supported audio formats for OpenAI text-to-speech models. --- src/Files/ValueObjects/MimeType.php | 1 + .../OpenAi/OpenAiModelMetadataDirectory.php | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Files/ValueObjects/MimeType.php b/src/Files/ValueObjects/MimeType.php index c44eef7d..966f7423 100644 --- a/src/Files/ValueObjects/MimeType.php +++ b/src/Files/ValueObjects/MimeType.php @@ -72,6 +72,7 @@ final class MimeType 'ogg' => 'audio/ogg', 'flac' => 'audio/flac', 'm4a' => 'audio/m4a', + 'aac' => 'audio/aac', // Video 'mp4' => 'video/mp4', diff --git a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php index d312fd32..45fc108c 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php +++ b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php @@ -146,7 +146,13 @@ protected function parseResponseToModelMetadataList(Response $response): array $ttsOptions = [ new SupportedOption(OptionEnum::inputModalities(), [[ModalityEnum::text()]]), new SupportedOption(OptionEnum::outputModalities(), [[ModalityEnum::audio()]]), - new SupportedOption(OptionEnum::outputMimeType(), ['audio/mpeg', 'audio/ogg', 'audio/wav']), + new SupportedOption(OptionEnum::outputMimeType(), [ + 'audio/mpeg', + 'audio/ogg', + 'audio/wav', + 'audio/flac', + 'audio/aac', + ]), new SupportedOption(OptionEnum::outputSpeechVoice()), ]; From ef3b0ef200d427dcf6be06b08aa688181fd4505a Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Fri, 29 Aug 2025 10:11:54 -0700 Subject: [PATCH 73/77] Add TODO about OpenAI system vs developer message role. --- .../AbstractOpenAiCompatibleTextGenerationModel.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php index a912bc5b..d0df3476 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php @@ -255,6 +255,10 @@ function (Message $message): array { array_unshift( $messagesParam, [ + /* + * TODO: Replace this with 'developer' in the future. + * See https://platform.openai.com/docs/api-reference/chat/create#chat_create-messages + */ 'role' => 'system', 'content' => [ [ From 296040a7b67f7b407a45017279a174fb0608c30c Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Fri, 29 Aug 2025 10:16:12 -0700 Subject: [PATCH 74/77] Clarify purpose of specific ProviderAvailability implementations. --- .../GenerateTextApiBasedProviderAvailability.php | 4 ++++ .../ListModelsApiBasedProviderAvailability.php | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/Providers/ApiBasedImplementation/GenerateTextApiBasedProviderAvailability.php b/src/Providers/ApiBasedImplementation/GenerateTextApiBasedProviderAvailability.php index 5b00ee72..37fcf998 100644 --- a/src/Providers/ApiBasedImplementation/GenerateTextApiBasedProviderAvailability.php +++ b/src/Providers/ApiBasedImplementation/GenerateTextApiBasedProviderAvailability.php @@ -16,6 +16,10 @@ /** * Class to check availability for an API-based provider via a test request to the endpoint to generate text. * + * This class should be used for cloud-based providers that do not offer a model listing endpoint, but do offer a + * text generation endpoint which requires authentication. A minimal request to this endpoint is used to determine + * if the provider is properly configured with valid credentials. + * * @since n.e.x.t */ class GenerateTextApiBasedProviderAvailability implements ProviderAvailabilityInterface diff --git a/src/Providers/ApiBasedImplementation/ListModelsApiBasedProviderAvailability.php b/src/Providers/ApiBasedImplementation/ListModelsApiBasedProviderAvailability.php index a5cf50db..ff1010ac 100644 --- a/src/Providers/ApiBasedImplementation/ListModelsApiBasedProviderAvailability.php +++ b/src/Providers/ApiBasedImplementation/ListModelsApiBasedProviderAvailability.php @@ -11,6 +11,10 @@ /** * Class to check availability for an API-based provider via a test request to the endpoint to list models. * + * This class should be used for cloud-based providers that offer a model listing endpoint which requires + * authentication. A request to this endpoint is used to determine if the provider is properly configured + * with valid credentials. + * * @since n.e.x.t */ class ListModelsApiBasedProviderAvailability implements ProviderAvailabilityInterface From 3186fbcacb057dbfeb1b3cefc44e26d37af1daf4 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Fri, 29 Aug 2025 10:18:56 -0700 Subject: [PATCH 75/77] Move sort call out of loop. --- src/Providers/Models/DTO/SupportedOption.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Providers/Models/DTO/SupportedOption.php b/src/Providers/Models/DTO/SupportedOption.php index cf03c48d..e9291b37 100644 --- a/src/Providers/Models/DTO/SupportedOption.php +++ b/src/Providers/Models/DTO/SupportedOption.php @@ -87,11 +87,11 @@ public function isSupportedValue($value): bool // If the value is an array, consider it a set (i.e. order doesn't matter). if (is_array($value)) { + sort($value); foreach ($this->supportedValues as $supportedValue) { if (!is_array($supportedValue)) { continue; } - sort($value); sort($supportedValue); if ($value === $supportedValue) { return true; From eea2c67fd741c1a9912c641a0e3bc818a65f4261 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Fri, 29 Aug 2025 10:29:44 -0700 Subject: [PATCH 76/77] test: fixes failing tests due to missing provider --- tests/traits/MockModelCreationTrait.php | 34 ++++++++++++++++++++--- tests/unit/AiClientTest.php | 36 ++++++++++++++++--------- 2 files changed, 55 insertions(+), 15 deletions(-) diff --git a/tests/traits/MockModelCreationTrait.php b/tests/traits/MockModelCreationTrait.php index 358ef0c1..3423f950 100644 --- a/tests/traits/MockModelCreationTrait.php +++ b/tests/traits/MockModelCreationTrait.php @@ -15,10 +15,12 @@ use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationModelInterface; use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface; +use WordPress\AiClient\Providers\ProviderRegistry; use WordPress\AiClient\Results\DTO\Candidate; use WordPress\AiClient\Results\DTO\GenerativeAiResult; use WordPress\AiClient\Results\DTO\TokenUsage; use WordPress\AiClient\Results\Enums\FinishReasonEnum; +use WordPress\AiClient\Tests\mocks\MockProvider; /** * Trait providing shared mock model creation methods for testing. @@ -30,6 +32,19 @@ */ trait MockModelCreationTrait { + /** + * Creates a provider registry with the mock provider registered. + * + * @since n.e.x.t + * + * @return ProviderRegistry The registry with mock provider. + */ + protected function createRegistryWithMockProvider(): ProviderRegistry + { + $registry = new ProviderRegistry(); + $registry->registerProvider(MockProvider::class); + return $registry; + } /** * Creates a test GenerativeAiResult for testing purposes. * @@ -45,7 +60,7 @@ protected function createTestResult(string $content = 'Test response'): Generati $tokenUsage = new TokenUsage(10, 20, 30); $providerMetadata = new ProviderMetadata( - 'mock-provider', + 'mock', 'Mock Provider', ProviderTypeEnum::cloud() ); @@ -117,7 +132,7 @@ protected function createMockTextGenerationModel( $metadata = $metadata ?? $this->createTestTextModelMetadata(); $providerMetadata = new ProviderMetadata( - 'mock-provider', + 'mock', 'Mock Provider', ProviderTypeEnum::cloud() ); @@ -189,7 +204,7 @@ protected function createMockImageGenerationModel( $metadata = $metadata ?? $this->createTestImageModelMetadata(); $providerMetadata = new ProviderMetadata( - 'mock-provider', + 'mock', 'Mock Provider', ProviderTypeEnum::cloud() ); @@ -252,6 +267,11 @@ protected function createMockUnsupportedModel(string $modelId = 'unsupported-mod { $mockModel = $this->createMock(ModelInterface::class); $mockMetadata = $this->createMock(ModelMetadata::class); + $mockProviderMetadata = new ProviderMetadata( + 'mock', + 'Mock Provider', + ProviderTypeEnum::cloud() + ); $mockMetadata->expects($this->any()) ->method('getId') @@ -261,6 +281,14 @@ protected function createMockUnsupportedModel(string $modelId = 'unsupported-mod ->method('metadata') ->willReturn($mockMetadata); + $mockModel->expects($this->any()) + ->method('providerMetadata') + ->willReturn($mockProviderMetadata); + + $mockModel->expects($this->any()) + ->method('getConfig') + ->willReturn(new ModelConfig()); + return $mockModel; } } diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index 6cb76d7d..32f5b21b 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -94,8 +94,9 @@ public function testGenerateTextResultWithStringAndModel(): void $prompt = 'Generate text'; $expectedResult = $this->createTestResult(); $mockModel = $this->createMockTextGenerationModel($expectedResult); + $registry = $this->createRegistryWithMockProvider(); - $result = AiClient::generateTextResult($prompt, $mockModel); + $result = AiClient::generateTextResult($prompt, $mockModel, $registry); $this->assertSame($expectedResult, $result); } @@ -107,11 +108,12 @@ public function testGenerateTextResultWithInvalidModel(): void { $prompt = 'Generate text'; $invalidModel = $this->createMockUnsupportedModel('invalid-text-model'); + $registry = $this->createRegistryWithMockProvider(); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Model "invalid-text-model" does not support text generation.'); - AiClient::generateTextResult($prompt, $invalidModel); + AiClient::generateTextResult($prompt, $invalidModel, $registry); } /** @@ -122,8 +124,9 @@ public function testGenerateImageResultWithStringAndModel(): void $prompt = 'Generate image'; $expectedResult = $this->createTestResult(); $mockModel = $this->createMockImageGenerationModel($expectedResult); + $registry = $this->createRegistryWithMockProvider(); - $result = AiClient::generateImageResult($prompt, $mockModel); + $result = AiClient::generateImageResult($prompt, $mockModel, $registry); $this->assertSame($expectedResult, $result); } @@ -135,11 +138,12 @@ public function testGenerateImageResultWithInvalidModel(): void { $prompt = 'Generate image'; $invalidModel = $this->createMockUnsupportedModel('invalid-image-model'); + $registry = $this->createRegistryWithMockProvider(); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Model "invalid-image-model" does not support image generation.'); - AiClient::generateImageResult($prompt, $invalidModel); + AiClient::generateImageResult($prompt, $invalidModel, $registry); } @@ -152,8 +156,9 @@ public function testGenerateTextResultWithMessage(): void $message = new UserMessage([$messagePart]); $expectedResult = $this->createTestResult(); $mockModel = $this->createMockTextGenerationModel($expectedResult); + $registry = $this->createRegistryWithMockProvider(); - $result = AiClient::generateTextResult($message, $mockModel); + $result = AiClient::generateTextResult($message, $mockModel, $registry); $this->assertSame($expectedResult, $result); } @@ -166,8 +171,9 @@ public function testGenerateTextResultWithMessagePart(): void $messagePart = new MessagePart('Test message part'); $expectedResult = $this->createTestResult(); $mockModel = $this->createMockTextGenerationModel($expectedResult); + $registry = $this->createRegistryWithMockProvider(); - $result = AiClient::generateTextResult($messagePart, $mockModel); + $result = AiClient::generateTextResult($messagePart, $mockModel, $registry); $this->assertSame($expectedResult, $result); } @@ -185,8 +191,9 @@ public function testGenerateTextResultWithMessageArray(): void $expectedResult = $this->createTestResult(); $mockModel = $this->createMockTextGenerationModel($expectedResult); + $registry = $this->createRegistryWithMockProvider(); - $result = AiClient::generateTextResult($messages, $mockModel); + $result = AiClient::generateTextResult($messages, $mockModel, $registry); $this->assertSame($expectedResult, $result); } @@ -202,8 +209,9 @@ public function testGenerateTextResultWithMessagePartArray(): void $expectedResult = $this->createTestResult(); $mockModel = $this->createMockTextGenerationModel($expectedResult); + $registry = $this->createRegistryWithMockProvider(); - $result = AiClient::generateTextResult($messageParts, $mockModel); + $result = AiClient::generateTextResult($messageParts, $mockModel, $registry); $this->assertSame($expectedResult, $result); } @@ -246,8 +254,9 @@ public function testGenerateResultDelegatesToTextGeneration(): void $prompt = 'Test prompt'; $expectedResult = $this->createTestResult(); $mockModel = $this->createMockTextGenerationModel($expectedResult); + $registry = $this->createRegistryWithMockProvider(); - $result = AiClient::generateResult($prompt, $mockModel); + $result = AiClient::generateResult($prompt, $mockModel, $registry); $this->assertSame($expectedResult, $result); } @@ -260,8 +269,9 @@ public function testGenerateResultDelegatesToImageGeneration(): void $prompt = 'Generate image prompt'; $expectedResult = $this->createTestResult(); $mockModel = $this->createMockImageGenerationModel($expectedResult); + $registry = $this->createRegistryWithMockProvider(); - $result = AiClient::generateResult($prompt, $mockModel); + $result = AiClient::generateResult($prompt, $mockModel, $registry); $this->assertSame($expectedResult, $result); } @@ -288,8 +298,9 @@ public function testGenerateResultWithTextGenerationModel(): void $prompt = 'Generate text content'; $expectedResult = $this->createTestResult(); $mockModel = $this->createMockTextGenerationModel($expectedResult); + $registry = $this->createRegistryWithMockProvider(); - $result = AiClient::generateResult($prompt, $mockModel); + $result = AiClient::generateResult($prompt, $mockModel, $registry); $this->assertSame($expectedResult, $result); } @@ -302,8 +313,9 @@ public function testGenerateResultWithImageGenerationModel(): void $prompt = 'Generate an image'; $expectedResult = $this->createTestResult(); $mockModel = $this->createMockImageGenerationModel($expectedResult); + $registry = $this->createRegistryWithMockProvider(); - $result = AiClient::generateResult($prompt, $mockModel); + $result = AiClient::generateResult($prompt, $mockModel, $registry); $this->assertSame($expectedResult, $result); } From 381535573412f618a8446217734a7bda6f6b9582 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Fri, 29 Aug 2025 10:48:43 -0700 Subject: [PATCH 77/77] test: removes trailing commas breaking 7.4 --- .../Anthropic/AnthropicModelMetadataDirectory.php | 2 +- .../Google/GoogleModelMetadataDirectory.php | 2 +- .../OpenAi/OpenAiModelMetadataDirectory.php | 2 +- src/Results/DTO/Candidate.php | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php b/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php index 1770da68..0e75c714 100644 --- a/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php +++ b/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php @@ -116,7 +116,7 @@ protected function parseResponseToModelMetadataList(Response $response): array static function (array $modelData) use ( $anthropicCapabilities, $anthropicOptions, - $anthropicWebSearchOptions, + $anthropicWebSearchOptions ): ModelMetadata { $modelId = $modelData['id']; $modelCaps = $anthropicCapabilities; diff --git a/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php b/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php index 8f639f24..99f72ea6 100644 --- a/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php +++ b/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php @@ -175,7 +175,7 @@ static function (array $modelData) use ( $geminiWebSearchOptions, $geminiMultimodalImageOutputOptions, $imagenCapabilities, - $imagenOptions, + $imagenOptions ): ModelMetadata { $modelId = $modelData['baseModelId'] ?? $modelData['name']; if (str_starts_with($modelId, 'models/')) { diff --git a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php index 45fc108c..3c653619 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php +++ b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php @@ -169,7 +169,7 @@ static function (array $modelData) use ( $dalleImageOptions, $gptImageOptions, $ttsCapabilities, - $ttsOptions, + $ttsOptions ): ModelMetadata { $modelId = $modelData['id']; if ( diff --git a/src/Results/DTO/Candidate.php b/src/Results/DTO/Candidate.php index 11172336..3c5e6a95 100644 --- a/src/Results/DTO/Candidate.php +++ b/src/Results/DTO/Candidate.php @@ -130,7 +130,7 @@ public static function fromArray(array $array): self return new self( Message::fromArray($messageData), - FinishReasonEnum::from($array[self::KEY_FINISH_REASON]), + FinishReasonEnum::from($array[self::KEY_FINISH_REASON]) ); } }