Skip to content

Commit f22f738

Browse files
Merge pull request #39 from WordPress/provider-base-and-implementation
2 parents 2c8bfa2 + 3815355 commit f22f738

File tree

82 files changed

+6672
-244
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

82 files changed

+6672
-244
lines changed

.gitattributes

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
.gitattributes export-ignore
33
/.github/ export-ignore
44
.gitignore export-ignore
5+
/cli.php export-ignore
56
/*.md export-ignore
67
/LICENSE.md -export-ignore
78
/README.md -export-ignore

cli.php

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
<?php
2+
/**
3+
* CLI script for interacting with the AI client.
4+
*
5+
* This script allows users to send prompts to the AI and receive responses.
6+
* It supports named arguments for provider and model selection.
7+
*
8+
* Usage:
9+
* GOOGLE_API_KEY=123456 php cli.php 'Your prompt here' --providerId=google --modelId=gemini-2.5-flash
10+
* OPENAI_API_KEY=123456 php cli.php 'Your prompt here' --providerId=openai
11+
* GOOGLE_API_KEY=123456 OPENAI_API_KEY=123456 php cli.php 'Your prompt here'
12+
*/
13+
14+
declare(strict_types=1);
15+
16+
use WordPress\AiClient\AiClient;
17+
use WordPress\AiClient\Providers\Http\Exception\ResponseException;
18+
use WordPress\AiClient\Providers\Models\DTO\ModelConfig;
19+
20+
require_once __DIR__ . '/vendor/autoload.php';
21+
22+
/**
23+
* Prints the output to stdout.
24+
*
25+
* @param string $output The output to print.
26+
*/
27+
function printOutput(string $output): void
28+
{
29+
echo $output . PHP_EOL;
30+
}
31+
32+
/**
33+
* Logs an informational message to stderr.
34+
*
35+
* @param string $message The message to log.
36+
*/
37+
function logInfo(string $message): void
38+
{
39+
fwrite(STDERR, '[INFO] ' . $message . PHP_EOL);
40+
}
41+
42+
/**
43+
* Logs a warning message to stderr.
44+
*
45+
* @param string $message The message to log.
46+
*/
47+
function logWarning(string $message): void
48+
{
49+
fwrite(STDERR, '[WARNING] ' . $message . PHP_EOL);
50+
}
51+
52+
/**
53+
* Logs an error message to stderr and terminates the script.
54+
*
55+
* @param string $message The message to log.
56+
* @param int $exit_code The exit code to use.
57+
*/
58+
function logError(string $message, int $exit_code = 1): void
59+
{
60+
fwrite(STDERR, '[ERROR] ' . $message . PHP_EOL);
61+
exit($exit_code);
62+
}
63+
64+
// --- Argument parsing ---
65+
66+
$positional_args = [];
67+
$named_args = [];
68+
69+
for ($i = 1; $i < $argc; $i++) {
70+
$arg = $argv[$i];
71+
if (str_starts_with($arg, '--')) {
72+
$parts = explode('=', substr($arg, 2), 2);
73+
$key = $parts[0];
74+
$value = $parts[1] ?? true;
75+
if (empty($key)) {
76+
logWarning("Ignoring invalid named argument: {$arg}");
77+
continue;
78+
}
79+
$named_args[$key] = $value;
80+
} else {
81+
$positional_args[] = $arg;
82+
}
83+
}
84+
85+
// --- Input validation ---
86+
87+
if (empty($positional_args[0])) {
88+
logError('Missing required positional argument "prompt input".');
89+
}
90+
91+
// Prompt input. Allow complex input as a JSON string.
92+
$promptInput = $positional_args[0];
93+
if (strpos($promptInput, '{') === 0 || strpos($promptInput, '[') === 0) {
94+
$decodedInput = json_decode($promptInput, true);
95+
if ($decodedInput) {
96+
$promptInput = $decodedInput;
97+
}
98+
}
99+
100+
// Provider ID, model ID, and output format.
101+
$providerId = $named_args['providerId'] ?? null;
102+
$modelId = $named_args['modelId'] ?? null;
103+
$outputFormat = $named_args['outputFormat'] ?? 'message-text';
104+
105+
// Any model configuration options.
106+
$schema = ModelConfig::getJsonSchema()['properties'];
107+
$model_config_data = [];
108+
foreach ($named_args as $key => $value) {
109+
if (!isset($schema[$key])) {
110+
continue;
111+
}
112+
113+
$property_schema = $schema[$key];
114+
$type = $property_schema['type'] ?? null;
115+
116+
$processed_value = $value;
117+
if ($type === 'array' || $type === 'object') {
118+
$decoded = json_decode((string) $value, true);
119+
if (json_last_error() !== JSON_ERROR_NONE) {
120+
logWarning("Invalid JSON for argument --{$key}: " . json_last_error_msg());
121+
continue;
122+
}
123+
$processed_value = $decoded;
124+
} elseif ($type === 'integer') {
125+
$processed_value = (int) $value;
126+
} elseif ($type === 'number') {
127+
$processed_value = (float) $value;
128+
} elseif ($type === 'boolean') {
129+
$processed_value = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
130+
if (null === $processed_value) {
131+
logWarning("Invalid boolean for argument --{$key}: {$value}");
132+
continue;
133+
}
134+
}
135+
136+
$model_config_data[$key] = $processed_value;
137+
}
138+
139+
// --- Main logic ---
140+
141+
try {
142+
$modelConfig = ModelConfig::fromArray($model_config_data);
143+
144+
$promptBuilder = AiClient::prompt($promptInput);
145+
$promptBuilder = $promptBuilder->usingModelConfig($modelConfig);
146+
if ($providerId && $modelId) {
147+
$providerClassName = AiClient::defaultRegistry()->getProviderClassName($providerId);
148+
$promptBuilder = $promptBuilder->usingModel($providerClassName::model($modelId));
149+
} elseif ($providerId) {
150+
$promptBuilder = $promptBuilder->usingProvider($providerId);
151+
}
152+
} catch (InvalidArgumentException $e) {
153+
logError('Invalid arguments while trying to set up prompt builder: ' . $e->getMessage());
154+
} catch (ResponseException $e) {
155+
logError('Request failed while trying to set up prompt builder: ' . $e->getMessage());
156+
}
157+
158+
try {
159+
$result = $promptBuilder->generateTextResult();
160+
} catch (InvalidArgumentException $e) {
161+
logError('Invalid arguments while trying to generate text result: ' . $e->getMessage());
162+
} catch (ResponseException $e) {
163+
logError('Request failed while trying to generate text result: ' . $e->getMessage());
164+
}
165+
166+
logInfo("Using provider ID: \"{$result->getProviderMetadata()->getId()}\"");
167+
logInfo("Using model ID: \"{$result->getModelMetadata()->getId()}\"");
168+
169+
switch ($outputFormat) {
170+
case 'result-json':
171+
$output = json_encode($result, JSON_PRETTY_PRINT);
172+
break;
173+
case 'candidates-json':
174+
$output = json_encode($result->getCandidates(), JSON_PRETTY_PRINT);
175+
break;
176+
case 'message-text':
177+
default:
178+
$output = $result->toText();
179+
}
180+
181+
printOutput($output);

docs/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -893,7 +893,7 @@ direction LR
893893
+send(Request $request) Response
894894
}
895895
class RequestAuthenticationInterface {
896-
+authenticate(Request $request) void
896+
+authenticateRequest(Request $request) Request
897897
+getJsonSchema() array< string, mixed >$
898898
}
899899
class WithHttpTransporterInterface {

src/AiClient.php

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
namespace WordPress\AiClient;
66

77
use WordPress\AiClient\Builders\PromptBuilder;
8+
use WordPress\AiClient\ProviderImplementations\Anthropic\AnthropicProvider;
9+
use WordPress\AiClient\ProviderImplementations\Google\GoogleProvider;
10+
use WordPress\AiClient\ProviderImplementations\OpenAi\OpenAiProvider;
811
use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface;
12+
use WordPress\AiClient\Providers\Http\HttpTransporterFactory;
913
use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
1014
use WordPress\AiClient\Providers\Models\DTO\ModelConfig;
1115
use WordPress\AiClient\Providers\ProviderRegistry;
@@ -95,12 +99,11 @@ public static function defaultRegistry(): ProviderRegistry
9599
if (self::$defaultRegistry === null) {
96100
$registry = new ProviderRegistry();
97101

98-
// Provider registration will be enabled once concrete provider implementations are available.
99-
// This follows the pattern established in the provider registry architecture.
100-
//$registry->setHttpTransporter(HttpTransporterFactory::createTransporter());
101-
//$registry->registerProvider(AnthropicProvider::class);
102-
//$registry->registerProvider(GoogleProvider::class);
103-
//$registry->registerProvider(OpenAiProvider::class);
102+
// Set up default HTTP transporter and register built-in providers.
103+
$registry->setHttpTransporter(HttpTransporterFactory::createTransporter());
104+
$registry->registerProvider(AnthropicProvider::class);
105+
$registry->registerProvider(GoogleProvider::class);
106+
$registry->registerProvider(OpenAiProvider::class);
104107

105108
self::$defaultRegistry = $registry;
106109
}

src/Builders/PromptBuilder.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,9 @@ public function withHistory(Message ...$messages): self
191191
/**
192192
* Sets the model to use for generation.
193193
*
194+
* The model's configuration will be merged with the builder's configuration,
195+
* with the builder's configuration taking precedence for any overlapping settings.
196+
*
194197
* @since n.e.x.t
195198
*
196199
* @param ModelInterface $model The model to use.
@@ -199,6 +202,14 @@ public function withHistory(Message ...$messages): self
199202
public function usingModel(ModelInterface $model): self
200203
{
201204
$this->model = $model;
205+
206+
// Merge model's config with builder's config, with builder's config taking precedence
207+
$modelConfigArray = $model->getConfig()->toArray();
208+
$builderConfigArray = $this->modelConfig->toArray();
209+
$mergedConfigArray = array_merge($modelConfigArray, $builderConfigArray);
210+
211+
$this->modelConfig = ModelConfig::fromArray($mergedConfigArray);
212+
202213
return $this;
203214
}
204215

@@ -1009,6 +1020,7 @@ private function getConfiguredModel(CapabilityEnum $capability): ModelInterface
10091020
// If a model has been explicitly set, return it
10101021
if ($this->model !== null) {
10111022
$this->model->setConfig($this->modelConfig);
1023+
$this->registry->bindModelDependencies($this->model);
10121024
return $this->model;
10131025
}
10141026

src/Common/AbstractEnum.php

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use BadMethodCallException;
88
use InvalidArgumentException;
9+
use JsonSerializable;
910
use ReflectionClass;
1011
use RuntimeException;
1112

@@ -36,7 +37,7 @@
3637
*
3738
* @since n.e.x.t
3839
*/
39-
abstract class AbstractEnum
40+
abstract class AbstractEnum implements JsonSerializable
4041
{
4142
/**
4243
* @var string The value of the enum instance.
@@ -393,4 +394,17 @@ final public function __toString(): string
393394
{
394395
return $this->value;
395396
}
397+
398+
/**
399+
* Converts the enum to a JSON-serializable format.
400+
*
401+
* @since n.e.x.t
402+
*
403+
* @return string The enum value.
404+
*/
405+
#[\ReturnTypeWillChange]
406+
public function jsonSerialize()
407+
{
408+
return $this->value;
409+
}
396410
}

src/Files/ValueObjects/MimeType.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ final class MimeType
7272
'ogg' => 'audio/ogg',
7373
'flac' => 'audio/flac',
7474
'm4a' => 'audio/m4a',
75+
'aac' => 'audio/aac',
7576

7677
// Video
7778
'mp4' => 'video/mp4',
@@ -130,6 +131,27 @@ public function __construct(string $value)
130131
$this->value = strtolower($value);
131132
}
132133

134+
/**
135+
* Gets the primary known file extension for this MIME type.
136+
*
137+
* @since n.e.x.t
138+
*
139+
* @return string The file extension (without the dot).
140+
* @throws InvalidArgumentException If no known extension exists for this MIME type.
141+
*/
142+
public function toExtension(): string
143+
{
144+
// Reverse lookup for the MIME type to find the extension.
145+
$extension = array_search($this->value, self::$extensionMap, true);
146+
if ($extension === false) {
147+
throw new InvalidArgumentException(
148+
sprintf('No known extension for MIME type: %s', $this->value)
149+
);
150+
}
151+
152+
return $extension;
153+
}
154+
133155
/**
134156
* Creates a MimeType from a file extension.
135157
*
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace WordPress\AiClient\ProviderImplementations\Anthropic;
6+
7+
use WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication;
8+
use WordPress\AiClient\Providers\Http\DTO\Request;
9+
10+
/**
11+
* Class for HTTP request authentication using an API key in a Anthropic API compliant way.
12+
*
13+
* @since n.e.x.t
14+
*/
15+
class AnthropicApiKeyRequestAuthentication extends ApiKeyRequestAuthentication
16+
{
17+
public const ANTHROPIC_API_VERSION = '2023-06-01';
18+
19+
/**
20+
* {@inheritDoc}
21+
*
22+
* @since n.e.x.t
23+
*/
24+
public function authenticateRequest(Request $request): Request
25+
{
26+
// Anthropic requires this header to be set for all requests.
27+
$request = $request->withHeader('anthropic-version', self::ANTHROPIC_API_VERSION);
28+
29+
// Add the API key to the request headers.
30+
return $request->withHeader('x-api-key', $this->apiKey);
31+
}
32+
}

0 commit comments

Comments
 (0)