diff --git a/composer.json b/composer.json index 612e95bd..b98fc2d4 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,6 @@ "ext-json": "*", "php-http/discovery": "^1.0", "php-http/httplug": "^2.0", - "php-http/message-factory": "^1.0", "psr/http-client": "^1.0", "psr/http-factory": "^1.0", "psr/event-dispatcher": "^1.0", @@ -40,9 +39,31 @@ "phpstan/phpstan": "~2.1", "phpunit/phpunit": "^9.5 || ^10.0", "slevomat/coding-standard": "^8.20", - "squizlabs/php_codesniffer": "^3.7", - "symfony/dotenv": "^5.4" + "squizlabs/php_codesniffer": "^3.7 || ^4.0", + "symfony/dotenv": "^5.4", + "wordpress/anthropic-ai-provider": "dev-provider-implementation", + "wordpress/google-ai-provider": "dev-provider-implementation", + "wordpress/openai-ai-provider": "dev-provider-implementation" }, + "suggest": { + "wordpress/anthropic-ai-provider": "For Anthropic Claude model support", + "wordpress/google-ai-provider": "For Google Gemini model support", + "wordpress/openai-ai-provider": "For OpenAI GPT model support" + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/WordPress/anthropic-ai-provider" + }, + { + "type": "vcs", + "url": "https://github.com/WordPress/google-ai-provider" + }, + { + "type": "vcs", + "url": "https://github.com/WordPress/openai-ai-provider" + } + ], "minimum-stability": "dev", "prefer-stable": true, "autoload": { diff --git a/composer.lock b/composer.lock index 4f9e7529..ef1ff08b 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": "61d50540cdf6ab250eb7fed927280793", + "content-hash": "5edd0be356b10d1734e6fed7ff957ecc", "packages": [ { "name": "php-http/discovery", @@ -142,61 +142,6 @@ }, "time": "2024-09-23T11:39:58+00:00" }, - { - "name": "php-http/message-factory", - "version": "1.1.0", - "source": { - "type": "git", - "url": "https://github.com/php-http/message-factory.git", - "reference": "4d8778e1c7d405cbb471574821c1ff5b68cc8f57" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-http/message-factory/zipball/4d8778e1c7d405cbb471574821c1ff5b68cc8f57", - "reference": "4d8778e1c7d405cbb471574821c1ff5b68cc8f57", - "shasum": "" - }, - "require": { - "php": ">=5.4", - "psr/http-message": "^1.0 || ^2.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.x-dev" - } - }, - "autoload": { - "psr-4": { - "Http\\Message\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com" - } - ], - "description": "Factory interfaces for PSR-7 HTTP Message", - "homepage": "http://php-http.org", - "keywords": [ - "factory", - "http", - "message", - "stream", - "uri" - ], - "support": { - "issues": "https://github.com/php-http/message-factory/issues", - "source": "https://github.com/php-http/message-factory/tree/1.1.0" - }, - "abandoned": "psr/http-factory", - "time": "2023-04-14T14:16:17+00:00" - }, { "name": "php-http/promise", "version": "1.3.1", @@ -580,29 +525,29 @@ }, { "name": "dealerdirect/phpcodesniffer-composer-installer", - "version": "v1.1.2", + "version": "v1.2.0", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/composer-installer.git", - "reference": "e9cf5e4bbf7eeaf9ef5db34938942602838fc2b1" + "reference": "845eb62303d2ca9b289ef216356568ccc075ffd1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/e9cf5e4bbf7eeaf9ef5db34938942602838fc2b1", - "reference": "e9cf5e4bbf7eeaf9ef5db34938942602838fc2b1", + "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/845eb62303d2ca9b289ef216356568ccc075ffd1", + "reference": "845eb62303d2ca9b289ef216356568ccc075ffd1", "shasum": "" }, "require": { "composer-plugin-api": "^2.2", "php": ">=5.4", - "squizlabs/php_codesniffer": "^2.0 || ^3.1.0 || ^4.0" + "squizlabs/php_codesniffer": "^3.1.0 || ^4.0" }, "require-dev": { "composer/composer": "^2.2", "ext-json": "*", "ext-zip": "*", "php-parallel-lint/php-parallel-lint": "^1.4.0", - "phpcompatibility/php-compatibility": "^9.0", + "phpcompatibility/php-compatibility": "^9.0 || ^10.0.0@dev", "yoast/phpunit-polyfills": "^1.0" }, "type": "composer-plugin", @@ -672,7 +617,7 @@ "type": "thanks_dev" } ], - "time": "2025-07-17T20:45:56+00:00" + "time": "2025-11-11T04:32:07+00:00" }, { "name": "doctrine/instantiator", @@ -746,16 +691,16 @@ }, { "name": "guzzlehttp/psr7", - "version": "2.7.1", + "version": "2.8.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16" + "reference": "21dc724a0583619cd1652f673303492272778051" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/c2270caaabe631b3b44c85f99e5a04bbb8060d16", - "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", + "reference": "21dc724a0583619cd1652f673303492272778051", "shasum": "" }, "require": { @@ -771,7 +716,7 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "http-interop/http-factory-tests": "0.9.0", - "phpunit/phpunit": "^8.5.39 || ^9.6.20" + "phpunit/phpunit": "^8.5.44 || ^9.6.25" }, "suggest": { "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" @@ -842,7 +787,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.7.1" + "source": "https://github.com/guzzle/psr7/tree/2.8.0" }, "funding": [ { @@ -858,7 +803,7 @@ "type": "tidelift" } ], - "time": "2025-03-27T12:30:47+00:00" + "time": "2025-08-23T21:21:41+00:00" }, { "name": "myclabs/deep-copy", @@ -922,16 +867,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.6.1", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2" + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2", - "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", "shasum": "" }, "require": { @@ -974,9 +919,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.1" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" }, - "time": "2025-08-13T20:13:15+00:00" + "time": "2025-12-06T11:56:16+00:00" }, { "name": "phar-io/manifest", @@ -1098,16 +1043,16 @@ }, { "name": "php-http/client-common", - "version": "2.7.2", + "version": "2.7.3", "source": { "type": "git", "url": "https://github.com/php-http/client-common.git", - "reference": "0cfe9858ab9d3b213041b947c881d5b19ceeca46" + "reference": "dcc6de29c90dd74faab55f71b79d89409c4bf0c1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-http/client-common/zipball/0cfe9858ab9d3b213041b947c881d5b19ceeca46", - "reference": "0cfe9858ab9d3b213041b947c881d5b19ceeca46", + "url": "https://api.github.com/repos/php-http/client-common/zipball/dcc6de29c90dd74faab55f71b79d89409c4bf0c1", + "reference": "dcc6de29c90dd74faab55f71b79d89409c4bf0c1", "shasum": "" }, "require": { @@ -1117,15 +1062,13 @@ "psr/http-client": "^1.0", "psr/http-factory": "^1.0", "psr/http-message": "^1.0 || ^2.0", - "symfony/options-resolver": "~4.0.15 || ~4.1.9 || ^4.2.1 || ^5.0 || ^6.0 || ^7.0", + "symfony/options-resolver": "~4.0.15 || ~4.1.9 || ^4.2.1 || ^5.0 || ^6.0 || ^7.0 || ^8.0", "symfony/polyfill-php80": "^1.17" }, "require-dev": { "doctrine/instantiator": "^1.1", "guzzlehttp/psr7": "^1.4", "nyholm/psr7": "^1.2", - "phpspec/phpspec": "^5.1 || ^6.3 || ^7.1", - "phpspec/prophecy": "^1.10.2", "phpunit/phpunit": "^7.5.20 || ^8.5.33 || ^9.6.7" }, "suggest": { @@ -1161,22 +1104,22 @@ ], "support": { "issues": "https://github.com/php-http/client-common/issues", - "source": "https://github.com/php-http/client-common/tree/2.7.2" + "source": "https://github.com/php-http/client-common/tree/2.7.3" }, - "time": "2024-09-24T06:21:48+00:00" + "time": "2025-11-29T19:12:34+00:00" }, { "name": "php-http/curl-client", - "version": "2.3.3", + "version": "2.3.4", "source": { "type": "git", "url": "https://github.com/php-http/curl-client.git", - "reference": "f3eb48d266341afec0229a7a37a03521d3646b81" + "reference": "cc3b48603b0181c7d9984503f7b70fd6a7b37592" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-http/curl-client/zipball/f3eb48d266341afec0229a7a37a03521d3646b81", - "reference": "f3eb48d266341afec0229a7a37a03521d3646b81", + "url": "https://api.github.com/repos/php-http/curl-client/zipball/cc3b48603b0181c7d9984503f7b70fd6a7b37592", + "reference": "cc3b48603b0181c7d9984503f7b70fd6a7b37592", "shasum": "" }, "require": { @@ -1187,7 +1130,7 @@ "php-http/message": "^1.2", "psr/http-client": "^1.0", "psr/http-factory-implementation": "^1.0", - "symfony/options-resolver": "^3.4 || ^4.0 || ^5.0 || ^6.0 || ^7.0" + "symfony/options-resolver": "^3.4 || ^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0" }, "provide": { "php-http/async-client-implementation": "1.0", @@ -1226,9 +1169,9 @@ ], "support": { "issues": "https://github.com/php-http/curl-client/issues", - "source": "https://github.com/php-http/curl-client/tree/2.3.3" + "source": "https://github.com/php-http/curl-client/tree/2.3.4" }, - "time": "2024-10-31T07:36:58+00:00" + "time": "2025-12-08T14:11:43+00:00" }, { "name": "php-http/message", @@ -1367,18 +1310,18 @@ "source": { "type": "git", "url": "https://github.com/PHPCompatibility/PHPCompatibility.git", - "reference": "6e10469b0f3827862b37df2ac2b7ec4580ce888f" + "reference": "eed6b91eab37a1ae6d093d5e75b45f8a08d18457" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibility/zipball/6e10469b0f3827862b37df2ac2b7ec4580ce888f", - "reference": "6e10469b0f3827862b37df2ac2b7ec4580ce888f", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibility/zipball/eed6b91eab37a1ae6d093d5e75b45f8a08d18457", + "reference": "eed6b91eab37a1ae6d093d5e75b45f8a08d18457", "shasum": "" }, "require": { - "php": ">=5.4", - "phpcsstandards/phpcsutils": "^1.1.0", - "squizlabs/php_codesniffer": "^3.13.0" + "php": ">=7.2", + "phpcsstandards/phpcsutils": "^1.1.2", + "squizlabs/php_codesniffer": "^4.0.1" }, "replace": { "wimg/php-compatibility": "*" @@ -1386,13 +1329,10 @@ "require-dev": { "php-parallel-lint/php-console-highlighter": "^1.0.0", "php-parallel-lint/php-parallel-lint": "^1.4.0", - "phpcsstandards/phpcsdevcs": "^1.1.3", - "phpcsstandards/phpcsdevtools": "^1.2.0", - "phpunit/phpunit": "^4.8.36 || ^5.7.21 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4 || ^10.5.32 || ^11.3.3", - "yoast/phpunit-polyfills": "^1.0.5 || ^2.0 || ^3.0" - }, - "suggest": { - "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." + "phpcsstandards/phpcsdevcs": "^1.2.0", + "phpcsstandards/phpcsdevtools": "^1.2.3", + "phpunit/phpunit": "^8.0 || ^9.3.4 || ^10.5.32 || ^11.3.3", + "yoast/phpunit-polyfills": "^1.1.5 || ^2.0.5 || ^3.1.0" }, "default-branch": true, "type": "phpcodesniffer-standard", @@ -1453,32 +1393,32 @@ "type": "thanks_dev" } ], - "time": "2025-08-20T03:33:09+00:00" + "time": "2025-12-27T22:29:25+00:00" }, { "name": "phpcsstandards/phpcsutils", - "version": "1.1.1", + "version": "1.2.2", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHPCSUtils.git", - "reference": "f7eb16f2fa4237d5db9e8fed8050239bee17a9bd" + "reference": "c216317e96c8b3f5932808f9b0f1f7a14e3bbf55" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/f7eb16f2fa4237d5db9e8fed8050239bee17a9bd", - "reference": "f7eb16f2fa4237d5db9e8fed8050239bee17a9bd", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/c216317e96c8b3f5932808f9b0f1f7a14e3bbf55", + "reference": "c216317e96c8b3f5932808f9b0f1f7a14e3bbf55", "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.5 || ^4.0.1" }, "require-dev": { "ext-filter": "*", "php-parallel-lint/php-console-highlighter": "^1.0", "php-parallel-lint/php-parallel-lint": "^1.4.0", - "phpcsstandards/phpcsdevcs": "^1.1.6", + "phpcsstandards/phpcsdevcs": "^1.2.0", "yoast/phpunit-polyfills": "^1.1.0 || ^2.0.0 || ^3.0.0" }, "type": "phpcodesniffer-standard", @@ -1546,20 +1486,20 @@ "type": "thanks_dev" } ], - "time": "2025-08-10T01:04:45+00:00" + "time": "2025-12-08T14:27:58+00:00" }, { "name": "phpstan/phpdoc-parser", - "version": "2.2.0", + "version": "2.3.2", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "b9e61a61e39e02dd90944e9115241c7f7e76bfd8" + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/b9e61a61e39e02dd90944e9115241c7f7e76bfd8", - "reference": "b9e61a61e39e02dd90944e9115241c7f7e76bfd8", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", "shasum": "" }, "require": { @@ -1591,22 +1531,17 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.2.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" }, - "time": "2025-07-13T07:04:09+00:00" + "time": "2026-01-25T14:56:51+00:00" }, { "name": "phpstan/phpstan", - "version": "2.1.22", - "source": { - "type": "git", - "url": "https://github.com/phpstan/phpstan.git", - "reference": "41600c8379eb5aee63e9413fe9e97273e25d57e4" - }, + "version": "2.1.38", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/41600c8379eb5aee63e9413fe9e97273e25d57e4", - "reference": "41600c8379eb5aee63e9413fe9e97273e25d57e4", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dfaf1f530e1663aa167bc3e52197adb221582629", + "reference": "dfaf1f530e1663aa167bc3e52197adb221582629", "shasum": "" }, "require": { @@ -1651,7 +1586,7 @@ "type": "github" } ], - "time": "2025-08-04T19:17:37+00:00" + "time": "2026-01-30T17:12:46+00:00" }, { "name": "phpunit/php-code-coverage", @@ -1974,16 +1909,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.24", + "version": "9.6.34", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "ea49afa29aeea25ea7bf9de9fdd7cab163cc0701" + "reference": "b36f02317466907a230d3aa1d34467041271ef4a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/ea49afa29aeea25ea7bf9de9fdd7cab163cc0701", - "reference": "ea49afa29aeea25ea7bf9de9fdd7cab163cc0701", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b36f02317466907a230d3aa1d34467041271ef4a", + "reference": "b36f02317466907a230d3aa1d34467041271ef4a", "shasum": "" }, "require": { @@ -2005,10 +1940,10 @@ "phpunit/php-timer": "^5.0.3", "sebastian/cli-parser": "^1.0.2", "sebastian/code-unit": "^1.0.8", - "sebastian/comparator": "^4.0.9", + "sebastian/comparator": "^4.0.10", "sebastian/diff": "^4.0.6", "sebastian/environment": "^5.1.5", - "sebastian/exporter": "^4.0.6", + "sebastian/exporter": "^4.0.8", "sebastian/global-state": "^5.0.8", "sebastian/object-enumerator": "^4.0.4", "sebastian/resource-operations": "^3.0.4", @@ -2057,7 +1992,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.24" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.34" }, "funding": [ { @@ -2081,7 +2016,7 @@ "type": "tidelift" } ], - "time": "2025-08-10T08:32:42+00:00" + "time": "2026-01-27T05:45:00+00:00" }, { "name": "ralouphie/getallheaders", @@ -2296,16 +2231,16 @@ }, { "name": "sebastian/comparator", - "version": "4.0.9", + "version": "4.0.10", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "67a2df3a62639eab2cc5906065e9805d4fd5dfc5" + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/67a2df3a62639eab2cc5906065e9805d4fd5dfc5", - "reference": "67a2df3a62639eab2cc5906065e9805d4fd5dfc5", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d", + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d", "shasum": "" }, "require": { @@ -2358,7 +2293,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", - "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.9" + "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10" }, "funding": [ { @@ -2378,7 +2313,7 @@ "type": "tidelift" } ], - "time": "2025-08-10T06:51:50+00:00" + "time": "2026-01-24T09:22:56+00:00" }, { "name": "sebastian/complexity", @@ -2568,16 +2503,16 @@ }, { "name": "sebastian/exporter", - "version": "4.0.6", + "version": "4.0.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72" + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/78c00df8f170e02473b682df15bfcdacc3d32d72", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/14c6ba52f95a36c3d27c835d65efc7123c446e8c", + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c", "shasum": "" }, "require": { @@ -2633,15 +2568,27 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", - "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.6" + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.8" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" } ], - "time": "2024-03-02T06:33:00+00:00" + "time": "2025-09-24T06:03:27+00:00" }, { "name": "sebastian/global-state", @@ -3128,32 +3075,32 @@ }, { "name": "slevomat/coding-standard", - "version": "8.20.0", + "version": "8.27.1", "source": { "type": "git", "url": "https://github.com/slevomat/coding-standard.git", - "reference": "b4f9f02edd4e6a586777f0cabe8d05574323f3eb" + "reference": "29bdaee8b65e7ed2b8e702b01852edba8bae1769" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/b4f9f02edd4e6a586777f0cabe8d05574323f3eb", - "reference": "b4f9f02edd4e6a586777f0cabe8d05574323f3eb", + "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/29bdaee8b65e7ed2b8e702b01852edba8bae1769", + "reference": "29bdaee8b65e7ed2b8e702b01852edba8bae1769", "shasum": "" }, "require": { - "dealerdirect/phpcodesniffer-composer-installer": "^0.6.2 || ^0.7 || ^1.1.2", + "dealerdirect/phpcodesniffer-composer-installer": "^0.7 || ^1.2.0", "php": "^7.4 || ^8.0", - "phpstan/phpdoc-parser": "^2.2.0", - "squizlabs/php_codesniffer": "^3.13.2" + "phpstan/phpdoc-parser": "^2.3.1", + "squizlabs/php_codesniffer": "^4.0.1" }, "require-dev": { - "phing/phing": "3.0.1|3.1.0", + "phing/phing": "3.0.1|3.1.1", "php-parallel-lint/php-parallel-lint": "1.4.0", - "phpstan/phpstan": "2.1.19", + "phpstan/phpstan": "2.1.37", "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.2.7" + "phpstan/phpstan-phpunit": "2.0.12", + "phpstan/phpstan-strict-rules": "2.0.7", + "phpunit/phpunit": "9.6.31|10.5.60|11.4.4|11.5.49|12.5.7" }, "type": "phpcodesniffer-standard", "extra": { @@ -3177,7 +3124,7 @@ ], "support": { "issues": "https://github.com/slevomat/coding-standard/issues", - "source": "https://github.com/slevomat/coding-standard/tree/8.20.0" + "source": "https://github.com/slevomat/coding-standard/tree/8.27.1" }, "funding": [ { @@ -3189,41 +3136,36 @@ "type": "tidelift" } ], - "time": "2025-07-26T15:35:10+00:00" + "time": "2026-01-25T15:57:07+00:00" }, { "name": "squizlabs/php_codesniffer", - "version": "3.13.2", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "5b5e3821314f947dd040c70f7992a64eac89025c" + "reference": "0525c73950de35ded110cffafb9892946d7771b5" }, "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/0525c73950de35ded110cffafb9892946d7771b5", + "reference": "0525c73950de35ded110cffafb9892946d7771b5", "shasum": "" }, "require": { "ext-simplexml": "*", "ext-tokenizer": "*", "ext-xmlwriter": "*", - "php": ">=5.4.0" + "php": ">=7.2.0" }, "require-dev": { - "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + "phpunit/phpunit": "^8.4.0 || ^9.3.4 || ^10.5.32 || 11.3.3 - 11.5.28 || ^11.5.31" }, "bin": [ "bin/phpcbf", "bin/phpcs" ], "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" @@ -3242,7 +3184,7 @@ "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" } ], - "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", + "description": "PHP_CodeSniffer tokenizes PHP files and detects violations of a defined set of coding standards.", "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", "keywords": [ "phpcs", @@ -3273,7 +3215,7 @@ "type": "thanks_dev" } ], - "time": "2025-06-17T22:17:01+00:00" + "time": "2025-11-10T16:43:36+00:00" }, { "name": "symfony/deprecation-contracts", @@ -3484,7 +3426,7 @@ }, { "name": "symfony/polyfill-php73", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php73.git", @@ -3540,7 +3482,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php73/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-php73/tree/v1.33.0" }, "funding": [ { @@ -3551,6 +3493,10 @@ "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" @@ -3560,7 +3506,7 @@ }, { "name": "symfony/polyfill-php80", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", @@ -3620,7 +3566,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" }, "funding": [ { @@ -3631,6 +3577,10 @@ "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" @@ -3640,16 +3590,16 @@ }, { "name": "theseer/tokenizer", - "version": "1.2.3", + "version": "1.3.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", "shasum": "" }, "require": { @@ -3678,7 +3628,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.3" + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" }, "funding": [ { @@ -3686,13 +3636,235 @@ "type": "github" } ], - "time": "2024-03-03T12:36:25+00:00" + "time": "2025-11-17T20:03:58+00:00" + }, + { + "name": "wordpress/anthropic-ai-provider", + "version": "dev-provider-implementation", + "source": { + "type": "git", + "url": "git@github.com:WordPress/anthropic-ai-provider.git", + "reference": "405bc59acb5686d1f8e2aac181a51ec4cdf7dcde" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WordPress/anthropic-ai-provider/zipball/405bc59acb5686d1f8e2aac181a51ec4cdf7dcde", + "reference": "405bc59acb5686d1f8e2aac181a51ec4cdf7dcde", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "phpcompatibility/php-compatibility": "dev-develop", + "phpstan/phpstan": "~2.1", + "slevomat/coding-standard": "^8.20", + "squizlabs/php_codesniffer": "^3.7 || ^4.0", + "wordpress/php-ai-client": "^0.4 || dev-trunk" + }, + "suggest": { + "wordpress/php-ai-client": "Required. The core PHP AI Client SDK that this provider extends." + }, + "type": "wordpress-plugin", + "autoload": { + "psr-4": { + "WordPress\\AnthropicAiProvider\\": "src/" + } + }, + "scripts": { + "lint": [ + "@phpcs", + "@phpstan" + ], + "phpcbf": [ + "phpcbf" + ], + "phpcs": [ + "phpcs" + ], + "phpstan": [ + "phpstan analyze --memory-limit=256M" + ] + }, + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "WordPress AI Team", + "homepage": "https://make.wordpress.org/ai/" + } + ], + "description": "Anthropic AI provider for the PHP AI Client SDK. Works as both a Composer package and WordPress plugin.", + "homepage": "https://github.com/WordPress/anthropic-ai-provider", + "keywords": [ + "ai", + "anthropic", + "claude", + "llm", + "wordpress" + ], + "support": { + "issues": "https://github.com/WordPress/anthropic-ai-provider/issues", + "source": "https://github.com/WordPress/anthropic-ai-provider" + }, + "time": "2026-02-04T22:20:42+00:00" + }, + { + "name": "wordpress/google-ai-provider", + "version": "dev-provider-implementation", + "source": { + "type": "git", + "url": "git@github.com:WordPress/google-ai-provider.git", + "reference": "afd5f18b88d7b889d5a82684dde66be58ad05eb4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WordPress/google-ai-provider/zipball/afd5f18b88d7b889d5a82684dde66be58ad05eb4", + "reference": "afd5f18b88d7b889d5a82684dde66be58ad05eb4", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "phpcompatibility/php-compatibility": "dev-develop", + "phpstan/phpstan": "~2.1", + "slevomat/coding-standard": "^8.20", + "squizlabs/php_codesniffer": "^3.7 || ^4.0", + "wordpress/php-ai-client": "^0.4 || dev-trunk" + }, + "suggest": { + "wordpress/php-ai-client": "Required. The core PHP AI Client SDK that this provider extends." + }, + "type": "wordpress-plugin", + "autoload": { + "psr-4": { + "WordPress\\GoogleAiProvider\\": "src/" + } + }, + "scripts": { + "lint": [ + "@phpcs", + "@phpstan" + ], + "phpcbf": [ + "phpcbf" + ], + "phpcs": [ + "phpcs" + ], + "phpstan": [ + "phpstan analyze --memory-limit=256M" + ] + }, + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "WordPress AI Team", + "homepage": "https://make.wordpress.org/ai/" + } + ], + "description": "Google AI provider for the PHP AI Client SDK. Works as both a Composer package and WordPress plugin.", + "homepage": "https://github.com/WordPress/google-ai-provider", + "keywords": [ + "ai", + "gemini", + "google", + "llm", + "wordpress" + ], + "support": { + "issues": "https://github.com/WordPress/google-ai-provider/issues", + "source": "https://github.com/WordPress/google-ai-provider" + }, + "time": "2026-02-04T19:20:01+00:00" + }, + { + "name": "wordpress/openai-ai-provider", + "version": "dev-provider-implementation", + "source": { + "type": "git", + "url": "git@github.com:WordPress/openai-ai-provider.git", + "reference": "841ef4a39b96f88b7b2aa6fb8f87c9b2a49edee5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WordPress/openai-ai-provider/zipball/841ef4a39b96f88b7b2aa6fb8f87c9b2a49edee5", + "reference": "841ef4a39b96f88b7b2aa6fb8f87c9b2a49edee5", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "phpcompatibility/php-compatibility": "dev-develop", + "phpstan/phpstan": "~2.1", + "slevomat/coding-standard": "^8.20", + "squizlabs/php_codesniffer": "^3.7 || ^4.0", + "wordpress/php-ai-client": "^0.4 || dev-trunk" + }, + "suggest": { + "wordpress/php-ai-client": "Required. The core PHP AI Client SDK that this provider extends." + }, + "type": "wordpress-plugin", + "autoload": { + "psr-4": { + "WordPress\\OpenAiAiProvider\\": "src/" + } + }, + "scripts": { + "lint": [ + "@phpcs", + "@phpstan" + ], + "phpcbf": [ + "phpcbf" + ], + "phpcs": [ + "phpcs" + ], + "phpstan": [ + "phpstan analyze --memory-limit=256M" + ] + }, + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "WordPress AI Team", + "homepage": "https://make.wordpress.org/ai/" + } + ], + "description": "OpenAI provider for the PHP AI Client SDK. Works as both a Composer package and WordPress plugin.", + "homepage": "https://github.com/WordPress/openai-ai-provider", + "keywords": [ + "ai", + "gpt", + "llm", + "openai", + "wordpress" + ], + "support": { + "issues": "https://github.com/WordPress/openai-ai-provider/issues", + "source": "https://github.com/WordPress/openai-ai-provider" + }, + "time": "2026-02-04T22:23:27+00:00" } ], "aliases": [], "minimum-stability": "dev", "stability-flags": { - "phpcompatibility/php-compatibility": 20 + "phpcompatibility/php-compatibility": 20, + "wordpress/anthropic-ai-provider": 20, + "wordpress/google-ai-provider": 20, + "wordpress/openai-ai-provider": 20 }, "prefer-stable": true, "prefer-lowest": false, diff --git a/src/AiClient.php b/src/AiClient.php index c30508b7..00b235bf 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -9,9 +9,6 @@ use WordPress\AiClient\Builders\PromptBuilder; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Common\Exception\RuntimeException; -use WordPress\AiClient\ProviderImplementations\Anthropic\AnthropicProvider; -use WordPress\AiClient\ProviderImplementations\Google\GoogleProvider; -use WordPress\AiClient\ProviderImplementations\OpenAi\OpenAiProvider; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\Contracts\ProviderInterface; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; @@ -116,14 +113,7 @@ class AiClient public static function defaultRegistry(): ProviderRegistry { if (self::$defaultRegistry === null) { - $registry = new ProviderRegistry(); - - // Register built-in providers. - $registry->registerProvider(AnthropicProvider::class); - $registry->registerProvider(GoogleProvider::class); - $registry->registerProvider(OpenAiProvider::class); - - self::$defaultRegistry = $registry; + self::$defaultRegistry = new ProviderRegistry(); } return self::$defaultRegistry; diff --git a/src/ProviderImplementations/Anthropic/AnthropicApiKeyRequestAuthentication.php b/src/ProviderImplementations/Anthropic/AnthropicApiKeyRequestAuthentication.php deleted file mode 100644 index 57a93cd0..00000000 --- a/src/ProviderImplementations/Anthropic/AnthropicApiKeyRequestAuthentication.php +++ /dev/null @@ -1,32 +0,0 @@ -withHeader('anthropic-version', self::ANTHROPIC_API_VERSION); - - // Add the API key to the request headers. - return $request->withHeader('x-api-key', $this->apiKey); - } -} diff --git a/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php b/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php deleted file mode 100644 index 2c0bb111..00000000 --- a/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php +++ /dev/null @@ -1,236 +0,0 @@ - - * } - */ -class AnthropicModelMetadataDirectory extends AbstractOpenAiCompatibleModelMetadataDirectory -{ - /** - * {@inheritDoc} - * - * @since 0.1.0 - */ - public function getRequestAuthentication(): RequestAuthenticationInterface - { - /* - * Since we're calling the Anthropic API here, we need to use the Anthropic specific - * API key authentication class. - */ - $requestAuthentication = parent::getRequestAuthentication(); - if (!$requestAuthentication instanceof ApiKeyRequestAuthentication) { - return $requestAuthentication; - } - return new AnthropicApiKeyRequestAuthentication($requestAuthentication->getApiKey()); - } - - /** - * {@inheritDoc} - * - * @since 0.1.0 - */ - protected function createRequest(HttpMethodEnum $method, string $path, array $headers = [], $data = null): Request - { - return new Request( - $method, - AnthropicProvider::url($path), - $headers, - $data - ); - } - - /** - * {@inheritDoc} - * - * @since 0.1.0 - */ - protected function parseResponseToModelMetadataList(Response $response): array - { - /** @var ModelsResponseData $responseData */ - $responseData = $response->getData(); - if (!isset($responseData['data']) || !$responseData['data']) { - throw ResponseException::fromMissingData('Anthropic', 'data'); - } - - // Unfortunately, the Anthropic API does not return model capabilities, so we have to hardcode them here. - $anthropicCapabilities = [ - CapabilityEnum::textGeneration(), - CapabilityEnum::chatHistory(), - ]; - $anthropicOptions = [ - new SupportedOption(OptionEnum::systemInstruction()), - new SupportedOption(OptionEnum::maxTokens()), - new SupportedOption(OptionEnum::temperature()), - new SupportedOption(OptionEnum::topP()), - new SupportedOption(OptionEnum::topK()), - new SupportedOption(OptionEnum::stopSequences()), - new SupportedOption(OptionEnum::outputMimeType(), ['text/plain', 'application/json']), - new SupportedOption(OptionEnum::outputSchema()), - new SupportedOption(OptionEnum::functionDeclarations()), - new SupportedOption(OptionEnum::customOptions()), - new SupportedOption( - OptionEnum::inputModalities(), - [ - [ModalityEnum::text()], - [ModalityEnum::text(), ModalityEnum::image()], - [ModalityEnum::text(), ModalityEnum::document()], - [ModalityEnum::text(), ModalityEnum::image(), ModalityEnum::document()], - ] - ), - new SupportedOption(OptionEnum::outputModalities(), [[ModalityEnum::text()]]), - ]; - $anthropicWebSearchOptions = array_merge($anthropicOptions, [ - new SupportedOption(OptionEnum::webSearch()), - ]); - - $modelsData = (array) $responseData['data']; - - $models = array_values( - array_map( - static function (array $modelData) use ( - $anthropicCapabilities, - $anthropicOptions, - $anthropicWebSearchOptions - ): ModelMetadata { - $modelId = $modelData['id']; - $modelCaps = $anthropicCapabilities; - if (!preg_match('/^claude-3-[a-z]+/', $modelId)) { - // Only models newer than Claude 3 support web search. - $modelOptions = $anthropicWebSearchOptions; - } else { - $modelOptions = $anthropicOptions; - } - - $modelName = $modelData['display_name'] ?? $modelId; - - return new ModelMetadata( - $modelId, - $modelName, - $modelCaps, - $modelOptions - ); - }, - $modelsData - ) - ); - - usort($models, [$this, 'modelSortCallback']); - - return $models; - } - - /** - * Callback function for sorting models by ID, to be used with `usort()`. - * - * This method expresses preferences for certain models or model families within the provider by putting them - * earlier in the sorted list. The objective is not to be opinionated about which models are better, but to ensure - * that more commonly used, more recent, or flagship models are presented first to users. - * - * @since 0.2.1 - * - * @param ModelMetadata $a First model. - * @param ModelMetadata $b Second model. - * @return int Comparison result. - */ - protected function modelSortCallback(ModelMetadata $a, ModelMetadata $b): int - { - $aId = $a->getId(); - $bId = $b->getId(); - - // Prefer Claude models over non-Claude models. - if (str_starts_with($aId, 'claude-') && !str_starts_with($bId, 'claude-')) { - return -1; - } - if (str_starts_with($bId, 'claude-') && !str_starts_with($aId, 'claude-')) { - return 1; - } - - /* - * Prefer Claude models where the version number isn't the second segment (e.g. 'claude-sonnet-4') - * over those where it is (e.g. 'claude-2', 'claude-3-5-sonnet'). The latter is only used for older models. - */ - if (!preg_match('/^claude-\d/', $aId) && preg_match('/^claude-\d/', $bId)) { - return -1; - } - if (!preg_match('/^claude-\d/', $bId) && preg_match('/^claude-\d/', $aId)) { - return 1; - } - - /* - * Prefer Claude models with type and version number (e.g. 'claude-sonnet-4', 'claude-sonnet-4-5-20250929') - * over those without. An optional date suffix may also be present. - */ - $aMatch = preg_match('/^claude-([a-z]+)-(\d(-\d)?)(-[0-9]+)?$/', $aId, $aMatches); - $bMatch = preg_match('/^claude-([a-z]+)-(\d(-\d)?)(-[0-9]+)?$/', $bId, $bMatches); - if ($aMatch && !$bMatch) { - return -1; - } - if ($bMatch && !$aMatch) { - return 1; - } - if ($aMatch && $bMatch) { - // Prefer later model versions. - $aVersion = str_replace('-', '.', $aMatches[2]); - $bVersion = str_replace('-', '.', $bMatches[2]); - if (version_compare($aVersion, $bVersion, '>')) { - return -1; - } - if (version_compare($bVersion, $aVersion, '>')) { - return 1; - } - - // Prefer models without a suffix (i.e. base models) over those with a suffix. - if (!isset($aMatches[4]) && isset($bMatches[4])) { - return -1; - } - if (!isset($bMatches[4]) && isset($aMatches[4])) { - return 1; - } - - // Prefer 'sonnet' models over other types. - if ($aMatches[1] === 'sonnet' && $bMatches[1] !== 'sonnet') { - return -1; - } - if ($bMatches[1] === 'sonnet' && $aMatches[1] !== 'sonnet') { - return 1; - } - - // Prefer later release dates. - if (isset($aMatches[4]) && isset($bMatches[4])) { - $aDate = (int) substr($aMatches[4], 1); - $bDate = (int) substr($bMatches[4], 1); - if ($aDate > $bDate) { - return -1; - } - if ($bDate > $aDate) { - return 1; - } - } - } - - // Fallback: Sort alphabetically. - return strcmp($a->getId(), $b->getId()); - } -} diff --git a/src/ProviderImplementations/Anthropic/AnthropicProvider.php b/src/ProviderImplementations/Anthropic/AnthropicProvider.php deleted file mode 100644 index 4d21b967..00000000 --- a/src/ProviderImplementations/Anthropic/AnthropicProvider.php +++ /dev/null @@ -1,94 +0,0 @@ -getSupportedCapabilities(); - foreach ($capabilities as $capability) { - if ($capability->isTextGeneration()) { - return new AnthropicTextGenerationModel($modelMetadata, $providerMetadata); - } - } - - throw new RuntimeException( - 'Unsupported model capabilities: ' . implode(', ', $capabilities) - ); - } - - /** - * {@inheritDoc} - * - * @since 0.1.0 - */ - protected static function createProviderMetadata(): ProviderMetadata - { - return new ProviderMetadata( - 'anthropic', - 'Anthropic', - ProviderTypeEnum::cloud(), - 'https://console.anthropic.com/settings/keys', - RequestAuthenticationMethod::apiKey() - ); - } - - /** - * {@inheritDoc} - * - * @since 0.1.0 - */ - protected static function createProviderAvailability(): ProviderAvailabilityInterface - { - // Check valid API access by attempting to list models. - return new ListModelsApiBasedProviderAvailability( - static::modelMetadataDirectory() - ); - } - - /** - * {@inheritDoc} - * - * @since 0.1.0 - */ - protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface - { - return new AnthropicModelMetadataDirectory(); - } -} diff --git a/src/ProviderImplementations/Anthropic/AnthropicTextGenerationModel.php b/src/ProviderImplementations/Anthropic/AnthropicTextGenerationModel.php deleted file mode 100644 index 0a4952f0..00000000 --- a/src/ProviderImplementations/Anthropic/AnthropicTextGenerationModel.php +++ /dev/null @@ -1,586 +0,0 @@ ->, - * stop_reason?: string, - * usage?: UsageData - * } - */ -class AnthropicTextGenerationModel extends AbstractApiBasedModel implements TextGenerationModelInterface -{ - /** - * {@inheritDoc} - * - * @since 0.4.0 - */ - public function getRequestAuthentication(): RequestAuthenticationInterface - { - /* - * Since we're calling the Anthropic API here, we need to use the Anthropic specific - * API key authentication class. - */ - $requestAuthentication = parent::getRequestAuthentication(); - if (!$requestAuthentication instanceof ApiKeyRequestAuthentication) { - return $requestAuthentication; - } - return new AnthropicApiKeyRequestAuthentication($requestAuthentication->getApiKey()); - } - - /** - * {@inheritDoc} - * - * @since 0.4.0 - */ - final public function generateTextResult(array $prompt): GenerativeAiResult - { - $httpTransporter = $this->getHttpTransporter(); - - $params = $this->prepareGenerateTextParams($prompt); - - $headers = ['Content-Type' => 'application/json']; - - // Add beta header for structured outputs if JSON schema output is requested. - $config = $this->getConfig(); - if ('application/json' === $config->getOutputMimeType() && $config->getOutputSchema()) { - $headers['anthropic-beta'] = 'structured-outputs-2025-11-13'; - } - - $request = new Request( - HttpMethodEnum::POST(), - AnthropicProvider::url('messages'), - $headers, - $params, - $this->getRequestOptions() - ); - - // Add authentication credentials to the request. - $request = $this->getRequestAuthentication()->authenticateRequest($request); - - // Send and process the request. - $response = $httpTransporter->send($request); - ResponseUtil::throwIfNotSuccessful($response); - return $this->parseResponseToGenerativeAiResult($response); - } - - /** - * Prepares the given prompt and the model configuration into parameters for the API request. - * - * @since 0.4.0 - * - * @param list $prompt The prompt to generate text for. Either a single message or a list of messages - * from a chat. - * @return array The parameters for the API request. - */ - protected function prepareGenerateTextParams(array $prompt): array - { - $config = $this->getConfig(); - - $params = [ - 'model' => $this->metadata()->getId(), - 'messages' => $this->prepareMessagesParam($prompt), - ]; - - $systemInstruction = $config->getSystemInstruction(); - if ($systemInstruction) { - $params['system'] = $systemInstruction; - } - - $maxTokens = $config->getMaxTokens(); - if ($maxTokens !== null) { - $params['max_tokens'] = $maxTokens; - } else { - // The 'max_tokens' parameter is required in the Anthropic API, so we need a default. - $params['max_tokens'] = 4096; - } - - $temperature = $config->getTemperature(); - if ($temperature !== null) { - $params['temperature'] = $temperature; - } - - $topP = $config->getTopP(); - if ($topP !== null) { - $params['top_p'] = $topP; - } - - $topK = $config->getTopK(); - if ($topK !== null) { - $params['top_k'] = $topK; - } - - $stopSequences = $config->getStopSequences(); - if (is_array($stopSequences)) { - $params['stop_sequences'] = $stopSequences; - } - - $outputMimeType = $config->getOutputMimeType(); - $outputSchema = $config->getOutputSchema(); - if ($outputMimeType === 'application/json' && $outputSchema) { - $params['output_format'] = [ - 'type' => 'json_schema', - 'schema' => $outputSchema, - ]; - } - - $functionDeclarations = $config->getFunctionDeclarations(); - $webSearch = $config->getWebSearch(); - if (is_array($functionDeclarations) || $webSearch) { - $params['tools'] = $this->prepareToolsParam($functionDeclarations, $webSearch); - } - - /* - * Any custom options are added to the parameters as well. - * This allows developers to pass other options that may be more niche or not yet supported by the SDK. - */ - $customOptions = $config->getCustomOptions(); - foreach ($customOptions as $key => $value) { - if (isset($params[$key])) { - throw new InvalidArgumentException( - sprintf( - 'The custom option "%s" conflicts with an existing parameter.', - $key - ) - ); - } - $params[$key] = $value; - } - - return $params; - } - - /** - * Prepares the messages parameter for the API request. - * - * @since 0.4.0 - * - * @param list $messages The messages to prepare. - * @return list> The prepared messages parameter. - */ - protected function prepareMessagesParam(array $messages): array - { - return array_map( - function (Message $message): array { - return [ - 'role' => $this->getMessageRoleString($message->getRole()), - 'content' => array_values(array_filter(array_map( - [$this, 'getMessagePartData'], - $message->getParts() - ))), - ]; - }, - $messages - ); - } - - /** - * Returns the Anthropic API specific role string for the given message role. - * - * @since 0.4.0 - * - * @param MessageRoleEnum $role The message role. - * @return string The role for the API request. - */ - protected function getMessageRoleString(MessageRoleEnum $role): string - { - if ($role === MessageRoleEnum::model()) { - return 'assistant'; - } - return 'user'; - } - - /** - * Returns the Anthropic API specific data for a message part. - * - * @since 0.4.0 - * - * @param MessagePart $part The message part to get the data for. - * @return ?array The data for the message part, or null if not applicable. - * @throws InvalidArgumentException If the message part type or data is unsupported. - */ - protected function getMessagePartData(MessagePart $part): ?array - { - $type = $part->getType(); - if ($type->isText()) { - if ($part->getChannel()->isThought()) { - return [ - 'type' => 'thinking', - 'thinking' => $part->getText(), - ]; - } - return [ - 'type' => 'text', - 'text' => $part->getText(), - ]; - } - if ($type->isFile()) { - $file = $part->getFile(); - if (!$file) { - // This should be impossible due to class internals, but still needs to be checked. - throw new RuntimeException( - 'The file typed message part must contain a file.' - ); - } - if ($file->isRemote()) { - $fileUrl = $file->getUrl(); - if (!$fileUrl) { - throw new RuntimeException( - 'The remote file must contain a URL.' - ); - } - if ($file->isDocument()) { - return [ - 'type' => 'document', - 'source' => [ - 'type' => 'url', - 'url' => $fileUrl, - ], - ]; - } - throw new InvalidArgumentException( - 'Unsupported file type: The API only supports inline files for non-document types.' - ); - } - // Else, it is an inline file. - $fileBase64Data = $file->getBase64Data(); - if (!$fileBase64Data) { - // This should be impossible due to class internals, but still needs to be checked. - throw new RuntimeException( - 'The inline file must contain base64 data.' - ); - } - if ($file->isImage()) { - return [ - 'type' => 'image', - 'source' => array( - 'type' => 'base64', - 'media_type' => $file->getMimeType(), - 'data' => $fileBase64Data, - ), - ]; - } - if ($file->isDocument()) { - return [ - 'type' => 'document', - 'source' => [ - 'type' => 'base64', - 'media_type' => $file->getMimeType(), - 'data' => $fileBase64Data, - ], - ]; - } - throw new InvalidArgumentException( - sprintf( - 'Unsupported MIME type "%s" for inline file message part.', - $file->getMimeType() - ) - ); - } - if ($type->isFunctionCall()) { - $functionCall = $part->getFunctionCall(); - if (!$functionCall) { - // This should be impossible due to class internals, but still needs to be checked. - throw new RuntimeException( - 'The function_call typed message part must contain a function call.' - ); - } - // Ensure null becomes empty object for Anthropic's API which expects an object. - $input = $functionCall->getArgs(); - if ($input === null) { - $input = new \stdClass(); - } - return [ - 'type' => 'tool_use', - 'id' => $functionCall->getId(), - 'name' => $functionCall->getName(), - 'input' => $input, - ]; - } - if ($type->isFunctionResponse()) { - $functionResponse = $part->getFunctionResponse(); - if (!$functionResponse) { - // This should be impossible due to class internals, but still needs to be checked. - throw new RuntimeException( - 'The function_response typed message part must contain a function response.' - ); - } - return [ - 'type' => 'tool_result', - 'tool_use_id' => $functionResponse->getId(), - 'content' => json_encode($functionResponse->getResponse()), - ]; - } - throw new InvalidArgumentException( - sprintf( - 'Unsupported message part type "%s".', - $type - ) - ); - } - - /** - * Prepares the tools parameter for the API request. - * - * @since 0.4.0 - * - * @param list|null $functionDeclarations The function declarations, or null if none. - * @param WebSearch|null $webSearch The web search config, or null if none. - * @return list> The prepared tools parameter. - */ - protected function prepareToolsParam(?array $functionDeclarations, ?WebSearch $webSearch): array - { - $tools = []; - - if (is_array($functionDeclarations)) { - foreach ($functionDeclarations as $functionDeclaration) { - /* - * Anthropic requires input_schema to always be present, even for - * functions with no parameters. Use an empty object schema in that case. - */ - $inputSchema = $functionDeclaration->getParameters(); - if ($inputSchema === null) { - $inputSchema = [ - 'type' => 'object', - 'properties' => new \stdClass(), - ]; - } - - $tools[] = array_filter([ - 'name' => $functionDeclaration->getName(), - 'description' => $functionDeclaration->getDescription(), - 'input_schema' => $inputSchema, - ]); - } - } - - if ($webSearch) { - $tools[] = array_filter([ - 'type' => 'web_search_20250305', - 'name' => 'web_search', - 'max_uses' => 1, - 'allowed_domains' => $webSearch->getAllowedDomains(), - 'blocked_domains' => $webSearch->getDisallowedDomains(), - ]); - } - - return $tools; - } - - /** - * Parses the response from the API endpoint to a generative AI result. - * - * @since 0.4.0 - * - * @param Response $response The response from the API endpoint. - * @return GenerativeAiResult The parsed generative AI result. - */ - protected function parseResponseToGenerativeAiResult(Response $response): GenerativeAiResult - { - /** @var ResponseData $responseData */ - $responseData = $response->getData(); - if (!isset($responseData['content']) || !$responseData['content']) { - throw ResponseException::fromMissingData($this->providerMetadata()->getName(), 'content'); - } - if (!is_array($responseData['content']) || !array_is_list($responseData['content'])) { - throw ResponseException::fromInvalidData( - $this->providerMetadata()->getName(), - 'content', - 'The value must be an indexed array.' - ); - } - - $role = isset($responseData['role']) && 'user' === $responseData['role'] - ? MessageRoleEnum::user() - : MessageRoleEnum::model(); - - $parts = []; - foreach ($responseData['content'] as $partIndex => $messagePartData) { - try { - $newPart = $this->parseResponseContentMessagePart($messagePartData); - if ($newPart) { - $parts[] = $newPart; - } - } catch (InvalidArgumentException $e) { - throw ResponseException::fromInvalidData( - $this->providerMetadata()->getName(), - "content[{$partIndex}]", - $e->getMessage() - ); - } - } - - if (!isset($responseData['stop_reason'])) { - throw ResponseException::fromMissingData( - $this->providerMetadata()->getName(), - 'stop_reason' - ); - } - - switch ($responseData['stop_reason']) { - case 'pause_turn': - case 'end_turn': - case 'stop_sequence': - $finishReason = FinishReasonEnum::stop(); - break; - case 'max_tokens': - case 'model_context_window_exceeded': - $finishReason = FinishReasonEnum::length(); - break; - case 'refusal': - $finishReason = FinishReasonEnum::contentFilter(); - break; - case 'tool_use': - $finishReason = FinishReasonEnum::toolCalls(); - break; - default: - throw ResponseException::fromInvalidData( - $this->providerMetadata()->getName(), - 'stop_reason', - sprintf('Invalid stop reason "%s".', $responseData['stop_reason']) - ); - } - - $candidates = [new Candidate( - new Message($role, $parts), - $finishReason - )]; - - $id = isset($responseData['id']) && is_string($responseData['id']) ? $responseData['id'] : ''; - - if (isset($responseData['usage']) && is_array($responseData['usage'])) { - $usage = $responseData['usage']; - $inputTokens = ($usage['input_tokens'] ?? 0) + - ($usage['cache_creation_input_tokens'] ?? 0) + - ($usage['cache_read_input_tokens'] ?? 0); - - $tokenUsage = new TokenUsage( - $inputTokens, - $usage['output_tokens'] ?? 0, - $inputTokens + ($usage['output_tokens'] ?? 0) - ); - } else { - $tokenUsage = new TokenUsage(0, 0, 0); - } - - // Use any other data from the response as provider-specific response metadata. - $additionalData = $responseData; - unset( - $additionalData['id'], - $additionalData['role'], - $additionalData['content'], - $additionalData['stop_reason'], - $additionalData['usage'] - ); - - return new GenerativeAiResult( - $id, - $candidates, - $tokenUsage, - $this->providerMetadata(), - $this->metadata(), - $additionalData - ); - } - - /** - * Parses a message part from the content in the API response. - * - * @since 0.4.0 - * - * @param array $partData The message part data from the API response. - * @return MessagePart|null The parsed message part, or null to ignore. - */ - protected function parseResponseContentMessagePart(array $partData): ?MessagePart - { - if (!isset($partData['type'])) { - throw new InvalidArgumentException('Part is missing a type field.'); - } - - switch ($partData['type']) { - case 'text': - if (!isset($partData['text']) || !is_string($partData['text'])) { - throw new InvalidArgumentException('Part has an invalid text shape.'); - } - return new MessagePart($partData['text']); - case 'thinking': - if (!isset($partData['thinking']) || !is_string($partData['thinking'])) { - throw new InvalidArgumentException('Part has an invalid thinking shape.'); - } - return new MessagePart($partData['thinking'], MessagePartChannelEnum::thought()); - case 'tool_use': - if ( - !isset($partData['id']) || - !is_string($partData['id']) || - !isset($partData['name']) || - !is_string($partData['name']) || - !isset($partData['input']) - ) { - throw new InvalidArgumentException('Part has an invalid tool_use shape.'); - } - /* - * Normalize empty object/array to null. - * Anthropic returns `input: {}` for functions with no arguments, - * which becomes an empty array after json_decode. Semantically, - * an empty object means "no arguments". - */ - $args = $partData['input']; - if (is_array($args) && count($args) === 0) { - $args = null; - } - return new MessagePart( - new FunctionCall( - $partData['id'], - $partData['name'], - $args - ) - ); - case 'redacted_thinking': - case 'server_tool_use': - case 'web_search_tool_result': - // No special handling for now. These can be ignored for now. - return null; - } - - throw new InvalidArgumentException('Part has an unexpected type.'); - } -} diff --git a/src/ProviderImplementations/Google/GoogleApiKeyRequestAuthentication.php b/src/ProviderImplementations/Google/GoogleApiKeyRequestAuthentication.php deleted file mode 100644 index 38f5a2b0..00000000 --- a/src/ProviderImplementations/Google/GoogleApiKeyRequestAuthentication.php +++ /dev/null @@ -1,30 +0,0 @@ -withHeader('X-Goog-Api-Key', $this->apiKey); - } -} diff --git a/src/ProviderImplementations/Google/GoogleImageGenerationModel.php b/src/ProviderImplementations/Google/GoogleImageGenerationModel.php deleted file mode 100644 index c7bfc436..00000000 --- a/src/ProviderImplementations/Google/GoogleImageGenerationModel.php +++ /dev/null @@ -1,376 +0,0 @@ - - * } - * @phpstan-type RequestParams array{ - * instances: list, - * parameters: array{sampleCount: int, outputOptions?: array{mimeType: string}}, - * aspectRatio?: string, - * ... - * } - */ -class GoogleImageGenerationModel extends AbstractApiBasedModel implements ImageGenerationModelInterface -{ - /** - * {@inheritDoc} - * - * @since 0.4.0 - */ - public function getRequestAuthentication(): RequestAuthenticationInterface - { - /* - * Since we're calling the Google API here, we need to use the Google specific - * API key authentication class. - */ - $requestAuthentication = parent::getRequestAuthentication(); - if (!$requestAuthentication instanceof ApiKeyRequestAuthentication) { - return $requestAuthentication; - } - return new GoogleApiKeyRequestAuthentication($requestAuthentication->getApiKey()); - } - - /** - * {@inheritDoc} - * - * @since 0.4.0 - */ - public function generateImageResult(array $prompt): GenerativeAiResult - { - /* - * Gemini models that can generate images are multimodal and therefore - * go through the more flexible `generateContent` endpoint, which is - * used by the `GoogleTextGenerationModel` class. - */ - if (str_starts_with($this->metadata()->getId(), 'gemini-')) { - $multimodalOutputModel = new GoogleTextGenerationModel($this->metadata(), $this->providerMetadata()); - $multimodalOutputModel->setConfig($this->getConfig()); - $multimodalOutputModel->setHttpTransporter($this->getHttpTransporter()); - $multimodalOutputModel->setRequestAuthentication($this->getRequestAuthentication()); - $requestOptions = $this->getRequestOptions(); - if ($requestOptions) { - $multimodalOutputModel->setRequestOptions($requestOptions); - } - return $multimodalOutputModel->generateTextResult($prompt); - } - - $httpTransporter = $this->getHttpTransporter(); - - $params = $this->prepareGenerateImageParams($prompt); - - $request = new Request( - HttpMethodEnum::POST(), - GoogleProvider::url("models/{$this->metadata()->getId()}:predict"), - ['Content-Type' => 'application/json'], - $params, - $this->getRequestOptions() - ); - - // Add authentication credentials to the request. - $request = $this->getRequestAuthentication()->authenticateRequest($request); - - // Send and process the request. - $response = $httpTransporter->send($request); - ResponseUtil::throwIfNotSuccessful($response); - return $this->parseResponseToGenerativeAiResult( - $response, - $params['parameters']['outputOptions']['mimeType'] ?? 'image/png' - ); - } - - /** - * Prepares the given prompt and the model configuration into parameters for the API request. - * - * @since 0.4.0 - * - * @param list $prompt The prompt to generate an image for. Either a single message or a list of messages - * from a chat. However as of today, Google image generation endpoints only support a - * single user message. - * @return RequestParams The parameters for the API request. - */ - protected function prepareGenerateImageParams(array $prompt): array - { - $config = $this->getConfig(); - - $params = [ - 'instances' => [ - ['prompt' => $this->preparePromptParam($prompt)], - ], - 'parameters' => ['sampleCount' => 1], - ]; - - $candidateCount = $config->getCandidateCount(); - if ($candidateCount !== null) { - $params['parameters']['sampleCount'] = $candidateCount; - } - - if ($config->getOutputFileType() && $config->getOutputFileType()->isRemote()) { - throw new InvalidArgumentException( - 'Unsupported output file type: Only inline is supported.' - ); - } - - $outputMimeType = $config->getOutputMimeType(); - if ($outputMimeType !== null) { - $params['parameters']['outputOptions'] = ['mimeType' => $outputMimeType]; - } - - $outputMediaOrientation = $config->getOutputMediaOrientation(); - $outputMediaAspectRatio = $config->getOutputMediaAspectRatio(); - if ($outputMediaOrientation !== null || $outputMediaAspectRatio !== null) { - $params['aspectRatio'] = $this->prepareAspectRatioParam($outputMediaOrientation, $outputMediaAspectRatio); - } - - return $this->applyCustomOptions($params, $config->getCustomOptions()); - } - - /** - * Prepares the prompt parameter for the API request. - * - * @since 0.4.0 - * - * @param list $messages The messages to prepare. However as of today, Google image generation endpoints - * only support a single user message. - * @return string The prepared prompt parameter. - */ - protected function preparePromptParam(array $messages): string - { - if (count($messages) !== 1) { - throw new InvalidArgumentException( - 'The API requires a single user message as prompt.' - ); - } - $message = $messages[0]; - if (!$message->getRole()->isUser()) { - throw new InvalidArgumentException( - 'The API requires a user message as prompt.' - ); - } - - $text = null; - foreach ($message->getParts() as $part) { - $text = $part->getText(); - if ($text !== null) { - break; - } - } - - if ($text === null) { - throw new InvalidArgumentException( - 'The API requires a single text message part as prompt.' - ); - } - - return $text; - } - - /** - * Prepares the aspect ratio parameter for the API request. - * - * @since 0.4.0 - * - * @param MediaOrientationEnum|null $orientation The desired media orientation. - * @param string|null $aspectRatio The desired media aspect ratio. - * @return string The prepared aspect ratio parameter. - */ - protected function prepareAspectRatioParam(?MediaOrientationEnum $orientation, ?string $aspectRatio): string - { - // Use aspect ratio if set, as it is more specific. - if ($aspectRatio !== null) { - return $aspectRatio; - } - - // This should always have a value, as the method is only called if at least one or the other is set. - if ($orientation !== null) { - if ($orientation->isLandscape()) { - return '16:9'; - } - if ($orientation->isPortrait()) { - return '9:16'; - } - } - return '1:1'; - } - - /** - * Applies custom options to the given parameters array. - * - * This allows developers to pass options that may be more niche or not yet supported by the SDK. - * Custom options with a `parameters.` prefix are added nested within the `parameters` key. - * - * @since 0.4.0 - * - * @template T of array - * @param T $params The base parameters. - * @param array $customOptions The custom options to apply. - * @return T The parameters with custom options applied. - */ - private function applyCustomOptions(array $params, array $customOptions): array - { - foreach ($customOptions as $key => $value) { - // Special case: Support custom values as part of `parameters`. - if (str_starts_with($key, 'parameters.')) { - $key = substr($key, strlen('parameters.')); - if (!isset($params['parameters']) || !is_array($params['parameters'])) { - $params['parameters'] = [$key => $value]; - continue; - } - if (isset($params['parameters'][$key])) { - throw new InvalidArgumentException( - sprintf( - 'The custom parameters option "%s" conflicts with an existing parameter.', - $key - ) - ); - } - $params['parameters'][$key] = $value; - continue; - } - - if (isset($params[$key])) { - throw new InvalidArgumentException( - sprintf( - 'The custom option "%s" conflicts with an existing parameter.', - $key - ) - ); - } - $params[$key] = $value; - } - - /** @var T */ - return $params; - } - - /** - * Parses the response from the API endpoint to a generative AI result. - * - * @since 0.4.0 - * - * @param Response $response The response from the API endpoint. - * @param string $expectedMimeType The expected MIME type the response is in. - * @return GenerativeAiResult The parsed generative AI result. - */ - protected function parseResponseToGenerativeAiResult( - Response $response, - string $expectedMimeType = 'image/png' - ): GenerativeAiResult { - /** @var ResponseData $responseData */ - $responseData = $response->getData(); - if (!isset($responseData['predictions']) || !$responseData['predictions']) { - throw ResponseException::fromMissingData($this->providerMetadata()->getName(), 'predictions'); - } - if (!is_array($responseData['predictions'])) { - throw ResponseException::fromInvalidData( - $this->providerMetadata()->getName(), - 'predictions', - 'The value must be an array.' - ); - } - - $candidates = []; - foreach ($responseData['predictions'] as $index => $predictionData) { - if (!is_array($predictionData) || array_is_list($predictionData)) { - throw ResponseException::fromInvalidData( - $this->providerMetadata()->getName(), - "predictions[{$index}]", - 'The value must be an associative array.' - ); - } - - $candidates[] = $this->parseResponsePredictionToCandidate($predictionData, $index, $expectedMimeType); - } - - $id = isset($responseData['id']) && is_string($responseData['id']) ? $responseData['id'] : ''; - - // Use any other data from the response as provider-specific response metadata. - $providerMetadata = $responseData; - unset($providerMetadata['id'], $providerMetadata['predictions']); - - return new GenerativeAiResult( - $id, - $candidates, - new TokenUsage(0, 0, 0), - $this->providerMetadata(), - $this->metadata(), - $providerMetadata - ); - } - - /** - * Parses a single prediction from the API response into a Candidate object. - * - * @since 0.4.0 - * - * @param PredictionData $predictionData The prediction data from the API response. - * @param int $index The index of the prediction in the predictions array. - * @param string $expectedMimeType The expected MIME type the response is in. - * @return Candidate The parsed candidate. - * @throws RuntimeException If the prediction data is invalid. - */ - protected function parseResponsePredictionToCandidate( - array $predictionData, - int $index, - string $expectedMimeType = 'image/png' - ): Candidate { - $mimeType = isset($predictionData['mimeType']) ? $predictionData['mimeType'] : $expectedMimeType; - - if (isset($predictionData['url']) && is_string($predictionData['url'])) { - $imageFile = new File($predictionData['url'], $mimeType); - } elseif (isset($predictionData['bytesBase64Encoded']) && is_string($predictionData['bytesBase64Encoded'])) { - $imageFile = new File($predictionData['bytesBase64Encoded'], $mimeType); - } else { - throw ResponseException::fromInvalidData( - $this->providerMetadata()->getName(), - "predictions[{$index}]", - 'The value must contain either a url or bytesBase64Encoded key with a string value.' - ); - } - - $parts = [new MessagePart($imageFile)]; - - $message = new Message(MessageRoleEnum::model(), $parts); - - return new Candidate($message, FinishReasonEnum::stop()); - } -} diff --git a/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php b/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php deleted file mode 100644 index 8f7d6a3d..00000000 --- a/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php +++ /dev/null @@ -1,312 +0,0 @@ -, - * displayName?: string - * }> - * } - */ -class GoogleModelMetadataDirectory extends AbstractOpenAiCompatibleModelMetadataDirectory -{ - /** - * {@inheritDoc} - * - * @since 0.1.0 - */ - public function getRequestAuthentication(): RequestAuthenticationInterface - { - /* - * Since we're calling the Google API here, we need to use the Google specific - * API key authentication class. - */ - $requestAuthentication = parent::getRequestAuthentication(); - if (!$requestAuthentication instanceof ApiKeyRequestAuthentication) { - return $requestAuthentication; - } - return new GoogleApiKeyRequestAuthentication($requestAuthentication->getApiKey()); - } - - /** - * {@inheritDoc} - * - * @since 0.1.0 - */ - protected function createRequest(HttpMethodEnum $method, string $path, array $headers = [], $data = null): Request - { - /* - * We don't call Google's OpenAI compatible models endpoint here because it provides fewer details about the - * models than the primary models endpoint. - * For Google's models endpoint, set pageSize=1000 which is the maximum page size. - * This allows us to retrieve all models in one go. - */ - if ($path === 'models' && $data === null) { - $data = ['pageSize' => 1000]; - } - return new Request( - $method, - GoogleProvider::url($path), - $headers, - $data - ); - } - - /** - * {@inheritDoc} - * - * @since 0.1.0 - */ - protected function parseResponseToModelMetadataList(Response $response): array - { - /** @var ModelsResponseData $responseData */ - $responseData = $response->getData(); - if (!isset($responseData['models']) || !$responseData['models']) { - throw ResponseException::fromMissingData('Google', 'models'); - } - - $geminiCapabilities = [ - CapabilityEnum::textGeneration(), - CapabilityEnum::chatHistory(), - ]; - $geminiMultimodalImageOutputCapabilities = [ - CapabilityEnum::textGeneration(), - CapabilityEnum::imageGeneration(), - CapabilityEnum::chatHistory(), - ]; - $geminiBaseOptions = [ - new SupportedOption(OptionEnum::systemInstruction()), - new SupportedOption(OptionEnum::candidateCount()), - new SupportedOption(OptionEnum::maxTokens()), - new SupportedOption(OptionEnum::temperature()), - new SupportedOption(OptionEnum::topP()), - new SupportedOption(OptionEnum::topK()), - new SupportedOption(OptionEnum::stopSequences()), - new SupportedOption(OptionEnum::presencePenalty()), - new SupportedOption(OptionEnum::frequencyPenalty()), - new SupportedOption(OptionEnum::logprobs()), - new SupportedOption(OptionEnum::topLogprobs()), - new SupportedOption(OptionEnum::outputMimeType(), ['text/plain', 'application/json']), - new SupportedOption(OptionEnum::outputSchema()), - new SupportedOption(OptionEnum::functionDeclarations()), - new SupportedOption(OptionEnum::customOptions()), - ]; - $geminiOptions = array_merge($geminiBaseOptions, [ - new SupportedOption( - OptionEnum::inputModalities(), - [ - [ModalityEnum::text()], - [ModalityEnum::text(), ModalityEnum::image()], - [ModalityEnum::text(), ModalityEnum::image(), ModalityEnum::audio()], - ] - ), - new SupportedOption(OptionEnum::outputModalities(), [[ModalityEnum::text()]]), - new SupportedOption(OptionEnum::webSearch()), - ]); - $geminiMultimodalImageOutputOptions = array_merge($geminiBaseOptions, [ - new SupportedOption( - OptionEnum::inputModalities(), - [ - [ModalityEnum::text()], - [ModalityEnum::text(), ModalityEnum::image()], - [ModalityEnum::text(), ModalityEnum::image(), ModalityEnum::audio()], - ] - ), - new SupportedOption( - OptionEnum::outputModalities(), - [ - [ModalityEnum::text()], - [ModalityEnum::text(), ModalityEnum::image()], - ] - ), - ]); - $imagenCapabilities = [ - CapabilityEnum::imageGeneration(), - ]; - $imagenOptions = [ - new SupportedOption(OptionEnum::inputModalities(), [[ModalityEnum::text()]]), - new SupportedOption(OptionEnum::outputModalities(), [[ModalityEnum::image()]]), - new SupportedOption(OptionEnum::candidateCount()), - new SupportedOption(OptionEnum::outputMimeType(), ['image/png', 'image/jpeg', 'image/webp']), - new SupportedOption(OptionEnum::outputFileType(), [FileTypeEnum::inline()]), - new SupportedOption(OptionEnum::outputMediaOrientation(), [ - MediaOrientationEnum::square(), - MediaOrientationEnum::landscape(), - MediaOrientationEnum::portrait(), - ]), - new SupportedOption(OptionEnum::outputMediaAspectRatio(), ['1:1', '16:9', '4:3', '9:16', '3:4']), - new SupportedOption(OptionEnum::customOptions()), - ]; - - $modelsData = (array) $responseData['models']; - - $models = array_values( - array_map( - static function (array $modelData) use ( - $geminiCapabilities, - $geminiMultimodalImageOutputCapabilities, - $geminiOptions, - $geminiMultimodalImageOutputOptions, - $imagenCapabilities, - $imagenOptions - ): ModelMetadata { - $modelId = $modelData['baseModelId'] ?? $modelData['name']; - if (str_starts_with($modelId, 'models/')) { - $modelId = substr($modelId, 7); - } - if ( - isset($modelData['supportedGenerationMethods']) && - is_array($modelData['supportedGenerationMethods']) && - in_array('generateContent', $modelData['supportedGenerationMethods'], true) - ) { - $modelCaps = $geminiCapabilities; - if ( - // Multimodal output models for image generation. - str_ends_with($modelId, '-image') || - str_ends_with($modelId, '-image-preview') || - str_ends_with($modelId, '-image-generation') || - str_starts_with($modelId, 'gemini-2.0-flash-exp') - ) { - $modelCaps = $geminiMultimodalImageOutputCapabilities; - $modelOptions = $geminiMultimodalImageOutputOptions; - } else { - $modelOptions = $geminiOptions; - } - } elseif ( - isset($modelData['supportedGenerationMethods']) && - is_array($modelData['supportedGenerationMethods']) && - in_array('predict', $modelData['supportedGenerationMethods'], true) - ) { - $modelCaps = $imagenCapabilities; - $modelOptions = $imagenOptions; - } else { - $modelCaps = []; - $modelOptions = []; - } - - $modelName = $modelData['displayName'] ?? $modelId; - - return new ModelMetadata( - $modelId, - $modelName, - $modelCaps, - $modelOptions - ); - }, - $modelsData - ) - ); - - usort($models, [$this, 'modelSortCallback']); - - return $models; - } - - /** - * Callback function for sorting models by ID, to be used with `usort()`. - * - * This method expresses preferences for certain models or model families within the provider by putting them - * earlier in the sorted list. The objective is not to be opinionated about which models are better, but to ensure - * that more commonly used, more recent, or flagship models are presented first to users. - * - * @since 0.2.1 - * - * @param ModelMetadata $a First model. - * @param ModelMetadata $b Second model. - * @return int Comparison result. - */ - protected function modelSortCallback(ModelMetadata $a, ModelMetadata $b): int - { - $aId = $a->getId(); - $bId = $b->getId(); - - // Prefer non-experimental models over experimental models. - if (str_contains($aId, '-exp') && !str_contains($bId, '-exp')) { - return 1; - } - if (str_contains($bId, '-exp') && !str_contains($aId, '-exp')) { - return -1; - } - - // Prefer non-preview models over preview models. - if (str_contains($aId, '-preview') && !str_contains($bId, '-preview')) { - return 1; - } - if (str_contains($bId, '-preview') && !str_contains($aId, '-preview')) { - return -1; - } - - // Prefer Gemini models over non-Gemini models. - if (str_starts_with($aId, 'gemini-') && !str_starts_with($bId, 'gemini-')) { - return -1; - } - if (str_starts_with($bId, 'gemini-') && !str_starts_with($aId, 'gemini-')) { - return 1; - } - - // Prefer Gemini models with version numbers (e.g. 'gemini-2.5', 'gemini-2.0') over those without. - $aMatch = preg_match('/^gemini-([0-9.]+)(-[a-z0-9-]+)$/', $aId, $aMatches); - $bMatch = preg_match('/^gemini-([0-9.]+)(-[a-z0-9-]+)$/', $bId, $bMatches); - if ($aMatch && !$bMatch) { - return -1; - } - if ($bMatch && !$aMatch) { - return 1; - } - if ($aMatch && $bMatch) { - // Prefer later model versions. - $aVersion = $aMatches[1]; - $bVersion = $bMatches[1]; - if (version_compare($aVersion, $bVersion, '>')) { - return -1; - } - if (version_compare($bVersion, $aVersion, '>')) { - return 1; - } - - // Prefer '-pro' models over other suffixes. - if ($aMatches[2] === '-pro' && $bMatches[2] !== '-pro') { - return -1; - } - if ($bMatches[2] === '-pro' && $aMatches[2] !== '-pro') { - return 1; - } - - // Prefer '-flash' models over other suffixes. - if ($aMatches[2] === '-flash' && $bMatches[2] !== '-flash') { - return -1; - } - if ($bMatches[2] === '-flash' && $aMatches[2] !== '-flash') { - return 1; - } - } - - // Fallback: Sort alphabetically. - return strcmp($a->getId(), $b->getId()); - } -} diff --git a/src/ProviderImplementations/Google/GoogleProvider.php b/src/ProviderImplementations/Google/GoogleProvider.php deleted file mode 100644 index 8d3bfae5..00000000 --- a/src/ProviderImplementations/Google/GoogleProvider.php +++ /dev/null @@ -1,109 +0,0 @@ -toArray()[ModelMetadata::KEY_SUPPORTED_CAPABILITIES]; - if (in_array('image_generation', $capabilitiesStringList, true)) { - return new GoogleImageGenerationModel($modelMetadata, $providerMetadata); - } - - $capabilities = $modelMetadata->getSupportedCapabilities(); - foreach ($capabilities as $capability) { - if ($capability->isTextGeneration()) { - return new GoogleTextGenerationModel($modelMetadata, $providerMetadata); - } - if ($capability->isImageGeneration()) { - return new GoogleImageGenerationModel($modelMetadata, $providerMetadata); - } - } - - throw new RuntimeException( - 'Unsupported model capabilities: ' . implode(', ', $capabilities) - ); - } - - /** - * {@inheritDoc} - * - * @since 0.1.0 - */ - protected static function createProviderMetadata(): ProviderMetadata - { - return new ProviderMetadata( - 'google', - 'Google', - ProviderTypeEnum::cloud(), - 'https://aistudio.google.com/app/api-keys', - RequestAuthenticationMethod::apiKey() - ); - } - - /** - * {@inheritDoc} - * - * @since 0.1.0 - */ - protected static function createProviderAvailability(): ProviderAvailabilityInterface - { - // Check valid API access by attempting to list models. - return new ListModelsApiBasedProviderAvailability( - static::modelMetadataDirectory() - ); - } - - /** - * {@inheritDoc} - * - * @since 0.1.0 - */ - protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface - { - return new GoogleModelMetadataDirectory(); - } -} diff --git a/src/ProviderImplementations/Google/GoogleTextGenerationModel.php b/src/ProviderImplementations/Google/GoogleTextGenerationModel.php deleted file mode 100644 index 076a027b..00000000 --- a/src/ProviderImplementations/Google/GoogleTextGenerationModel.php +++ /dev/null @@ -1,767 +0,0 @@ -> - * } - * @phpstan-type CandidateData array{ - * content?: MessageData, - * finishReason?: string - * } - * @phpstan-type UsageData array{ - * promptTokenCount?: int, - * candidatesTokenCount?: int, - * thoughtsTokenCount?: int - * } - * @phpstan-type ResponseData array{ - * id?: string, - * candidates?: list, - * usageMetadata?: UsageData - * } - */ -class GoogleTextGenerationModel extends AbstractApiBasedModel implements TextGenerationModelInterface -{ - /** - * {@inheritDoc} - * - * @since 0.4.0 - */ - public function getRequestAuthentication(): RequestAuthenticationInterface - { - /* - * Since we're calling the Google API here, we need to use the Google specific - * API key authentication class. - */ - $requestAuthentication = parent::getRequestAuthentication(); - if (!$requestAuthentication instanceof ApiKeyRequestAuthentication) { - return $requestAuthentication; - } - return new GoogleApiKeyRequestAuthentication($requestAuthentication->getApiKey()); - } - - /** - * {@inheritDoc} - * - * @since 0.4.0 - */ - final public function generateTextResult(array $prompt): GenerativeAiResult - { - $httpTransporter = $this->getHttpTransporter(); - - $params = $this->prepareGenerateTextParams($prompt); - - $request = new Request( - HttpMethodEnum::POST(), - GoogleProvider::url("models/{$this->metadata()->getId()}:generateContent"), - ['Content-Type' => 'application/json'], - $params, - $this->getRequestOptions() - ); - - // Add authentication credentials to the request. - $request = $this->getRequestAuthentication()->authenticateRequest($request); - - // Send and process the request. - $response = $httpTransporter->send($request); - ResponseUtil::throwIfNotSuccessful($response); - return $this->parseResponseToGenerativeAiResult($response); - } - - /** - * Prepares the given prompt and the model configuration into parameters for the API request. - * - * @since 0.4.0 - * - * @param list $prompt The prompt to generate text for. Either a single message or a list of messages - * from a chat. - * @return array The parameters for the API request. - */ - protected function prepareGenerateTextParams(array $prompt): array - { - $config = $this->getConfig(); - - $params = [ - 'contents' => $this->prepareContentsParam($prompt), - ]; - - $systemInstruction = $config->getSystemInstruction(); - if ($systemInstruction) { - $params['systemInstruction'] = $this->prepareSystemInstructionParam($systemInstruction); - } - - $generationConfig = []; - - $outputModalities = $config->getOutputModalities(); - if (is_array($outputModalities)) { - $generationConfig['responseModalities'] = $this->prepareResponseModalitiesParam($outputModalities); - } - - $candidateCount = $config->getCandidateCount(); - if ($candidateCount !== null) { - $generationConfig['candidateCount'] = $candidateCount; - } - - $maxTokens = $config->getMaxTokens(); - if ($maxTokens !== null) { - $generationConfig['maxOutputTokens'] = $maxTokens; - } - - $temperature = $config->getTemperature(); - if ($temperature !== null) { - $generationConfig['temperature'] = $temperature; - } - - $topP = $config->getTopP(); - if ($topP !== null) { - $generationConfig['topP'] = $topP; - } - - $topK = $config->getTopK(); - if ($topK !== null) { - $generationConfig['topK'] = $topK; - } - - $stopSequences = $config->getStopSequences(); - if (is_array($stopSequences)) { - $generationConfig['stopSequences'] = $stopSequences; - } - - $presencePenalty = $config->getPresencePenalty(); - if ($presencePenalty !== null) { - $generationConfig['presencePenalty'] = $presencePenalty; - } - - $frequencyPenalty = $config->getFrequencyPenalty(); - if ($frequencyPenalty !== null) { - $generationConfig['frequencyPenalty'] = $frequencyPenalty; - } - - $logprobs = $config->getLogprobs(); - if ($logprobs !== null) { - $generationConfig['responseLogprobs'] = $logprobs; - } - - $topLogprobs = $config->getTopLogprobs(); - if ($topLogprobs !== null) { - $generationConfig['logprobs'] = $topLogprobs; - } - - $outputMimeType = $config->getOutputMimeType(); - if ($outputMimeType) { - $generationConfig['responseMimeType'] = $outputMimeType; - if ($outputMimeType === 'application/json') { - $outputSchema = $config->getOutputSchema(); - if ($outputSchema) { - $generationConfig['responseSchema'] = $outputSchema; - } - } - } - - if ($generationConfig) { - $params['generationConfig'] = $generationConfig; - } - - $tools = []; - - $functionDeclarations = $config->getFunctionDeclarations(); - if (is_array($functionDeclarations)) { - $tools[] = [ - 'functionDeclarations' => $this->prepareFunctionDeclarationsParam($functionDeclarations), - ]; - } - - $webSearch = $config->getWebSearch(); - if ($webSearch) { - // Filtering by allowed or disallowed domains is not supported by the Google AI API. - $tools[] = ['googleSearch' => new \stdClass()]; - } - - if ($tools) { - $params['tools'] = $tools; - } - - /* - * Any custom options are added to the parameters as well. - * This allows developers to pass other options that may be more niche or not yet supported by the SDK. - */ - $customOptions = $config->getCustomOptions(); - foreach ($customOptions as $key => $value) { - // Special case: Support custom values as part of `generationConfig`. - if (str_starts_with($key, 'generationConfig.')) { - $key = substr($key, strlen('generationConfig.')); - if (!isset($params['generationConfig']) || !is_array($params['generationConfig'])) { - $params['generationConfig'] = [$key => $value]; - continue; - } - if (isset($params['generationConfig'][$key])) { - throw new InvalidArgumentException( - sprintf( - 'The custom generationConfig option "%s" conflicts with an existing parameter.', - $key - ) - ); - } - $params['generationConfig'][$key] = $value; - continue; - } - - if (isset($params[$key])) { - throw new InvalidArgumentException( - sprintf( - 'The custom option "%s" conflicts with an existing parameter.', - $key - ) - ); - } - $params[$key] = $value; - } - - return $params; - } - - /** - * Prepares the contents parameter for the API request. - * - * @since 0.4.0 - * - * @param list $messages The messages to prepare. - * @return list> The prepared contents parameter. - */ - protected function prepareContentsParam(array $messages): array - { - return array_map( - function (Message $message): array { - return [ - 'role' => $this->getMessageRoleString($message->getRole()), - 'parts' => array_values(array_filter(array_map( - [$this, 'getMessagePartData'], - $message->getParts() - ))), - ]; - }, - $messages - ); - } - - /** - * Returns the Google API specific role string for the given message role. - * - * @since 0.4.0 - * - * @param MessageRoleEnum $role The message role. - * @return string The role for the API request. - */ - protected function getMessageRoleString(MessageRoleEnum $role): string - { - if ($role === MessageRoleEnum::model()) { - return 'model'; - } - return 'user'; - } - - /** - * Returns the Google API specific data for a message part. - * - * @since 0.4.0 - * - * @param MessagePart $part The message part to get the data for. - * @return ?array The data for the message part, or null if not applicable. - * @throws InvalidArgumentException If the message part type or data is unsupported. - */ - protected function getMessagePartData(MessagePart $part): ?array - { - $type = $part->getType(); - if ($type->isText()) { - if ($part->getChannel()->isThought()) { - return [ - 'text' => $part->getText(), - 'thought' => true, - ]; - } - return [ - 'text' => $part->getText(), - ]; - } - if ($type->isFile()) { - $file = $part->getFile(); - if (!$file) { - // This should be impossible due to class internals, but still needs to be checked. - throw new RuntimeException( - 'The file typed message part must contain a file.' - ); - } - if ($file->isRemote()) { - $fileUrl = $file->getUrl(); - if (!$fileUrl) { - // This should be impossible due to class internals, but still needs to be checked. - throw new RuntimeException( - 'The remote file must contain a URL.' - ); - } - // Special case for YouTube video URLs. - if (preg_match('/^https?:\/\/(?:www\.)?(?:m\.)?(?:youtu\.be\/|youtube\.com\/)/', $fileUrl)) { - return [ - 'fileData' => [ - 'fileUri' => $fileUrl, - ], - ]; - } - return [ - 'fileData' => [ - 'mimeType' => $file->getMimeType(), - 'fileUri' => $fileUrl, - ], - ]; - } - // Else, it is an inline file. - $fileBase64Data = $file->getBase64Data(); - if (!$fileBase64Data) { - // This should be impossible due to class internals, but still needs to be checked. - throw new RuntimeException( - 'The inline file must contain base64 data.' - ); - } - return [ - 'inlineData' => [ - 'mimeType' => $file->getMimeType(), - 'data' => $fileBase64Data, - ], - ]; - } - if ($type->isFunctionCall()) { - $functionCall = $part->getFunctionCall(); - if (!$functionCall) { - // This should be impossible due to class internals, but still needs to be checked. - throw new RuntimeException( - 'The function_call typed message part must contain a function call.' - ); - } - $functionCallData = [ - 'name' => $functionCall->getName(), - ]; - // Only include args if present; Google's API accepts omitting args for no-argument functions. - $args = $functionCall->getArgs(); - if ($args !== null) { - $functionCallData['args'] = $args; - } - return [ - 'functionCall' => $functionCallData, - ]; - } - if ($type->isFunctionResponse()) { - $functionResponse = $part->getFunctionResponse(); - if (!$functionResponse) { - // This should be impossible due to class internals, but still needs to be checked. - throw new RuntimeException( - 'The function_response typed message part must contain a function response.' - ); - } - return [ - 'functionResponse' => [ - 'name' => $functionResponse->getName(), - - /* - * The Google AI API requires function responses to be objects. - * See also https://ai.google.dev/gemini-api/docs/function-calling#multi-turn-example-1 - */ - 'response' => [ - 'name' => $functionResponse->getName(), - 'content' => $functionResponse->getResponse(), - ], - ], - ]; - } - throw new InvalidArgumentException( - sprintf( - 'Unsupported message part type "%s".', - $type - ) - ); - } - - /** - * Prepares the system instruction parameter for the API request. - * - * @since 0.4.1 - * - * @param string $systemInstruction The system instruction to prepare. - * @return array The prepared system instruction parameter. - */ - protected function prepareSystemInstructionParam(string $systemInstruction): array - { - return [ - 'parts' => [ - [ - 'text' => $systemInstruction, - ], - ], - ]; - } - - /** - * Prepares the response modalities parameter for the API request. - * - * @since 0.4.0 - * - * @param array $modalities The modalities to prepare. - * @return list The prepared modalities parameter. - */ - protected function prepareResponseModalitiesParam(array $modalities): array - { - $prepared = []; - foreach ($modalities as $modality) { - if ($modality->isText()) { - $prepared[] = 'Text'; - } elseif ($modality->isImage()) { - $prepared[] = 'Image'; - } elseif ($modality->isAudio()) { - $prepared[] = 'Audio'; - } else { - throw new InvalidArgumentException( - sprintf( - 'Unsupported output modality "%s".', - $modality - ) - ); - } - } - return $prepared; - } - - /** - * Prepares the function declarations parameter for the API request. - * - * @since 0.4.0 - * - * @param list $functionDeclarations The function declarations. - * @return list> The prepared tools parameter. - */ - protected function prepareFunctionDeclarationsParam(array $functionDeclarations): array - { - $preparedFunctionDeclarations = []; - foreach ($functionDeclarations as $functionDeclaration) { - $data = $functionDeclaration->toArray(); - if (isset($data['parameters'])) { - // The Google AI API does not allow the `additionalProperties` key for function parameters. - $data['parameters'] = $this->removeAdditionalPropertiesKey($data['parameters']); - } - $preparedFunctionDeclarations[] = $data; - } - - return $preparedFunctionDeclarations; - } - - /** - * Removes the `additionalProperties` key from the schema, including child schemas. - * - * @since 0.4.0 - * - * @param array $schema The schema to remove the `additionalProperties` key from. - * @return array The schema without the `additionalProperties` key. - */ - protected function removeAdditionalPropertiesKey(array $schema): array - { - if (isset($schema['additionalProperties'])) { - unset($schema['additionalProperties']); - } - if (isset($schema['properties']) && is_array($schema['properties'])) { - /** @var array $childSchema */ - foreach ($schema['properties'] as $key => $childSchema) { - $schema['properties'][$key] = $this->removeAdditionalPropertiesKey($childSchema); - } - } - return $schema; - } - - /** - * Parses the response from the API endpoint to a generative AI result. - * - * @since 0.4.0 - * - * @param Response $response The response from the API endpoint. - * @return GenerativeAiResult The parsed generative AI result. - */ - protected function parseResponseToGenerativeAiResult(Response $response): GenerativeAiResult - { - /** @var ResponseData $responseData */ - $responseData = $response->getData(); - if (!isset($responseData['candidates']) || !$responseData['candidates']) { - throw ResponseException::fromMissingData($this->providerMetadata()->getName(), 'candidates'); - } - if (!is_array($responseData['candidates'])) { - throw ResponseException::fromInvalidData( - $this->providerMetadata()->getName(), - 'candidates', - 'The value must be an array.' - ); - } - - $candidates = []; - foreach ($responseData['candidates'] as $index => $candidateData) { - if (!is_array($candidateData) || array_is_list($candidateData)) { - throw ResponseException::fromInvalidData( - $this->providerMetadata()->getName(), - "candidates[{$index}]", - 'The value must be an associative array.' - ); - } - - $candidates[] = $this->parseResponseCandidateToCandidate($candidateData, $index); - } - - $id = isset($responseData['id']) && is_string($responseData['id']) ? $responseData['id'] : ''; - - if (isset($responseData['usageMetadata']) && is_array($responseData['usageMetadata'])) { - $usage = $responseData['usageMetadata']; - - $tokenUsage = new TokenUsage( - $usage['promptTokenCount'] ?? 0, - $usage['candidatesTokenCount'] ?? 0, - ($usage['candidatesTokenCount'] ?? 0) + ($usage['thoughtsTokenCount'] ?? 0) - ); - } else { - $tokenUsage = new TokenUsage(0, 0, 0); - } - - // Use any other data from the response as provider-specific response metadata. - $additionalData = $responseData; - unset($additionalData['id'], $additionalData['candidates'], $additionalData['usageMetadata']); - - return new GenerativeAiResult( - $id, - $candidates, - $tokenUsage, - $this->providerMetadata(), - $this->metadata(), - $additionalData - ); - } - - /** - * Parses a single candidate from the API response into a Candidate object. - * - * @since 0.4.0 - * - * @param CandidateData $candidateData The candidate data from the API response. - * @param int $index The index of the candidate in the candidates array. - * @return Candidate The parsed candidate. - * @throws RuntimeException If the candidate data is invalid. - */ - protected function parseResponseCandidateToCandidate(array $candidateData, int $index): Candidate - { - if ( - !isset($candidateData['content']) || - !is_array($candidateData['content']) || - array_is_list($candidateData['content']) - ) { - throw ResponseException::fromMissingData( - $this->providerMetadata()->getName(), - "candidates[{$index}].content" - ); - } - - if (!isset($candidateData['finishReason']) || !is_string($candidateData['finishReason'])) { - throw ResponseException::fromMissingData( - $this->providerMetadata()->getName(), - "candidates[{$index}].finishReason" - ); - } - - $messageData = $candidateData['content']; - $message = $this->parseResponseCandidateMessage($messageData, $index); - - switch ($candidateData['finishReason']) { - case 'STOP': - /* - * Google API doesn't make a difference between regular stop vs because of tool calls. - * So we have to check ourselves. - */ - $finishReason = FinishReasonEnum::stop(); - foreach ($message->getParts() as $messagePart) { - if ($messagePart->getType()->isFunctionCall()) { - $finishReason = FinishReasonEnum::toolCalls(); - break; - } - } - break; - case 'MAX_TOKENS': - $finishReason = FinishReasonEnum::length(); - break; - case 'IMAGE_SAFETY': - case 'RECITATION': - case 'SAFETY': - case 'BLOCKLIST': - case 'PROHIBITED_CONTENT': - case 'SPII': - $finishReason = FinishReasonEnum::contentFilter(); - break; - default: - throw ResponseException::fromInvalidData( - $this->providerMetadata()->getName(), - "candidates[{$index}].finishReason", - sprintf('Invalid finish reason "%s".', $candidateData['finishReason']) - ); - } - - return new Candidate($message, $finishReason); - } - - /** - * Parses the message from a candidate in the API response. - * - * @since 0.4.0 - * - * @param MessageData $messageData The message data from the API response. - * @param int $index The index of the candidate in the candidates array. - * @return Message The parsed message. - */ - protected function parseResponseCandidateMessage(array $messageData, int $index): Message - { - $role = isset($messageData['role']) && 'user' === $messageData['role'] - ? MessageRoleEnum::user() - : MessageRoleEnum::model(); - - if (!isset($messageData['parts'])) { - throw ResponseException::fromMissingData( - $this->providerMetadata()->getName(), - "candidates[{$index}].content.parts" - ); - } - if (!is_array($messageData['parts']) || !array_is_list($messageData['parts'])) { - throw ResponseException::fromInvalidData( - $this->providerMetadata()->getName(), - "candidates[{$index}].content.parts", - 'The value must be an indexed array.' - ); - } - - $parts = []; - foreach ($messageData['parts'] as $partIndex => $messagePartData) { - try { - $parts[] = $this->parseResponseCandidateMessagePart($messagePartData); - } catch (InvalidArgumentException $e) { - throw ResponseException::fromInvalidData( - $this->providerMetadata()->getName(), - "candidates[{$index}].content.parts[{$partIndex}]", - $e->getMessage() - ); - } - } - - return new Message($role, $parts); - } - - /** - * Parses a message part from a candidate in the API response. - * - * @since 0.4.0 - * - * @param array $partData The message part data from the API response. - * @return MessagePart The parsed message part. - */ - protected function parseResponseCandidateMessagePart(array $partData): MessagePart - { - if (isset($partData['text'])) { - if (!is_string($partData['text'])) { - throw new InvalidArgumentException('Part has an invalid text shape.'); - } - if (isset($partData['thought']) && $partData['thought']) { - return new MessagePart($partData['text'], MessagePartChannelEnum::thought()); - } - return new MessagePart($partData['text']); - } - if (isset($partData['inlineData'])) { - if ( - !is_array($partData['inlineData']) || - !isset($partData['inlineData']['data']) || - !is_string($partData['inlineData']['data']) - ) { - throw new InvalidArgumentException('Part has an invalid inlineData shape.'); - } - return new MessagePart( - new File( - $partData['inlineData']['data'], - isset($partData['inlineData']['mimeType']) && is_string($partData['inlineData']['mimeType']) ? - $partData['inlineData']['mimeType'] : - null - ) - ); - } - if (isset($partData['fileData'])) { - if ( - !is_array($partData['fileData']) || - !isset($partData['fileData']['fileUri']) || - !is_string($partData['fileData']['fileUri']) - ) { - throw new InvalidArgumentException('Part has an invalid fileData shape.'); - } - return new MessagePart( - new File( - $partData['fileData']['fileUri'], - isset($partData['fileData']['mimeType']) && is_string($partData['fileData']['mimeType']) ? - $partData['fileData']['mimeType'] : - null - ) - ); - } - if (isset($partData['functionCall'])) { - if ( - !is_array($partData['functionCall']) || - !isset($partData['functionCall']['name']) || - !is_string($partData['functionCall']['name']) - ) { - throw new InvalidArgumentException('Part has an invalid functionCall shape.'); - } - /* - * Google may omit `args` for no-argument functions, or return `args: {}`. - * Normalize both cases to null. - */ - $args = $partData['functionCall']['args'] ?? null; - if (is_array($args) && count($args) === 0) { - $args = null; - } - return new MessagePart( - new FunctionCall( - null, - $partData['functionCall']['name'], - $args - ) - ); - } - throw new InvalidArgumentException('Part has an unexpected type.'); - } -} diff --git a/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php b/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php deleted file mode 100644 index 3883606c..00000000 --- a/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php +++ /dev/null @@ -1,195 +0,0 @@ -getRequestOptions() - ); - } - - /** - * {@inheritDoc} - * - * @since 0.1.0 - */ - protected function prepareGenerateImageParams(array $prompt): array - { - $params = parent::prepareGenerateImageParams($prompt); - - /* - * Only the newer 'gpt-image-' models support passing a MIME type ('output_format'). - * Conversely, they do not support 'response_format', but always return a base64 encoded image. - */ - if ($this->isGptImageModel($params['model'])) { - unset($params['response_format']); - } else { - unset($params['output_format']); - } - - return $params; - } - - /** - * {@inheritDoc} - * - * @since 0.4.0 - */ - protected function prepareSizeParam(?MediaOrientationEnum $orientation, ?string $aspectRatio): string - { - $modelId = $this->metadata()->getId(); - - if ($this->isGptImageModel($modelId)) { - return $this->prepareGptImageSizeParam($orientation, $aspectRatio); - } - - return $this->prepareDalleSizeParam($modelId, $orientation, $aspectRatio); - } - - /** - * {@inheritDoc} - * - * @since 0.4.0 - */ - protected function getResultId(array $responseData): string - { - // The Images API returns `created` timestamp instead of `id`. - return isset($responseData['created']) && is_int($responseData['created']) - ? 'img-' . $responseData['created'] - : ''; - } - - /** - * Checks if the given model ID is a GPT image model. - * - * @since 0.4.0 - * - * @param string $modelId The model ID to check. - * @return bool True if it's a GPT image model, false otherwise. - */ - protected function isGptImageModel(string $modelId): bool - { - return str_starts_with($modelId, 'gpt-image-'); - } - - /** - * Prepares the size parameter for GPT image models. - * - * @since 0.4.0 - * - * @param MediaOrientationEnum|null $orientation The desired media orientation. - * @param string|null $aspectRatio The desired media aspect ratio. - * @return string The size parameter value. - */ - protected function prepareGptImageSizeParam(?MediaOrientationEnum $orientation, ?string $aspectRatio): string - { - // If aspect ratio is provided, map it to OpenAI size format. - if ($aspectRatio !== null) { - $aspectRatioMap = [ - '1:1' => '1024x1024', - '3:2' => '1536x1024', - '2:3' => '1024x1536', - ]; - if (isset($aspectRatioMap[$aspectRatio])) { - return $aspectRatioMap[$aspectRatio]; - } - } - - // Map orientation to size. - if ($orientation !== null) { - if ($orientation->isLandscape()) { - return '1536x1024'; - } - if ($orientation->isPortrait()) { - return '1024x1536'; - } - } - - // Default to square. - return '1024x1024'; - } - - /** - * Prepares the size parameter for DALL-E models. - * - * @since 0.4.0 - * - * @param string $modelId The model ID (dall-e-2 or dall-e-3). - * @param MediaOrientationEnum|null $orientation The desired media orientation. - * @param string|null $aspectRatio The desired media aspect ratio. - * @return string The size parameter value. - */ - protected function prepareDalleSizeParam( - string $modelId, - ?MediaOrientationEnum $orientation, - ?string $aspectRatio - ): string { - $isDalle3 = $modelId === 'dall-e-3'; - - // If aspect ratio is provided, map it to size. - if ($aspectRatio !== null) { - if ($isDalle3) { - $aspectRatioMap = [ - '1:1' => '1024x1024', - '7:4' => '1792x1024', - '4:7' => '1024x1792', - ]; - } else { - // DALL-E 2 only supports square images at various resolutions. - $aspectRatioMap = [ - '1:1' => '1024x1024', - ]; - } - if (isset($aspectRatioMap[$aspectRatio])) { - return $aspectRatioMap[$aspectRatio]; - } - } - - // Map orientation to size. - if ($orientation !== null) { - if ($isDalle3) { - if ($orientation->isLandscape()) { - return '1792x1024'; - } - if ($orientation->isPortrait()) { - return '1024x1792'; - } - } - // DALL-E 2 only supports square, so orientation doesn't change the size. - } - - // Default to square. - return '1024x1024'; - } -} diff --git a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php deleted file mode 100644 index a4c878b3..00000000 --- a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php +++ /dev/null @@ -1,324 +0,0 @@ - - * } - */ -class OpenAiModelMetadataDirectory extends AbstractOpenAiCompatibleModelMetadataDirectory -{ - /** - * {@inheritDoc} - * - * @since 0.1.0 - */ - protected function createRequest(HttpMethodEnum $method, string $path, array $headers = [], $data = null): Request - { - return new Request( - $method, - OpenAiProvider::url($path), - $headers, - $data - ); - } - - /** - * {@inheritDoc} - * - * @since 0.1.0 - */ - protected function parseResponseToModelMetadataList(Response $response): array - { - /** @var ModelsResponseData $responseData */ - $responseData = $response->getData(); - if (!isset($responseData['data']) || !$responseData['data']) { - throw ResponseException::fromMissingData('OpenAI', 'data'); - } - - // Unfortunately, the OpenAI API does not return model capabilities, so we have to hardcode them here. - $gptCapabilities = [ - CapabilityEnum::textGeneration(), - CapabilityEnum::chatHistory(), - ]; - $gptBaseOptions = [ - new SupportedOption(OptionEnum::systemInstruction()), - new SupportedOption(OptionEnum::candidateCount()), - new SupportedOption(OptionEnum::maxTokens()), - new SupportedOption(OptionEnum::temperature()), - new SupportedOption(OptionEnum::topP()), - new SupportedOption(OptionEnum::stopSequences()), - new SupportedOption(OptionEnum::presencePenalty()), - new SupportedOption(OptionEnum::frequencyPenalty()), - new SupportedOption(OptionEnum::logprobs()), - new SupportedOption(OptionEnum::topLogprobs()), - new SupportedOption(OptionEnum::outputMimeType(), ['text/plain', 'application/json']), - new SupportedOption(OptionEnum::outputSchema()), - new SupportedOption(OptionEnum::functionDeclarations()), - new SupportedOption(OptionEnum::webSearch()), - new SupportedOption(OptionEnum::customOptions()), - ]; - $gptOptions = array_merge($gptBaseOptions, [ - new SupportedOption(OptionEnum::inputModalities(), [[ModalityEnum::text()]]), - new SupportedOption(OptionEnum::outputModalities(), [[ModalityEnum::text()]]), - ]); - $gptMultimodalInputOptions = array_merge($gptBaseOptions, [ - new SupportedOption( - OptionEnum::inputModalities(), - [ - [ModalityEnum::text()], - [ModalityEnum::text(), ModalityEnum::image()], - [ModalityEnum::text(), ModalityEnum::image(), ModalityEnum::audio()], - [ModalityEnum::text(), ModalityEnum::document()], - [ModalityEnum::text(), ModalityEnum::image(), ModalityEnum::document()], - ] - ), - new SupportedOption(OptionEnum::outputModalities(), [[ModalityEnum::text()]]), - ]); - $gptMultimodalSpeechOutputOptions = array_merge($gptBaseOptions, [ - new SupportedOption( - OptionEnum::inputModalities(), - [ - [ModalityEnum::text()], - [ModalityEnum::text(), ModalityEnum::image()], - [ModalityEnum::text(), ModalityEnum::image(), ModalityEnum::audio()], - [ModalityEnum::text(), ModalityEnum::document()], - [ModalityEnum::text(), ModalityEnum::image(), ModalityEnum::document()], - ] - ), - new SupportedOption( - OptionEnum::outputModalities(), - [ - [ModalityEnum::text()], - [ModalityEnum::text(), ModalityEnum::audio()], - ] - ), - ]); - $gptSearchOptions = [ - new SupportedOption(OptionEnum::systemInstruction()), - new SupportedOption(OptionEnum::outputMimeType(), ['text/plain', 'application/json']), - new SupportedOption(OptionEnum::outputSchema()), - new SupportedOption(OptionEnum::customOptions()), - new SupportedOption(OptionEnum::inputModalities(), [[ModalityEnum::text()]]), - new SupportedOption(OptionEnum::outputModalities(), [[ModalityEnum::text()]]), - ]; - $imageCapabilities = [ - CapabilityEnum::imageGeneration(), - ]; - $dalleImageOptions = [ - new SupportedOption(OptionEnum::inputModalities(), [[ModalityEnum::text()]]), - new SupportedOption(OptionEnum::outputModalities(), [[ModalityEnum::image()]]), - new SupportedOption(OptionEnum::candidateCount()), - new SupportedOption(OptionEnum::outputMimeType(), ['image/png']), - new SupportedOption(OptionEnum::outputFileType(), [FileTypeEnum::inline(), FileTypeEnum::remote()]), - new SupportedOption(OptionEnum::outputMediaOrientation(), [ - MediaOrientationEnum::square(), - MediaOrientationEnum::landscape(), - MediaOrientationEnum::portrait(), - ]), - new SupportedOption(OptionEnum::outputMediaAspectRatio(), ['1:1', '7:4', '4:7']), - new SupportedOption(OptionEnum::customOptions()), - ]; - $gptImageOptions = [ - new SupportedOption(OptionEnum::inputModalities(), [[ModalityEnum::text()]]), - new SupportedOption(OptionEnum::outputModalities(), [[ModalityEnum::image()]]), - new SupportedOption(OptionEnum::candidateCount()), - new SupportedOption(OptionEnum::outputMimeType(), ['image/png', 'image/jpeg', 'image/webp']), - new SupportedOption(OptionEnum::outputFileType(), [FileTypeEnum::inline()]), - new SupportedOption(OptionEnum::outputMediaOrientation(), [ - MediaOrientationEnum::square(), - MediaOrientationEnum::landscape(), - MediaOrientationEnum::portrait(), - ]), - new SupportedOption(OptionEnum::outputMediaAspectRatio(), ['1:1', '3:2', '2:3']), - new SupportedOption(OptionEnum::customOptions()), - ]; - $ttsCapabilities = [ - CapabilityEnum::textToSpeechConversion(), - ]; - $ttsOptions = [ - new SupportedOption(OptionEnum::inputModalities(), [[ModalityEnum::text()]]), - new SupportedOption(OptionEnum::outputModalities(), [[ModalityEnum::audio()]]), - new SupportedOption(OptionEnum::outputMimeType(), [ - 'audio/mpeg', - 'audio/ogg', - 'audio/wav', - 'audio/flac', - 'audio/aac', - ]), - new SupportedOption(OptionEnum::outputSpeechVoice()), - new SupportedOption(OptionEnum::customOptions()), - ]; - - $modelsData = (array) $responseData['data']; - - $models = array_values( - array_map( - static function (array $modelData) use ( - $gptCapabilities, - $gptOptions, - $gptMultimodalInputOptions, - $gptMultimodalSpeechOutputOptions, - $gptSearchOptions, - $imageCapabilities, - $dalleImageOptions, - $gptImageOptions, - $ttsCapabilities, - $ttsOptions - ): ModelMetadata { - $modelId = $modelData['id']; - if ( - str_starts_with($modelId, 'dall-e-') || - str_starts_with($modelId, 'gpt-image-') - ) { - $modelCaps = $imageCapabilities; - if (str_starts_with($modelId, 'gpt-image-')) { - $modelOptions = $gptImageOptions; - } else { - $modelOptions = $dalleImageOptions; - } - } elseif ( - str_starts_with($modelId, 'tts-') || - str_contains($modelId, '-tts') - ) { - $modelCaps = $ttsCapabilities; - $modelOptions = $ttsOptions; - } elseif ( - (str_starts_with($modelId, 'gpt-') || str_starts_with($modelId, 'o1-')) - && !str_contains($modelId, '-instruct') - && !str_contains($modelId, '-realtime') - ) { - if (str_starts_with($modelId, 'gpt-4o')) { - $modelCaps = $gptCapabilities; - $modelOptions = $gptMultimodalInputOptions; - // New multimodal output model for audio generation. - if (str_contains($modelId, '-audio')) { - $modelOptions = $gptMultimodalSpeechOutputOptions; - } elseif (str_contains($modelId, '-search')) { - $modelOptions = $gptSearchOptions; - } - } elseif (!str_contains($modelId, '-audio')) { - $modelCaps = $gptCapabilities; - $modelOptions = $gptOptions; - } else { - $modelCaps = []; - $modelOptions = []; - } - } else { - $modelCaps = []; - $modelOptions = []; - } - - return new ModelMetadata( - $modelId, - $modelId, // The OpenAI API does not return a display name. - $modelCaps, - $modelOptions - ); - }, - $modelsData - ) - ); - - usort($models, [$this, 'modelSortCallback']); - - return $models; - } - - /** - * Callback function for sorting models by ID, to be used with `usort()`. - * - * This method expresses preferences for certain models or model families within the provider by putting them - * earlier in the sorted list. The objective is not to be opinionated about which models are better, but to ensure - * that more commonly used, more recent, or flagship models are presented first to users. - * - * @since 0.2.1 - * - * @param ModelMetadata $a First model. - * @param ModelMetadata $b Second model. - * @return int Comparison result. - */ - protected function modelSortCallback(ModelMetadata $a, ModelMetadata $b): int - { - $aId = $a->getId(); - $bId = $b->getId(); - - // Prefer non-preview models over preview models. - if (str_contains($aId, '-preview') && !str_contains($bId, '-preview')) { - return 1; - } - if (str_contains($bId, '-preview') && !str_contains($aId, '-preview')) { - return -1; - } - - // Prefer GPT models over non-GPT models. - if (str_starts_with($aId, 'gpt-') && !str_starts_with($bId, 'gpt-')) { - return -1; - } - if (str_starts_with($bId, 'gpt-') && !str_starts_with($aId, 'gpt-')) { - return 1; - } - - // Prefer GPT models with version numbers (e.g. 'gpt-5.1', 'gpt-5') over those without. - $aMatch = preg_match('/^gpt-([0-9.]+)(-[a-z0-9-]+)?$/', $aId, $aMatches); - $bMatch = preg_match('/^gpt-([0-9.]+)(-[a-z0-9-]+)?$/', $bId, $bMatches); - if ($aMatch && !$bMatch) { - return -1; - } - if ($bMatch && !$aMatch) { - return 1; - } - if ($aMatch && $bMatch) { - // Prefer later model versions. - $aVersion = $aMatches[1]; - $bVersion = $bMatches[1]; - if (version_compare($aVersion, $bVersion, '>')) { - return -1; - } - if (version_compare($bVersion, $aVersion, '>')) { - return 1; - } - - // Prefer models without a suffix (i.e. base models) over those with a suffix. - if (!isset($aMatches[2]) && isset($bMatches[2])) { - return -1; - } - if (!isset($bMatches[2]) && isset($aMatches[2])) { - return 1; - } - - // Prefer '-mini' models over others with a suffix. - if (isset($aMatches[2]) && isset($bMatches[2])) { - if ($aMatches[2] === '-mini' && $bMatches[2] !== '-mini') { - return -1; - } - if ($bMatches[2] === '-mini' && $aMatches[2] !== '-mini') { - return 1; - } - } - } - - // Fallback: Sort alphabetically. - return strcmp($a->getId(), $b->getId()); - } -} diff --git a/src/ProviderImplementations/OpenAi/OpenAiProvider.php b/src/ProviderImplementations/OpenAi/OpenAiProvider.php deleted file mode 100644 index 11974de1..00000000 --- a/src/ProviderImplementations/OpenAi/OpenAiProvider.php +++ /dev/null @@ -1,103 +0,0 @@ -getSupportedCapabilities(); - foreach ($capabilities as $capability) { - if ($capability->isTextGeneration()) { - return new OpenAiTextGenerationModel($modelMetadata, $providerMetadata); - } - if ($capability->isImageGeneration()) { - return new OpenAiImageGenerationModel($modelMetadata, $providerMetadata); - } - if ($capability->isTextToSpeechConversion()) { - // TODO: Implement OpenAiTextToSpeechConversionModel. - throw new RuntimeException( - 'OpenAI text to speech conversion model class is not yet implemented.' - ); - } - } - - throw new RuntimeException( - 'Unsupported model capabilities: ' . implode(', ', $capabilities) - ); - } - - /** - * {@inheritDoc} - * - * @since 0.1.0 - */ - protected static function createProviderMetadata(): ProviderMetadata - { - return new ProviderMetadata( - 'openai', - 'OpenAI', - ProviderTypeEnum::cloud(), - 'https://platform.openai.com/api-keys', - RequestAuthenticationMethod::apiKey() - ); - } - - /** - * {@inheritDoc} - * - * @since 0.1.0 - */ - protected static function createProviderAvailability(): ProviderAvailabilityInterface - { - // Check valid API access by attempting to list models. - return new ListModelsApiBasedProviderAvailability( - static::modelMetadataDirectory() - ); - } - - /** - * {@inheritDoc} - * - * @since 0.1.0 - */ - protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface - { - return new OpenAiModelMetadataDirectory(); - } -} diff --git a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php deleted file mode 100644 index f891cfc0..00000000 --- a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php +++ /dev/null @@ -1,699 +0,0 @@ - - * } - * @phpstan-type UsageData array{ - * input_tokens?: int, - * output_tokens?: int, - * total_tokens?: int - * } - * @phpstan-type ResponseData array{ - * id?: string, - * status?: string, - * output?: list, - * output_text?: string, - * usage?: UsageData - * } - */ -class OpenAiTextGenerationModel extends AbstractApiBasedModel implements TextGenerationModelInterface -{ - /** - * {@inheritDoc} - * - * @since 0.4.0 - */ - final public function generateTextResult(array $prompt): GenerativeAiResult - { - $httpTransporter = $this->getHttpTransporter(); - - $params = $this->prepareGenerateTextParams($prompt); - - $request = new Request( - HttpMethodEnum::POST(), - OpenAiProvider::url('responses'), - ['Content-Type' => 'application/json'], - $params, - $this->getRequestOptions() - ); - - // Add authentication credentials to the request. - $request = $this->getRequestAuthentication()->authenticateRequest($request); - - // Send and process the request. - $response = $httpTransporter->send($request); - ResponseUtil::throwIfNotSuccessful($response); - return $this->parseResponseToGenerativeAiResult($response); - } - - /** - * Prepares the given prompt and the model configuration into parameters for the API request. - * - * @since 0.4.0 - * - * @param list $prompt The prompt to generate text for. Either a single message or a list of messages - * from a chat. - * @return array The parameters for the API request. - */ - protected function prepareGenerateTextParams(array $prompt): array - { - $config = $this->getConfig(); - - $params = [ - 'model' => $this->metadata()->getId(), - 'input' => $this->prepareInputParam($prompt), - ]; - - $systemInstruction = $config->getSystemInstruction(); - if ($systemInstruction) { - $params['instructions'] = $systemInstruction; - } - - $maxTokens = $config->getMaxTokens(); - if ($maxTokens !== null) { - $params['max_output_tokens'] = $maxTokens; - } - - $temperature = $config->getTemperature(); - if ($temperature !== null) { - $params['temperature'] = $temperature; - } - - $topP = $config->getTopP(); - if ($topP !== null) { - $params['top_p'] = $topP; - } - - // Note: OpenAI does not support top_k parameter. - - $outputMimeType = $config->getOutputMimeType(); - $outputSchema = $config->getOutputSchema(); - if ($outputMimeType === 'application/json' && $outputSchema) { - $params['text'] = [ - 'format' => [ - 'type' => 'json_schema', - 'name' => 'response_schema', - 'schema' => $outputSchema, - 'strict' => true, - ], - ]; - } - - $functionDeclarations = $config->getFunctionDeclarations(); - $webSearch = $config->getWebSearch(); - $customOptions = $config->getCustomOptions(); - - if (is_array($functionDeclarations) || $webSearch) { - $params['tools'] = $this->prepareToolsParam( - $functionDeclarations, - $webSearch - ); - } - - /* - * Any custom options are added to the parameters as well. - * This allows developers to pass other options that may be more niche or not yet supported by the SDK. - */ - foreach ($customOptions as $key => $value) { - if (isset($params[$key])) { - throw new InvalidArgumentException( - sprintf( - 'The custom option "%s" conflicts with an existing parameter.', - $key - ) - ); - } - $params[$key] = $value; - } - - return $params; - } - - /** - * Prepares the input parameter for the API request. - * - * @since 0.4.0 - * - * @param list $messages The messages to prepare. - * @return list> The prepared input parameter. - */ - protected function prepareInputParam(array $messages): array - { - $this->validateMessages($messages); - - $input = []; - foreach ($messages as $message) { - $inputItem = $this->getMessageInputItem($message); - if ($inputItem !== null) { - $input[] = $inputItem; - } - } - return $input; - } - - /** - * Validates that the messages are appropriate for the OpenAI Responses API. - * - * The OpenAI Responses API requires function calls and function responses to be - * sent as top-level input items rather than nested in message content. As such, - * they must be the only part in a message. - * - * @since 0.4.0 - * - * @param list $messages The messages to validate. - * @return void - * @throws InvalidArgumentException If validation fails. - */ - protected function validateMessages(array $messages): void - { - foreach ($messages as $message) { - $parts = $message->getParts(); - - if (count($parts) <= 1) { - continue; - } - - foreach ($parts as $part) { - $type = $part->getType(); - - if ($type->isFunctionCall()) { - throw new InvalidArgumentException( - 'Function call parts must be the only part in a message for the OpenAI Responses API.' - ); - } - - if ($type->isFunctionResponse()) { - throw new InvalidArgumentException( - 'Function response parts must be the only part in a message for the OpenAI Responses API.' - ); - } - } - } - } - - /** - * Converts a Message object to a Responses API input item. - * - * @since 0.4.0 - * - * @param Message $message The message to convert. - * @return array|null The input item, or null if the message is empty. - */ - protected function getMessageInputItem(Message $message): ?array - { - $parts = $message->getParts(); - - if (empty($parts)) { - return null; - } - - $role = $message->getRole(); - $content = []; - foreach ($parts as $part) { - $partData = $this->getMessagePartData($part, $role); - - // Function calls and responses are top-level items, not wrapped in a message. - // validateMessages() ensures these are the only part in a message. - $partType = $partData['type'] ?? ''; - if ($partType === 'function_call' || $partType === 'function_call_output') { - return $partData; - } - - $content[] = $partData; - } - - return [ - 'role' => $this->getMessageRoleString($role), - 'content' => $content, - ]; - } - - /** - * Returns the OpenAI API specific role string for the given message role. - * - * @since 0.4.0 - * - * @param MessageRoleEnum $role The message role. - * @return string The role for the API request. - */ - protected function getMessageRoleString(MessageRoleEnum $role): string - { - if ($role === MessageRoleEnum::model()) { - return 'assistant'; - } - return 'user'; - } - - /** - * Returns the OpenAI API specific data for a message part. - * - * @since 0.4.0 - * - * @param MessagePart $part The message part to get the data for. - * @param MessageRoleEnum $role The role of the message containing the part. - * @return array The data for the message part. - * @throws InvalidArgumentException If the message part type or data is unsupported. - */ - protected function getMessagePartData(MessagePart $part, MessageRoleEnum $role): array - { - $type = $part->getType(); - if ($type->isText()) { - return [ - 'type' => $role->isModel() ? 'output_text' : 'input_text', - 'text' => $part->getText(), - ]; - } - if ($type->isFile()) { - $file = $part->getFile(); - if (!$file) { - // This should be impossible due to class internals, but still needs to be checked. - throw new RuntimeException( - 'The file typed message part must contain a file.' - ); - } - if ($file->isRemote()) { - $fileUrl = $file->getUrl(); - if (!$fileUrl) { - // This should be impossible due to class internals, but still needs to be checked. - throw new RuntimeException( - 'The remote file must contain a URL.' - ); - } - if ($file->isImage()) { - return [ - 'type' => 'input_image', - 'image_url' => $fileUrl, - ]; - } - // For other file types, use input_file with URL. - return [ - 'type' => 'input_file', - 'file_url' => $fileUrl, - ]; - } - // Else, it is an inline file. - $dataUri = $file->getDataUri(); - if (!$dataUri) { - // This should be impossible due to class internals, but still needs to be checked. - throw new RuntimeException( - 'The inline file must contain base64 data.' - ); - } - if ($file->isImage()) { - return [ - 'type' => 'input_image', - 'image_url' => $dataUri, - ]; - } - // For other file types (like PDF), use input_file. - return [ - 'type' => 'input_file', - 'filename' => 'file', - 'file_data' => $dataUri, - ]; - } - if ($type->isFunctionCall()) { - $functionCall = $part->getFunctionCall(); - if (!$functionCall) { - throw new RuntimeException( - 'The function_call typed message part must contain a function call.' - ); - } - return [ - 'type' => 'function_call', - 'call_id' => $functionCall->getId(), - 'name' => $functionCall->getName(), - 'arguments' => json_encode($functionCall->getArgs()), - ]; - } - if ($type->isFunctionResponse()) { - $functionResponse = $part->getFunctionResponse(); - if (!$functionResponse) { - throw new RuntimeException( - 'The function_response typed message part must contain a function response.' - ); - } - return [ - 'type' => 'function_call_output', - 'call_id' => $functionResponse->getId(), - 'output' => json_encode($functionResponse->getResponse()), - ]; - } - throw new InvalidArgumentException( - sprintf( - 'Unsupported message part type "%s".', - $type - ) - ); - } - - /** - * Prepares the tools parameter for the API request. - * - * @since 0.4.0 - * - * @param list|null $functionDeclarations The function declarations, or null if none. - * @param WebSearch|null $webSearch The web search config, or null if none. - * @return list> The prepared tools parameter. - */ - protected function prepareToolsParam( - ?array $functionDeclarations, - ?WebSearch $webSearch - ): array { - $tools = []; - - if (is_array($functionDeclarations)) { - foreach ($functionDeclarations as $functionDeclaration) { - $tools[] = [ - 'type' => 'function', - 'name' => $functionDeclaration->getName(), - 'description' => $functionDeclaration->getDescription(), - 'parameters' => $functionDeclaration->getParameters(), - ]; - } - } - - if ($webSearch) { - $webSearchTool = ['type' => 'web_search']; - // Note: The OpenAI Responses API web_search tool may have different filtering options. - // For now, we use the basic form. - $tools[] = $webSearchTool; - } - - return $tools; - } - - /** - * Parses the response from the API endpoint to a generative AI result. - * - * @since 0.4.0 - * - * @param Response $response The response from the API endpoint. - * @return GenerativeAiResult The parsed generative AI result. - */ - protected function parseResponseToGenerativeAiResult(Response $response): GenerativeAiResult - { - /** @var ResponseData $responseData */ - $responseData = $response->getData(); - - if (!isset($responseData['output']) || !$responseData['output']) { - throw ResponseException::fromMissingData($this->providerMetadata()->getName(), 'output'); - } - if (!is_array($responseData['output']) || !array_is_list($responseData['output'])) { - throw ResponseException::fromInvalidData( - $this->providerMetadata()->getName(), - 'output', - 'The value must be an indexed array.' - ); - } - - $candidates = []; - foreach ($responseData['output'] as $index => $outputItem) { - if (!is_array($outputItem) || array_is_list($outputItem)) { - throw ResponseException::fromInvalidData( - $this->providerMetadata()->getName(), - "output[{$index}]", - 'The value must be an associative array.' - ); - } - - $candidate = $this->parseOutputItemToCandidate($outputItem, $index, $responseData['status'] ?? 'completed'); - if ($candidate !== null) { - $candidates[] = $candidate; - } - } - - $id = isset($responseData['id']) && is_string($responseData['id']) ? $responseData['id'] : ''; - - if (isset($responseData['usage']) && is_array($responseData['usage'])) { - $usage = $responseData['usage']; - $tokenUsage = new TokenUsage( - $usage['input_tokens'] ?? 0, - $usage['output_tokens'] ?? 0, - $usage['total_tokens'] ?? (($usage['input_tokens'] ?? 0) + ($usage['output_tokens'] ?? 0)) - ); - } else { - $tokenUsage = new TokenUsage(0, 0, 0); - } - - // Use any other data from the response as provider-specific response metadata. - $additionalData = $responseData; - unset($additionalData['id'], $additionalData['output'], $additionalData['usage']); - - return new GenerativeAiResult( - $id, - $candidates, - $tokenUsage, - $this->providerMetadata(), - $this->metadata(), - $additionalData - ); - } - - /** - * Parses a single output item from the API response into a Candidate object. - * - * @since 0.4.0 - * - * @param OutputItemData $outputItem The output item data from the API response. - * @param int $index The index of the output item in the output array. - * @param string $responseStatus The overall response status. - * @return Candidate|null The parsed candidate, or null if the output item should be skipped. - */ - protected function parseOutputItemToCandidate(array $outputItem, int $index, string $responseStatus): ?Candidate - { - $type = $outputItem['type'] ?? ''; - - // Handle message output type. - if ($type === 'message') { - return $this->parseMessageOutputToCandidate($outputItem, $index, $responseStatus); - } - - // Handle function_call output type (top-level function call). - if ($type === 'function_call') { - return $this->parseFunctionCallOutputToCandidate($outputItem, $index); - } - - // Skip other output types for now (e.g., image_generation_call is handled in image model). - return null; - } - - /** - * Parses a message output item into a Candidate object. - * - * @since 0.4.0 - * - * @param OutputItemData $outputItem The output item data. - * @param int $index The index of the output item. - * @param string $responseStatus The overall response status. - * @return Candidate The parsed candidate. - */ - protected function parseMessageOutputToCandidate( - array $outputItem, - int $index, - string $responseStatus - ): Candidate { - $role = isset($outputItem['role']) && $outputItem['role'] === 'user' - ? MessageRoleEnum::user() - : MessageRoleEnum::model(); - - $parts = []; - $hasFunctionCalls = false; - - if (isset($outputItem['content']) && is_array($outputItem['content'])) { - foreach ($outputItem['content'] as $contentIndex => $contentItem) { - try { - $part = $this->parseOutputContentToPart($contentItem); - if ($part !== null) { - $parts[] = $part; - if ($part->getType()->isFunctionCall()) { - $hasFunctionCalls = true; - } - } - } catch (InvalidArgumentException $e) { - throw ResponseException::fromInvalidData( - $this->providerMetadata()->getName(), - "output[{$index}].content[{$contentIndex}]", - $e->getMessage() - ); - } - } - } - - $message = new Message($role, $parts); - $finishReason = $this->parseStatusToFinishReason($responseStatus, $hasFunctionCalls); - - return new Candidate($message, $finishReason); - } - - /** - * Parses a function_call output item into a Candidate object. - * - * @since 0.4.0 - * - * @param OutputItemData $outputItem The output item data. - * @param int $index The index of the output item. - * @return Candidate The parsed candidate. - */ - protected function parseFunctionCallOutputToCandidate(array $outputItem, int $index): Candidate - { - if (!isset($outputItem['call_id']) || !is_string($outputItem['call_id'])) { - throw ResponseException::fromMissingData( - $this->providerMetadata()->getName(), - "output[{$index}].call_id" - ); - } - if (!isset($outputItem['name']) || !is_string($outputItem['name'])) { - throw ResponseException::fromMissingData( - $this->providerMetadata()->getName(), - "output[{$index}].name" - ); - } - - /* - * Parse and normalize function arguments. - * OpenAI returns arguments as a JSON string. An empty object "{}" - * decodes to an empty array, which semantically means "no arguments" - * and should be normalized to null. - */ - $args = null; - if (isset($outputItem['arguments']) && is_string($outputItem['arguments'])) { - $decoded = json_decode($outputItem['arguments'], true); - if (is_array($decoded) && count($decoded) > 0) { - $args = $decoded; - } - } - - $functionCall = new FunctionCall( - $outputItem['call_id'], - $outputItem['name'], - $args - ); - - $part = new MessagePart($functionCall); - $message = new Message(MessageRoleEnum::model(), [$part]); - - return new Candidate($message, FinishReasonEnum::toolCalls()); - } - - /** - * Parses an output content item into a MessagePart. - * - * @since 0.4.0 - * - * @param array $contentItem The content item data. - * @return MessagePart|null The parsed message part, or null to skip. - */ - protected function parseOutputContentToPart(array $contentItem): ?MessagePart - { - $type = $contentItem['type'] ?? ''; - - if ($type === 'output_text') { - if (!isset($contentItem['text']) || !is_string($contentItem['text'])) { - throw new InvalidArgumentException('Content has an invalid output_text shape.'); - } - return new MessagePart($contentItem['text']); - } - - if ($type === 'function_call') { - if ( - !isset($contentItem['call_id']) || - !is_string($contentItem['call_id']) || - !isset($contentItem['name']) || - !is_string($contentItem['name']) - ) { - throw new InvalidArgumentException('Content has an invalid function_call shape.'); - } - - /* - * Parse and normalize function arguments. - * OpenAI returns arguments as a JSON string. An empty object "{}" - * decodes to an empty array, which semantically means "no arguments" - * and should be normalized to null. - */ - $args = null; - if (isset($contentItem['arguments']) && is_string($contentItem['arguments'])) { - $decoded = json_decode($contentItem['arguments'], true); - if (is_array($decoded) && count($decoded) > 0) { - $args = $decoded; - } - } - - return new MessagePart( - new FunctionCall( - $contentItem['call_id'], - $contentItem['name'], - $args - ) - ); - } - - // Skip unknown content types. - return null; - } - - /** - * Parses the response status to a finish reason. - * - * @since 0.4.0 - * - * @param string $status The response status. - * @param bool $hasFunctionCalls Whether the response contains function calls. - * @return FinishReasonEnum The finish reason. - */ - protected function parseStatusToFinishReason(string $status, bool $hasFunctionCalls): FinishReasonEnum - { - switch ($status) { - case 'completed': - return $hasFunctionCalls ? FinishReasonEnum::toolCalls() : FinishReasonEnum::stop(); - case 'incomplete': - return FinishReasonEnum::length(); - case 'failed': - case 'cancelled': - return FinishReasonEnum::error(); - default: - // Default to stop for unknown statuses. - return FinishReasonEnum::stop(); - } - } -} diff --git a/tests/integration/bootstrap.php b/tests/integration/bootstrap.php index 3e52d7c4..01f6ce8a 100644 --- a/tests/integration/bootstrap.php +++ b/tests/integration/bootstrap.php @@ -4,6 +4,7 @@ * Bootstrap file for integration tests. * * Loads environment variables from .env file before running tests. + * Registers AI provider packages for testing. */ declare(strict_types=1); @@ -11,6 +12,10 @@ require_once dirname(__DIR__, 2) . '/vendor/autoload.php'; use Symfony\Component\Dotenv\Dotenv; +use WordPress\AiClient\AiClient; +use WordPress\AnthropicAiProvider\Provider\AnthropicProvider; +use WordPress\GoogleAiProvider\Provider\GoogleProvider; +use WordPress\OpenAiAiProvider\Provider\OpenAiProvider; $envFile = dirname(__DIR__, 2) . '/.env'; if (file_exists($envFile)) { @@ -19,3 +24,9 @@ $dotenv->usePutenv(true); $dotenv->load($envFile); } + +// Register provider packages for integration tests. +$registry = AiClient::defaultRegistry(); +$registry->registerProvider(AnthropicProvider::class); +$registry->registerProvider(GoogleProvider::class); +$registry->registerProvider(OpenAiProvider::class); diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index 5ab0a6a2..5bc73242 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -9,10 +9,10 @@ use WordPress\AiClient\AiClient; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\UserMessage; -use WordPress\AiClient\ProviderImplementations\OpenAi\OpenAiProvider; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\ProviderRegistry; +use WordPress\AiClient\Tests\mocks\MockProvider; use WordPress\AiClient\Tests\traits\MockModelCreationTrait; /** @@ -241,9 +241,11 @@ public function testIsConfiguredReturnsFalseWhenProviderIsNotConfigured(): void */ public function testIsConfiguredWithProviderIdString(): void { - // This test will use the actual default registry since we can't easily mock static methods - // The default registry should have providers registered, so we test the delegation path - $result = AiClient::isConfigured('openai'); + // Register a mock provider to test the delegation path + $registry = AiClient::defaultRegistry(); + $registry->registerProvider(MockProvider::class); + + $result = AiClient::isConfigured('mock'); // The result will be false because no actual API keys are configured in tests, // but the important thing is that no exception is thrown and the registry delegation works @@ -255,9 +257,11 @@ public function testIsConfiguredWithProviderIdString(): void */ public function testIsConfiguredWithProviderClassName(): void { - // This test will use the actual default registry since we can't easily mock static methods - // The default registry should have providers registered, so we test the delegation path - $result = AiClient::isConfigured(OpenAiProvider::class); + // Register a mock provider to test the delegation path + $registry = AiClient::defaultRegistry(); + $registry->registerProvider(MockProvider::class); + + $result = AiClient::isConfigured(MockProvider::class); // The result will be false because no actual API keys are configured in tests, // but the important thing is that no exception is thrown and the registry delegation works diff --git a/tests/unit/ProviderImplementations/OpenAi/MockOpenAiImageGenerationModel.php b/tests/unit/ProviderImplementations/OpenAi/MockOpenAiImageGenerationModel.php deleted file mode 100644 index b72e974c..00000000 --- a/tests/unit/ProviderImplementations/OpenAi/MockOpenAiImageGenerationModel.php +++ /dev/null @@ -1,160 +0,0 @@ -mockHttpTransporter = $mockHttpTransporter; - $this->mockRequestAuthentication = $mockRequestAuthentication; - } - - /** - * {@inheritDoc} - */ - public function getHttpTransporter(): HttpTransporterInterface - { - return $this->mockHttpTransporter; - } - - /** - * {@inheritDoc} - */ - public function getRequestAuthentication(): RequestAuthenticationInterface - { - return $this->mockRequestAuthentication; - } - - /** - * Sets a mock generative AI result to be returned by parseResponseToGenerativeAiResult. - * - * @param GenerativeAiResult $result - */ - public function setMockGenerativeAiResult(GenerativeAiResult $result): void - { - $this->mockGenerativeAiResult = $result; - } - - /** - * {@inheritDoc} - */ - protected function parseResponseToGenerativeAiResult( - Response $response, - string $expectedMimeType = 'image/png' - ): GenerativeAiResult { - if ($this->mockGenerativeAiResult) { - return $this->mockGenerativeAiResult; - } - return parent::parseResponseToGenerativeAiResult($response, $expectedMimeType); - } - - // Expose protected methods for testing. - - /** - * Exposes prepareGenerateImageParams for testing. - * - * @param list $prompt - * @return array - */ - public function exposePrepareGenerateImageParams(array $prompt): array - { - return $this->prepareGenerateImageParams($prompt); - } - - /** - * Exposes isGptImageModel for testing. - * - * @param string $modelId - * @return bool - */ - public function exposeIsGptImageModel(string $modelId): bool - { - return $this->isGptImageModel($modelId); - } - - /** - * Exposes preparePromptParam for testing. - * - * @param list $messages - * @return string - */ - public function exposePreparePromptParam(array $messages): string - { - return $this->preparePromptParam($messages); - } - - /** - * Exposes prepareSizeParam for testing. - * - * @param MediaOrientationEnum|null $orientation - * @param string|null $aspectRatio - * @return string - */ - public function exposePrepareSize( - ?MediaOrientationEnum $orientation, - ?string $aspectRatio - ): string { - return $this->prepareSizeParam($orientation, $aspectRatio); - } - - /** - * Exposes parseResponseChoiceToCandidate for testing. - * - * @param array $choiceData - * @param int $index - * @param string $expectedMimeType - * @return Candidate - */ - public function exposeParseResponseChoiceToCandidate( - array $choiceData, - int $index, - string $expectedMimeType = 'image/png' - ): Candidate { - return $this->parseResponseChoiceToCandidate($choiceData, $index, $expectedMimeType); - } -} diff --git a/tests/unit/ProviderImplementations/OpenAi/MockOpenAiTextGenerationModel.php b/tests/unit/ProviderImplementations/OpenAi/MockOpenAiTextGenerationModel.php deleted file mode 100644 index deb738b4..00000000 --- a/tests/unit/ProviderImplementations/OpenAi/MockOpenAiTextGenerationModel.php +++ /dev/null @@ -1,236 +0,0 @@ -mockHttpTransporter = $mockHttpTransporter; - $this->mockRequestAuthentication = $mockRequestAuthentication; - } - - /** - * {@inheritDoc} - */ - public function getHttpTransporter(): HttpTransporterInterface - { - return $this->mockHttpTransporter; - } - - /** - * {@inheritDoc} - */ - public function getRequestAuthentication(): RequestAuthenticationInterface - { - return $this->mockRequestAuthentication; - } - - /** - * Sets a mock generative AI result to be returned by parseResponseToGenerativeAiResult. - * - * @param GenerativeAiResult $result - */ - public function setMockGenerativeAiResult(GenerativeAiResult $result): void - { - $this->mockGenerativeAiResult = $result; - } - - /** - * {@inheritDoc} - */ - public function parseResponseToGenerativeAiResult(Response $response): GenerativeAiResult - { - if ($this->mockGenerativeAiResult) { - return $this->mockGenerativeAiResult; - } - return parent::parseResponseToGenerativeAiResult($response); - } - - // Expose protected methods for testing. - - /** - * Exposes prepareGenerateTextParams for testing. - * - * @param list $prompt - * @return array - */ - public function exposePrepareGenerateTextParams(array $prompt): array - { - return $this->prepareGenerateTextParams($prompt); - } - - /** - * Exposes prepareInputParam for testing. - * - * @param list $messages - * @return list> - */ - public function exposePrepareInputParam(array $messages): array - { - return $this->prepareInputParam($messages); - } - - /** - * Exposes getMessageInputItem for testing. - * - * @param Message $message - * @return array|null - */ - public function exposeGetMessageInputItem(Message $message): ?array - { - return $this->getMessageInputItem($message); - } - - /** - * Exposes getMessageRoleString for testing. - * - * @param MessageRoleEnum $role - * @return string - */ - public function exposeGetMessageRoleString(MessageRoleEnum $role): string - { - return $this->getMessageRoleString($role); - } - - /** - * Exposes getMessagePartData for testing. - * - * @param MessagePart $part - * @param MessageRoleEnum $role - * @return array - */ - public function exposeGetMessagePartData(MessagePart $part, MessageRoleEnum $role): array - { - return $this->getMessagePartData($part, $role); - } - - /** - * Exposes prepareToolsParam for testing. - * - * @param list|null $functionDeclarations - * @param WebSearch|null $webSearch - * @return list> - */ - public function exposePrepareToolsParam( - ?array $functionDeclarations, - ?WebSearch $webSearch - ): array { - return $this->prepareToolsParam($functionDeclarations, $webSearch); - } - - /** - * Exposes parseOutputItemToCandidate for testing. - * - * @param array $outputItem - * @param int $index - * @param string $responseStatus - * @return Candidate|null - */ - public function exposeParseOutputItemToCandidate( - array $outputItem, - int $index, - string $responseStatus - ): ?Candidate { - return $this->parseOutputItemToCandidate($outputItem, $index, $responseStatus); - } - - /** - * Exposes parseMessageOutputToCandidate for testing. - * - * @param array $outputItem - * @param int $index - * @param string $responseStatus - * @return Candidate - */ - public function exposeParseMessageOutputToCandidate( - array $outputItem, - int $index, - string $responseStatus - ): Candidate { - return $this->parseMessageOutputToCandidate($outputItem, $index, $responseStatus); - } - - /** - * Exposes parseFunctionCallOutputToCandidate for testing. - * - * @param array $outputItem - * @param int $index - * @return Candidate - */ - public function exposeParseFunctionCallOutputToCandidate(array $outputItem, int $index): Candidate - { - return $this->parseFunctionCallOutputToCandidate($outputItem, $index); - } - - /** - * Exposes parseOutputContentToPart for testing. - * - * @param array $contentItem - * @return MessagePart|null - */ - public function exposeParseOutputContentToPart(array $contentItem): ?MessagePart - { - return $this->parseOutputContentToPart($contentItem); - } - - /** - * Exposes parseStatusToFinishReason for testing. - * - * @param string $status - * @param bool $hasFunctionCalls - * @return FinishReasonEnum - */ - public function exposeParseStatusToFinishReason(string $status, bool $hasFunctionCalls): FinishReasonEnum - { - return $this->parseStatusToFinishReason($status, $hasFunctionCalls); - } -} diff --git a/tests/unit/ProviderImplementations/OpenAi/OpenAiImageGenerationModelTest.php b/tests/unit/ProviderImplementations/OpenAi/OpenAiImageGenerationModelTest.php deleted file mode 100644 index 1b5b6bc9..00000000 --- a/tests/unit/ProviderImplementations/OpenAi/OpenAiImageGenerationModelTest.php +++ /dev/null @@ -1,431 +0,0 @@ -modelMetadata = $this->createStub(ModelMetadata::class); - $this->modelMetadata->method('getId')->willReturn('gpt-image-1'); - $this->providerMetadata = $this->createStub(ProviderMetadata::class); - $this->providerMetadata->method('getName')->willReturn('OpenAI'); - $this->mockHttpTransporter = $this->createMock(HttpTransporterInterface::class); - $this->mockRequestAuthentication = $this->createMock(RequestAuthenticationInterface::class); - } - - /** - * Creates a mock instance of OpenAiImageGenerationModel. - * - * @param ModelConfig|null $modelConfig - * @param string $modelId - * @return MockOpenAiImageGenerationModel - */ - private function createModel( - ?ModelConfig $modelConfig = null, - string $modelId = 'gpt-image-1' - ): MockOpenAiImageGenerationModel { - $this->modelMetadata = $this->createStub(ModelMetadata::class); - $this->modelMetadata->method('getId')->willReturn($modelId); - - $model = new MockOpenAiImageGenerationModel( - $this->modelMetadata, - $this->providerMetadata, - $this->mockHttpTransporter, - $this->mockRequestAuthentication - ); - if ($modelConfig) { - $model->setConfig($modelConfig); - } - return $model; - } - - /** - * A minimal valid 1x1 pixel PNG image encoded in base64. - */ - private const VALID_BASE64_IMAGE = - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; - - /** - * Tests generateImageResult() method on success. - * - * @return void - */ - public function testGenerateImageResultSuccess(): void - { - $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Generate a cat')])]; - $response = new Response( - 200, - [], - json_encode([ - 'created' => 1234567890, - 'data' => [ - [ - 'b64_json' => self::VALID_BASE64_IMAGE, - ], - ], - ]) - ); - - $this->mockRequestAuthentication - ->expects($this->once()) - ->method('authenticateRequest') - ->willReturnArgument(0); - - $this->mockHttpTransporter - ->expects($this->once()) - ->method('send') - ->willReturn($response); - - $model = $this->createModel(); - $result = $model->generateImageResult($prompt); - - $this->assertInstanceOf(GenerativeAiResult::class, $result); - $this->assertEquals('img-1234567890', $result->getId()); - $this->assertCount(1, $result->getCandidates()); - $candidate = $result->getCandidates()[0]; - $this->assertEquals(FinishReasonEnum::stop(), $candidate->getFinishReason()); - $parts = $candidate->getMessage()->getParts(); - $this->assertCount(1, $parts); - $this->assertTrue($parts[0]->getType()->isFile()); - $file = $parts[0]->getFile(); - $this->assertNotNull($file); - $this->assertEquals(self::VALID_BASE64_IMAGE, $file->getBase64Data()); - } - - /** - * Tests generateImageResult() method on API failure. - * - * @return void - */ - public function testGenerateImageResultApiFailure(): void - { - $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Generate a cat')])]; - $response = new Response(400, [], '{"error": "Invalid request."}'); - - $this->mockRequestAuthentication - ->expects($this->once()) - ->method('authenticateRequest') - ->willReturnArgument(0); - - $this->mockHttpTransporter - ->expects($this->once()) - ->method('send') - ->willReturn($response); - - $model = $this->createModel(); - - $this->expectException(ClientException::class); - $this->expectExceptionMessage('Bad Request (400) - Invalid request.'); - - $model->generateImageResult($prompt); - } - - /** - * Tests isGptImageModel() method. - * - * @return void - */ - public function testIsGptImageModel(): void - { - $model = $this->createModel(); - - // GPT image models should return true. - $this->assertTrue($model->exposeIsGptImageModel('gpt-image-1')); - $this->assertTrue($model->exposeIsGptImageModel('gpt-image-1-mini')); - $this->assertTrue($model->exposeIsGptImageModel('gpt-image-1.5')); - - // DALL-E models should return false. - $this->assertFalse($model->exposeIsGptImageModel('dall-e-2')); - $this->assertFalse($model->exposeIsGptImageModel('dall-e-3')); - - // Other models should return false. - $this->assertFalse($model->exposeIsGptImageModel('gpt-4o')); - } - - /** - * Tests preparePromptParam() with valid single message. - * - * @return void - */ - public function testPreparePromptParamWithValidMessage(): void - { - $model = $this->createModel(); - $messages = [new Message(MessageRoleEnum::user(), [new MessagePart('Generate a cat')])]; - - $prompt = $model->exposePreparePromptParam($messages); - - $this->assertEquals('Generate a cat', $prompt); - } - - /** - * Tests preparePromptParam() with multiple messages throws exception. - * - * @return void - */ - public function testPreparePromptParamWithMultipleMessagesThrowsException(): void - { - $model = $this->createModel(); - $messages = [ - new Message(MessageRoleEnum::user(), [new MessagePart('Message 1')]), - new Message(MessageRoleEnum::user(), [new MessagePart('Message 2')]), - ]; - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The API requires a single user message as prompt.'); - - $model->exposePreparePromptParam($messages); - } - - /** - * Tests preparePromptParam() with non-user message throws exception. - * - * @return void - */ - public function testPreparePromptParamWithNonUserMessageThrowsException(): void - { - $model = $this->createModel(); - $messages = [new Message(MessageRoleEnum::model(), [new MessagePart('Response')])]; - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The API requires a user message as prompt.'); - - $model->exposePreparePromptParam($messages); - } - - /** - * Tests prepareSizeParam() with GPT image model aspect ratios. - * - * @return void - */ - public function testPrepareSizeParamWithGptImageModelAspectRatios(): void - { - $model = $this->createModel(null, 'gpt-image-1'); - - $this->assertEquals('1024x1024', $model->exposePrepareSize(null, '1:1')); - $this->assertEquals('1536x1024', $model->exposePrepareSize(null, '3:2')); - $this->assertEquals('1024x1536', $model->exposePrepareSize(null, '2:3')); - } - - /** - * Tests prepareSizeParam() with GPT image model orientations. - * - * @return void - */ - public function testPrepareSizeParamWithGptImageModelOrientations(): void - { - $model = $this->createModel(null, 'gpt-image-1'); - - $landscape = MediaOrientationEnum::landscape(); - $portrait = MediaOrientationEnum::portrait(); - $square = MediaOrientationEnum::square(); - - $this->assertEquals('1536x1024', $model->exposePrepareSize($landscape, null)); - $this->assertEquals('1024x1536', $model->exposePrepareSize($portrait, null)); - $this->assertEquals('1024x1024', $model->exposePrepareSize($square, null)); - } - - /** - * Tests prepareSizeParam() with DALL-E 3 aspect ratios. - * - * @return void - */ - public function testPrepareSizeParamWithDalle3AspectRatios(): void - { - $model = $this->createModel(null, 'dall-e-3'); - - $this->assertEquals('1024x1024', $model->exposePrepareSize(null, '1:1')); - $this->assertEquals('1792x1024', $model->exposePrepareSize(null, '7:4')); - $this->assertEquals('1024x1792', $model->exposePrepareSize(null, '4:7')); - } - - /** - * Tests prepareSizeParam() with DALL-E 3 orientations. - * - * @return void - */ - public function testPrepareSizeParamWithDalle3Orientations(): void - { - $model = $this->createModel(null, 'dall-e-3'); - - $landscape = MediaOrientationEnum::landscape(); - $portrait = MediaOrientationEnum::portrait(); - $square = MediaOrientationEnum::square(); - - $this->assertEquals('1792x1024', $model->exposePrepareSize($landscape, null)); - $this->assertEquals('1024x1792', $model->exposePrepareSize($portrait, null)); - $this->assertEquals('1024x1024', $model->exposePrepareSize($square, null)); - } - - /** - * Tests prepareSizeParam() with DALL-E 2 (only supports square). - * - * @return void - */ - public function testPrepareSizeParamWithDalle2(): void - { - $model = $this->createModel(null, 'dall-e-2'); - - $landscape = MediaOrientationEnum::landscape(); - $portrait = MediaOrientationEnum::portrait(); - - // DALL-E 2 only supports square images. - $this->assertEquals('1024x1024', $model->exposePrepareSize(null, '1:1')); - $this->assertEquals('1024x1024', $model->exposePrepareSize($landscape, null)); - $this->assertEquals('1024x1024', $model->exposePrepareSize($portrait, null)); - } - - /** - * Tests prepareSizeParam() defaults to square. - * - * @return void - */ - public function testPrepareSizeParamDefaultsToSquare(): void - { - $gptModel = $this->createModel(null, 'gpt-image-1'); - $dalleModel = $this->createModel(null, 'dall-e-3'); - - $this->assertEquals('1024x1024', $gptModel->exposePrepareSize(null, null)); - $this->assertEquals('1024x1024', $dalleModel->exposePrepareSize(null, null)); - } - - /** - * Tests parseResponseChoiceToCandidate() method. - * - * @return void - */ - public function testParseResponseChoiceToCandidate(): void - { - $model = $this->createModel(); - - $candidate = $model->exposeParseResponseChoiceToCandidate([ - 'b64_json' => self::VALID_BASE64_IMAGE, - ], 0); - - $this->assertNotNull($candidate); - $this->assertEquals(FinishReasonEnum::stop(), $candidate->getFinishReason()); - $parts = $candidate->getMessage()->getParts(); - $this->assertCount(1, $parts); - $this->assertTrue($parts[0]->getType()->isFile()); - $file = $parts[0]->getFile(); - $this->assertNotNull($file); - $this->assertEquals(self::VALID_BASE64_IMAGE, $file->getBase64Data()); - $this->assertEquals('image/png', $file->getMimeType()); - } - - /** - * Tests parseResponseChoiceToCandidate() with custom MIME type. - * - * @return void - */ - public function testParseResponseChoiceToCandidateWithCustomMimeType(): void - { - $model = $this->createModel(); - - $candidate = $model->exposeParseResponseChoiceToCandidate([ - 'b64_json' => self::VALID_BASE64_IMAGE, - ], 0, 'image/jpeg'); - - $file = $candidate->getMessage()->getParts()[0]->getFile(); - $this->assertNotNull($file); - $this->assertEquals('image/jpeg', $file->getMimeType()); - } - - /** - * Tests prepareGenerateImageParams() for GPT image model. - * - * @return void - */ - public function testPrepareGenerateImageParamsForGptImageModel(): void - { - $config = new ModelConfig(); - $config->setOutputMimeType('image/webp'); - $model = $this->createModel($config, 'gpt-image-1'); - - $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Generate a cat')])]; - $params = $model->exposePrepareGenerateImageParams($prompt); - - $this->assertEquals('gpt-image-1', $params['model']); - $this->assertEquals('Generate a cat', $params['prompt']); - $this->assertEquals('webp', $params['output_format']); - $this->assertArrayNotHasKey('response_format', $params); - } - - /** - * Tests prepareGenerateImageParams() for DALL-E model. - * - * @return void - */ - public function testPrepareGenerateImageParamsForDalleModel(): void - { - $model = $this->createModel(null, 'dall-e-3'); - - $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Generate a cat')])]; - $params = $model->exposePrepareGenerateImageParams($prompt); - - $this->assertEquals('dall-e-3', $params['model']); - $this->assertEquals('Generate a cat', $params['prompt']); - $this->assertEquals('b64_json', $params['response_format']); - $this->assertArrayNotHasKey('output_format', $params); - } - - /** - * Tests prepareGenerateImageParams() with size configuration. - * - * @return void - */ - public function testPrepareGenerateImageParamsWithSizeConfig(): void - { - $config = new ModelConfig(); - $config->setOutputMediaAspectRatio('3:2'); - $model = $this->createModel($config, 'gpt-image-1'); - - $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Generate a cat')])]; - $params = $model->exposePrepareGenerateImageParams($prompt); - - $this->assertEquals('1536x1024', $params['size']); - } -} diff --git a/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php b/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php deleted file mode 100644 index 78d350a1..00000000 --- a/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php +++ /dev/null @@ -1,719 +0,0 @@ -modelMetadata = $this->createStub(ModelMetadata::class); - $this->modelMetadata->method('getId')->willReturn('gpt-4o'); - $this->providerMetadata = $this->createStub(ProviderMetadata::class); - $this->providerMetadata->method('getName')->willReturn('OpenAI'); - $this->mockHttpTransporter = $this->createMock(HttpTransporterInterface::class); - $this->mockRequestAuthentication = $this->createMock(RequestAuthenticationInterface::class); - } - - /** - * Creates a mock instance of OpenAiTextGenerationModel. - * - * @param ModelConfig|null $modelConfig - * @return MockOpenAiTextGenerationModel - */ - private function createModel(?ModelConfig $modelConfig = null): MockOpenAiTextGenerationModel - { - $model = new MockOpenAiTextGenerationModel( - $this->modelMetadata, - $this->providerMetadata, - $this->mockHttpTransporter, - $this->mockRequestAuthentication - ); - if ($modelConfig) { - $model->setConfig($modelConfig); - } - return $model; - } - - /** - * Tests generateTextResult() method on success. - * - * @return void - */ - public function testGenerateTextResultSuccess(): void - { - $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Hello')])]; - $response = new Response( - 200, - [], - json_encode([ - 'id' => 'resp_123', - 'status' => 'completed', - 'output' => [ - [ - 'type' => 'message', - 'role' => 'assistant', - 'content' => [ - ['type' => 'output_text', 'text' => 'Hi there!'], - ], - ], - ], - 'usage' => [ - 'input_tokens' => 10, - 'output_tokens' => 5, - 'total_tokens' => 15, - ], - ]) - ); - - $this->mockRequestAuthentication - ->expects($this->once()) - ->method('authenticateRequest') - ->willReturnArgument(0); - - $this->mockHttpTransporter - ->expects($this->once()) - ->method('send') - ->willReturn($response); - - $model = $this->createModel(); - $result = $model->generateTextResult($prompt); - - $this->assertInstanceOf(GenerativeAiResult::class, $result); - $this->assertEquals('resp_123', $result->getId()); - $this->assertCount(1, $result->getCandidates()); - $this->assertEquals('Hi there!', $result->getCandidates()[0]->getMessage()->getParts()[0]->getText()); - $this->assertEquals(FinishReasonEnum::stop(), $result->getCandidates()[0]->getFinishReason()); - $this->assertEquals(10, $result->getTokenUsage()->getPromptTokens()); - $this->assertEquals(5, $result->getTokenUsage()->getCompletionTokens()); - $this->assertEquals(15, $result->getTokenUsage()->getTotalTokens()); - } - - /** - * Tests generateTextResult() method on API failure. - * - * @return void - */ - public function testGenerateTextResultApiFailure(): void - { - $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Hello')])]; - $response = new Response(400, [], '{"error": "Invalid parameter."}'); - - $this->mockRequestAuthentication - ->expects($this->once()) - ->method('authenticateRequest') - ->willReturnArgument(0); - - $this->mockHttpTransporter - ->expects($this->once()) - ->method('send') - ->willReturn($response); - - $model = $this->createModel(); - - $this->expectException(ClientException::class); - $this->expectExceptionMessage('Bad Request (400) - Invalid parameter.'); - - $model->generateTextResult($prompt); - } - - /** - * Tests prepareGenerateTextParams() with basic text prompt. - * - * @return void - */ - public function testPrepareGenerateTextParamsBasicText(): void - { - $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Test message')])]; - $model = $this->createModel(); - - $params = $model->exposePrepareGenerateTextParams($prompt); - - $this->assertArrayHasKey('model', $params); - $this->assertEquals('gpt-4o', $params['model']); - $this->assertArrayHasKey('input', $params); - $this->assertCount(1, $params['input']); - $this->assertArrayNotHasKey('type', $params['input'][0]); - $this->assertEquals('user', $params['input'][0]['role']); - $this->assertCount(1, $params['input'][0]['content']); - $this->assertEquals('input_text', $params['input'][0]['content'][0]['type']); - $this->assertEquals('Test message', $params['input'][0]['content'][0]['text']); - } - - /** - * Tests prepareGenerateTextParams() with system instruction. - * - * @return void - */ - public function testPrepareGenerateTextParamsWithSystemInstruction(): void - { - $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Hello')])]; - $config = new ModelConfig(); - $config->setSystemInstruction('You are a helpful assistant.'); - $model = $this->createModel($config); - - $params = $model->exposePrepareGenerateTextParams($prompt); - - $this->assertArrayHasKey('instructions', $params); - $this->assertEquals('You are a helpful assistant.', $params['instructions']); - } - - /** - * Tests prepareGenerateTextParams() with max tokens. - * - * @return void - */ - public function testPrepareGenerateTextParamsWithMaxTokens(): void - { - $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Hello')])]; - $config = new ModelConfig(); - $config->setMaxTokens(1000); - $model = $this->createModel($config); - - $params = $model->exposePrepareGenerateTextParams($prompt); - - $this->assertArrayHasKey('max_output_tokens', $params); - $this->assertEquals(1000, $params['max_output_tokens']); - } - - /** - * Tests prepareGenerateTextParams() with temperature and topP. - * - * @return void - */ - public function testPrepareGenerateTextParamsWithTemperatureAndTopP(): void - { - $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Hello')])]; - $config = new ModelConfig(); - $config->setTemperature(0.7); - $config->setTopP(0.9); - $model = $this->createModel($config); - - $params = $model->exposePrepareGenerateTextParams($prompt); - - $this->assertArrayHasKey('temperature', $params); - $this->assertEquals(0.7, $params['temperature']); - $this->assertArrayHasKey('top_p', $params); - $this->assertEquals(0.9, $params['top_p']); - } - - /** - * Tests prepareGenerateTextParams() with function declarations. - * - * @return void - */ - public function testPrepareGenerateTextParamsWithFunctionDeclarations(): void - { - $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('What is the weather?')])]; - $functionDeclaration = new FunctionDeclaration( - 'get_weather', - 'Get the current weather', - ['type' => 'object', 'properties' => ['location' => ['type' => 'string']]] - ); - $config = new ModelConfig(); - $config->setFunctionDeclarations([$functionDeclaration]); - $model = $this->createModel($config); - - $params = $model->exposePrepareGenerateTextParams($prompt); - - $this->assertArrayHasKey('tools', $params); - $this->assertCount(1, $params['tools']); - $this->assertEquals('function', $params['tools'][0]['type']); - $this->assertEquals('get_weather', $params['tools'][0]['name']); - $this->assertEquals('Get the current weather', $params['tools'][0]['description']); - } - - /** - * Tests prepareGenerateTextParams() with web search. - * - * @return void - */ - public function testPrepareGenerateTextParamsWithWebSearch(): void - { - $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Search for news')])]; - $webSearch = new WebSearch(); - $config = new ModelConfig(); - $config->setWebSearch($webSearch); - $model = $this->createModel($config); - - $params = $model->exposePrepareGenerateTextParams($prompt); - - $this->assertArrayHasKey('tools', $params); - $this->assertCount(1, $params['tools']); - $this->assertEquals('web_search', $params['tools'][0]['type']); - } - - /** - * Tests prepareGenerateTextParams() with previous_response_id for conversation state. - * - * @return void - */ - public function testPrepareGenerateTextParamsWithPreviousResponseId(): void - { - $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Continue the conversation')])]; - $config = new ModelConfig(); - $config->setCustomOptions(['previous_response_id' => 'resp_abc123']); - $model = $this->createModel($config); - - $params = $model->exposePrepareGenerateTextParams($prompt); - - $this->assertArrayHasKey('previous_response_id', $params); - $this->assertEquals('resp_abc123', $params['previous_response_id']); - } - - /** - * Tests prepareGenerateTextParams() with JSON output schema. - * - * @return void - */ - public function testPrepareGenerateTextParamsWithJsonOutput(): void - { - $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Return JSON')])]; - $schema = [ - 'type' => 'object', - 'properties' => [ - 'name' => ['type' => 'string'], - ], - ]; - $config = new ModelConfig(); - $config->setOutputMimeType('application/json'); - $config->setOutputSchema($schema); - $model = $this->createModel($config); - - $params = $model->exposePrepareGenerateTextParams($prompt); - - $this->assertArrayHasKey('text', $params); - $this->assertArrayHasKey('format', $params['text']); - $this->assertEquals('json_schema', $params['text']['format']['type']); - $this->assertEquals($schema, $params['text']['format']['schema']); - } - - /** - * Tests getMessageRoleString() method. - * - * @return void - */ - public function testGetMessageRoleString(): void - { - $model = $this->createModel(); - - $this->assertEquals('user', $model->exposeGetMessageRoleString(MessageRoleEnum::user())); - $this->assertEquals('assistant', $model->exposeGetMessageRoleString(MessageRoleEnum::model())); - } - - /** - * Tests getMessagePartData() with text part. - * - * @return void - */ - public function testGetMessagePartDataWithText(): void - { - $model = $this->createModel(); - $part = new MessagePart('Hello world'); - - $data = $model->exposeGetMessagePartData($part, MessageRoleEnum::user()); - - $this->assertEquals('input_text', $data['type']); - $this->assertEquals('Hello world', $data['text']); - } - - /** - * Tests getMessagePartData() with text part. - * - * @return void - */ - public function testGetMessagePartDataWithModelText(): void - { - $model = $this->createModel(); - $part = new MessagePart('Hello world'); - - $data = $model->exposeGetMessagePartData($part, MessageRoleEnum::model()); - - $this->assertEquals('output_text', $data['type']); - $this->assertEquals('Hello world', $data['text']); - } - - /** - * Tests getMessagePartData() with remote image. - * - * @return void - */ - public function testGetMessagePartDataWithRemoteImage(): void - { - $model = $this->createModel(); - $file = new File('https://example.com/image.png', 'image/png'); - $part = new MessagePart($file); - - $data = $model->exposeGetMessagePartData($part, MessageRoleEnum::user()); - - $this->assertEquals('input_image', $data['type']); - $this->assertEquals('https://example.com/image.png', $data['image_url']); - } - - /** - * Tests getMessagePartData() with inline image. - * - * @return void - */ - public function testGetMessagePartDataWithInlineImage(): void - { - $model = $this->createModel(); - // A minimal 1x1 pixel PNG image encoded in base64. - $b64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; - $file = new File($b64, 'image/png'); - $part = new MessagePart($file); - - $data = $model->exposeGetMessagePartData($part, MessageRoleEnum::user()); - - $this->assertEquals('input_image', $data['type']); - $this->assertStringStartsWith('data:image/png;base64,', $data['image_url']); - } - - /** - * Tests getMessageInputItem() with function response message. - * - * @return void - */ - public function testGetMessageInputItemWithFunctionResponse(): void - { - $model = $this->createModel(); - $functionResponse = new FunctionResponse('call_123', 'get_weather', ['temperature' => 72]); - $part = new MessagePart($functionResponse); - $message = new Message(MessageRoleEnum::user(), [$part]); - - $data = $model->exposeGetMessageInputItem($message); - - $this->assertNotNull($data); - $this->assertEquals('function_call_output', $data['type']); - $this->assertEquals('call_123', $data['call_id']); - $this->assertEquals('{"temperature":72}', $data['output']); - } - - /** - * Tests getMessageInputItem() with function call message. - * - * @return void - */ - public function testGetMessageInputItemWithFunctionCall(): void - { - $model = $this->createModel(); - $functionCall = new FunctionCall('call_456', 'search', ['query' => 'test']); - $part = new MessagePart($functionCall); - $message = new Message(MessageRoleEnum::model(), [$part]); - - $data = $model->exposeGetMessageInputItem($message); - - $this->assertNotNull($data); - $this->assertEquals('function_call', $data['type']); - $this->assertEquals('call_456', $data['call_id']); - $this->assertEquals('search', $data['name']); - $this->assertEquals('{"query":"test"}', $data['arguments']); - } - - /** - * Tests that function response must be the only part in a message for OpenAI. - * - * @return void - */ - public function testValidateMessagesRejectsFunctionResponseMixedWithText(): void - { - $model = $this->createModel(); - $messages = [ - new Message( - MessageRoleEnum::user(), - [ - new MessagePart('Some text'), - new MessagePart(new FunctionResponse('func_123', 'search', ['result' => 'data'])), - ] - ), - ]; - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage( - 'Function response parts must be the only part in a message for the OpenAI Responses API.' - ); - - $model->exposePrepareInputParam($messages); - } - - /** - * Tests that function call must be the only part in a message for OpenAI. - * - * @return void - */ - public function testValidateMessagesRejectsFunctionCallMixedWithText(): void - { - $model = $this->createModel(); - $messages = [ - new Message( - MessageRoleEnum::model(), - [ - new MessagePart('Some text'), - new MessagePart(new FunctionCall('call_123', 'search', ['query' => 'test'])), - ] - ), - ]; - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage( - 'Function call parts must be the only part in a message for the OpenAI Responses API.' - ); - - $model->exposePrepareInputParam($messages); - } - - /** - * Tests prepareToolsParam() with function declarations and web search. - * - * @return void - */ - public function testPrepareToolsParamWithFunctionAndWebSearch(): void - { - $model = $this->createModel(); - $functionDeclaration = new FunctionDeclaration( - 'test_func', - 'A test function', - ['type' => 'object'] - ); - $webSearch = new WebSearch(); - - $tools = $model->exposePrepareToolsParam( - [$functionDeclaration], - $webSearch - ); - - $this->assertCount(2, $tools); - $toolTypes = array_column($tools, 'type'); - $this->assertContains('function', $toolTypes); - $this->assertContains('web_search', $toolTypes); - } - - /** - * Tests parseStatusToFinishReason() method. - * - * @return void - */ - public function testParseStatusToFinishReason(): void - { - $model = $this->createModel(); - - $this->assertEquals( - FinishReasonEnum::stop(), - $model->exposeParseStatusToFinishReason('completed', false) - ); - $this->assertEquals( - FinishReasonEnum::toolCalls(), - $model->exposeParseStatusToFinishReason('completed', true) - ); - $this->assertEquals( - FinishReasonEnum::length(), - $model->exposeParseStatusToFinishReason('incomplete', false) - ); - $this->assertEquals( - FinishReasonEnum::error(), - $model->exposeParseStatusToFinishReason('failed', false) - ); - $this->assertEquals( - FinishReasonEnum::error(), - $model->exposeParseStatusToFinishReason('cancelled', false) - ); - } - - /** - * Tests parseOutputContentToPart() with text content. - * - * @return void - */ - public function testParseOutputContentToPartWithText(): void - { - $model = $this->createModel(); - - $part = $model->exposeParseOutputContentToPart([ - 'type' => 'output_text', - 'text' => 'Hello world', - ]); - - $this->assertNotNull($part); - $this->assertTrue($part->getType()->isText()); - $this->assertEquals('Hello world', $part->getText()); - } - - /** - * Tests parseOutputContentToPart() with function call. - * - * @return void - */ - public function testParseOutputContentToPartWithFunctionCall(): void - { - $model = $this->createModel(); - - $part = $model->exposeParseOutputContentToPart([ - 'type' => 'function_call', - 'call_id' => 'call_123', - 'name' => 'get_weather', - 'arguments' => '{"location": "Paris"}', - ]); - - $this->assertNotNull($part); - $this->assertTrue($part->getType()->isFunctionCall()); - $functionCall = $part->getFunctionCall(); - $this->assertNotNull($functionCall); - $this->assertEquals('call_123', $functionCall->getId()); - $this->assertEquals('get_weather', $functionCall->getName()); - $this->assertEquals(['location' => 'Paris'], $functionCall->getArgs()); - } - - /** - * Tests parseMessageOutputToCandidate() method. - * - * @return void - */ - public function testParseMessageOutputToCandidate(): void - { - $model = $this->createModel(); - - $candidate = $model->exposeParseMessageOutputToCandidate( - [ - 'type' => 'message', - 'role' => 'assistant', - 'content' => [ - ['type' => 'output_text', 'text' => 'Hello!'], - ], - ], - 0, - 'completed' - ); - - $this->assertNotNull($candidate); - $this->assertEquals(FinishReasonEnum::stop(), $candidate->getFinishReason()); - $this->assertEquals(MessageRoleEnum::model(), $candidate->getMessage()->getRole()); - $this->assertEquals('Hello!', $candidate->getMessage()->getParts()[0]->getText()); - } - - /** - * Tests parseFunctionCallOutputToCandidate() method. - * - * @return void - */ - public function testParseFunctionCallOutputToCandidate(): void - { - $model = $this->createModel(); - - $candidate = $model->exposeParseFunctionCallOutputToCandidate( - [ - 'type' => 'function_call', - 'call_id' => 'call_abc', - 'name' => 'search', - 'arguments' => '{"query": "test"}', - ], - 0 - ); - - $this->assertNotNull($candidate); - $this->assertEquals(FinishReasonEnum::toolCalls(), $candidate->getFinishReason()); - $parts = $candidate->getMessage()->getParts(); - $this->assertCount(1, $parts); - $this->assertTrue($parts[0]->getType()->isFunctionCall()); - } - - /** - * Tests generateTextResult() with function call response. - * - * @return void - */ - public function testGenerateTextResultWithFunctionCallResponse(): void - { - $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('What is the weather?')])]; - $response = new Response( - 200, - [], - json_encode([ - 'id' => 'resp_456', - 'status' => 'completed', - 'output' => [ - [ - 'type' => 'function_call', - 'call_id' => 'call_789', - 'name' => 'get_weather', - 'arguments' => '{"location": "Paris"}', - ], - ], - 'usage' => [ - 'input_tokens' => 20, - 'output_tokens' => 10, - 'total_tokens' => 30, - ], - ]) - ); - - $this->mockRequestAuthentication - ->expects($this->once()) - ->method('authenticateRequest') - ->willReturnArgument(0); - - $this->mockHttpTransporter - ->expects($this->once()) - ->method('send') - ->willReturn($response); - - $model = $this->createModel(); - $result = $model->generateTextResult($prompt); - - $this->assertInstanceOf(GenerativeAiResult::class, $result); - $this->assertCount(1, $result->getCandidates()); - $candidate = $result->getCandidates()[0]; - $this->assertEquals(FinishReasonEnum::toolCalls(), $candidate->getFinishReason()); - $parts = $candidate->getMessage()->getParts(); - $this->assertCount(1, $parts); - $this->assertTrue($parts[0]->getType()->isFunctionCall()); - $functionCall = $parts[0]->getFunctionCall(); - $this->assertNotNull($functionCall); - $this->assertEquals('get_weather', $functionCall->getName()); - } -}