Commit f7285e55a3 for wordpress.org
commit f7285e55a3206748130792bbbb1f44a4684a89fa
Author: gziolo <gziolo@git.wordpress.org>
Date: Thu Oct 29 09:55:49 2026 +0000
AI: Update the PHP AI Client library to version 1.5.1
This maintenance release hardens file input handling and deserialization, adds explicit file constructors, and deprecates implicit local file loading through the File constructor. Local paths remain supported by the prompt and message builders without deprecation notices.
The update also adds a provider hook for preparing function parameter schemas and excludes function annotations from OpenAI-compatible tool request payloads.
Developed in https://github.com/WordPress/wordpress-develop/pull/15142.
Props alekv, aryanjasala, gziolo, jason_the_adams, jeffpaul, jorgefilipecosta, laurisaarni.
Fixes #66400.
Built from https://develop.svn.wordpress.org/trunk@65001
git-svn-id: http://core.svn.wordpress.org/trunk@64151 1a063a9b-81f0-0310-95a4-ce76da25c4cd
diff --git a/wp-includes/php-ai-client/src/AiClient.php b/wp-includes/php-ai-client/src/AiClient.php
index 4ee6ec969f..a00d19ee54 100644
--- a/wp-includes/php-ai-client/src/AiClient.php
+++ b/wp-includes/php-ai-client/src/AiClient.php
@@ -90,7 +90,7 @@ class AiClient
/**
* @var string The version of the AI Client.
*/
- public const VERSION = '1.5.0';
+ public const VERSION = '1.5.1';
/**
* @var ProviderRegistry|null The default provider registry instance.
*/
diff --git a/wp-includes/php-ai-client/src/Builders/MessageBuilder.php b/wp-includes/php-ai-client/src/Builders/MessageBuilder.php
index 1f1f046733..f7bce7cc73 100644
--- a/wp-includes/php-ai-client/src/Builders/MessageBuilder.php
+++ b/wp-includes/php-ai-client/src/Builders/MessageBuilder.php
@@ -5,6 +5,7 @@ namespace WordPress\AiClient\Builders;
use InvalidArgumentException;
use WordPress\AiClient\Files\DTO\File;
+use WordPress\AiClient\Files\Util\FileInputUtil;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Messages\DTO\MessagePart;
use WordPress\AiClient\Messages\Enums\MessageRoleEnum;
@@ -143,6 +144,9 @@ class MessageBuilder
* - Data URI string (data:mime/type;base64,data)
* - Local file path string
*
+ * Resolving a local file path reads from the filesystem. Only pass strings you control; for untrusted
+ * input, construct a File via File::fromUrl(), File::fromDataUri() or File::fromBase64() instead.
+ *
* @since 0.2.0
*
* @param string|File $file The file to add.
@@ -152,7 +156,7 @@ class MessageBuilder
*/
public function withFile($file, ?string $mimeType = null): self
{
- $file = $file instanceof File ? $file : new File($file, $mimeType);
+ $file = $file instanceof File ? $file : FileInputUtil::fromString($file, $mimeType);
$this->parts[] = new MessagePart($file);
return $this;
}
diff --git a/wp-includes/php-ai-client/src/Builders/PromptBuilder.php b/wp-includes/php-ai-client/src/Builders/PromptBuilder.php
index 641d1915f1..8119cf5037 100644
--- a/wp-includes/php-ai-client/src/Builders/PromptBuilder.php
+++ b/wp-includes/php-ai-client/src/Builders/PromptBuilder.php
@@ -12,6 +12,7 @@ use WordPress\AiClient\Events\BeforeGenerateResultEvent;
use WordPress\AiClient\Files\DTO\File;
use WordPress\AiClient\Files\Enums\FileTypeEnum;
use WordPress\AiClient\Files\Enums\MediaOrientationEnum;
+use WordPress\AiClient\Files\Util\FileInputUtil;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Messages\DTO\MessagePart;
use WordPress\AiClient\Messages\DTO\UserMessage;
@@ -133,6 +134,9 @@ class PromptBuilder
* - Data URI string (data:mime/type;base64,data)
* - Local file path string
*
+ * Resolving a local file path reads from the filesystem. Only pass strings you control; for untrusted
+ * input, construct a File via File::fromUrl(), File::fromDataUri() or File::fromBase64() instead.
+ *
* @since 0.1.0
*
* @param string|File $file The file (File object or string representation).
@@ -142,7 +146,7 @@ class PromptBuilder
*/
public function withFile($file, ?string $mimeType = null): self
{
- $file = $file instanceof File ? $file : new File($file, $mimeType);
+ $file = $file instanceof File ? $file : FileInputUtil::fromString($file, $mimeType);
$part = new MessagePart($file);
$this->appendPartToMessages($part);
return $this;
diff --git a/wp-includes/php-ai-client/src/Common/AbstractDataTransferObject.php b/wp-includes/php-ai-client/src/Common/AbstractDataTransferObject.php
index ec989aa688..862611789c 100644
--- a/wp-includes/php-ai-client/src/Common/AbstractDataTransferObject.php
+++ b/wp-includes/php-ai-client/src/Common/AbstractDataTransferObject.php
@@ -51,6 +51,9 @@ abstract class AbstractDataTransferObject implements WithArrayTransformationInte
/**
* {@inheritDoc}
*
+ * The default implementation attempts to construct the object. Subclasses whose fromArray() has side
+ * effects, is expensive, or handles untrusted input should override this with a structural check.
+ *
* @since 0.1.0
*/
public static function isArrayShape(array $array): bool
diff --git a/wp-includes/php-ai-client/src/Files/DTO/File.php b/wp-includes/php-ai-client/src/Files/DTO/File.php
index cafbc3cd1d..1731b2f718 100644
--- a/wp-includes/php-ai-client/src/Files/DTO/File.php
+++ b/wp-includes/php-ai-client/src/Files/DTO/File.php
@@ -11,10 +11,19 @@ use WordPress\AiClient\Files\ValueObjects\MimeType;
/**
* Represents a file in the AI client.
*
- * This DTO automatically detects whether a file is a URL, base64 data, or local file path
- * and handles them appropriately.
+ * A file is either remote (an http(s) URL that the provider fetches) or inline (base64-encoded data).
+ *
+ * The constructor accepts an http(s) URL, a data URI, or plain base64 data with a MIME type. For backward
+ * compatibility it still detects an existing local file path and reads it, but this is deprecated and will be
+ * removed in the next major version: load local files explicitly via {@see File::fromLocalPath()} instead.
+ * The named constructors {@see File::fromUrl()}, {@see File::fromDataUri()} and {@see File::fromBase64()}
+ * never access the filesystem and should be preferred whenever the kind of input is known, especially for
+ * data that comes from an untrusted source such as an API response or user input. {@see File::fromArray()}
+ * never accesses the filesystem.
*
* @since 0.1.0
+ * @since 1.5.1 Implicit local file path detection in the constructor is deprecated; non-http(s) URL schemes
+ * are rejected before any filesystem access.
*
* @phpstan-type FileArrayShape array{
* fileType: string,
@@ -31,6 +40,20 @@ class File extends AbstractDataTransferObject
public const KEY_MIME_TYPE = 'mimeType';
public const KEY_URL = 'url';
public const KEY_BASE64_DATA = 'base64Data';
+ /**
+ * Pattern matching a data URI with base64 payload.
+ *
+ * Group 1 is the optional MIME type (with parameters), group 2 is the base64 payload.
+ *
+ * @since 1.5.1
+ */
+ private const DATA_URI_PATTERN = '/^data:(?:([a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*' . '(?:;[a-zA-Z0-9\-]+=[a-zA-Z0-9\-]+)*)?;)?base64,([A-Za-z0-9+\/]*={0,2})\z/';
+ /**
+ * Pattern matching plain base64 data (an empty payload is allowed, e.g. for an empty file).
+ *
+ * @since 1.5.1
+ */
+ private const BASE64_PATTERN = '/^[A-Za-z0-9+\/]*={0,2}\z/';
/**
* @var MimeType The MIME type of the file.
*/
@@ -50,21 +73,124 @@ class File extends AbstractDataTransferObject
/**
* Constructor.
*
+ * Passing a local file path is deprecated; use {@see File::fromLocalPath()} instead. When a local path is
+ * detected, an E_USER_DEPRECATED notice is emitted and the file is read.
+ *
* @since 0.1.0
+ * @since 1.5.1 Local file path detection is deprecated. Non-http(s) URL schemes are rejected.
*
- * @param string $file The file string (URL, base64 data, or local path).
- * @param string|null $mimeType The MIME type of the file (optional).
+ * @param string $file The file string (http(s) URL, data URI, plain base64 data, or deprecated local path).
+ * @param string|null $mimeType The MIME type of the file (optional, required for plain base64 data).
* @throws InvalidArgumentException If the file format is invalid or MIME type cannot be determined.
*/
public function __construct(string $file, ?string $mimeType = null)
{
- // Detect and process the file type (will set MIME type if possible)
$this->detectAndProcessFile($file, $mimeType);
}
+ /**
+ * Creates a remote file from an http(s) URL.
+ *
+ * The URL is not fetched by the client; it is passed on to the provider.
+ *
+ * @since 1.5.1
+ *
+ * @param string $url The http(s) URL.
+ * @param string|null $mimeType The MIME type (optional, otherwise inferred from the URL path extension).
+ * @return self The remote file.
+ * @throws InvalidArgumentException If the URL is not an http(s) URL or the MIME type cannot be determined.
+ */
+ public static function fromUrl(string $url, ?string $mimeType = null): self
+ {
+ if (!self::isHttpUrl($url)) {
+ throw new InvalidArgumentException('Invalid URL. Only http:// and https:// URLs are supported.');
+ }
+ return new self($url, $mimeType);
+ }
+ /**
+ * Creates an inline file from a data URI (data:[mime/type];base64,[data]).
+ *
+ * @since 1.5.1
+ *
+ * @param string $dataUri The data URI.
+ * @param string|null $mimeType The MIME type (optional, overrides the MIME type embedded in the data URI).
+ * @return self The inline file.
+ * @throws InvalidArgumentException If the data URI is invalid or the MIME type cannot be determined.
+ */
+ public static function fromDataUri(string $dataUri, ?string $mimeType = null): self
+ {
+ if (!preg_match(self::DATA_URI_PATTERN, $dataUri)) {
+ throw new InvalidArgumentException('Invalid data URI. Expected the format data:[mime/type];base64,[data].');
+ }
+ return new self($dataUri, $mimeType);
+ }
+ /**
+ * Creates an inline file from plain base64-encoded data.
+ *
+ * @since 1.5.1
+ *
+ * @param string $base64 The base64-encoded data (without data URI prefix).
+ * @param string $mimeType The MIME type of the data.
+ * @return self The inline file.
+ * @throws InvalidArgumentException If the data is not valid base64 or the MIME type is invalid.
+ */
+ public static function fromBase64(string $base64, string $mimeType): self
+ {
+ if (str_starts_with($base64, 'data:')) {
+ throw new InvalidArgumentException('Expected raw base64 data; use File::fromDataUri() for data URIs.');
+ }
+ if (!self::isBase64($base64)) {
+ throw new InvalidArgumentException('Invalid base64 data provided.');
+ }
+ if (!MimeType::isValid($mimeType)) {
+ throw new InvalidArgumentException(sprintf('Invalid MIME type: %s', $mimeType));
+ }
+ /*
+ * Build an empty inline file through the data URI path (deterministic, filesystem-free), then attach the
+ * payload directly. Embedding the payload in a data URI and parsing it again would create several
+ * additional copies of a potentially large string.
+ */
+ $instance = new self(sprintf('data:%s;base64,', $mimeType));
+ $instance->base64Data = $base64;
+ return $instance;
+ }
+ /**
+ * Creates an inline file by reading a file from the local filesystem.
+ *
+ * Never pass untrusted input (such as user input or API response data) to this method.
+ *
+ * @since 1.5.1
+ *
+ * @param string $path The path to the local file. Stream wrappers (e.g. ftp://, phar://) are not supported.
+ * @param string|null $mimeType The MIME type (optional, otherwise inferred from the file extension).
+ * @return self The inline file.
+ * @throws InvalidArgumentException If the path is empty, uses a stream wrapper, is not a regular file,
+ * or the MIME type cannot be determined.
+ * @throws RuntimeException If the file cannot be read.
+ */
+ public static function fromLocalPath(string $path, ?string $mimeType = null): self
+ {
+ if ($path === '') {
+ throw new InvalidArgumentException('Local file path must not be empty.');
+ }
+ // Reject stream wrappers before touching the filesystem, so no wrapper can open a connection.
+ if (str_contains($path, '://')) {
+ throw new InvalidArgumentException('Stream wrappers are not supported in local file paths.');
+ }
+ if (!is_file($path)) {
+ throw new InvalidArgumentException(sprintf('Local file not found or not a regular file: %s', $path));
+ }
+ $resolvedMimeType = $mimeType !== null ? new MimeType($mimeType) : self::determineMimeType(null, null, $path);
+ $fileContent = @file_get_contents($path);
+ if ($fileContent === \false) {
+ throw new RuntimeException(sprintf('Unable to read file: %s', $path));
+ }
+ return self::fromBase64(base64_encode($fileContent), (string) $resolvedMimeType);
+ }
/**
* Detects the file type and processes it accordingly.
*
* @since 0.1.0
+ * @since 1.5.1 Rejects non-http(s) URL schemes before any filesystem access; local path detection deprecated.
*
* @param string $file The file string to process.
* @param string|null $providedMimeType The explicitly provided MIME type.
@@ -72,33 +198,38 @@ class File extends AbstractDataTransferObject
*/
private function detectAndProcessFile(string $file, ?string $providedMimeType): void
{
- // Check if it's a URL
- if ($this->isUrl($file)) {
+ // Check if it's an http(s) URL.
+ if (self::isHttpUrl($file)) {
$this->fileType = FileTypeEnum::remote();
$this->url = $file;
- $this->mimeType = $this->determineMimeType($providedMimeType, null, $file);
+ $this->mimeType = self::determineMimeType($providedMimeType, null, $file);
return;
}
- // Data URI pattern.
- $dataUriPattern = '/^data:(?:([a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*' . '(?:;[a-zA-Z0-9\-]+=[a-zA-Z0-9\-]+)*)?;)?base64,([A-Za-z0-9+\/]*={0,2})$/';
+ // Any other URL scheme (file://, ftp://, phar://, ...) is rejected before anything else runs.
+ if (str_contains($file, '://')) {
+ throw new InvalidArgumentException('Unsupported URL scheme. Only http:// and https:// URLs are supported.');
+ }
// Check if it's a data URI.
- if (preg_match($dataUriPattern, $file, $matches)) {
+ if (preg_match(self::DATA_URI_PATTERN, $file, $matches)) {
$this->fileType = FileTypeEnum::inline();
$this->base64Data = $matches[2];
// Extract just the base64 data
$extractedMimeType = empty($matches[1]) ? null : $matches[1];
- $this->mimeType = $this->determineMimeType($providedMimeType, $extractedMimeType, null);
+ $this->mimeType = self::determineMimeType($providedMimeType, $extractedMimeType, null);
return;
}
- // Check if it's a local file path (before base64 check)
- if (file_exists($file) && is_file($file)) {
- $this->fileType = FileTypeEnum::inline();
- $this->base64Data = $this->convertFileToBase64($file);
- $this->mimeType = $this->determineMimeType($providedMimeType, null, $file);
+ // Deprecated: local file path detection (kept for backward compatibility, before the base64 check).
+ // Stream wrappers were rejected above, so this cannot open a network connection.
+ if (strpos($file, "\x00") === \false && is_file($file)) {
+ @trigger_error('Passing a local file path to ' . self::class . '::__construct() is deprecated since 1.5.1; ' . 'use File::fromLocalPath() instead.', \E_USER_DEPRECATED);
+ $local = self::fromLocalPath($file, $providedMimeType);
+ $this->fileType = $local->fileType;
+ $this->base64Data = $local->base64Data;
+ $this->mimeType = $local->mimeType;
return;
}
- // Check if it's plain base64
- if (preg_match('/^[A-Za-z0-9+\/]*={0,2}$/', $file)) {
+ // Check if it's plain base64.
+ if (self::isBase64($file)) {
if ($providedMimeType === null) {
throw new InvalidArgumentException('MIME type is required when providing plain base64 data without data URI format.');
}
@@ -107,36 +238,32 @@ class File extends AbstractDataTransferObject
$this->mimeType = new MimeType($providedMimeType);
return;
}
- throw new InvalidArgumentException('Invalid file provided. Expected URL, base64 data, or valid local file path.');
+ throw new InvalidArgumentException('Invalid file provided. Expected an http(s) URL, a data URI, base64 data with a MIME type, ' . 'or a valid local file path.');
}
/**
- * Checks if a string is a valid URL.
+ * Checks if a string is an http(s) URL.
*
* @since 0.1.0
+ * @since 1.5.1 Made static.
*
* @param string $string The string to check.
- * @return bool True if the string is a URL.
+ * @return bool True if the string is an http(s) URL.
*/
- private function isUrl(string $string): bool
+ private static function isHttpUrl(string $string): bool
{
- return filter_var($string, \FILTER_VALIDATE_URL) !== \false && preg_match('/^https?:\/\//i', $string);
+ return filter_var($string, \FILTER_VALIDATE_URL) !== \false && preg_match('/^https?:\/\//i', $string) === 1;
}
/**
- * Converts a local file to base64.
+ * Checks if a string is plain base64 data (possibly empty).
*
- * @since 0.1.0
+ * @since 1.5.1
*
- * @param string $filePath The path to the local file.
- * @return string The base64-encoded file data.
- * @throws RuntimeException If the file cannot be read.
+ * @param string $string The string to check.
+ * @return bool True if the string is plain base64 data.
*/
- private function convertFileToBase64(string $filePath): string
+ private static function isBase64(string $string): bool
{
- $fileContent = @file_get_contents($filePath);
- if ($fileContent === \false) {
- throw new RuntimeException(sprintf('Unable to read file: %s', $filePath));
- }
- return base64_encode($fileContent);
+ return preg_match(self::BASE64_PATTERN, $string) === 1;
}
/**
* Gets the file type.
@@ -301,6 +428,7 @@ class File extends AbstractDataTransferObject
* Determines the MIME type from various sources.
*
* @since 0.1.0
+ * @since 1.5.1 Made static.
*
* @param string|null $providedMimeType The explicitly provided MIME type.
* @param string|null $extractedMimeType The MIME type extracted from data URI.
@@ -308,7 +436,7 @@ class File extends AbstractDataTransferObject
* @return MimeType The determined MIME type.
* @throws InvalidArgumentException If MIME type cannot be determined.
*/
- private function determineMimeType(?string $providedMimeType, ?string $extractedMimeType, ?string $pathOrUrl): MimeType
+ private static function determineMimeType(?string $providedMimeType, ?string $extractedMimeType, ?string $pathOrUrl): MimeType
{
// Prefer explicitly provided MIME type
if ($providedMimeType !== null) {
@@ -367,22 +495,87 @@ class File extends AbstractDataTransferObject
}
return $data;
}
+ /**
+ * {@inheritDoc}
+ *
+ * This check is purely structural and never accesses the filesystem.
+ *
+ * @since 1.5.1
+ */
+ public static function isArrayShape(array $array): bool
+ {
+ try {
+ self::assertArrayShape($array);
+ return \true;
+ } catch (InvalidArgumentException $e) {
+ return \false;
+ }
+ }
/**
* {@inheritDoc}
*
* @since 0.1.0
+ * @since 1.5.1 The fileType key is authoritative: remote files require an http(s) url, inline files
+ * require base64Data and mimeType. Local file paths are rejected.
*/
public static function fromArray(array $array): self
+ {
+ self::assertArrayShape($array);
+ $mimeType = $array[self::KEY_MIME_TYPE] ?? null;
+ if ($array[self::KEY_FILE_TYPE] === FileTypeEnum::REMOTE) {
+ return self::fromUrl($array[self::KEY_URL] ?? '', $mimeType);
+ }
+ return self::fromBase64($array[self::KEY_BASE64_DATA] ?? '', $mimeType ?? '');
+ }
+ /**
+ * Asserts that an array has a valid file array shape, without constructing the file.
+ *
+ * @since 1.5.1
+ *
+ * @param array<mixed> $array The array to check.
+ * @throws InvalidArgumentException If the array is not a valid file array shape.
+ */
+ private static function assertArrayShape(array $array): void
{
static::validateFromArrayData($array, [self::KEY_FILE_TYPE]);
- // Check which properties are set to determine how to construct the File
+ $fileType = $array[self::KEY_FILE_TYPE];
+ if ($fileType !== FileTypeEnum::REMOTE && $fileType !== FileTypeEnum::INLINE) {
+ throw new InvalidArgumentException(sprintf('Invalid file type. Expected "%s" or "%s".', FileTypeEnum::REMOTE, FileTypeEnum::INLINE));
+ }
+ // A null mimeType is treated like an absent one (for remote files it is then inferred from the URL).
$mimeType = $array[self::KEY_MIME_TYPE] ?? null;
- if (isset($array[self::KEY_URL])) {
- return new self($array[self::KEY_URL], $mimeType);
- } elseif (isset($array[self::KEY_BASE64_DATA])) {
- return new self($array[self::KEY_BASE64_DATA], $mimeType);
- } else {
- throw new InvalidArgumentException('File requires either url or base64Data.');
+ if ($mimeType !== null && (!is_string($mimeType) || !MimeType::isValid($mimeType))) {
+ throw new InvalidArgumentException('Invalid MIME type provided in file array.');
+ }
+ if ($fileType === FileTypeEnum::REMOTE) {
+ if (array_key_exists(self::KEY_BASE64_DATA, $array)) {
+ throw new InvalidArgumentException('Remote files must not contain base64Data.');
+ }
+ $url = $array[self::KEY_URL] ?? null;
+ if (!is_string($url) || !self::isHttpUrl($url)) {
+ throw new InvalidArgumentException('Remote files require a url with an http:// or https:// scheme.');
+ }
+ if ($mimeType === null) {
+ // Without an explicit MIME type it must be inferable from the URL (no I/O involved).
+ self::determineMimeType(null, null, $url);
+ }
+ return;
+ }
+ if (array_key_exists(self::KEY_URL, $array)) {
+ throw new InvalidArgumentException('Inline files must not contain url.');
+ }
+ $base64Data = $array[self::KEY_BASE64_DATA] ?? null;
+ if (!is_string($base64Data)) {
+ throw new InvalidArgumentException('Inline files require base64Data.');
+ }
+ if (str_starts_with($base64Data, 'data:')) {
+ throw new InvalidArgumentException('Inline files require raw base64 data in base64Data, not a data URI.');
+ }
+ if (!self::isBase64($base64Data)) {
+ throw new InvalidArgumentException('Inline files require valid base64 data in base64Data. Local file paths are not supported.');
+ }
+ if ($mimeType === null) {
+ throw new InvalidArgumentException('Inline files require a mimeType.');
}
}
/**
diff --git a/wp-includes/php-ai-client/src/Files/Util/FileInputUtil.php b/wp-includes/php-ai-client/src/Files/Util/FileInputUtil.php
new file mode 100644
index 0000000000..6579d64052
--- /dev/null
+++ b/wp-includes/php-ai-client/src/Files/Util/FileInputUtil.php
@@ -0,0 +1,60 @@
+<?php
+
+declare (strict_types=1);
+namespace WordPress\AiClient\Files\Util;
+
+use WordPress\AiClient\Common\Exception\InvalidArgumentException;
+use WordPress\AiClient\Common\Exception\RuntimeException;
+use WordPress\AiClient\Files\DTO\File;
+/**
+ * Resolves developer-supplied file strings into File objects.
+ *
+ * This utility backs the convenience of passing a plain string to builder methods such as
+ * PromptBuilder::withFile(). Because it may read from the local filesystem, it must only be used with
+ * strings the developer controls. Never pass untrusted input (user input, API response data); construct
+ * a File explicitly via File::fromUrl(), File::fromDataUri() or File::fromBase64() instead.
+ *
+ * @since 1.5.1
+ */
+final class FileInputUtil
+{
+ /**
+ * Resolves a file string into a File object.
+ *
+ * The string is interpreted, in this order, as an http(s) URL, a data URI, a local file path, or plain
+ * base64 data (which requires a MIME type). Any other URL scheme is rejected before the filesystem is
+ * consulted.
+ *
+ * @since 1.5.1
+ *
+ * @param string $input The file string.
+ * @param string|null $mimeType The MIME type (optional, required for plain base64 data).
+ * @return File The resolved file.
+ * @throws InvalidArgumentException If the string is not a supported file representation.
+ * @throws RuntimeException If a local file cannot be read.
+ */
+ public static function fromString(string $input, ?string $mimeType = null): File
+ {
+ if (preg_match('/^https?:\/\//i', $input) === 1) {
+ return File::fromUrl($input, $mimeType);
+ }
+ if (str_contains($input, '://')) {
+ throw new InvalidArgumentException('Unsupported URL scheme. Only http:// and https:// URLs are supported.');
+ }
+ if (str_starts_with($input, 'data:')) {
+ return File::fromDataUri($input, $mimeType);
+ }
+ // Local paths take precedence over plain base64: an extension-less path can look like base64.
+ if (is_file($input)) {
+ return File::fromLocalPath($input, $mimeType);
+ }
+ // Plain base64 (an empty payload is allowed, matching an empty local file).
+ if (preg_match('/^[A-Za-z0-9+\/]*={0,2}\z/', $input) === 1) {
+ if ($mimeType === null) {
+ throw new InvalidArgumentException('MIME type is required when providing plain base64 data without data URI format.');
+ }
+ return File::fromBase64($input, $mimeType);
+ }
+ throw new InvalidArgumentException('Invalid file provided. Expected URL, data URI, base64 data, or valid local file path.');
+ }
+}
diff --git a/wp-includes/php-ai-client/src/Files/ValueObjects/MimeType.php b/wp-includes/php-ai-client/src/Files/ValueObjects/MimeType.php
index f002a6a0d0..dcdcb05403 100644
--- a/wp-includes/php-ai-client/src/Files/ValueObjects/MimeType.php
+++ b/wp-includes/php-ai-client/src/Files/ValueObjects/MimeType.php
@@ -150,7 +150,7 @@ final class MimeType
public static function isValid(string $mimeType): bool
{
// Basic MIME type validation: type/subtype
- return (bool) preg_match('/^[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*$/', $mimeType);
+ return (bool) preg_match('/^[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*$/D', $mimeType);
}
/**
* Checks if this MIME type is a specific type.
diff --git a/wp-includes/php-ai-client/src/Messages/DTO/Message.php b/wp-includes/php-ai-client/src/Messages/DTO/Message.php
index 616a939f93..34142a09d4 100644
--- a/wp-includes/php-ai-client/src/Messages/DTO/Message.php
+++ b/wp-includes/php-ai-client/src/Messages/DTO/Message.php
@@ -129,6 +129,41 @@ class Message extends AbstractDataTransferObject
return $part->toArray();
}, $this->parts)];
}
+ /**
+ * {@inheritDoc}
+ *
+ * This check is purely structural: it does not construct the message or its parts, so it has no
+ * side effects (in particular, no filesystem access for file parts).
+ *
+ * @since 1.5.1
+ */
+ public static function isArrayShape(array $array): bool
+ {
+ if (!isset($array[self::KEY_ROLE]) || !is_string($array[self::KEY_ROLE]) || !MessageRoleEnum::isValidValue($array[self::KEY_ROLE])) {
+ return \false;
+ }
+ if (!isset($array[self::KEY_PARTS]) || !is_array($array[self::KEY_PARTS])) {
+ return \false;
+ }
+ $role = MessageRoleEnum::from($array[self::KEY_ROLE]);
+ if (!$role->isUser() && !$role->isModel()) {
+ return \false;
+ }
+ // Keys are intentionally not required to be consecutive, matching fromArray() (e.g. after array_filter()).
+ foreach ($array[self::KEY_PARTS] as $partData) {
+ if (!is_array($partData) || !\WordPress\AiClient\Messages\DTO\MessagePart::isArrayShape($partData)) {
+ return \false;
+ }
+ // Mirror the role/part rules enforced by the constructor (validateParts()).
+ if ($role->isUser() && isset($partData[\WordPress\AiClient\Messages\DTO\MessagePart::KEY_FUNCTION_CALL])) {
+ return \false;
+ }
+ if ($role->isModel() && isset($partData[\WordPress\AiClient\Messages\DTO\MessagePart::KEY_FUNCTION_RESPONSE])) {
+ return \false;
+ }
+ }
+ return \true;
+ }
/**
* {@inheritDoc}
*
diff --git a/wp-includes/php-ai-client/src/Messages/DTO/MessagePart.php b/wp-includes/php-ai-client/src/Messages/DTO/MessagePart.php
index 264b2c5b37..7f6f2b8ecc 100644
--- a/wp-includes/php-ai-client/src/Messages/DTO/MessagePart.php
+++ b/wp-includes/php-ai-client/src/Messages/DTO/MessagePart.php
@@ -217,27 +217,87 @@ class MessagePart extends AbstractDataTransferObject
}
return $data;
}
+ /**
+ * {@inheritDoc}
+ *
+ * This check is purely structural: it does not construct the part or any nested object, so it has no
+ * side effects (in particular, no filesystem access for file parts).
+ *
+ * @since 1.5.1
+ */
+ public static function isArrayShape(array $array): bool
+ {
+ if (isset($array[self::KEY_CHANNEL])) {
+ if (!is_string($array[self::KEY_CHANNEL]) || !MessagePartChannelEnum::isValidValue($array[self::KEY_CHANNEL])) {
+ return \false;
+ }
+ }
+ if (isset($array[self::KEY_TYPE])) {
+ if (!is_string($array[self::KEY_TYPE]) || !MessagePartTypeEnum::isValidValue($array[self::KEY_TYPE])) {
+ return \false;
+ }
+ }
+ if (isset($array[self::KEY_THOUGHT_SIGNATURE]) && !is_string($array[self::KEY_THOUGHT_SIGNATURE])) {
+ return \false;
+ }
+ $contentKeys = [self::KEY_TEXT, self::KEY_FILE, self::KEY_FUNCTION_CALL, self::KEY_FUNCTION_RESPONSE];
+ $presentKeys = array_values(array_filter($contentKeys, static function (string $key) use ($array): bool {
+ return isset($array[$key]);
+ }));
+ if (count($presentKeys) !== 1) {
+ return \false;
+ }
+ switch ($presentKeys[0]) {
+ case self::KEY_TEXT:
+ return is_string($array[self::KEY_TEXT]);
+ case self::KEY_FILE:
+ return is_array($array[self::KEY_FILE]) && File::isArrayShape($array[self::KEY_FILE]);
+ case self::KEY_FUNCTION_CALL:
+ return is_array($array[self::KEY_FUNCTION_CALL]) && FunctionCall::isArrayShape($array[self::KEY_FUNCTION_CALL]);
+ default:
+ return is_array($array[self::KEY_FUNCTION_RESPONSE]) && FunctionResponse::isArrayShape($array[self::KEY_FUNCTION_RESPONSE]);
+ }
+ }
/**
* {@inheritDoc}
*
* @since 0.1.0
+ * @since 1.5.1 Values of the wrong type are rejected with an InvalidArgumentException.
*/
public static function fromArray(array $array): self
{
if (isset($array[self::KEY_CHANNEL])) {
+ if (!is_string($array[self::KEY_CHANNEL])) {
+ throw new InvalidArgumentException('MessagePart channel must be a string.');
+ }
$channel = MessagePartChannelEnum::from($array[self::KEY_CHANNEL]);
} else {
$channel = null;
}
$thoughtSignature = $array[self::KEY_THOUGHT_SIGNATURE] ?? null;
+ if ($thoughtSignature !== null && !is_string($thoughtSignature)) {
+ throw new InvalidArgumentException('MessagePart thoughtSignature must be a string.');
+ }
// Check which properties are set to determine how to construct the MessagePart
if (isset($array[self::KEY_TEXT])) {
+ if (!is_string($array[self::KEY_TEXT])) {
+ throw new InvalidArgumentException('MessagePart text must be a string.');
+ }
return new self($array[self::KEY_TEXT], $channel, $thoughtSignature);
} elseif (isset($array[self::KEY_FILE])) {
+ if (!is_array($array[self::KEY_FILE])) {
+ throw new InvalidArgumentException('MessagePart file must be an array.');
+ }
return new self(File::fromArray($array[self::KEY_FILE]), $channel, $thoughtSignature);
} elseif (isset($array[self::KEY_FUNCTION_CALL])) {
+ if (!is_array($array[self::KEY_FUNCTION_CALL])) {
+ throw new InvalidArgumentException('MessagePart functionCall must be an array.');
+ }
return new self(FunctionCall::fromArray($array[self::KEY_FUNCTION_CALL]), $channel, $thoughtSignature);
} elseif (isset($array[self::KEY_FUNCTION_RESPONSE])) {
+ if (!is_array($array[self::KEY_FUNCTION_RESPONSE])) {
+ throw new InvalidArgumentException('MessagePart functionResponse must be an array.');
+ }
return new self(FunctionResponse::fromArray($array[self::KEY_FUNCTION_RESPONSE]), $channel, $thoughtSignature);
} else {
throw new InvalidArgumentException('MessagePart requires one of: text, file, functionCall, or functionResponse.');
diff --git a/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/AbstractApiBasedModel.php b/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/AbstractApiBasedModel.php
index 30705e64cb..5db346a8ef 100644
--- a/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/AbstractApiBasedModel.php
+++ b/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/AbstractApiBasedModel.php
@@ -12,6 +12,7 @@ use WordPress\AiClient\Providers\Http\Traits\WithHttpTransporterTrait;
use WordPress\AiClient\Providers\Http\Traits\WithRequestAuthenticationTrait;
use WordPress\AiClient\Providers\Models\DTO\ModelConfig;
use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;
+use WordPress\AiClient\Tools\DTO\FunctionDeclaration;
/**
* Base class for an API-based model for a provider.
*
@@ -108,4 +109,21 @@ abstract class AbstractApiBasedModel implements ApiBasedModelInterface, WithHttp
{
return $this->requestOptions;
}
+ /**
+ * Prepares a function parameter schema for a provider request.
+ *
+ * Returns the original parameters by default. Providers must call this hook
+ * when building their tool payload. Overrides can read the declaration annotations
+ * and return an adjusted schema without changing the original declaration.
+ * Providers still choose the API fields and encode the request.
+ *
+ * @since 1.5.1
+ *
+ * @param FunctionDeclaration $functionDeclaration The original function declaration.
+ * @return array<string, mixed>|null The parameter schema to use in this request.
+ */
+ protected function prepareFunctionParametersForRequest(FunctionDeclaration $functionDeclaration): ?array
+ {
+ return $functionDeclaration->getParameters();
+ }
}
diff --git a/wp-includes/php-ai-client/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php b/wp-includes/php-ai-client/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php
index c0747093ef..66d6389e9d 100644
--- a/wp-includes/php-ai-client/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php
+++ b/wp-includes/php-ai-client/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php
@@ -4,7 +4,6 @@ declare (strict_types=1);
namespace WordPress\AiClient\Providers\OpenAiCompatibleImplementation;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
-use WordPress\AiClient\Common\Exception\RuntimeException;
use WordPress\AiClient\Files\DTO\File;
use WordPress\AiClient\Files\Enums\MediaOrientationEnum;
use WordPress\AiClient\Messages\DTO\Message;
@@ -268,16 +267,21 @@ abstract class AbstractOpenAiCompatibleImageGenerationModel extends AbstractApiB
* @param int $index The index of the choice in the choices array.
* @param string $expectedMimeType The expected MIME type the response is in.
* @return Candidate The parsed candidate.
- * @throws RuntimeException If the choice data is invalid.
+ * @throws ResponseException If the choice data is invalid.
*/
protected function parseResponseChoiceToCandidate(array $choiceData, int $index, string $expectedMimeType = 'image/png'): Candidate
{
- if (isset($choiceData['url']) && is_string($choiceData['url'])) {
- $imageFile = new File($choiceData['url'], $expectedMimeType);
- } elseif (isset($choiceData['b64_json']) && is_string($choiceData['b64_json'])) {
- $imageFile = new File($choiceData['b64_json'], $expectedMimeType);
- } else {
- throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), "choices[{$index}]", 'The value must contain either a url or b64_json key with a string value.');
+ // Provider-supplied values are untrusted: only accept http(s) URLs or base64 data, never local paths.
+ try {
+ if (isset($choiceData['url']) && is_string($choiceData['url'])) {
+ $imageFile = File::fromUrl($choiceData['url'], $expectedMimeType);
+ } elseif (isset($choiceData['b64_json']) && is_string($choiceData['b64_json'])) {
+ $imageFile = File::fromBase64($choiceData['b64_json'], $expectedMimeType);
+ } else {
+ throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), "data[{$index}]", 'The value must contain either a url or b64_json key with a string value.');
+ }
+ } catch (InvalidArgumentException $e) {
+ throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), "data[{$index}]", $e->getMessage());
}
$parts = [new MessagePart($imageFile)];
$message = new Message(MessageRoleEnum::model(), $parts);
diff --git a/wp-includes/php-ai-client/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php b/wp-includes/php-ai-client/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php
index adbbd5dad9..ded2b53786 100644
--- a/wp-includes/php-ai-client/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php
+++ b/wp-includes/php-ai-client/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php
@@ -361,7 +361,12 @@ abstract class AbstractOpenAiCompatibleTextGenerationModel extends AbstractApiBa
{
$tools = [];
foreach ($functionDeclarations as $functionDeclaration) {
- $tools[] = ['type' => 'function', 'function' => $functionDeclaration->toArray()];
+ $function = ['name' => $functionDeclaration->getName(), 'description' => $functionDeclaration->getDescription()];
+ $parameters = $this->prepareFunctionParametersForRequest($functionDeclaration);
+ if ($parameters !== null) {
+ $function['parameters'] = $parameters;
+ }
+ $tools[] = ['type' => 'function', 'function' => $function];
}
return $tools;
}
diff --git a/wp-includes/version.php b/wp-includes/version.php
index 9d66c771ee..f576c45448 100644
--- a/wp-includes/version.php
+++ b/wp-includes/version.php
@@ -16,7 +16,7 @@
*
* @global string $wp_version
*/
-$wp_version = '7.2-beta3-65000';
+$wp_version = '7.2-beta3-65001';
/**
* Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.