| [ Index ] |
PHP Cross Reference of WordPress Trunk (Updated Daily) |
[Summary view] [Print] [Text view]
1 <?php 2 3 declare (strict_types=1); 4 namespace WordPress\AiClient\Providers\OpenAiCompatibleImplementation; 5 6 use WordPress\AiClient\Common\Exception\InvalidArgumentException; 7 use WordPress\AiClient\Common\Exception\RuntimeException; 8 use WordPress\AiClient\Messages\DTO\Message; 9 use WordPress\AiClient\Messages\DTO\MessagePart; 10 use WordPress\AiClient\Messages\Enums\MessagePartChannelEnum; 11 use WordPress\AiClient\Messages\Enums\MessageRoleEnum; 12 use WordPress\AiClient\Messages\Enums\ModalityEnum; 13 use WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiBasedModel; 14 use WordPress\AiClient\Providers\Http\DTO\Request; 15 use WordPress\AiClient\Providers\Http\DTO\Response; 16 use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; 17 use WordPress\AiClient\Providers\Http\Exception\ResponseException; 18 use WordPress\AiClient\Providers\Http\Util\ResponseUtil; 19 use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface; 20 use WordPress\AiClient\Results\DTO\Candidate; 21 use WordPress\AiClient\Results\DTO\GenerativeAiResult; 22 use WordPress\AiClient\Results\DTO\TokenUsage; 23 use WordPress\AiClient\Results\Enums\FinishReasonEnum; 24 use WordPress\AiClient\Tools\DTO\FunctionCall; 25 use WordPress\AiClient\Tools\DTO\FunctionDeclaration; 26 /** 27 * Base class for a text 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, including but not limited to Anthropic, Google, and other providers 31 * that have adopted OpenAI's API specification as a standard interface. 32 * 33 * @since 0.1.0 34 * 35 * @phpstan-type ToolCallData array{ 36 * type?: string, 37 * id?: string, 38 * function?: array{ 39 * name?: string, 40 * arguments: string|array<string, mixed> 41 * } 42 * } 43 * @phpstan-type MessageData array{ 44 * role?: string, 45 * reasoning_content?: string, 46 * content?: string, 47 * tool_calls?: list<ToolCallData> 48 * } 49 * @phpstan-type ChoiceData array{ 50 * message?: MessageData, 51 * finish_reason?: string 52 * } 53 * @phpstan-type UsageData array{ 54 * prompt_tokens?: int, 55 * completion_tokens?: int, 56 * total_tokens?: int 57 * } 58 * @phpstan-type ResponseData array{ 59 * id?: string, 60 * choices?: list<ChoiceData>, 61 * usage?: UsageData 62 * } 63 */ 64 abstract class AbstractOpenAiCompatibleTextGenerationModel extends AbstractApiBasedModel implements TextGenerationModelInterface 65 { 66 /** 67 * {@inheritDoc} 68 * 69 * @since 0.1.0 70 */ 71 final public function generateTextResult(array $prompt): GenerativeAiResult 72 { 73 $httpTransporter = $this->getHttpTransporter(); 74 $params = $this->prepareGenerateTextParams($prompt); 75 $request = $this->createRequest(HttpMethodEnum::POST(), 'chat/completions', ['Content-Type' => 'application/json'], $params); 76 // Add authentication credentials to the request. 77 $request = $this->getRequestAuthentication()->authenticateRequest($request); 78 // Send and process the request. 79 $response = $httpTransporter->send($request); 80 $this->throwIfNotSuccessful($response); 81 return $this->parseResponseToGenerativeAiResult($response); 82 } 83 /** 84 * Prepares the given prompt and the model configuration into parameters for the API request. 85 * 86 * @since 0.1.0 87 * 88 * @param list<Message> $prompt The prompt to generate text for. Either a single message or a list of messages 89 * from a chat. 90 * @return array<string, mixed> The parameters for the API request. 91 */ 92 protected function prepareGenerateTextParams(array $prompt): array 93 { 94 $config = $this->getConfig(); 95 $params = ['model' => $this->metadata()->getId(), 'messages' => $this->prepareMessagesParam($prompt, $config->getSystemInstruction())]; 96 $outputModalities = $config->getOutputModalities(); 97 if (is_array($outputModalities)) { 98 $this->validateOutputModalities($outputModalities); 99 if (count($outputModalities) > 1) { 100 $params['modalities'] = $this->prepareOutputModalitiesParam($outputModalities); 101 } 102 } 103 $candidateCount = $config->getCandidateCount(); 104 if ($candidateCount !== null) { 105 $params['n'] = $candidateCount; 106 } 107 $maxTokens = $config->getMaxTokens(); 108 if ($maxTokens !== null) { 109 $params['max_tokens'] = $maxTokens; 110 } 111 $temperature = $config->getTemperature(); 112 if ($temperature !== null) { 113 $params['temperature'] = $temperature; 114 } 115 $topP = $config->getTopP(); 116 if ($topP !== null) { 117 $params['top_p'] = $topP; 118 } 119 $stopSequences = $config->getStopSequences(); 120 if (is_array($stopSequences)) { 121 $params['stop'] = $stopSequences; 122 } 123 $presencePenalty = $config->getPresencePenalty(); 124 if ($presencePenalty !== null) { 125 $params['presence_penalty'] = $presencePenalty; 126 } 127 $frequencyPenalty = $config->getFrequencyPenalty(); 128 if ($frequencyPenalty !== null) { 129 $params['frequency_penalty'] = $frequencyPenalty; 130 } 131 $logprobs = $config->getLogprobs(); 132 if ($logprobs !== null) { 133 $params['logprobs'] = $logprobs; 134 } 135 $topLogprobs = $config->getTopLogprobs(); 136 if ($topLogprobs !== null) { 137 $params['top_logprobs'] = $topLogprobs; 138 } 139 $functionDeclarations = $config->getFunctionDeclarations(); 140 if (is_array($functionDeclarations)) { 141 $params['tools'] = $this->prepareToolsParam($functionDeclarations); 142 } 143 $outputMimeType = $config->getOutputMimeType(); 144 if ('application/json' === $outputMimeType) { 145 $outputSchema = $config->getOutputSchema(); 146 $params['response_format'] = $this->prepareResponseFormatParam($outputSchema); 147 } 148 /* 149 * Any custom options are added to the parameters as well. 150 * This allows developers to pass other options that may be more niche or not yet supported by the SDK. 151 */ 152 $customOptions = $config->getCustomOptions(); 153 foreach ($customOptions as $key => $value) { 154 if (isset($params[$key])) { 155 throw new InvalidArgumentException(sprintf('The custom option "%s" conflicts with an existing parameter.', $key)); 156 } 157 $params[$key] = $value; 158 } 159 return $params; 160 } 161 /** 162 * Prepares the messages parameter for the API request. 163 * 164 * @since 0.1.0 165 * 166 * @param list<Message> $messages The messages to prepare. 167 * @param string|null $systemInstruction An optional system instruction to prepend to the messages. 168 * @return list<array<string, mixed>> The prepared messages parameter. 169 */ 170 protected function prepareMessagesParam(array $messages, ?string $systemInstruction = null): array 171 { 172 $messagesParam = array_map(function (Message $message): array { 173 // Special case: Function response. 174 $messageParts = $message->getParts(); 175 if (count($messageParts) === 1 && $messageParts[0]->getType()->isFunctionResponse()) { 176 $functionResponse = $messageParts[0]->getFunctionResponse(); 177 if (!$functionResponse) { 178 // This should be impossible due to class internals, but still needs to be checked. 179 throw new RuntimeException('The function response typed message part must contain a function response.'); 180 } 181 return ['role' => 'tool', 'content' => json_encode($functionResponse->getResponse()), 'tool_call_id' => $functionResponse->getId()]; 182 } 183 $messageData = ['role' => $this->getMessageRoleString($message->getRole()), 'content' => array_values(array_filter(array_map([$this, 'getMessagePartContentData'], $messageParts)))]; 184 // Only include tool_calls if there are any (OpenAI rejects empty arrays). 185 $toolCalls = array_values(array_filter(array_map([$this, 'getMessagePartToolCallData'], $messageParts))); 186 if (!empty($toolCalls)) { 187 $messageData['tool_calls'] = $toolCalls; 188 } 189 return $messageData; 190 }, $messages); 191 if ($systemInstruction) { 192 array_unshift($messagesParam, [ 193 /* 194 * TODO: Replace this with 'developer' in the future. 195 * See https://platform.openai.com/docs/api-reference/chat/create#chat_create-messages 196 */ 197 'role' => 'system', 198 'content' => [['type' => 'text', 'text' => $systemInstruction]], 199 ]); 200 } 201 return $messagesParam; 202 } 203 /** 204 * Returns the OpenAI API specific role string for the given message role. 205 * 206 * @since 0.1.0 207 * 208 * @param MessageRoleEnum $role The message role. 209 * @return string The role for the API request. 210 */ 211 protected function getMessageRoleString(MessageRoleEnum $role): string 212 { 213 if ($role === MessageRoleEnum::model()) { 214 return 'assistant'; 215 } 216 return 'user'; 217 } 218 /** 219 * Returns the OpenAI API specific content data for a message part. 220 * 221 * @since 0.1.0 222 * 223 * @param MessagePart $part The message part to get the data for. 224 * @return ?array<string, mixed> The data for the message content part, or null if not applicable. 225 * @throws InvalidArgumentException If the message part type or data is unsupported. 226 */ 227 protected function getMessagePartContentData(MessagePart $part): ?array 228 { 229 $type = $part->getType(); 230 if ($type->isText()) { 231 /* 232 * The OpenAI Chat Completions API spec does not support annotating thought parts as input, 233 * so we instead skip them. 234 */ 235 if ($part->getChannel()->isThought()) { 236 return null; 237 } 238 return ['type' => 'text', 'text' => $part->getText()]; 239 } 240 if ($type->isFile()) { 241 $file = $part->getFile(); 242 if (!$file) { 243 // This should be impossible due to class internals, but still needs to be checked. 244 throw new RuntimeException('The file typed message part must contain a file.'); 245 } 246 if ($file->isRemote()) { 247 if ($file->isImage()) { 248 return ['type' => 'image_url', 'image_url' => ['url' => $file->getUrl()]]; 249 } 250 throw new InvalidArgumentException(sprintf('Unsupported MIME type "%s" for remote file message part.', $file->getMimeType())); 251 } 252 // Else, it is an inline file. 253 if ($file->isImage()) { 254 return ['type' => 'image_url', 'image_url' => ['url' => $file->getDataUri()]]; 255 } 256 if ($file->isAudio()) { 257 return ['type' => 'input_audio', 'input_audio' => ['data' => $file->getBase64Data(), 'format' => $file->getMimeTypeObject()->toExtension()]]; 258 } 259 throw new InvalidArgumentException(sprintf('Unsupported MIME type "%s" for inline file message part.', $file->getMimeType())); 260 } 261 if ($type->isFunctionCall()) { 262 // Skip, as this is separately included. See `getMessagePartToolCallData()`. 263 return null; 264 } 265 if ($type->isFunctionResponse()) { 266 // Special case: Function response. 267 throw new InvalidArgumentException('The API only allows a single function response, as the only content of the message.'); 268 } 269 throw new InvalidArgumentException(sprintf('Unsupported message part type "%s".', $type)); 270 } 271 /** 272 * Returns the OpenAI API specific tool calls data for a message part. 273 * 274 * @since 0.1.0 275 * 276 * @param MessagePart $part The message part to get the data for. 277 * @return ?array<string, mixed> The data for the message tool call part, or null if not applicable. 278 * @throws InvalidArgumentException If the message part type or data is unsupported. 279 */ 280 protected function getMessagePartToolCallData(MessagePart $part): ?array 281 { 282 $type = $part->getType(); 283 if ($type->isFunctionCall()) { 284 $functionCall = $part->getFunctionCall(); 285 if (!$functionCall) { 286 // This should be impossible due to class internals, but still needs to be checked. 287 throw new RuntimeException('The function call typed message part must contain a function call.'); 288 } 289 $args = $functionCall->getArgs(); 290 /* 291 * Ensure null or empty arrays become empty objects for JSON encoding. 292 * While in theory the JSON schema could also dictate a type of 293 * 'array', in practice function arguments are typically of type 294 * 'object'. More importantly, the OpenAI API specification seems 295 * to expect that, and does not support passing arrays as the root 296 * value. The null check handles the case where FunctionCall normalizes 297 * empty arrays to null. 298 */ 299 if ($args === null || is_array($args) && count($args) === 0) { 300 $args = new \stdClass(); 301 } 302 return ['type' => 'function', 'id' => $functionCall->getId(), 'function' => ['name' => $functionCall->getName(), 'arguments' => json_encode($args)]]; 303 } 304 // All other types are handled in `getMessagePartContentData()`. 305 return null; 306 } 307 /** 308 * Validates that the given output modalities to ensure that at least one output modality is text. 309 * 310 * @since 0.1.0 311 * 312 * @param array<ModalityEnum> $outputModalities The output modalities to validate. 313 * @throws InvalidArgumentException If no text output modality is present. 314 */ 315 protected function validateOutputModalities(array $outputModalities): void 316 { 317 // If no output modalities are set, it's fine, as we can assume text. 318 if (count($outputModalities) === 0) { 319 return; 320 } 321 foreach ($outputModalities as $modality) { 322 if ($modality->isText()) { 323 return; 324 } 325 } 326 throw new InvalidArgumentException('A text output modality must be present when generating text.'); 327 } 328 /** 329 * Prepares the output modalities parameter for the API request. 330 * 331 * @since 0.1.0 332 * 333 * @param array<ModalityEnum> $modalities The modalities to prepare. 334 * @return list<string> The prepared modalities parameter. 335 */ 336 protected function prepareOutputModalitiesParam(array $modalities): array 337 { 338 $prepared = []; 339 foreach ($modalities as $modality) { 340 if ($modality->isText()) { 341 $prepared[] = 'text'; 342 } elseif ($modality->isImage()) { 343 $prepared[] = 'image'; 344 } elseif ($modality->isAudio()) { 345 $prepared[] = 'audio'; 346 } else { 347 throw new InvalidArgumentException(sprintf('Unsupported output modality "%s".', $modality)); 348 } 349 } 350 return $prepared; 351 } 352 /** 353 * Prepares the tools parameter for the API request. 354 * 355 * @since 0.1.0 356 * 357 * @param list<FunctionDeclaration> $functionDeclarations The function declarations. 358 * @return list<array<string, mixed>> The prepared tools parameter. 359 */ 360 protected function prepareToolsParam(array $functionDeclarations): array 361 { 362 $tools = []; 363 foreach ($functionDeclarations as $functionDeclaration) { 364 $tools[] = ['type' => 'function', 'function' => $functionDeclaration->toArray()]; 365 } 366 return $tools; 367 } 368 /** 369 * Prepares the response format parameter for the API request. 370 * 371 * This is only called if the output MIME type is `application/json`. 372 * 373 * @since 0.1.0 374 * 375 * @param array<string, mixed>|null $outputSchema The output schema. 376 * @return array<string, mixed> The prepared response format parameter. 377 */ 378 protected function prepareResponseFormatParam(?array $outputSchema): array 379 { 380 if (is_array($outputSchema)) { 381 return ['type' => 'json_schema', 'json_schema' => $outputSchema]; 382 } 383 return ['type' => 'json_object']; 384 } 385 /** 386 * Creates a request object for the provider's API. 387 * 388 * Implementations should use $this->getRequestOptions() to attach any 389 * configured request options to the Request. 390 * 391 * @since 0.1.0 392 * 393 * @param HttpMethodEnum $method The HTTP method. 394 * @param string $path The API endpoint path, relative to the base URI. 395 * @param array<string, string|list<string>> $headers The request headers. 396 * @param string|array<string, mixed>|null $data The request data. 397 * @return Request The request object. 398 */ 399 abstract protected function createRequest(HttpMethodEnum $method, string $path, array $headers = [], $data = null): Request; 400 /** 401 * Throws an exception if the response is not successful. 402 * 403 * @since 0.1.0 404 * 405 * @param Response $response The HTTP response to check. 406 * @throws ResponseException If the response is not successful. 407 */ 408 protected function throwIfNotSuccessful(Response $response): void 409 { 410 /* 411 * While this method only calls the utility method, it's important to have it here as a protected method so 412 * that child classes can override it if needed. 413 */ 414 ResponseUtil::throwIfNotSuccessful($response); 415 } 416 /** 417 * Parses the response from the API endpoint to a generative AI result. 418 * 419 * @since 0.1.0 420 * 421 * @param Response $response The response from the API endpoint. 422 * @return GenerativeAiResult The parsed generative AI result. 423 */ 424 protected function parseResponseToGenerativeAiResult(Response $response): GenerativeAiResult 425 { 426 /** @var ResponseData $responseData */ 427 $responseData = $response->getData(); 428 if (!isset($responseData['choices']) || !$responseData['choices']) { 429 throw ResponseException::fromMissingData($this->providerMetadata()->getName(), 'choices'); 430 } 431 if (!is_array($responseData['choices'])) { 432 throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), 'choices', 'The value must be an array.'); 433 } 434 $candidates = []; 435 foreach ($responseData['choices'] as $index => $choiceData) { 436 if (!is_array($choiceData) || array_is_list($choiceData)) { 437 throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), "choices[{$index}]", 'The value must be an associative array.'); 438 } 439 $candidates[] = $this->parseResponseChoiceToCandidate($choiceData, $index); 440 } 441 $id = isset($responseData['id']) && is_string($responseData['id']) ? $responseData['id'] : ''; 442 if (isset($responseData['usage']) && is_array($responseData['usage'])) { 443 $usage = $responseData['usage']; 444 $tokenUsage = new TokenUsage($usage['prompt_tokens'] ?? 0, $usage['completion_tokens'] ?? 0, $usage['total_tokens'] ?? 0); 445 } else { 446 $tokenUsage = new TokenUsage(0, 0, 0); 447 } 448 // Use any other data from the response as provider-specific response metadata. 449 $additionalData = $responseData; 450 unset($additionalData['id'], $additionalData['choices'], $additionalData['usage']); 451 return new GenerativeAiResult($id, $candidates, $tokenUsage, $this->providerMetadata(), $this->metadata(), $additionalData); 452 } 453 /** 454 * Parses a single choice from the API response into a Candidate object. 455 * 456 * @since 0.1.0 457 * 458 * @param ChoiceData $choiceData The choice data from the API response. 459 * @param int $index The index of the choice in the choices array. 460 * @return Candidate The parsed candidate. 461 * @throws RuntimeException If the choice data is invalid. 462 */ 463 protected function parseResponseChoiceToCandidate(array $choiceData, int $index): Candidate 464 { 465 if (!isset($choiceData['message']) || !is_array($choiceData['message']) || array_is_list($choiceData['message'])) { 466 throw ResponseException::fromMissingData($this->providerMetadata()->getName(), "choices[{$index}].message"); 467 } 468 if (!isset($choiceData['finish_reason']) || !is_string($choiceData['finish_reason'])) { 469 throw ResponseException::fromMissingData($this->providerMetadata()->getName(), "choices[{$index}].finish_reason"); 470 } 471 $messageData = $choiceData['message']; 472 $message = $this->parseResponseChoiceMessage($messageData, $index); 473 switch ($choiceData['finish_reason']) { 474 case 'stop': 475 $finishReason = FinishReasonEnum::stop(); 476 break; 477 case 'length': 478 $finishReason = FinishReasonEnum::length(); 479 break; 480 case 'content_filter': 481 $finishReason = FinishReasonEnum::contentFilter(); 482 break; 483 case 'tool_calls': 484 $finishReason = FinishReasonEnum::toolCalls(); 485 break; 486 default: 487 throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), "choices[{$index}].finish_reason", sprintf('Invalid finish reason "%s".', $choiceData['finish_reason'])); 488 } 489 return new Candidate($message, $finishReason); 490 } 491 /** 492 * Parses the message from a choice in the API response. 493 * 494 * @since 0.1.0 495 * 496 * @param MessageData $messageData The message data from the API response. 497 * @param int $index The index of the choice in the choices array. 498 * @return Message The parsed message. 499 */ 500 protected function parseResponseChoiceMessage(array $messageData, int $index): Message 501 { 502 $role = isset($messageData['role']) && 'user' === $messageData['role'] ? MessageRoleEnum::user() : MessageRoleEnum::model(); 503 $parts = $this->parseResponseChoiceMessageParts($messageData, $index); 504 return new Message($role, $parts); 505 } 506 /** 507 * Parses the message parts from a choice in the API response. 508 * 509 * @since 0.1.0 510 * 511 * @param MessageData $messageData The message data from the API response. 512 * @param int $index The index of the choice in the choices array. 513 * @return MessagePart[] The parsed message parts. 514 */ 515 protected function parseResponseChoiceMessageParts(array $messageData, int $index): array 516 { 517 $parts = []; 518 if (isset($messageData['reasoning_content']) && is_string($messageData['reasoning_content'])) { 519 $parts[] = new MessagePart($messageData['reasoning_content'], MessagePartChannelEnum::thought()); 520 } 521 if (isset($messageData['content']) && is_string($messageData['content'])) { 522 $parts[] = new MessagePart($messageData['content']); 523 } 524 if (isset($messageData['tool_calls']) && is_array($messageData['tool_calls'])) { 525 foreach ($messageData['tool_calls'] as $toolCallIndex => $toolCallData) { 526 $toolCallPart = $this->parseResponseChoiceMessageToolCallPart($toolCallData); 527 if (!$toolCallPart) { 528 throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), "choices[{$index}].message.tool_calls[{$toolCallIndex}]", 'The response includes a tool call of an unexpected type.'); 529 } 530 $parts[] = $toolCallPart; 531 } 532 } 533 return $parts; 534 } 535 /** 536 * Parses a tool call part from the API response. 537 * 538 * @since 0.1.0 539 * 540 * @param ToolCallData $toolCallData The tool call data from the API response. 541 * @return MessagePart|null The parsed message part for the tool call, or null if not applicable. 542 */ 543 protected function parseResponseChoiceMessageToolCallPart(array $toolCallData): ?MessagePart 544 { 545 /* 546 * For now, only function calls are supported. 547 * 548 * Not all OpenAI compatible APIs include a 'type' key, so we only check its value if it is set. 549 */ 550 if (isset($toolCallData['type']) && 'function' !== $toolCallData['type'] || !isset($toolCallData['function']) || !is_array($toolCallData['function'])) { 551 return null; 552 } 553 $functionArguments = is_string($toolCallData['function']['arguments']) ? json_decode($toolCallData['function']['arguments'], \true) : $toolCallData['function']['arguments']; 554 $functionCall = new FunctionCall(isset($toolCallData['id']) && is_string($toolCallData['id']) ? $toolCallData['id'] : null, isset($toolCallData['function']['name']) && is_string($toolCallData['function']['name']) ? $toolCallData['function']['name'] : null, $functionArguments); 555 return new MessagePart($functionCall); 556 } 557 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
| Generated : Sat Jun 13 09:38:55 2026 | Cross-referenced by PHPXref |