diff --git a/composer.json b/composer.json index dde2514..aa838d2 100644 --- a/composer.json +++ b/composer.json @@ -30,15 +30,16 @@ "require": { "php": ">=7.4", "ext-json": "*", - "wordpress/php-ai-client": "^0.1" + "wordpress/php-ai-client": "^0.2" }, "require-dev": { "automattic/vipwpcs": "^3.0", "dealerdirect/phpcodesniffer-composer-installer": "^1.0", "phpcompatibility/phpcompatibility-wp": "^2.1", - "phpstan/phpstan": "~2.1", + "phpstan/phpstan": "^1.10 | ^2.1", "slevomat/coding-standard": "^8.0", "squizlabs/php_codesniffer": "^3.7", + "szepeviktor/phpstan-wordpress": "^1.3", "wp-coding-standards/wpcs": "^3.0" }, "config": { @@ -58,6 +59,6 @@ ], "phpcs": "phpcs", "phpcbf": "phpcbf", - "phpstan": "phpstan analyze --memory-limit=256M" + "phpstan": "phpstan analyze --memory-limit=1024M" } } diff --git a/composer.lock b/composer.lock index 396adb7..3257540 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7dbba8d15fdd8095bb14f9df989589e1", + "content-hash": "5b8fdfe3093f519c707f0e4fbd115da6", "packages": [ { "name": "php-http/discovery", @@ -411,16 +411,16 @@ }, { "name": "wordpress/php-ai-client", - "version": "0.1.0", + "version": "0.2.0", "source": { "type": "git", "url": "https://github.com/WordPress/php-ai-client.git", - "reference": "9ec56e70e692791493a3eaff1b69f25f4daeded7" + "reference": "81a104a9bc5f887e3fbecea6e0d9cd8eab3be0b2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/WordPress/php-ai-client/zipball/9ec56e70e692791493a3eaff1b69f25f4daeded7", - "reference": "9ec56e70e692791493a3eaff1b69f25f4daeded7", + "url": "https://api.github.com/repos/WordPress/php-ai-client/zipball/81a104a9bc5f887e3fbecea6e0d9cd8eab3be0b2", + "reference": "81a104a9bc5f887e3fbecea6e0d9cd8eab3be0b2", "shasum": "" }, "require": { @@ -474,7 +474,7 @@ "issues": "https://github.com/WordPress/php-ai-client/issues", "source": "https://github.com/WordPress/php-ai-client" }, - "time": "2025-08-29T22:46:54+00:00" + "time": "2025-10-21T00:05:14+00:00" } ], "packages-dev": [ @@ -628,6 +628,57 @@ ], "time": "2025-07-17T20:45:56+00:00" }, + { + "name": "php-stubs/wordpress-stubs", + "version": "v6.8.2", + "source": { + "type": "git", + "url": "https://github.com/php-stubs/wordpress-stubs.git", + "reference": "9c8e22e437463197c1ec0d5eaa9ddd4a0eb6d7f8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/9c8e22e437463197c1ec0d5eaa9ddd4a0eb6d7f8", + "reference": "9c8e22e437463197c1ec0d5eaa9ddd4a0eb6d7f8", + "shasum": "" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "5.6.1" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "nikic/php-parser": "^5.5", + "php": "^7.4 || ^8.0", + "php-stubs/generator": "^0.8.3", + "phpdocumentor/reflection-docblock": "^5.4.1", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^9.5", + "szepeviktor/phpcs-psr-12-neutron-hybrid-ruleset": "^1.1.1", + "wp-coding-standards/wpcs": "3.1.0 as 2.3.0" + }, + "suggest": { + "paragonie/sodium_compat": "Pure PHP implementation of libsodium", + "symfony/polyfill-php80": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "szepeviktor/phpstan-wordpress": "WordPress extensions for PHPStan" + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "WordPress function and class declaration stubs for static analysis.", + "homepage": "https://github.com/php-stubs/wordpress-stubs", + "keywords": [ + "PHPStan", + "static analysis", + "wordpress" + ], + "support": { + "issues": "https://github.com/php-stubs/wordpress-stubs/issues", + "source": "https://github.com/php-stubs/wordpress-stubs/tree/v6.8.2" + }, + "time": "2025-07-16T06:41:00+00:00" + }, { "name": "phpcompatibility/php-compatibility", "version": "9.3.5", @@ -692,16 +743,16 @@ }, { "name": "phpcompatibility/phpcompatibility-paragonie", - "version": "1.3.3", + "version": "1.3.4", "source": { "type": "git", "url": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie.git", - "reference": "293975b465e0e709b571cbf0c957c6c0a7b9a2ac" + "reference": "244d7b04fc4bc2117c15f5abe23eb933b5f02bbf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityParagonie/zipball/293975b465e0e709b571cbf0c957c6c0a7b9a2ac", - "reference": "293975b465e0e709b571cbf0c957c6c0a7b9a2ac", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityParagonie/zipball/244d7b04fc4bc2117c15f5abe23eb933b5f02bbf", + "reference": "244d7b04fc4bc2117c15f5abe23eb933b5f02bbf", "shasum": "" }, "require": { @@ -758,22 +809,26 @@ { "url": "https://opencollective.com/php_codesniffer", "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcompatibility", + "type": "thanks_dev" } ], - "time": "2024-04-24T21:30:46+00:00" + "time": "2025-09-19T17:43:28+00:00" }, { "name": "phpcompatibility/phpcompatibility-wp", - "version": "2.1.7", + "version": "2.1.8", "source": { "type": "git", "url": "https://github.com/PHPCompatibility/PHPCompatibilityWP.git", - "reference": "5bfbbfbabb3df2b9a83e601de9153e4a7111962c" + "reference": "7c8d18b4d90dac9e86b0869a608fa09158e168fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityWP/zipball/5bfbbfbabb3df2b9a83e601de9153e4a7111962c", - "reference": "5bfbbfbabb3df2b9a83e601de9153e4a7111962c", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityWP/zipball/7c8d18b4d90dac9e86b0869a608fa09158e168fa", + "reference": "7c8d18b4d90dac9e86b0869a608fa09158e168fa", "shasum": "" }, "require": { @@ -835,26 +890,26 @@ "type": "thanks_dev" } ], - "time": "2025-05-12T16:38:37+00:00" + "time": "2025-10-18T00:05:59+00:00" }, { "name": "phpcsstandards/phpcsextra", - "version": "1.4.0", + "version": "1.4.1", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHPCSExtra.git", - "reference": "fa4b8d051e278072928e32d817456a7fdb57b6ca" + "reference": "882b8c947ada27eb002870fe77fee9ce0a454cdb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/fa4b8d051e278072928e32d817456a7fdb57b6ca", - "reference": "fa4b8d051e278072928e32d817456a7fdb57b6ca", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/882b8c947ada27eb002870fe77fee9ce0a454cdb", + "reference": "882b8c947ada27eb002870fe77fee9ce0a454cdb", "shasum": "" }, "require": { "php": ">=5.4", - "phpcsstandards/phpcsutils": "^1.1.0", - "squizlabs/php_codesniffer": "^3.13.0 || ^4.0" + "phpcsstandards/phpcsutils": "^1.1.2", + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0" }, "require-dev": { "php-parallel-lint/php-console-highlighter": "^1.0", @@ -917,26 +972,26 @@ "type": "thanks_dev" } ], - "time": "2025-06-14T07:40:39+00:00" + "time": "2025-09-05T06:54:52+00:00" }, { "name": "phpcsstandards/phpcsutils", - "version": "1.1.1", + "version": "1.1.3", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHPCSUtils.git", - "reference": "f7eb16f2fa4237d5db9e8fed8050239bee17a9bd" + "reference": "8b8e17615d04f2fc2cd46fc1d2fd888fa21b3cf9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/f7eb16f2fa4237d5db9e8fed8050239bee17a9bd", - "reference": "f7eb16f2fa4237d5db9e8fed8050239bee17a9bd", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/8b8e17615d04f2fc2cd46fc1d2fd888fa21b3cf9", + "reference": "8b8e17615d04f2fc2cd46fc1d2fd888fa21b3cf9", "shasum": "" }, "require": { "dealerdirect/phpcodesniffer-composer-installer": "^0.4.1 || ^0.5 || ^0.6.2 || ^0.7 || ^1.0", "php": ">=5.4", - "squizlabs/php_codesniffer": "^3.13.0 || ^4.0" + "squizlabs/php_codesniffer": "^3.13.3 || ^4.0" }, "require-dev": { "ext-filter": "*", @@ -1010,7 +1065,7 @@ "type": "thanks_dev" } ], - "time": "2025-08-10T01:04:45+00:00" + "time": "2025-10-16T16:39:32+00:00" }, { "name": "phpstan/phpdoc-parser", @@ -1061,20 +1116,15 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.22", - "source": { - "type": "git", - "url": "https://github.com/phpstan/phpstan.git", - "reference": "41600c8379eb5aee63e9413fe9e97273e25d57e4" - }, + "version": "1.12.32", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/41600c8379eb5aee63e9413fe9e97273e25d57e4", - "reference": "41600c8379eb5aee63e9413fe9e97273e25d57e4", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/2770dcdf5078d0b0d53f94317e06affe88419aa8", + "reference": "2770dcdf5078d0b0d53f94317e06affe88419aa8", "shasum": "" }, "require": { - "php": "^7.4|^8.0" + "php": "^7.2|^8.0" }, "conflict": { "phpstan/phpstan-shim": "*" @@ -1115,32 +1165,31 @@ "type": "github" } ], - "time": "2025-08-04T19:17:37+00:00" + "time": "2025-09-30T10:16:31+00:00" }, { "name": "sirbrillig/phpcs-variable-analysis", - "version": "v2.12.0", + "version": "v2.13.0", "source": { "type": "git", "url": "https://github.com/sirbrillig/phpcs-variable-analysis.git", - "reference": "4debf5383d9ade705e0a25121f16c3fecaf433a7" + "reference": "a15e970b8a0bf64cfa5e86d941f5e6b08855f369" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sirbrillig/phpcs-variable-analysis/zipball/4debf5383d9ade705e0a25121f16c3fecaf433a7", - "reference": "4debf5383d9ade705e0a25121f16c3fecaf433a7", + "url": "https://api.github.com/repos/sirbrillig/phpcs-variable-analysis/zipball/a15e970b8a0bf64cfa5e86d941f5e6b08855f369", + "reference": "a15e970b8a0bf64cfa5e86d941f5e6b08855f369", "shasum": "" }, "require": { "php": ">=5.4.0", - "squizlabs/php_codesniffer": "^3.5.6" + "squizlabs/php_codesniffer": "^3.5.7 || ^4.0.0" }, "require-dev": { "dealerdirect/phpcodesniffer-composer-installer": "^0.7 || ^1.0", - "phpcsstandards/phpcsdevcs": "^1.1", - "phpstan/phpstan": "^1.7", + "phpstan/phpstan": "^1.7 || ^2.0", "phpunit/phpunit": "^4.8.36 || ^5.7.21 || ^6.5 || ^7.0 || ^8.0 || ^9.0 || ^10.5.32 || ^11.3.3", - "vimeo/psalm": "^0.2 || ^0.3 || ^1.1 || ^4.24 || ^5.0" + "vimeo/psalm": "^0.2 || ^0.3 || ^1.1 || ^4.24 || ^5.0 || ^6.0 || ^7.0" }, "type": "phpcodesniffer-standard", "autoload": { @@ -1172,36 +1221,36 @@ "source": "https://github.com/sirbrillig/phpcs-variable-analysis", "wiki": "https://github.com/sirbrillig/phpcs-variable-analysis/wiki" }, - "time": "2025-03-17T16:17:38+00:00" + "time": "2025-09-30T22:22:48+00:00" }, { "name": "slevomat/coding-standard", - "version": "8.21.1", + "version": "8.22.1", "source": { "type": "git", "url": "https://github.com/slevomat/coding-standard.git", - "reference": "2b801e950ae1cceb30bb3c0373141f553c99d3c3" + "reference": "1dd80bf3b93692bedb21a6623c496887fad05fec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/2b801e950ae1cceb30bb3c0373141f553c99d3c3", - "reference": "2b801e950ae1cceb30bb3c0373141f553c99d3c3", + "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/1dd80bf3b93692bedb21a6623c496887fad05fec", + "reference": "1dd80bf3b93692bedb21a6623c496887fad05fec", "shasum": "" }, "require": { "dealerdirect/phpcodesniffer-composer-installer": "^0.6.2 || ^0.7 || ^1.1.2", "php": "^7.4 || ^8.0", "phpstan/phpdoc-parser": "^2.3.0", - "squizlabs/php_codesniffer": "^3.13.2" + "squizlabs/php_codesniffer": "^3.13.4" }, "require-dev": { "phing/phing": "3.0.1|3.1.0", "php-parallel-lint/php-parallel-lint": "1.4.0", - "phpstan/phpstan": "2.1.22", + "phpstan/phpstan": "2.1.24", "phpstan/phpstan-deprecation-rules": "2.0.3", "phpstan/phpstan-phpunit": "2.0.7", "phpstan/phpstan-strict-rules": "2.0.6", - "phpunit/phpunit": "9.6.8|10.5.48|11.4.4|11.5.27|12.3.7" + "phpunit/phpunit": "9.6.8|10.5.48|11.4.4|11.5.36|12.3.10" }, "type": "phpcodesniffer-standard", "extra": { @@ -1225,7 +1274,7 @@ ], "support": { "issues": "https://github.com/slevomat/coding-standard/issues", - "source": "https://github.com/slevomat/coding-standard/tree/8.21.1" + "source": "https://github.com/slevomat/coding-standard/tree/8.22.1" }, "funding": [ { @@ -1237,20 +1286,20 @@ "type": "tidelift" } ], - "time": "2025-08-31T13:32:28+00:00" + "time": "2025-09-13T08:53:30+00:00" }, { "name": "squizlabs/php_codesniffer", - "version": "3.13.2", + "version": "3.13.4", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "5b5e3821314f947dd040c70f7992a64eac89025c" + "reference": "ad545ea9c1b7d270ce0fc9cbfb884161cd706119" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/5b5e3821314f947dd040c70f7992a64eac89025c", - "reference": "5b5e3821314f947dd040c70f7992a64eac89025c", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/ad545ea9c1b7d270ce0fc9cbfb884161cd706119", + "reference": "ad545ea9c1b7d270ce0fc9cbfb884161cd706119", "shasum": "" }, "require": { @@ -1321,7 +1370,150 @@ "type": "thanks_dev" } ], - "time": "2025-06-17T22:17:01+00:00" + "time": "2025-09-05T05:47:09+00:00" + }, + { + "name": "symfony/polyfill-php73", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php73.git", + "reference": "0f68c03565dcaaf25a890667542e8bd75fe7e5bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/0f68c03565dcaaf25a890667542e8bd75fe7e5bb", + "reference": "0f68c03565dcaaf25a890667542e8bd75fe7e5bb", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php73\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php73/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "szepeviktor/phpstan-wordpress", + "version": "v1.3.5", + "source": { + "type": "git", + "url": "https://github.com/szepeviktor/phpstan-wordpress.git", + "reference": "7f8cfe992faa96b6a33bbd75c7bace98864161e7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/szepeviktor/phpstan-wordpress/zipball/7f8cfe992faa96b6a33bbd75c7bace98864161e7", + "reference": "7f8cfe992faa96b6a33bbd75c7bace98864161e7", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0", + "php-stubs/wordpress-stubs": "^4.7 || ^5.0 || ^6.0", + "phpstan/phpstan": "^1.10.31", + "symfony/polyfill-php73": "^1.12.0" + }, + "require-dev": { + "composer/composer": "^2.1.14", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.1", + "phpstan/phpstan-strict-rules": "^1.2", + "phpunit/phpunit": "^8.0 || ^9.0", + "szepeviktor/phpcs-psr-12-neutron-hybrid-ruleset": "^1.0", + "wp-coding-standards/wpcs": "3.1.0 as 2.3.0" + }, + "suggest": { + "swissspidy/phpstan-no-private": "Detect usage of internal core functions, classes and methods" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + } + }, + "autoload": { + "psr-4": { + "SzepeViktor\\PHPStan\\WordPress\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "WordPress extensions for PHPStan", + "keywords": [ + "PHPStan", + "code analyse", + "code analysis", + "static analysis", + "wordpress" + ], + "support": { + "issues": "https://github.com/szepeviktor/phpstan-wordpress/issues", + "source": "https://github.com/szepeviktor/phpstan-wordpress/tree/v1.3.5" + }, + "time": "2024-06-28T22:27:19+00:00" }, { "name": "wp-coding-standards/wpcs", diff --git a/includes/API_Credentials/API_Credentials_Manager.php b/includes/API_Credentials/API_Credentials_Manager.php new file mode 100644 index 0000000..bfb8ad1 --- /dev/null +++ b/includes/API_Credentials/API_Credentials_Manager.php @@ -0,0 +1,285 @@ + + * } + * + * @phpstan-type ProviderExtendedMetadataArrayShape array{ + * id: string, + * name: string, + * type: string, + * ai_client_classnames: array + * } + */ +class API_Credentials_Manager { + + private const OPTION_GROUP = 'wp-ai-client-settings'; + private const OPTION_PROVIDER_CREDENTIALS = 'wp_ai_client_provider_credentials'; + + /** + * Initializes the API credentials manager. + * + * This method needs to be called by the consumer of this package, on the WordPress 'init' action hook. + * + * @since n.e.x.t + */ + public function initialize(): void { + $this->collect_providers(); + $this->register_settings(); + $this->pass_credentials_to_client(); + + add_action( + 'admin_menu', + function () { + $this->add_admin_screen(); + } + ); + } + + /** + * Collects metadata for all registered providers in the PHP AI Client SDK. + * + * Since the PHP AI Client SDK as well as the WordPress AI Client package can be loaded multiple times, + * including with different namespace or class name prefixes, this method ensures that the provider metadata is + * collected only once across all instances of the package. + * + * This unified collection mechanism allows the WordPress AI Client package to expose a single settings screen in + * WordPress for managing API credentials for all providers, regardless of how many times the package is loaded and + * regardless of whether a provider is only registered in one of the instances. + * + * To safely do that, the method uses a global variable. It stores the provider metadata array keyed by the + * provider ID, and for each provider metadata it also stores a map of the AiClient class names where the provider + * is registered in. + * + * @since n.e.x.t + */ + private function collect_providers(): void { + /** + * The internal global, to collect providers metadata across duplicate clients, including prefixed versions. + * + * @var array|null $wp_ai_client_providers_metadata + */ + global $wp_ai_client_providers_metadata; + + if ( ! isset( $wp_ai_client_providers_metadata ) ) { + $wp_ai_client_providers_metadata = array(); + } + + $registry = AiClient::defaultRegistry(); + + $provider_ids = $registry->getRegisteredProviderIds(); + foreach ( $provider_ids as $provider_id ) { + // If the provider was already found via another client class, just add this client class name to the list. + if ( isset( $wp_ai_client_providers_metadata[ $provider_id ] ) ) { + $wp_ai_client_providers_metadata[ $provider_id ]['ai_client_classnames'][ AiClient::class ] = true; + continue; + } + + // Otherwise, get the provider metadata and add it to the global. + $provider_class_name = $registry->getProviderClassName( $provider_id ); + $provider_metadata = $provider_class_name::metadata(); + + $wp_ai_client_providers_metadata[ $provider_id ] = array_merge( + $provider_metadata->toArray(), + array( + 'ai_client_classnames' => array( AiClient::class => true ), + ) + ); + } + } + + /** + * Returns the metadata for all registered providers across all instances of the PHP AI Client SDK. + * + * See {@see API_Credentials_Manager::collect_providers()} for details on how this works and why it uses a global. + * + * @since n.e.x.t + * @see API_Credentials_Manager::collect_providers() + * + * @return array Array of provider metadata objects, keyed by provider ID. + */ + private function get_all_providers_metadata(): array { + /** + * The internal global, to collect providers metadata across duplicate clients, including prefixed versions. + * + * @var ?array $wp_ai_client_providers_metadata + */ + global $wp_ai_client_providers_metadata; + + if ( ! isset( $wp_ai_client_providers_metadata ) ) { + $wp_ai_client_providers_metadata = array(); + } + + return array_map( + static function ( array $provider_metadata ) { + unset( $provider_metadata['ai_client_classnames'] ); + return ProviderMetadata::fromArray( $provider_metadata ); + }, + $wp_ai_client_providers_metadata + ); + } + + /** + * Returns the metadata for all registered cloud providers across all instances of the PHP AI Client SDK. + * + * @since n.e.x.t + * + * @return array Array of cloud provider metadata objects, keyed by provider ID. + */ + private function get_all_cloud_providers_metadata(): array { + $all_providers = $this->get_all_providers_metadata(); + + return array_filter( + $all_providers, + static function ( ProviderMetadata $metadata ) { + return $metadata->getType()->isCloud(); + } + ); + } + + /** + * Registers the settings for storing the API credentials. + * + * The setting will only be registered once, even if the class is used multiple times. + * + * @since n.e.x.t + */ + private function register_settings(): void { + // Avoid registering the setting multiple times. + $registered_settings = get_registered_settings(); + if ( isset( $registered_settings[ self::OPTION_PROVIDER_CREDENTIALS ] ) ) { + return; + } + + register_setting( + self::OPTION_GROUP, + self::OPTION_PROVIDER_CREDENTIALS, + array( + 'type' => 'object', + 'default' => array(), + 'sanitize_callback' => function ( $credentials ) { + if ( ! is_array( $credentials ) ) { + return array(); + } + + // Assume that all cloud providers require an API key. + $providers_metadata_keyed_by_ids = $this->get_all_cloud_providers_metadata(); + + $credentials = array_intersect_key( $credentials, $providers_metadata_keyed_by_ids ); + foreach ( $credentials as $provider_id => $api_key ) { + $credentials[ $provider_id ] = sanitize_text_field( $api_key ); + } + return $credentials; + }, + ) + ); + } + + /** + * Passes the stored API credentials to the PHP AI Client SDK. + * + * This method should be called on every request, before any API requests are made via the PHP AI Client SDK. + * + * @since n.e.x.t + * + * @throws RuntimeException If the stored credentials option is in an invalid format. + */ + private function pass_credentials_to_client(): void { + $credentials = get_option( self::OPTION_PROVIDER_CREDENTIALS, array() ); + if ( ! is_array( $credentials ) ) { + throw new RuntimeException( 'Invalid format for stored provider credentials option.' ); + } + + $registry = AiClient::defaultRegistry(); + + // Set available API keys for all registered providers. + foreach ( $credentials as $provider_id => $api_key ) { + if ( '' === $api_key ) { + continue; + } + + if ( ! $registry->hasProvider( $provider_id ) ) { + continue; + } + + $registry->setProviderRequestAuthentication( + $provider_id, + new ApiKeyRequestAuthentication( $api_key ) + ); + } + } + + /** + * Adds the admin screen for managing API credentials. + * + * The screen will only be added once, even if the class is used multiple times. + * + * @since n.e.x.t + */ + private function add_admin_screen(): void { + global $_wp_submenu_nopriv, $_parent_pages; + + $parent_slug = 'options-general.php'; // Used via `add_options_page()`. + $screen_slug = 'wp-ai-client'; + + // Bail if the screen was already added (e.g. by another instance of this package). + if ( + isset( $_wp_submenu_nopriv[ $parent_slug ][ $screen_slug ] ) || + isset( $_parent_pages[ $screen_slug ] ) + ) { + return; + } + + $screen_title = __( 'AI Client Credentials', 'wp-ai-client' ); + + $settings_screen = new API_Credentials_Settings_Screen( + $screen_slug, + $screen_title, + __( 'Paste your API credentials for one or more AI providers you would like to use throughout your site.', 'wp-ai-client' ), + self::OPTION_GROUP, + self::OPTION_PROVIDER_CREDENTIALS, + $this->get_all_cloud_providers_metadata() + ); + + $hook_suffix = add_options_page( + $screen_title, + __( 'AI Credentials', 'wp-ai-client' ), + 'manage_options', + $screen_slug, + array( $settings_screen, 'render_screen' ) + ); + + if ( ! is_string( $hook_suffix ) ) { + return; + } + + add_action( + "load-{$hook_suffix}", + array( $settings_screen, 'initialize_screen' ) + ); + } +} diff --git a/includes/API_Credentials/API_Credentials_Settings_Screen.php b/includes/API_Credentials/API_Credentials_Settings_Screen.php new file mode 100644 index 0000000..4552151 --- /dev/null +++ b/includes/API_Credentials/API_Credentials_Settings_Screen.php @@ -0,0 +1,262 @@ + An array of provider metadata, keyed by provider ID. + */ + private array $providers_metadata = array(); + + /** + * Constructor. + * + * @since n.e.x.t + * + * @param string $screen_slug The screen slug. + * @param string $screen_title The screen title. + * @param string $screen_description The screen description. + * @param string $option_group The option group for the settings. + * @param string $option_name The option name for storing the provider credentials. + * @param array $providers_metadata An array of provider metadata, keyed by provider ID. + */ + public function __construct( string $screen_slug, string $screen_title, string $screen_description, string $option_group, string $option_name, array $providers_metadata ) { + $this->screen_slug = $screen_slug; + $this->screen_title = $screen_title; + $this->screen_description = $screen_description; + $this->option_group = $option_group; + $this->option_name = $option_name; + $this->providers_metadata = $providers_metadata; + } + + /** + * Initializes the provider settings screen. + * + * This method adds a settings section for provider API credentials, including a field for each provider that + * requires API key authentication. + * + * @since n.e.x.t + */ + public function initialize_screen(): void { + $settings_section = 'wp-ai-client-provider-credentials'; + + add_settings_section( + $settings_section, + '', + function () { + ?> +

+ screen_description, $this->kses_description_allowed_html() ); ?> +

+ screen_slug + ); + + foreach ( $this->providers_metadata as $provider_metadata ) { + $provider_id = $provider_metadata->getId(); + $provider_name = $provider_metadata->getName(); + + /* + * This is a temporary hard-coded mapping of provider IDs to their API credentials URL. + * Instead, this should become an optional field during provider registration in the PHP AI Client SDK. + * + * TODO: Remove this eventually once the PHP AI Client SDK supports this natively. + */ + switch ( $provider_id ) { + case 'anthropic': + $provider_credentials_url = 'https://console.anthropic.com/settings/keys'; + break; + case 'google': + $provider_credentials_url = 'https://aistudio.google.com/app/api-keys'; + break; + case 'openai': + $provider_credentials_url = 'https://platform.openai.com/api-keys'; + break; + default: + $provider_credentials_url = ''; + } + + $field_id = "wp-ai-client-provider-api-key-{$provider_id}"; + $field_args = array( + 'type' => 'password', + 'label_for' => $field_id, + 'id' => $field_id, + 'name' => $this->option_name . '[' . $provider_id . ']', + ); + if ( $provider_credentials_url ) { + $field_args['description'] = sprintf( + /* translators: 1: provider name, 2: URL to the provider's API credentials page. */ + __( 'Create and manage your %1$s API keys in the %1$s account settings (opens in a new tab).', 'wp-ai-client' ), + $provider_name, + esc_url( $provider_credentials_url ) + ); + } + + add_settings_field( + $field_id, + $provider_name, + array( $this, 'render_field' ), + $this->screen_slug, + $settings_section, + $field_args + ); + } + } + + /** + * Renders the provider settings screen. + * + * @since n.e.x.t + */ + public function render_screen(): void { + ?> +
+

+ screen_title ); ?> +

+ +
+ option_group ); ?> + screen_slug ); ?> + +
+
+ $args Field arguments set up during `add_settings_field()`. + */ + public function render_field( array $args ): void { + $type = $args['type'] ?? 'text'; + $id = $args['id'] ?? ''; + $name = $args['name'] ?? ''; + $description = $args['description'] ?? ''; + $description_id = $args['id'] . '_description'; + + if ( str_contains( $name, '[' ) ) { + $parts = explode( '[', $name, 2 ); + $option = get_option( $parts[0] ); + $subkey = trim( $parts[1], ']' ); + if ( is_array( $option ) && isset( $option[ $subkey ] ) ) { + $value = $option[ $subkey ]; + } else { + $value = ''; + } + } else { + $option = get_option( $name ); + if ( is_string( $option ) ) { + $value = $option; + } else { + $value = ''; + } + } + + ?> + + > + +

+ kses_description_allowed_html() ); ?> +

+ > Allowed HTML tags and their attributes. + */ + private function kses_description_allowed_html(): array { + return array( + 'strong' => array(), + 'em' => array(), + 'span' => array( + 'class' => array(), + ), + 'a' => array( + 'class' => array(), + 'href' => array(), + 'target' => array(), + 'rel' => array(), + ), + ); + } +} diff --git a/includes/Test_Class.php b/includes/Test_Class.php deleted file mode 100644 index ba39831..0000000 --- a/includes/Test_Class.php +++ /dev/null @@ -1,28 +0,0 @@ -initialize(); + } +);