diff --git a/inc/Abilities/Media/ImageGenerationAbilities.php b/inc/Abilities/Media/ImageGenerationAbilities.php index f00e4c11f..d5c4acde5 100644 --- a/inc/Abilities/Media/ImageGenerationAbilities.php +++ b/inc/Abilities/Media/ImageGenerationAbilities.php @@ -211,6 +211,8 @@ public static function generateImage( array $input ): array { } try { + \DataMachine\Engine\AI\WpAiClientCache::install(); + $registry = \WordPress\AiClient\AiClient::defaultRegistry(); /** @var callable $has_provider wp-ai-client exposes this through __call() in some versions. */ $has_provider = array( $registry, 'hasProvider' ); diff --git a/inc/Engine/AI/RequestBuilder.php b/inc/Engine/AI/RequestBuilder.php index d3ea6f955..544f5bc77 100644 --- a/inc/Engine/AI/RequestBuilder.php +++ b/inc/Engine/AI/RequestBuilder.php @@ -16,6 +16,8 @@ defined( 'ABSPATH' ) || exit; +require_once __DIR__ . '/WpAiClientCache.php'; + class RequestBuilder { /** @@ -58,6 +60,8 @@ public static function build( array $payload = array(), ?array &$request_metadata = null ) { + WpAiClientCache::install(); + $assembled = self::assemble( $messages, $provider, $model, $tools, $mode, $payload ); $request = $assembled['request']; $provider_request = ProviderRequestAssembler::toProviderRequest( $request ); @@ -437,6 +441,8 @@ public static function wpAiClientUnavailableReason( string $provider ): ?string return 'wp-ai-client is unavailable: WordPress\\AiClient\\AiClient is not loaded'; } + WpAiClientCache::install(); + try { $registry = \WordPress\AiClient\AiClient::defaultRegistry(); /** @var callable $has_provider wp-ai-client exposes this through __call() in some versions. */ diff --git a/inc/Engine/AI/WpAiClientCache.php b/inc/Engine/AI/WpAiClientCache.php new file mode 100644 index 000000000..e0e68ca7f --- /dev/null +++ b/inc/Engine/AI/WpAiClientCache.php @@ -0,0 +1,278 @@ +validateKey( $key ); + + $cached = get_transient( $this->transientKey( $key ) ); + if ( ! is_array( $cached ) || ! array_key_exists( 'value', $cached ) ) { + return $default; + } + + return $cached['value']; + } + + /** + * Stores a value in the cache. + * + * @param string $key Cache key. + * @param mixed $value Cache value. + * @param null|int|\DateInterval $ttl TTL. + * @return bool + */ + public function set( $key, $value, $ttl = null ) { + $this->validateKey( $key ); + + $expiration = $this->ttlToSeconds( $ttl ); + if ( $expiration < 0 ) { + return $this->delete( $key ); + } + + return set_transient( $this->transientKey( $key ), array( 'value' => $value ), $expiration ); + } + + /** + * Deletes a value from the cache. + * + * @param string $key Cache key. + * @return bool + */ + public function delete( $key ) { + $this->validateKey( $key ); + + return delete_transient( $this->transientKey( $key ) ); + } + + /** + * Clears this adapter's logical namespace. + * + * @return bool + */ + public function clear() { + $version = (int) get_option( self::VERSION_OPTION, 1 ); + + return update_option( self::VERSION_OPTION, max( 1, $version ) + 1, false ); + } + + /** + * Fetches multiple values from the cache. + * + * @param iterable $keys Cache keys. + * @param mixed $default Default value. + * @return iterable + */ + public function getMultiple( $keys, $default = null ) { + $values = array(); + foreach ( $this->iterableKeys( $keys ) as $key ) { + $values[ $key ] = $this->get( $key, $default ); + } + + return $values; + } + + /** + * Stores multiple values in the cache. + * + * @param iterable $values Key/value map. + * @param null|int|\DateInterval $ttl TTL. + * @return bool + */ + public function setMultiple( $values, $ttl = null ) { + $ok = true; + foreach ( $this->iterableValues( $values ) as $key => $value ) { + $ok = $this->set( $key, $value, $ttl ) && $ok; + } + + return $ok; + } + + /** + * Deletes multiple values from the cache. + * + * @param iterable $keys Cache keys. + * @return bool + */ + public function deleteMultiple( $keys ) { + $ok = true; + foreach ( $this->iterableKeys( $keys ) as $key ) { + $ok = $this->delete( $key ) && $ok; + } + + return $ok; + } + + /** + * Determines whether a key exists in the cache. + * + * @param string $key Cache key. + * @return bool + */ + public function has( $key ) { + $missing = new \stdClass(); + + return $missing !== $this->get( $key, $missing ); + } + + /** + * Builds a WordPress-safe transient key from the wp-ai-client key. + * + * @param string $key Original cache key. + * @return string + */ + private function transientKey( string $key ): string { + $version = (int) get_option( self::VERSION_OPTION, 1 ); + + return self::TRANSIENT_PREFIX . max( 1, $version ) . '_' . md5( $key ); + } + + /** + * Converts a PSR-16 TTL to WordPress transient seconds. + * + * @param null|int|\DateInterval $ttl TTL. + * @return int + */ + private function ttlToSeconds( $ttl ): int { + if ( null === $ttl ) { + return 0; + } + + if ( $ttl instanceof \DateInterval ) { + $now = new \DateTimeImmutable(); + $future = $now->add( $ttl ); + + return $future->getTimestamp() - $now->getTimestamp(); + } + + if ( is_numeric( $ttl ) ) { + return (int) $ttl; + } + + throw new \InvalidArgumentException( 'Cache TTL must be null, an integer, or a DateInterval.' ); + } + + /** + * Validate a PSR-16 key. + * + * @param mixed $key Cache key. + * @return void + */ + private function validateKey( $key ): void { + if ( ! is_string( $key ) || '' === $key ) { + throw new \InvalidArgumentException( 'Cache key must be a non-empty string.' ); + } + } + + /** + * Normalize iterable keys. + * + * @param mixed $keys Keys. + * @return iterable + */ + private function iterableKeys( $keys ): iterable { + if ( ! is_iterable( $keys ) ) { + throw new \InvalidArgumentException( 'Cache keys must be iterable.' ); + } + + foreach ( $keys as $key ) { + $this->validateKey( $key ); + yield $key; + } + } + + /** + * Normalize iterable key/value pairs. + * + * @param mixed $values Values. + * @return iterable + */ + private function iterableValues( $values ): iterable { + if ( ! is_iterable( $values ) ) { + throw new \InvalidArgumentException( 'Cache values must be iterable.' ); + } + + foreach ( $values as $key => $value ) { + $this->validateKey( $key ); + yield $key => $value; + } + } +} + +if ( interface_exists( '\WordPress\AiClientDependencies\Psr\SimpleCache\CacheInterface' ) ) { + /** + * Transient-backed cache for WordPress' scoped wp-ai-client dependency namespace. + */ + class WpAiClientTransientCache extends WpAiClientTransientCacheBase implements \WordPress\AiClientDependencies\Psr\SimpleCache\CacheInterface {} +} elseif ( interface_exists( '\Psr\SimpleCache\CacheInterface' ) ) { + /** + * Transient-backed cache for unscoped php-ai-client installs. + */ + class WpAiClientTransientCache extends WpAiClientTransientCacheBase implements \Psr\SimpleCache\CacheInterface {} +} diff --git a/inc/Engine/AI/WpAiClientProviderAdmin.php b/inc/Engine/AI/WpAiClientProviderAdmin.php index 36e6c0bb9..507d87856 100644 --- a/inc/Engine/AI/WpAiClientProviderAdmin.php +++ b/inc/Engine/AI/WpAiClientProviderAdmin.php @@ -9,6 +9,8 @@ defined( 'ABSPATH' ) || exit; +require_once __DIR__ . '/WpAiClientCache.php'; + /** * Reads provider metadata and API-key settings through wp-ai-client / Connectors. */ @@ -355,6 +357,8 @@ private static function registry(): ?object { return null; } + WpAiClientCache::install(); + try { $registry = $ai_client_class::defaultRegistry(); } catch ( \Throwable $e ) { diff --git a/inc/bootstrap.php b/inc/bootstrap.php index 3f0d0782a..869f79346 100644 --- a/inc/bootstrap.php +++ b/inc/bootstrap.php @@ -67,8 +67,11 @@ use DataMachine\Engine\AI\MemoryFileRegistry; use DataMachine\Engine\AI\AgentModeRegistry; use DataMachine\Engine\AI\IterationBudgetRegistry; +use DataMachine\Engine\AI\WpAiClientCache; use DataMachine\Core\PluginSettings; +add_action( 'plugins_loaded', array( WpAiClientCache::class, 'install' ), 20 ); + /* |-------------------------------------------------------------------------- | Iteration budget registrations diff --git a/tests/wp-ai-client-persistent-cache-smoke.php b/tests/wp-ai-client-persistent-cache-smoke.php new file mode 100644 index 000000000..60b1cdf8d --- /dev/null +++ b/tests/wp-ai-client-persistent-cache-smoke.php @@ -0,0 +1,139 @@ + $value, + 'expires' => $expiration > 0 ? $GLOBALS['datamachine_cache_smoke_now'] + $expiration : 0, + ); + + return true; + } + + function delete_transient( string $key ): bool { + unset( $GLOBALS['datamachine_cache_smoke_transients'][ $key ] ); + + return true; + } + + function get_option( string $name, $default = false ) { + return $GLOBALS['datamachine_cache_smoke_options'][ $name ] ?? $default; + } + + function update_option( string $name, $value, bool $autoload = true ): bool { + unset( $autoload ); + $GLOBALS['datamachine_cache_smoke_options'][ $name ] = $value; + + return true; + } + + require_once __DIR__ . '/../inc/Engine/AI/WpAiClientCache.php'; + + use DataMachine\Engine\AI\WpAiClientCache; + + $assertions = 0; + $failures = array(); + $assert = function ( bool $condition, string $message ) use ( &$assertions, &$failures ): void { + ++$assertions; + if ( ! $condition ) { + $failures[] = $message; + } + }; + + WpAiClientCache::install(); + // @phpstan-ignore-next-line Smoke stub exposes getCache(). + $cache = \WordPress\AiClient\AiClient::getCache(); + + $assert( $cache instanceof \Psr\SimpleCache\CacheInterface, 'Data Machine installs a PSR-16 cache into wp-ai-client' ); + + $cache->set( 'ai_client_1.3.1_e723aa456086a7a24a491e8e87739a4b_models', array( 'models' => array( 'gpt-5' ) ), 60 ); + $assert( array( 'models' => array( 'gpt-5' ) ) === $cache->get( 'ai_client_1.3.1_e723aa456086a7a24a491e8e87739a4b_models' ), 'cache returns stored metadata payload' ); + + $GLOBALS['datamachine_cache_smoke_now'] += 61; + $assert( 'miss' === $cache->get( 'ai_client_1.3.1_e723aa456086a7a24a491e8e87739a4b_models', 'miss' ), 'cache respects TTL expiry' ); + + $cache->set( 'false-value', false, 60 ); + $assert( $cache->has( 'false-value' ), 'cache can distinguish false values from misses' ); + + $cache->set( 'clear-test', 'before-clear', 60 ); + $cache->clear(); + $assert( 'miss' === $cache->get( 'clear-test', 'miss' ), 'clear logically invalidates Data Machine namespace' ); + + $existing = new class() implements \Psr\SimpleCache\CacheInterface { + public function get( $key, $default = null ) { return $default; } + public function set( $key, $value, $ttl = null ) { return true; } + public function delete( $key ) { return true; } + public function clear() { return true; } + public function getMultiple( $keys, $default = null ) { return array(); } + public function setMultiple( $values, $ttl = null ) { return true; } + public function deleteMultiple( $keys ) { return true; } + public function has( $key ) { return false; } + }; + + // @phpstan-ignore-next-line Smoke stub exposes setCache(). + \WordPress\AiClient\AiClient::setCache( $existing ); + WpAiClientCache::install(); + // @phpstan-ignore-next-line Smoke stub exposes getCache(). + $assert( $existing === \WordPress\AiClient\AiClient::getCache(), 'installer preserves an existing wp-ai-client cache' ); + + if ( $failures ) { + foreach ( $failures as $failure ) { + fwrite( STDERR, "FAIL: {$failure}\n" ); + } + exit( 1 ); + } + + echo "wp-ai-client persistent cache smoke passed ({$assertions} assertions)\n"; +}