Skip to content

[WIP] Provider base and implementation #39

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 8 commits into
base: trunk
Choose a base branch
from
182 changes: 182 additions & 0 deletions src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
<?php

declare(strict_types=1);

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.
*
* @since n.e.x.t
*/
class OpenAiModelMetadataDirectory extends AbstractOpenAiCompatibleModelMetadataDirectory
{
/**
* @inheritDoc
*/
protected function createRequest(string $path): RequestInterface

Check failure on line 26 in src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php

View workflow job for this annotation

GitHub Actions / PHP

Return type WordPress\AiClient\ProviderImplementations\OpenAi\RequestInterface of method WordPress\AiClient\ProviderImplementations\OpenAi\OpenAiModelMetadataDirectory::createRequest() is not covariant with return type WordPress\AiClient\Providers\RequestInterface of method WordPress\AiClient\Providers\AbstractOpenAiCompatibleModelMetadataDirectory::createRequest().

Check failure on line 26 in src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php

View workflow job for this annotation

GitHub Actions / PHP

Method WordPress\AiClient\ProviderImplementations\OpenAi\OpenAiModelMetadataDirectory::createRequest() has invalid return type WordPress\AiClient\ProviderImplementations\OpenAi\RequestInterface.
{
// Something like this.
return new OpenAiCompatibleRequest('https://api.openai.com/v1', $path);

Check failure on line 29 in src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php

View workflow job for this annotation

GitHub Actions / PHP

Method WordPress\AiClient\ProviderImplementations\OpenAi\OpenAiModelMetadataDirectory::createRequest() should return WordPress\AiClient\ProviderImplementations\OpenAi\RequestInterface but returns WordPress\AiClient\ProviderImplementations\OpenAi\OpenAiCompatibleRequest.

Check failure on line 29 in src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php

View workflow job for this annotation

GitHub Actions / PHP

Instantiated class WordPress\AiClient\ProviderImplementations\OpenAi\OpenAiCompatibleRequest not found.
}

/**
* @inheritDoc
*/
protected function parseResponseToModelMetadataList(ResponseInterface $response): array

Check failure on line 35 in src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php

View workflow job for this annotation

GitHub Actions / PHP

Parameter $response of method WordPress\AiClient\ProviderImplementations\OpenAi\OpenAiModelMetadataDirectory::parseResponseToModelMetadataList() has invalid type WordPress\AiClient\Providers\ResponseInterface.

Check failure on line 35 in src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php

View workflow job for this annotation

GitHub Actions / PHP

Parameter $response of method WordPress\AiClient\ProviderImplementations\OpenAi\OpenAiModelMetadataDirectory::parseResponseToModelMetadataList() has invalid type WordPress\AiClient\ProviderImplementations\OpenAi\ResponseInterface.

Check failure on line 35 in src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php

View workflow job for this annotation

GitHub Actions / PHP

Parameter #1 $response (WordPress\AiClient\ProviderImplementations\OpenAi\ResponseInterface) of method WordPress\AiClient\ProviderImplementations\OpenAi\OpenAiModelMetadataDirectory::parseResponseToModelMetadataList() is not contravariant with parameter #1 $response (WordPress\AiClient\Providers\ResponseInterface) of method WordPress\AiClient\Providers\AbstractOpenAiCompatibleModelMetadataDirectory::parseResponseToModelMetadataList().
{
$responseData = $response->getData();

Check failure on line 37 in src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php

View workflow job for this annotation

GitHub Actions / PHP

Call to method getData() on an unknown class WordPress\AiClient\ProviderImplementations\OpenAi\ResponseInterface.
if (!isset($responseData['data']) || !$responseData['data']) {

Check failure on line 38 in src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php

View workflow job for this annotation

GitHub Actions / PHP

Cannot access offset 'data' on mixed.
throw new RuntimeException(
'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) use (

Check failure on line 119 in src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php

View workflow job for this annotation

GitHub Actions / PHP

Parameter #1 $callback of function array_map expects (callable(mixed): mixed)|null, Closure(array): WordPress\AiClient\Providers\Models\DTO\ModelMetadata given.
$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']
)
);
}
}
83 changes: 83 additions & 0 deletions src/ProviderImplementations/OpenAi/OpenAiProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

declare(strict_types=1);

namespace WordPress\AiClient\ProviderImplementations\OpenAi;

use RuntimeException;
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;

/**
* Class for the OpenAI provider.
*
* @since n.e.x.t
*/
class OpenAiProvider extends AbstractProvider
{
/**
* @inheritDoc
*/
protected static function createModel(
ModelMetadata $modelMetadata,
ProviderMetadata $providerMetadata
): ModelInterface {
$capabilities = $modelMetadata->getSupportedCapabilities();
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();
}
}
25 changes: 25 additions & 0 deletions src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

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.
*
* @since n.e.x.t
*/
class OpenAiTextGenerationModel extends AbstractOpenAiCompatibleTextGenerationModel
{
/**
* @inheritDoc
*/
protected function createRequest(string $path, array $params): RequestInterface
{
// Something like this.
return new OpenAiCompatibleRequest('https://api.openai.com/v1', $path);
}
}
88 changes: 88 additions & 0 deletions src/Providers/AbstractApiBasedModelMetadataDirectory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

declare(strict_types=1);

namespace WordPress\AiClient\Providers;

use InvalidArgumentException;
use WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface;
use WordPress\AiClient\Providers\Models\Contracts\WithHttpTransporterInterface;
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.
*
* @since n.e.x.t
*/
abstract class AbstractApiBasedModelMetadataDirectory implements
ModelMetadataDirectoryInterface,
WithHttpTransporterInterface
{
use WithHttpTransporterTrait;

/**
* @var ?array<string, ModelMetadata> Map of model ID to model metadata, effectively for caching.
*/
private ?array $modelMetadataMap = null;

/**
* @inheritdoc
*/
final public function listModelMetadata(): array
{
$modelsMetadata = $this->getModelMetadataMap();
return array_values($modelsMetadata);
}

/**
* @inheritdoc
*/
final public function hasModelMetadata(string $modelId): bool
{
try {
$this->getModelMetadata();
} catch (InvalidArgumentException $e) {
return false;
}
return true;
}

/**
* @inheritdoc
*/
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<string, ModelMetadata> 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<string, ModelMetadata> Map of model ID to model metadata.
*/
abstract protected function sendListModelsRequest(): array;
}
Loading