Skip to content

Commit 8dd8719

Browse files
committed
Merge branch 'trunk' into feature/exception-hierarchy
2 parents 8cde241 + 2fd7994 commit 8dd8719

File tree

10 files changed

+255
-16
lines changed

10 files changed

+255
-16
lines changed

.gitignore

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,13 @@ vendor/
2525
############
2626

2727
.claude/
28-
CLAUDE.md
28+
.clinerules
2929
.clinerules/
30+
.codex/
3031
.cursor/
32+
.gemini/
33+
.windsurf/
34+
CLAUDE.md
3135
GEMINI.md
3236

3337
############

docs/REQUIREMENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@ This document outlines the functional requirements for the PHP AI Client, as wel
44

55
## Target Audiences
66

7-
There are two primary developer audiences this client is intended for. This is important to understand as it significantly influences the thinking and complexity around the APIs introduced in this library.a
7+
There are two primary developer audiences this client is intended for. This is important to understand as it significantly influences the thinking and complexity around the APIs introduced in this library.
88

99
### Extenders
1010

1111
Extenders are the folks that will be adding providers, models, and otherwise extending the functionality of the client itself. These are highly technical people who likely have a stronger understanding of how models and model APIs work. Given their capabilities, these APIs will be more technical and formal in nature, using things such as interfaces, traits, and so forth, relying on a knowledge of inheritance and composition.
1212

1313
### Implementers
1414

15-
Implementors are the folks that will be utilizing the client to take advantage of AI features. These developers know their own codebase well, but their technical and model knowledge varies. It is important not to rely on this knowledge for them to get significant value from the client. The APIs for these people will be simpler, straightforward, readable, and composable, so they can interact with the model with only what they need to know in mind.
15+
Implementers are the folks that will be utilizing the client to take advantage of AI features. These developers know their own codebase well, but their technical and model knowledge varies. It is important not to rely on this knowledge for them to get significant value from the client. The APIs for these people will be simpler, straightforward, readable, and composable, so they can interact with the model with only what they need to know in mind.
1616

1717
## Objective
1818

src/AiClient.php

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use WordPress\AiClient\ProviderImplementations\Google\GoogleProvider;
1212
use WordPress\AiClient\ProviderImplementations\OpenAi\OpenAiProvider;
1313
use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface;
14+
use WordPress\AiClient\Providers\Contracts\ProviderInterface;
1415
use WordPress\AiClient\Providers\Http\HttpTransporterFactory;
1516
use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
1617
use WordPress\AiClient\Providers\Models\DTO\ModelConfig;
@@ -116,14 +117,43 @@ public static function defaultRegistry(): ProviderRegistry
116117
/**
117118
* Checks if a provider is configured and available for use.
118119
*
120+
* Supports multiple input formats for developer convenience:
121+
* - ProviderAvailabilityInterface: Direct availability check
122+
* - string (provider ID): e.g., AiClient::isConfigured('openai')
123+
* - string (class name): e.g., AiClient::isConfigured(OpenAiProvider::class)
124+
*
125+
* When using string input, this method leverages the ProviderRegistry's centralized
126+
* dependency management, ensuring HttpTransporter and authentication are properly
127+
* injected into availability instances.
128+
*
119129
* @since 0.1.0
130+
* @since n.e.x.t Now supports being passed a provider ID or class name.
120131
*
121-
* @param ProviderAvailabilityInterface $availability The provider availability instance to check.
132+
* @param ProviderAvailabilityInterface|string|class-string<ProviderInterface> $availabilityOrIdOrClassName
133+
* The provider availability instance, provider ID, or provider class name.
122134
* @return bool True if the provider is configured and available, false otherwise.
123135
*/
124-
public static function isConfigured(ProviderAvailabilityInterface $availability): bool
136+
public static function isConfigured($availabilityOrIdOrClassName): bool
125137
{
126-
return $availability->isConfigured();
138+
// Handle direct ProviderAvailabilityInterface (backward compatibility)
139+
if ($availabilityOrIdOrClassName instanceof ProviderAvailabilityInterface) {
140+
return $availabilityOrIdOrClassName->isConfigured();
141+
}
142+
143+
// Handle string input (provider ID or class name) via registry
144+
if (is_string($availabilityOrIdOrClassName)) {
145+
return self::defaultRegistry()->isProviderConfigured($availabilityOrIdOrClassName);
146+
}
147+
148+
throw new \InvalidArgumentException(
149+
'Parameter must be a ProviderAvailabilityInterface instance, provider ID string, or provider class name. ' .
150+
sprintf(
151+
'Received: %s',
152+
is_object($availabilityOrIdOrClassName)
153+
? get_class($availabilityOrIdOrClassName)
154+
: gettype($availabilityOrIdOrClassName)
155+
)
156+
);
127157
}
128158

129159
/**

src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@
2424
use WordPress\AiClient\Results\Enums\FinishReasonEnum;
2525

2626
/**
27-
* Base class for an image generation model for an OpenAI compatible provider.
27+
* Base class for an image generation model for providers that implement OpenAI's API format.
28+
*
29+
* This abstract class is designed to work with any AI provider that offers an OpenAI-compatible
30+
* API endpoint for image generation, including but not limited to Anthropic, Google, and other
31+
* providers that have adopted OpenAI's image generation API specification as a standard interface.
2832
*
2933
* @since 0.1.0
3034
*

src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectory.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@
1313
use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;
1414

1515
/**
16-
* Base class for a model metadata directory for an OpenAI compatible provider.
16+
* Base class for a model metadata directory for providers that implement OpenAI's API format.
17+
*
18+
* This abstract class is designed to work with any AI provider that offers an OpenAI-compatible
19+
* models listing endpoint, including but not limited to Anthropic, Google, and other
20+
* providers that have adopted OpenAI's models API specification as a standard interface.
1721
*
1822
* @since 0.1.0
1923
*/

src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@
2727
use WordPress\AiClient\Tools\DTO\FunctionDeclaration;
2828

2929
/**
30-
* Base class for a text generation model for an OpenAI compatible provider.
30+
* Base class for a text generation model for providers that implement OpenAI's API format.
31+
*
32+
* This abstract class is designed to work with any AI provider that offers an OpenAI-compatible
33+
* API endpoint, including but not limited to Anthropic, Google, and other providers
34+
* that have adopted OpenAI's API specification as a standard interface.
3135
*
3236
* @since 0.1.0
3337
*

src/Results/DTO/GenerativeAiResult.php

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,8 @@ public function hasMultipleCandidates(): bool
201201
/**
202202
* Converts the first candidate to text.
203203
*
204+
* Only text from the content channel is considered. Text within model thought or reasoning is ignored.
205+
*
204206
* @since 0.1.0
205207
*
206208
* @return string The text content.
@@ -210,8 +212,9 @@ public function toText(): string
210212
{
211213
$message = $this->candidates[0]->getMessage();
212214
foreach ($message->getParts() as $part) {
215+
$channel = $part->getChannel();
213216
$text = $part->getText();
214-
if ($text !== null) {
217+
if ($channel->isContent() && $text !== null) {
215218
return $text;
216219
}
217220
}
@@ -222,6 +225,8 @@ public function toText(): string
222225
/**
223226
* Converts the first candidate to a file.
224227
*
228+
* Only files from the content channel are considered. Files within model thought or reasoning are ignored.
229+
*
225230
* @since 0.1.0
226231
*
227232
* @return File The file.
@@ -231,8 +236,9 @@ public function toFile(): File
231236
{
232237
$message = $this->candidates[0]->getMessage();
233238
foreach ($message->getParts() as $part) {
239+
$channel = $part->getChannel();
234240
$file = $part->getFile();
235-
if ($file !== null) {
241+
if ($channel->isContent() && $file !== null) {
236242
return $file;
237243
}
238244
}
@@ -316,7 +322,7 @@ public function toMessage(): Message
316322
}
317323

318324
/**
319-
* Converts all candidates to text array.
325+
* Converts all candidates to text.
320326
*
321327
* @since 0.1.0
322328
*
@@ -328,8 +334,9 @@ public function toTexts(): array
328334
foreach ($this->candidates as $candidate) {
329335
$message = $candidate->getMessage();
330336
foreach ($message->getParts() as $part) {
337+
$channel = $part->getChannel();
331338
$text = $part->getText();
332-
if ($text !== null) {
339+
if ($channel->isContent() && $text !== null) {
333340
$texts[] = $text;
334341
break;
335342
}
@@ -351,8 +358,9 @@ public function toFiles(): array
351358
foreach ($this->candidates as $candidate) {
352359
$message = $candidate->getMessage();
353360
foreach ($message->getParts() as $part) {
361+
$channel = $part->getChannel();
354362
$file = $part->getFile();
355-
if ($file !== null) {
363+
if ($channel->isContent() && $file !== null) {
356364
$files[] = $file;
357365
break;
358366
}

tests/unit/AiClientTest.php

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use WordPress\AiClient\AiClient;
1010
use WordPress\AiClient\Messages\DTO\MessagePart;
1111
use WordPress\AiClient\Messages\DTO\UserMessage;
12+
use WordPress\AiClient\ProviderImplementations\OpenAi\OpenAiProvider;
1213
use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface;
1314
use WordPress\AiClient\Providers\Models\DTO\ModelConfig;
1415
use WordPress\AiClient\Providers\ProviderRegistry;
@@ -246,6 +247,115 @@ public function testIsConfiguredReturnsFalseWhenProviderIsNotConfigured(): void
246247
$this->assertFalse($result);
247248
}
248249

250+
/**
251+
* Tests isConfigured method with provider ID string leverages default registry.
252+
*/
253+
public function testIsConfiguredWithProviderIdString(): void
254+
{
255+
// This test will use the actual default registry since we can't easily mock static methods
256+
// The default registry should have providers registered, so we test the delegation path
257+
$result = AiClient::isConfigured('openai');
258+
259+
// The result will be false because no actual API keys are configured in tests,
260+
// but the important thing is that no exception is thrown and the registry delegation works
261+
$this->assertIsBool($result);
262+
}
263+
264+
/**
265+
* Tests isConfigured method with provider class name leverages default registry.
266+
*/
267+
public function testIsConfiguredWithProviderClassName(): void
268+
{
269+
// This test will use the actual default registry since we can't easily mock static methods
270+
// The default registry should have providers registered, so we test the delegation path
271+
$result = AiClient::isConfigured(OpenAiProvider::class);
272+
273+
// The result will be false because no actual API keys are configured in tests,
274+
// but the important thing is that no exception is thrown and the registry delegation works
275+
$this->assertIsBool($result);
276+
}
277+
278+
/**
279+
* Tests isConfigured method throws exception for invalid parameter types.
280+
*/
281+
public function testIsConfiguredThrowsExceptionForInvalidParameterTypes(): void
282+
{
283+
$this->expectException(\InvalidArgumentException::class);
284+
$this->expectExceptionMessage(
285+
'Parameter must be a ProviderAvailabilityInterface instance, provider ID string, or provider class name. ' .
286+
'Received: integer'
287+
);
288+
289+
AiClient::isConfigured(123);
290+
}
291+
292+
/**
293+
* Data provider for invalid isConfigured parameter types.
294+
*
295+
* @return array<string, array{mixed, string}>
296+
*/
297+
public function invalidIsConfiguredParameterTypesProvider(): array
298+
{
299+
return [
300+
'integer parameter' => [123, 'integer'],
301+
'array parameter' => [['invalid_array'], 'array'],
302+
'object parameter' => [new \stdClass(), 'stdClass'],
303+
'boolean parameter' => [true, 'boolean'],
304+
'null parameter' => [null, 'NULL'],
305+
];
306+
}
307+
308+
/**
309+
* Tests that isConfigured rejects all invalid parameter types consistently.
310+
*
311+
* @dataProvider invalidIsConfiguredParameterTypesProvider
312+
* @param mixed $invalidParam
313+
*/
314+
public function testIsConfiguredRejectsInvalidParameterTypes($invalidParam, string $expectedType): void
315+
{
316+
try {
317+
AiClient::isConfigured($invalidParam);
318+
$this->fail("Expected InvalidArgumentException for isConfigured with $expectedType");
319+
} catch (\InvalidArgumentException $e) {
320+
$this->assertStringContainsString(
321+
'Parameter must be a ProviderAvailabilityInterface instance, provider ID string, ' .
322+
'or provider class name.',
323+
$e->getMessage(),
324+
"isConfigured should reject invalid parameter type: $expectedType"
325+
);
326+
$this->assertStringContainsString(
327+
"Received: $expectedType",
328+
$e->getMessage(),
329+
"isConfigured should include received type in error message"
330+
);
331+
}
332+
}
333+
334+
/**
335+
* Tests backward compatibility - isConfigured still works with ProviderAvailabilityInterface.
336+
*/
337+
public function testIsConfiguredBackwardCompatibility(): void
338+
{
339+
// Test that the original interface-based approach still works exactly as before
340+
$mockAvailability = $this->createMock(ProviderAvailabilityInterface::class);
341+
$mockAvailability->expects($this->once())
342+
->method('isConfigured')
343+
->willReturn(true);
344+
345+
// Should work without registry parameter
346+
$result = AiClient::isConfigured($mockAvailability);
347+
$this->assertTrue($result);
348+
349+
// Should work in all cases with interface input
350+
$mockAvailability2 = $this->createMock(ProviderAvailabilityInterface::class);
351+
$mockAvailability2->expects($this->once())
352+
->method('isConfigured')
353+
->willReturn(false);
354+
355+
$result2 = AiClient::isConfigured($mockAvailability2);
356+
$this->assertFalse($result2);
357+
}
358+
249359
/**
250360
* Tests generateResult delegates to generateTextResult when model supports text generation.
251361
*/

tests/unit/Providers/OpenAiCompatibleImplementation/MockOpenAiCompatibleTextGenerationModel.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,9 @@ public function exposePrepareGenerateTextParams(array $prompt): array
113113
return $this->prepareGenerateTextParams($prompt);
114114
}
115115

116-
public function exposeMergeSystemInstruction(array $prompt, string $systemInstruction): array
116+
public function exposePrepareMessagesParamWithSystemInstruction(array $prompt, string $systemInstruction): array
117117
{
118-
return $this->mergeSystemInstruction($prompt, $systemInstruction);
118+
return $this->prepareMessagesParam($prompt, $systemInstruction);
119119
}
120120

121121
public function exposePrepareMessagesParam(array $messages): array

0 commit comments

Comments
 (0)