diff --git a/assets/js/connectors-extended.js b/assets/js/connectors-extended.js new file mode 100644 index 00000000..894e0fd6 --- /dev/null +++ b/assets/js/connectors-extended.js @@ -0,0 +1,663 @@ +/** + * Extended Providers – WP 7.0 Connectors Integration + * + * Registers additional AI provider connectors on the Settings > Connectors page. + * Provider data is injected by PHP into window.wpAiExtendedConnectors. + * + * @since 0.4.0 + * @package + */ + +/* eslint-disable @wordpress/no-unsafe-wp-apis, import/no-unresolved */ +/** + * WordPress dependencies + */ +import { + __experimentalRegisterConnector as registerConnector, + __experimentalConnectorItem as ConnectorItem, + __experimentalDefaultConnectorSettings as DefaultConnectorSettings, +} from '@wordpress/connectors'; +/* eslint-enable @wordpress/no-unsafe-wp-apis, import/no-unresolved */ + +const { + createElement: h, + useState, + useEffect, + useCallback, +} = window.wp.element; +const { + Button, + TextControl, + __experimentalHStack: HStack, + __experimentalVStack: VStack, + ExternalLink, +} = window.wp.components; +const apiFetch = window.wp.apiFetch; +const { __ } = window.wp.i18n; + +/** + * Provider data injected from PHP. + * + * @type {Array<{id: string, label: string, description: string, settingName: string, helpUrl: string, helpLabel: string, extraFields: Array}>} + */ +const providers = window.wpAiExtendedConnectors || []; + +/* ────────────────────────────────────────────── + * Provider SVG icons (40×40, matching core style) + * Extracted from feature/providers branch TSX. + * ────────────────────────────────────────────── */ + +function svg( viewBox, ...children ) { + return h( + 'svg', + { + width: '40', + height: '40', + viewBox, + fill: 'currentColor', + xmlns: 'http://www.w3.org/2000/svg', + }, + ...children + ); +} + +function path( d, extra ) { + return h( 'path', { d, ...extra } ); +} + +const ICONS = { + cloudflare: () => + h( + 'svg', + { + width: '40', + height: '40', + viewBox: '0 0 24 24', + fill: 'none', + xmlns: 'http://www.w3.org/2000/svg', + }, + path( + 'M16.493 17.4c.135-.52.08-.983-.161-1.338-.215-.328-.592-.519-1.05-.519l-8.663-.109a.148.148 0 01-.135-.082c-.027-.054-.027-.109-.027-.163.027-.082.108-.164.189-.164l8.744-.11c1.05-.054 2.153-.9 2.556-1.937l.511-1.31c.027-.055.027-.11.027-.164C17.92 8.91 15.66 7 12.942 7c-2.503 0-4.628 1.638-5.381 3.903a2.432 2.432 0 00-1.803-.491c-1.21.109-2.153 1.092-2.287 2.32-.027.328 0 .628.054.9C1.56 13.688 0 15.326 0 17.319c0 .19.027.355.027.545 0 .082.08.137.161.137h15.983c.08 0 .188-.055.215-.164l.107-.437', + { fill: '#F38020' } + ), + path( + 'M19.238 11.75h-.242c-.054 0-.108.054-.135.109l-.35 1.2c-.134.52-.08.983.162 1.338.215.328.592.518 1.05.518l1.855.11c.054 0 .108.027.135.082.027.054.027.109.027.163-.027.082-.108.164-.188.164l-1.91.11c-1.05.054-2.153.9-2.557 1.937l-.134.355c-.027.055.026.137.107.137h6.592c.081 0 .162-.055.162-.137.107-.41.188-.846.188-1.31-.027-2.62-2.153-4.777-4.762-4.777', + { fill: '#FCAD32' } + ) + ), + + cohere: () => + h( + 'svg', + { + width: '40', + height: '40', + viewBox: '0 0 24 24', + fill: 'none', + xmlns: 'http://www.w3.org/2000/svg', + }, + path( + 'M8.128 14.099c.592 0 1.77-.033 3.398-.703 1.897-.781 5.672-2.2 8.395-3.656 1.905-1.018 2.74-2.366 2.74-4.18A4.56 4.56 0 0018.1 1H7.549A6.55 6.55 0 001 7.55c0 3.617 2.745 6.549 7.128 6.549z', + { fill: '#39594D', fillRule: 'evenodd', clipRule: 'evenodd' } + ), + path( + 'M9.912 18.61a4.387 4.387 0 012.705-4.052l3.323-1.38c3.361-1.394 7.06 1.076 7.06 4.715a5.104 5.104 0 01-5.105 5.104l-3.597-.001a4.386 4.386 0 01-4.386-4.387z', + { fill: '#D18EE2', fillRule: 'evenodd', clipRule: 'evenodd' } + ), + path( + 'M4.776 14.962A3.775 3.775 0 001 18.738v.489a3.776 3.776 0 007.551 0v-.49a3.775 3.775 0 00-3.775-3.775z', + { fill: '#FF7759' } + ) + ), + + deepseek: () => + svg( + '0 0 24 24', + path( + 'M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z' + ) + ), + + fal: () => + svg( + '0 0 24 24', + path( + 'M15.477 0c.415 0 .749.338.788.752a7.775 7.775 0 006.985 6.984c.413.04.752.373.752.788v6.952c0 .415-.338.748-.752.788a7.775 7.775 0 00-6.985 6.984c-.04.414-.373.752-.788.752H8.525c-.416 0-.749-.338-.789-.752a7.775 7.775 0 00-6.984-6.984c-.414-.04-.752-.373-.752-.788V8.524c0-.415.338-.748.752-.788A7.775 7.775 0 007.736.752C7.776.338 8.11 0 8.526 0h6.95zM4.819 11.98a7.226 7.226 0 007.223 7.23 7.226 7.226 0 007.223-7.23c0-3.994-3.234-7.23-7.223-7.23a7.227 7.227 0 00-7.223 7.23z', + { fillRule: 'evenodd', clipRule: 'evenodd' } + ) + ), + + grok: () => + svg( + '0 0 24 24', + path( + 'M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815' + ) + ), + + groq: () => + svg( + '0 0 24 24', + path( + 'M12.036 2c-3.853-.035-7 3-7.036 6.781-.035 3.782 3.055 6.872 6.908 6.907h2.42v-2.566h-2.292c-2.407.028-4.38-1.866-4.408-4.23-.029-2.362 1.901-4.298 4.308-4.326h.1c2.407 0 4.358 1.915 4.365 4.278v6.305c0 2.342-1.944 4.25-4.323 4.279a4.375 4.375 0 01-3.033-1.252l-1.851 1.818A7 7 0 0012.029 22h.092c3.803-.056 6.858-3.083 6.879-6.816v-6.5C18.907 4.963 15.817 2 12.036 2z' + ) + ), + + huggingface: () => + h( + 'svg', + { + width: '40', + height: '40', + viewBox: '0 0 24 24', + fill: 'none', + xmlns: 'http://www.w3.org/2000/svg', + }, + path( + 'M2.25 11.535c0-3.407 1.847-6.554 4.844-8.258a9.822 9.822 0 019.687 0c2.997 1.704 4.844 4.851 4.844 8.258 0 5.266-4.337 9.535-9.687 9.535S2.25 16.8 2.25 11.535z', + { fill: '#FF9D0B' } + ), + path( + 'M11.938 20.086c4.797 0 8.687-3.829 8.687-8.551 0-4.722-3.89-8.55-8.687-8.55-4.798 0-8.688 3.828-8.688 8.55 0 4.722 3.89 8.55 8.688 8.55z', + { fill: '#FFD21E' } + ), + path( + 'M11.875 15.113c2.457 0 3.25-2.156 3.25-3.263 0-.576-.393-.394-1.023-.089-.582.283-1.365.675-2.224.675-1.798 0-3.25-1.693-3.25-.586 0 1.107.79 3.263 3.25 3.263h-.003z', + { fill: '#FF323D' } + ), + path( + 'M14.76 9.21c.32.108.445.753.767.585.447-.233.707-.708.659-1.204a1.235 1.235 0 00-.879-1.059 1.262 1.262 0 00-1.33.394c-.322.384-.377.92-.14 1.36.153.283.638-.177.925-.079l-.002.003zm-5.887 0c-.32.108-.448.753-.768.585a1.226 1.226 0 01-.658-1.204c.048-.495.395-.913.878-1.059a1.262 1.262 0 011.33.394c.322.384.377.92.14 1.36-.152.283-.64-.177-.925-.079l.003.003zm1.12 5.34a2.166 2.166 0 011.325-1.106c.07-.02.144.06.219.171l.192.306c.069.1.139.175.209.175.074 0 .15-.074.223-.172l.205-.302c.08-.11.157-.188.234-.165.537.168.986.536 1.25 1.026.932-.724 1.275-1.905 1.275-2.633 0-.508-.306-.426-.81-.19l-.616.296c-.52.24-1.148.48-1.824.48-.676 0-1.302-.24-1.823-.48l-.589-.283c-.52-.248-.838-.342-.838.177 0 .703.32 1.831 1.187 2.56l.18.14z', + { fill: '#3A3B45' } + ) + ), + + ollama: () => + svg( + '0 0 24 24', + path( + 'M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.375-1.451.557-2.403.557-1.009 0-1.871-.259-2.493-.734-.617-.47-.963-1.13-.963-1.845 0-.707.398-1.417 1.056-1.946.668-.537 1.55-.849 2.485-.849zm0 .896a3.07 3.07 0 00-1.916.65c-.461.37-.722.835-.722 1.25 0 .428.21.829.61 1.134.455.347 1.124.548 1.943.548.799 0 1.473-.147 1.932-.426.463-.28.7-.686.7-1.257 0-.423-.246-.89-.683-1.256-.484-.405-1.14-.643-1.864-.643zm.662 1.21l.004.004c.12.151.095.37-.056.49l-.292.23v.446a.375.375 0 01-.376.373.375.375 0 01-.376-.373v-.46l-.271-.218a.347.347 0 01-.052-.49.353.353 0 01.494-.051l.215.172.22-.174a.353.353 0 01.49.051zm-5.04-1.919c.478 0 .867.39.867.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zm8.706 0c.48 0 .868.39.868.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zM7.44 2.3l-.003.002a.659.659 0 00-.285.238l-.005.006c-.138.189-.258.467-.348.832-.17.692-.216 1.631-.124 2.782.43-.128.899-.208 1.404-.237l.01-.001.019-.034c.046-.082.095-.161.148-.239.123-.771.022-1.692-.253-2.444-.134-.364-.297-.65-.453-.813a.628.628 0 00-.107-.09L7.44 2.3zm9.174.04l-.002.001a.628.628 0 00-.107.09c-.156.163-.32.45-.453.814-.29.794-.387 1.776-.23 2.572l.058.097.008.014h.03a5.184 5.184 0 011.466.212c.086-1.124.038-2.043-.128-2.722-.09-.365-.21-.643-.349-.832l-.004-.006a.659.659 0 00-.285-.239h-.004z', + { fillRule: 'evenodd' } + ) + ), + + openrouter: () => + svg( + '0 0 24 24', + path( + 'M16.804 1.957l7.22 4.105v.087L16.73 10.21l.017-2.117-.821-.03c-1.059-.028-1.611.002-2.268.11-1.064.175-2.038.577-3.147 1.352L8.345 11.03c-.284.195-.495.336-.68.455l-.515.322-.397.234.385.23.53.338c.476.314 1.17.796 2.701 1.866 1.11.775 2.083 1.177 3.147 1.352l.3.045c.694.091 1.375.094 2.825.033l.022-2.159 7.22 4.105v.087L16.589 22l.014-1.862-.635.022c-1.386.042-2.137.002-3.138-.162-1.694-.28-3.26-.926-4.881-2.059l-2.158-1.5a21.997 21.997 0 00-.755-.498l-.467-.28a55.927 55.927 0 00-.76-.43C2.908 14.73.563 14.116 0 14.116V9.888l.14.004c.564-.007 2.91-.622 3.809-1.124l1.016-.58.438-.274c.428-.28 1.072-.726 2.686-1.853 1.621-1.133 3.186-1.78 4.881-2.059 1.152-.19 1.974-.213 3.814-.138l.02-1.907z', + { fillRule: 'evenodd' } + ) + ), +}; + +/* ────────────────────────────────────────────── + * Shared UI helpers + * ────────────────────────────────────────────── */ + +function ConnectedBadge() { + return h( + 'span', + { + style: { + color: '#345b37', + backgroundColor: '#eff8f0', + padding: '4px 12px', + borderRadius: '2px', + fontSize: '13px', + fontWeight: 500, + whiteSpace: 'nowrap', + }, + }, + __( 'Connected', 'ai' ) + ); +} + +/* ────────────────────────────────────────────── + * Cloudflare custom settings (Account ID + API Key) + * ────────────────────────────────────────────── */ + +function CloudflareConnectorSettings( { + onSave, + onRemove, + initialApiKey = '', + initialAccountId = '', + helpUrl, + helpLabel, + readOnly = false, +} ) { + const [ apiKey, setApiKey ] = useState( initialApiKey ); + const [ accountId, setAccountId ] = useState( initialAccountId ); + const [ isSaving, setIsSaving ] = useState( false ); + const [ saveError, setSaveError ] = useState( null ); + + const helpLinkLabel = helpLabel || helpUrl?.replace( /^https?:\/\//, '' ); + + const handleSave = async () => { + setSaveError( null ); + setIsSaving( true ); + try { + await onSave?.( apiKey, accountId ); + } catch ( error ) { + setSaveError( + error instanceof Error + ? error.message + : __( + 'It was not possible to connect to the provider using this key.', + 'ai' + ) + ); + } finally { + setIsSaving( false ); + } + }; + + const getHelp = () => { + if ( readOnly ) { + return h( + window.wp.element.Fragment, + null, + __( + 'Your API key is stored securely. You can reset it at', + 'ai' + ), + ' ', + helpUrl + ? h( ExternalLink, { href: helpUrl }, helpLinkLabel ) + : null + ); + } + if ( saveError ) { + return h( 'span', { style: { color: '#cc1818' } }, saveError ); + } + if ( helpUrl ) { + return h( + window.wp.element.Fragment, + null, + __( 'Get your API key at', 'ai' ), + ' ', + h( ExternalLink, { href: helpUrl }, helpLinkLabel ) + ); + } + return undefined; + }; + + return h( + VStack, + { + spacing: 4, + className: 'connector-settings', + style: readOnly + ? { '--wp-components-color-background': '#f0f0f0' } + : undefined, + }, + h( TextControl, { + __nextHasNoMarginBottom: true, + __next40pxDefaultSize: true, + label: __( 'Account ID', 'ai' ), + value: accountId, + onChange: ( v ) => { + if ( ! readOnly ) { + setSaveError( null ); + setAccountId( v ); + } + }, + placeholder: 'YOUR_ACCOUNT_ID', + disabled: readOnly || isSaving, + help: __( + 'Found in the Cloudflare dashboard under Workers & Pages.', + 'ai' + ), + } ), + h( TextControl, { + __nextHasNoMarginBottom: true, + __next40pxDefaultSize: true, + label: __( 'API Key', 'ai' ), + value: apiKey, + onChange: ( v ) => { + if ( ! readOnly ) { + setSaveError( null ); + setApiKey( v ); + } + }, + placeholder: 'YOUR_API_KEY', + disabled: readOnly || isSaving, + help: getHelp(), + } ), + readOnly + ? h( + Button, + { + variant: 'link', + isDestructive: true, + onClick: onRemove, + }, + __( 'Remove and replace', 'ai' ) + ) + : h( + HStack, + { justify: 'flex-start' }, + h( + Button, + { + __next40pxDefaultSize: true, + variant: 'primary', + disabled: ! apiKey || ! accountId || isSaving, + accessibleWhenDisabled: true, + isBusy: isSaving, + onClick: handleSave, + }, + __( 'Save', 'ai' ) + ) + ) + ); +} + +/* ────────────────────────────────────────────── + * Ollama custom settings (Endpoint URL only, no API key) + * ────────────────────────────────────────────── */ + +function OllamaConnectorSettings( { + onSave, + onRemove, + initialEndpoint = '', + readOnly = false, +} ) { + const [ endpoint, setEndpoint ] = useState( initialEndpoint ); + const [ isSaving, setIsSaving ] = useState( false ); + const [ saveError, setSaveError ] = useState( null ); + + const handleSave = async () => { + setSaveError( null ); + setIsSaving( true ); + try { + await onSave?.( endpoint ); + } catch ( error ) { + setSaveError( + error instanceof Error + ? error.message + : __( 'Could not save the endpoint.', 'ai' ) + ); + } finally { + setIsSaving( false ); + } + }; + + const getHelp = () => { + if ( readOnly ) { + return __( 'Your endpoint is configured.', 'ai' ); + } + if ( saveError ) { + return h( 'span', { style: { color: '#cc1818' } }, saveError ); + } + return __( + 'Enter the URL where Ollama is running. Default is http://localhost:11434', + 'ai' + ); + }; + + return h( + VStack, + { + spacing: 4, + className: 'connector-settings', + style: readOnly + ? { '--wp-components-color-background': '#f0f0f0' } + : undefined, + }, + h( TextControl, { + __nextHasNoMarginBottom: true, + __next40pxDefaultSize: true, + label: __( 'Endpoint URL', 'ai' ), + value: endpoint, + onChange: ( v ) => { + if ( ! readOnly ) { + setSaveError( null ); + setEndpoint( v ); + } + }, + placeholder: 'http://localhost:11434', + disabled: readOnly || isSaving, + help: getHelp(), + } ), + readOnly + ? h( + Button, + { + variant: 'link', + isDestructive: true, + onClick: onRemove, + }, + __( 'Remove and replace', 'ai' ) + ) + : h( + HStack, + { justify: 'flex-start' }, + h( + Button, + { + __next40pxDefaultSize: true, + variant: 'primary', + disabled: ! endpoint || isSaving, + accessibleWhenDisabled: true, + isBusy: isSaving, + onClick: handleSave, + }, + __( 'Save', 'ai' ) + ) + ) + ); +} + +/* ────────────────────────────────────────────── + * Generic extended provider connector component + * ────────────────────────────────────────────── */ + +function ExtendedProviderConnector( { label, description, slug } ) { + const provider = providers.find( + ( p ) => 'ai-experiments/' + p.id === slug + ); + + const providerData = provider || {}; + const id = providerData.id || ''; + const settingName = providerData.settingName || ''; + const helpUrl = providerData.helpUrl || ''; + const helpLabel = providerData.helpLabel || ''; + const type = providerData.type || ''; + const isCloudflare = id === 'cloudflare'; + const isEndpoint = type === 'endpoint'; + const accountIdSetting = isCloudflare ? 'ai_cloudflare_account_id' : null; + + const [ isExpanded, setIsExpanded ] = useState( false ); + const [ currentValue, setCurrentValue ] = useState( '' ); + const [ currentAccountId, setCurrentAccountId ] = useState( '' ); + const [ isLoading, setIsLoading ] = useState( true ); + + const isConnected = currentValue !== '' && currentValue !== 'invalid_key'; + + const fetchValue = useCallback( async () => { + try { + let fields = settingName; + if ( accountIdSetting ) { + fields += ',' + accountIdSetting; + } + const settings = await apiFetch( { + path: '/wp/v2/settings?_fields=' + fields, + } ); + const val = settings[ settingName ] || ''; + setCurrentValue( val === 'invalid_key' ? '' : val ); + if ( accountIdSetting ) { + setCurrentAccountId( settings[ accountIdSetting ] || '' ); + } + } catch ( e ) { + // Setting may not be registered yet. + } + setIsLoading( false ); + }, [ settingName, accountIdSetting ] ); + + useEffect( () => { + fetchValue(); + }, [ fetchValue ] ); + + if ( ! provider ) { + return null; + } + + const saveValue = async ( value, accountId ) => { + const data = { [ settingName ]: value }; + let fields = settingName; + if ( accountIdSetting && accountId !== undefined ) { + data[ accountIdSetting ] = accountId; + fields += ',' + accountIdSetting; + } + const result = await apiFetch( { + method: 'POST', + path: '/wp/v2/settings?_fields=' + fields, + data, + } ); + // If the key was submitted but the response is empty, the save failed. + if ( ! isEndpoint && value && ! result[ settingName ] ) { + throw new Error( + __( 'It was not possible to save the API key.', 'ai' ) + ); + } + setCurrentValue( result[ settingName ] || '' ); + if ( accountIdSetting ) { + setCurrentAccountId( result[ accountIdSetting ] || '' ); + } + }; + + const removeValue = async () => { + const data = { [ settingName ]: '' }; + let fields = settingName; + if ( accountIdSetting ) { + data[ accountIdSetting ] = ''; + fields += ',' + accountIdSetting; + } + await apiFetch( { + method: 'POST', + path: '/wp/v2/settings?_fields=' + fields, + data, + } ); + setCurrentValue( '' ); + setCurrentAccountId( '' ); + }; + + const handleButtonClick = () => setIsExpanded( ! isExpanded ); + + const getButtonLabel = () => { + if ( isLoading ) { + return __( 'Checking\u2026', 'ai' ); + } + if ( isExpanded ) { + return __( 'Cancel', 'ai' ); + } + if ( isConnected ) { + return __( 'Edit', 'ai' ); + } + return __( 'Set up', 'ai' ); + }; + + const IconComponent = ICONS[ id ]; + + const renderSettings = () => { + if ( ! isExpanded ) { + return null; + } + + if ( isEndpoint ) { + return h( OllamaConnectorSettings, { + key: isConnected ? 'connected' : 'setup', + initialEndpoint: currentValue, + readOnly: isConnected, + onRemove: removeValue, + onSave: async ( endpoint ) => { + await saveValue( endpoint ); + setIsExpanded( false ); + }, + } ); + } + + if ( isCloudflare ) { + return h( CloudflareConnectorSettings, { + key: isConnected ? 'connected' : 'setup', + initialApiKey: currentValue, + initialAccountId: currentAccountId, + helpUrl, + helpLabel, + readOnly: isConnected, + onRemove: removeValue, + onSave: async ( apiKey, accountId ) => { + await saveValue( apiKey, accountId ); + setIsExpanded( false ); + }, + } ); + } + + return h( DefaultConnectorSettings, { + key: isConnected ? 'connected' : 'setup', + initialValue: currentValue, + helpUrl, + helpLabel, + readOnly: isConnected, + onRemove: removeValue, + onSave: async ( apiKey ) => { + await saveValue( apiKey ); + setIsExpanded( false ); + }, + } ); + }; + + return h( + ConnectorItem, + { + icon: IconComponent ? h( IconComponent ) : undefined, + name: label, + description, + actionArea: h( + HStack, + { spacing: 3, expanded: false }, + isConnected && h( ConnectedBadge ), + h( + Button, + { + variant: + isExpanded || isConnected + ? 'tertiary' + : 'secondary', + size: isExpanded || isConnected ? undefined : 'compact', + onClick: handleButtonClick, + disabled: isLoading, + 'aria-expanded': isExpanded, + }, + getButtonLabel() + ) + ), + }, + renderSettings() + ); +} + +/* ────────────────────────────────────────────── + * Registration – deferred so core's 3 defaults register first. + * ────────────────────────────────────────────── */ + +/** + * Defer registration until after core's route modules have loaded. + * + * Core's connectors-home/content.js registers the 3 default connectors + * (OpenAI, Claude, Gemini) during route module initialisation. We use + * wp.domReady + requestAnimationFrame to ensure the Redux store already + * contains the defaults, so our extended providers appear below them + * in insertion order. + */ +window.wp.domReady( () => { + window.requestAnimationFrame( () => { + providers.forEach( ( provider ) => { + registerConnector( 'ai-experiments/' + provider.id, { + label: provider.label, + description: provider.description, + render: ExtendedProviderConnector, + } ); + } ); + } ); +} ); diff --git a/composer.json b/composer.json index 8fcf66a8..3b3d472a 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,10 @@ "ext-json": "*", "php": ">=7.4", "wordpress/mcp-adapter": "^0.3.0", - "wordpress/wp-ai-client": "^0.2.0" + "wordpress/wp-ai-client": "^0.3.0", + "wordpress/anthropic-ai-provider": "^1.0", + "wordpress/google-ai-provider": "^1.0", + "wordpress/openai-ai-provider": "^1.0" }, "require-dev": { "automattic/vipwpcs": "^3.0", diff --git a/composer.lock b/composer.lock index c5b92351..6412c078 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": "8c7b4ffe92f2ad6287883af4d77ae819", + "content-hash": "10dc5a302fe2921f526e0ac28c2be407", "packages": [ { "name": "automattic/jetpack-autoloader", @@ -286,32 +286,30 @@ "time": "2024-09-23T11:39:58+00:00" }, { - "name": "php-http/message-factory", - "version": "1.1.0", + "name": "php-http/promise", + "version": "1.3.1", "source": { "type": "git", - "url": "https://github.com/php-http/message-factory.git", - "reference": "4d8778e1c7d405cbb471574821c1ff5b68cc8f57" + "url": "https://github.com/php-http/promise.git", + "reference": "fc85b1fba37c169a69a07ef0d5a8075770cc1f83" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-http/message-factory/zipball/4d8778e1c7d405cbb471574821c1ff5b68cc8f57", - "reference": "4d8778e1c7d405cbb471574821c1ff5b68cc8f57", + "url": "https://api.github.com/repos/php-http/promise/zipball/fc85b1fba37c169a69a07ef0d5a8075770cc1f83", + "reference": "fc85b1fba37c169a69a07ef0d5a8075770cc1f83", "shasum": "" }, "require": { - "php": ">=5.4", - "psr/http-message": "^1.0 || ^2.0" + "php": "^7.1 || ^8.0" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.x-dev" - } + "require-dev": { + "friends-of-phpspec/phpspec-code-coverage": "^4.3.2 || ^6.3", + "phpspec/phpspec": "^5.1.2 || ^6.2 || ^7.4" }, + "type": "library", "autoload": { "psr-4": { - "Http\\Message\\": "src/" + "Http\\Promise\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -319,52 +317,52 @@ "MIT" ], "authors": [ + { + "name": "Joel Wurtz", + "email": "joel.wurtz@gmail.com" + }, { "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", + "description": "Promise used for asynchronous HTTP requests", + "homepage": "http://httplug.io", "keywords": [ - "factory", - "http", - "message", - "stream", - "uri" + "promise" ], "support": { - "issues": "https://github.com/php-http/message-factory/issues", - "source": "https://github.com/php-http/message-factory/tree/1.1.0" + "issues": "https://github.com/php-http/promise/issues", + "source": "https://github.com/php-http/promise/tree/1.3.1" }, - "abandoned": "psr/http-factory", - "time": "2023-04-14T14:16:17+00:00" + "time": "2024-03-15T13:55:21+00:00" }, { - "name": "php-http/promise", - "version": "1.3.1", + "name": "psr/event-dispatcher", + "version": "1.0.0", "source": { "type": "git", - "url": "https://github.com/php-http/promise.git", - "reference": "fc85b1fba37c169a69a07ef0d5a8075770cc1f83" + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-http/promise/zipball/fc85b1fba37c169a69a07ef0d5a8075770cc1f83", - "reference": "fc85b1fba37c169a69a07ef0d5a8075770cc1f83", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" - }, - "require-dev": { - "friends-of-phpspec/phpspec-code-coverage": "^4.3.2 || ^6.3", - "phpspec/phpspec": "^5.1.2 || ^6.2 || ^7.4" + "php": ">=7.2.0" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, "autoload": { "psr-4": { - "Http\\Promise\\": "src/" + "Psr\\EventDispatcher\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -373,24 +371,21 @@ ], "authors": [ { - "name": "Joel Wurtz", - "email": "joel.wurtz@gmail.com" - }, - { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com" + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" } ], - "description": "Promise used for asynchronous HTTP requests", - "homepage": "http://httplug.io", + "description": "Standard interfaces for event handling.", "keywords": [ - "promise" + "events", + "psr", + "psr-14" ], "support": { - "issues": "https://github.com/php-http/promise/issues", - "source": "https://github.com/php-http/promise/tree/1.3.1" + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" }, - "time": "2024-03-15T13:55:21+00:00" + "time": "2019-01-08T18:20:26+00:00" }, { "name": "psr/http-client", @@ -552,6 +547,175 @@ }, "time": "2023-04-04T09:54:51+00:00" }, + { + "name": "psr/simple-cache", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "408d5eafb83c57f6365a3ca330ff23aa4a5fa39b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/408d5eafb83c57f6365a3ca330ff23aa4a5fa39b", + "reference": "408d5eafb83c57f6365a3ca330ff23aa4a5fa39b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/master" + }, + "time": "2017-10-23T01:57:42+00:00" + }, + { + "name": "wordpress/anthropic-ai-provider", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/WordPress/anthropic-ai-provider.git", + "reference": "7abfab9d10f13c2c713484e3f5f096f81ad84b61" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WordPress/anthropic-ai-provider/zipball/7abfab9d10f13c2c713484e3f5f096f81ad84b61", + "reference": "7abfab9d10f13c2c713484e3f5f096f81ad84b61", + "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/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "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-12T05:24:09+00:00" + }, + { + "name": "wordpress/google-ai-provider", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/WordPress/google-ai-provider.git", + "reference": "ce063b2f13e54e2b59c0501c5437d4d7ae049d17" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WordPress/google-ai-provider/zipball/ce063b2f13e54e2b59c0501c5437d4d7ae049d17", + "reference": "ce063b2f13e54e2b59c0501c5437d4d7ae049d17", + "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/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "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": [ + "Gemini", + "ai", + "google", + "llm", + "wordpress" + ], + "support": { + "issues": "https://github.com/WordPress/google-ai-provider/issues", + "source": "https://github.com/WordPress/google-ai-provider" + }, + "time": "2026-02-12T05:21:47+00:00" + }, { "name": "wordpress/mcp-adapter", "version": "v0.3.0", @@ -626,29 +790,90 @@ }, "time": "2025-11-06T14:56:51+00:00" }, + { + "name": "wordpress/openai-ai-provider", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/WordPress/openai-ai-provider.git", + "reference": "5db93179e52e9b63ce94c2a339a9cf46d6d51a32" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WordPress/openai-ai-provider/zipball/5db93179e52e9b63ce94c2a339a9cf46d6d51a32", + "reference": "5db93179e52e9b63ce94c2a339a9cf46d6d51a32", + "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/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "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-12T05:23:02+00:00" + }, { "name": "wordpress/php-ai-client", - "version": "0.3.1", + "version": "1.1.0", "source": { "type": "git", "url": "https://github.com/WordPress/php-ai-client.git", - "reference": "48cc7de403e00d3035ce2fcb88128dea5e283444" + "reference": "c23867f6eb79028eeda89c6dcfd4467aaa7df14f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/WordPress/php-ai-client/zipball/48cc7de403e00d3035ce2fcb88128dea5e283444", - "reference": "48cc7de403e00d3035ce2fcb88128dea5e283444", + "url": "https://api.github.com/repos/WordPress/php-ai-client/zipball/c23867f6eb79028eeda89c6dcfd4467aaa7df14f", + "reference": "c23867f6eb79028eeda89c6dcfd4467aaa7df14f", "shasum": "" }, "require": { "ext-json": "*", + "nyholm/psr7": "^1.8", "php": ">=7.4", "php-http/discovery": "^1.0", "php-http/httplug": "^2.0", - "php-http/message-factory": "^1.0", + "psr/event-dispatcher": "^1.0", "psr/http-client": "^1.0", "psr/http-factory": "^1.0", - "psr/http-message": "^1.0 || ^2.0" + "psr/http-message": "^1.0 || ^2.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" }, "require-dev": { "dealerdirect/phpcodesniffer-composer-installer": "^1.0", @@ -659,7 +884,16 @@ "phpstan/phpstan": "~2.1", "phpunit/phpunit": "^9.5 || ^10.0", "slevomat/coding-standard": "^8.20", - "squizlabs/php_codesniffer": "^3.7" + "squizlabs/php_codesniffer": "^3.7 || ^4.0", + "symfony/dotenv": "^5.4", + "wordpress/anthropic-ai-provider": "^1.0", + "wordpress/google-ai-provider": "^1.0", + "wordpress/openai-ai-provider": "^1.0" + }, + "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" }, "type": "library", "autoload": { @@ -691,27 +925,28 @@ "issues": "https://github.com/WordPress/php-ai-client/issues", "source": "https://github.com/WordPress/php-ai-client" }, - "time": "2025-12-08T03:41:36+00:00" + "time": "2026-02-20T06:40:54+00:00" }, { "name": "wordpress/wp-ai-client", - "version": "0.2.1", + "version": "0.3.0", "source": { "type": "git", "url": "https://github.com/WordPress/wp-ai-client.git", - "reference": "3abf133dc8c964672d1012dca070c1cdb6f0ad58" + "reference": "ae559d9c6449f59b7d9a215be310655e131361f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/WordPress/wp-ai-client/zipball/3abf133dc8c964672d1012dca070c1cdb6f0ad58", - "reference": "3abf133dc8c964672d1012dca070c1cdb6f0ad58", + "url": "https://api.github.com/repos/WordPress/wp-ai-client/zipball/ae559d9c6449f59b7d9a215be310655e131361f6", + "reference": "ae559d9c6449f59b7d9a215be310655e131361f6", "shasum": "" }, "require": { "ext-json": "*", "nyholm/psr7": "^1.5", "php": ">=7.4", - "wordpress/php-ai-client": "^0.3" + "psr/simple-cache": "^1.0", + "wordpress/php-ai-client": "^1.0" }, "require-dev": { "automattic/vipwpcs": "^3.0", @@ -753,7 +988,7 @@ "issues": "https://github.com/WordPress/wp-ai-client/issues", "source": "https://github.com/WordPress/wp-ai-client" }, - "time": "2025-12-04T20:12:45+00:00" + "time": "2026-02-17T02:53:42+00:00" } ], "packages-dev": [ diff --git a/docs/experiments/extended-providers.md b/docs/experiments/extended-providers.md new file mode 100644 index 00000000..720fe479 --- /dev/null +++ b/docs/experiments/extended-providers.md @@ -0,0 +1,41 @@ +# Extended Providers + +## Summary +Toggles registration of a custom set of AI providers with the WP AI Client. When the experiment is enabled, any provider classes you supply via filters are registered with `AiClient::defaultRegistry()` so they can participate in model discovery alongside the core providers (OpenAI, Anthropic, Google). Disable the experiment to remove those providers without touching the default stack. + +### Included Providers +- Grok (xAI) – exposes Grok’s `/v1/models` listing and chat completion models. Add your Grok API key under **Settings → AI Credentials** (`options-general.php?page=wp-ai-client`) and the registry will inject it automatically. +- Groq – exposes Groq’s `https://api.groq.com/openai/v1` chat-completions interface. Store a **Groq API key** on the credentials screen and toggle the provider inside the Extended Providers experiment. +- Fal.ai – adds curated FLUX/SDXL image generators via `https://fal.run/{model}`. Provide your Fal.ai API token on the AI Credentials page and enable the provider to unlock Fal’s image-only models. +- Cohere – connects directly to Cohere’s `/chat` and `/models` APIs at `https://api.cohere.ai/v1`. Paste your Cohere API key on the credentials screen and use the experiment settings to toggle Cohere’s chat models. +- Hugging Face – targets the OpenAI-compatible router at `https://router.huggingface.co/v1`. Add a Hugging Face access token (with `inference:all` scope) on the credentials page and enable the provider to discover router-backed chat models. +- OpenRouter – connects to `https://openrouter.ai/api/v1`, honoring their `/models` and `/chat/completions` API. Supply your OpenRouter API key under AI Credentials and optionally set Referer/Title via the registry’s custom options filter if needed. +- Ollama – calls your local `http://localhost:11434/api` daemon for chat generation. No cloud credentials required; just install/serve models via Ollama and enable the provider to expose them in the registry. Use the `ai_ollama_base_url` filter if you need a custom host. +- DeepSeek – uses the `https://api.deepseek.com/v1` OpenAI-compatible surface. Create a DeepSeek API key, paste it on the AI Credentials page, and the models listed under `/v1/models` will automatically flow into discovery. +- Cloudflare Workers AI – calls `https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/ai/*` for model listing and inferencing. Generate a Workers AI API token plus note your Account ID (expose it via the `CLOUDFLARE_ACCOUNT_ID` environment variable or the `ai_cloudflare_account_id` filter) and provide the token through the AI Credentials screen. + +## Key Hooks & Entry Points +- `WordPress\AI\Experiments\Extended_Providers\Extended_Providers::register()` attaches to `init` (priority 20) and calls `register_providers()` only when the experiment is enabled. +- `ai_extended_provider_default_classes` – Filter the default list of provider class names bundled with the experiment (defaults to `WordPress\AI\Providers\Grok\GrokProvider`). +- `ai_extended_provider_classes` – Final filter to adjust the provider class list before registration. Receives the experiment instance so you can inspect settings if needed. + +```php +add_filter( 'ai_extended_provider_classes', function( $providers ) { + $providers[] = \MyPlugin\Providers\OpenRouterProvider::class; + $providers[] = \MyPlugin\Providers\TogetherAiProvider::class; + return $providers; +} ); +``` + +## Assets & Data Flow +No scripts or abilities are enqueued. The experiment simply calls `AiClient::defaultRegistry()->registerProvider()` for each class in the filtered list. Provider classes remain responsible for their own HTTP transport and credential handling (the WP AI Client will inject the WordPress HTTP transporter and default API-key authentication automatically). + +## Testing +1. Enable Experiments globally and toggle **Extended Providers** under `Settings → AI Experiments`. +2. Add your provider classes via the `ai_extended_provider_classes` filter. +3. Visit any screen that uses the AI Client and confirm the new provider appears in `AiClient::defaultRegistry()->getRegisteredProviderIds()`. +4. Disable the experiment and confirm the provider list reverts to the core set (OpenAI, Anthropic, Google). + +## Notes +- Only classes implementing `WordPress\AiClient\Providers\Contracts\ProviderInterface` are accepted. Missing or invalid classes trigger `_doing_it_wrong()` notices. +- The experiment does not ship provider implementations; it is simply a safe switch for loading your own provider packages or forks. diff --git a/includes/Abilities/Excerpt_Generation/Excerpt_Generation.php b/includes/Abilities/Excerpt_Generation/Excerpt_Generation.php index b24ae6a2..5704dc54 100644 --- a/includes/Abilities/Excerpt_Generation/Excerpt_Generation.php +++ b/includes/Abilities/Excerpt_Generation/Excerpt_Generation.php @@ -11,8 +11,8 @@ use WP_Error; use WordPress\AI\Abstracts\Abstract_Ability; -use WordPress\AI_Client\AI_Client; +use function WordPress\AI\ai_client_prompt_with_wp_error; use function WordPress\AI\get_post_context; use function WordPress\AI\get_preferred_models_for_text_generation; use function WordPress\AI\normalize_content; @@ -226,7 +226,7 @@ static function ( $key, $value ) { } // Generate an excerpt using the AI client. - return AI_Client::prompt_with_wp_error( $content ) + return ai_client_prompt_with_wp_error( $content ) ->using_system_instruction( $this->get_system_instruction() ) ->using_temperature( 0.7 ) ->using_model_preference( ...get_preferred_models_for_text_generation() ) diff --git a/includes/Abilities/Image/Alt_Text_Generation.php b/includes/Abilities/Image/Alt_Text_Generation.php index a3027f4e..deb3274d 100644 --- a/includes/Abilities/Image/Alt_Text_Generation.php +++ b/includes/Abilities/Image/Alt_Text_Generation.php @@ -11,8 +11,8 @@ use WP_Error; use WordPress\AI\Abstracts\Abstract_Ability; -use WordPress\AI_Client\AI_Client; +use function WordPress\AI\ai_client_prompt_with_wp_error; use function WordPress\AI\get_preferred_vision_models; use function WordPress\AI\normalize_content; @@ -189,7 +189,7 @@ protected function get_image_reference( array $args ) { * @return string|\WP_Error The generated alt text or WP_Error on failure. */ protected function generate_alt_text( array $image_reference, string $context = '' ) { - $result = AI_Client::prompt_with_wp_error( $this->build_prompt( $context ) ) + $result = ai_client_prompt_with_wp_error( $this->build_prompt( $context ) ) ->with_file( $image_reference['reference'] ) ->using_system_instruction( $this->get_system_instruction( 'alt-text-system-instruction.php' ) ) ->using_temperature( 0.3 ) diff --git a/includes/Abilities/Image/Generate_Image.php b/includes/Abilities/Image/Generate_Image.php index 2e3eb8d9..7caa563e 100644 --- a/includes/Abilities/Image/Generate_Image.php +++ b/includes/Abilities/Image/Generate_Image.php @@ -12,12 +12,12 @@ use Throwable; use WP_Error; use WordPress\AI\Abstracts\Abstract_Ability; -use WordPress\AI_Client\AI_Client; use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Http\DTO\RequestOptions; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; +use function WordPress\AI\ai_client_prompt_with_wp_error; use function WordPress\AI\get_preferred_image_models; /** @@ -170,7 +170,7 @@ protected function generate_image( string $prompt ) { // phpcs:ignore Generic.Na $request_options->setTimeout( 90 ); // Generate the image using the AI client. - $result = AI_Client::prompt_with_wp_error( $prompt ) + $result = ai_client_prompt_with_wp_error( $prompt ) ->using_request_options( $request_options ) ->as_output_file_type( FileTypeEnum::inline() ) ->using_model_preference( ...get_preferred_image_models() ) @@ -201,13 +201,27 @@ protected function generate_image( string $prompt ) { // phpcs:ignore Generic.Na } // Get details about the provider and model that generated the image. - $data['provider_metadata'] = $result->getProviderMetadata()->toArray(); - $data['model_metadata'] = $result->getModelMetadata()->toArray(); + $provider_raw = $result->getProviderMetadata()->toArray(); + $model_raw = $result->getModelMetadata()->toArray(); + + // Remove data we don't care about. + unset( $provider_raw[ ProviderMetadata::KEY_CREDENTIALS_URL ] ); + unset( $model_raw[ ModelMetadata::KEY_SUPPORTED_OPTIONS ] ); + unset( $model_raw[ ModelMetadata::KEY_SUPPORTED_CAPABILITIES ] ); + + $data['provider_metadata'] = array_map( + static function ( $value ): string { + return (string) $value; + }, + $provider_raw + ); - // Remove data we don't care about. - unset( $data['provider_metadata'][ ProviderMetadata::KEY_CREDENTIALS_URL ] ); - unset( $data['model_metadata'][ ModelMetadata::KEY_SUPPORTED_OPTIONS ] ); - unset( $data['model_metadata'][ ModelMetadata::KEY_SUPPORTED_CAPABILITIES ] ); + $data['model_metadata'] = array_map( + static function ( $value ): string { + return (string) $value; + }, + $model_raw + ); } catch ( Throwable $t ) { return new WP_Error( 'no_image_data', diff --git a/includes/Abilities/Image/Generate_Image_Prompt.php b/includes/Abilities/Image/Generate_Image_Prompt.php index b7b6706d..c19898c5 100644 --- a/includes/Abilities/Image/Generate_Image_Prompt.php +++ b/includes/Abilities/Image/Generate_Image_Prompt.php @@ -11,8 +11,8 @@ use WP_Error; use WordPress\AI\Abstracts\Abstract_Ability; -use WordPress\AI_Client\AI_Client; +use function WordPress\AI\ai_client_prompt_with_wp_error; use function WordPress\AI\get_post_context; use function WordPress\AI\get_preferred_models_for_text_generation; use function WordPress\AI\normalize_content; @@ -243,7 +243,7 @@ static function ( $key, $value ) { } // Generate the prompt using the AI client. - return AI_Client::prompt_with_wp_error( $content ) + return ai_client_prompt_with_wp_error( $content ) ->using_system_instruction( $this->get_system_instruction( 'image-prompt-system-instruction.php' ) ) ->using_temperature( 0.9 ) ->using_model_preference( ...get_preferred_models_for_text_generation() ) diff --git a/includes/Abilities/Summarization/Summarization.php b/includes/Abilities/Summarization/Summarization.php index 8d7f7b32..e6516475 100644 --- a/includes/Abilities/Summarization/Summarization.php +++ b/includes/Abilities/Summarization/Summarization.php @@ -11,8 +11,8 @@ use WP_Error; use WordPress\AI\Abstracts\Abstract_Ability; -use WordPress\AI_Client\AI_Client; +use function WordPress\AI\ai_client_prompt_with_wp_error; use function WordPress\AI\get_post_context; use function WordPress\AI\get_preferred_models_for_text_generation; use function WordPress\AI\normalize_content; @@ -243,7 +243,7 @@ static function ( $key, $value ) { } // Generate the summary using the AI client. - return AI_Client::prompt_with_wp_error( $content ) + return ai_client_prompt_with_wp_error( $content ) ->using_system_instruction( $this->get_system_instruction( 'system-instruction.php', array( 'length' => $length ) ) ) ->using_temperature( 0.9 ) ->using_model_preference( ...get_preferred_models_for_text_generation() ) diff --git a/includes/Abilities/Title_Generation/Title_Generation.php b/includes/Abilities/Title_Generation/Title_Generation.php index a8eda67e..31f5ade0 100644 --- a/includes/Abilities/Title_Generation/Title_Generation.php +++ b/includes/Abilities/Title_Generation/Title_Generation.php @@ -11,8 +11,8 @@ use WP_Error; use WordPress\AI\Abstracts\Abstract_Ability; -use WordPress\AI_Client\AI_Client; +use function WordPress\AI\ai_client_prompt_with_wp_error; use function WordPress\AI\get_post_context; use function WordPress\AI\get_preferred_models_for_text_generation; use function WordPress\AI\normalize_content; @@ -262,12 +262,22 @@ static function ( $key, $value ) { ); } - // Generate the titles using the AI client. - return AI_Client::prompt_with_wp_error( '"""' . $context . '"""' ) - ->using_system_instruction( $this->get_system_instruction() ) - ->using_temperature( 0.7 ) - ->using_candidate_count( (int) $candidates ) - ->using_model_preference( ...get_preferred_models_for_text_generation() ) - ->generate_texts(); + // Generate one title per request for broader provider compatibility. + $results = array(); + for ( $i = 0; $i < $candidates; $i++ ) { + $result = ai_client_prompt_with_wp_error( '"""' . $context . '"""' ) + ->using_system_instruction( $this->get_system_instruction() ) + ->using_temperature( 0.7 ) + ->using_model_preference( ...get_preferred_models_for_text_generation() ) + ->generate_text(); + + if ( is_wp_error( $result ) ) { + return $result; + } + + $results[] = $result; + } + + return $results; } } diff --git a/includes/Admin/Provider_Credentials_UI.php b/includes/Admin/Provider_Credentials_UI.php new file mode 100644 index 00000000..9c036230 --- /dev/null +++ b/includes/Admin/Provider_Credentials_UI.php @@ -0,0 +1,58 @@ + Provider_Metadata_Registry::get_metadata(), + 'cloudflareAccountId' => (string) get_option( 'ai_cloudflare_account_id', '' ), + ) + ); + } +} diff --git a/includes/Admin/Provider_Metadata_Registry.php b/includes/Admin/Provider_Metadata_Registry.php new file mode 100644 index 00000000..cacf6b0f --- /dev/null +++ b/includes/Admin/Provider_Metadata_Registry.php @@ -0,0 +1,292 @@ +> + */ + public static function get_metadata(): array { + $registry = AiClient::defaultRegistry(); + $providers = array(); + $overrides = self::get_branding_overrides(); + $credentials = get_option( 'wp_ai_client_provider_credentials', array() ); + + foreach ( $registry->getRegisteredProviderIds() as $provider_id ) { + $class_name = $registry->getProviderClassName( $provider_id ); + + if ( ! method_exists( $class_name, 'metadata' ) ) { + continue; + } + + /** @var \WordPress\AiClient\Providers\DTO\ProviderMetadata $metadata */ + $metadata = $class_name::metadata(); + $brand = $overrides[ $metadata->getId() ] ?? array(); + + $providers[ $metadata->getId() ] = array( + 'id' => $metadata->getId(), + 'name' => $metadata->getName(), + 'type' => $metadata->getType()->value, + 'icon' => $brand['icon'] ?? $metadata->getId(), + 'initials' => $brand['initials'] ?? self::get_initials( $metadata->getName() ), + 'color' => $brand['color'] ?? '#1d2327', + 'url' => $brand['url'] ?? '', + 'tooltip' => $brand['tooltip'] ?? '', + 'keepDescription' => ! empty( $brand['keepDescription'] ), + 'isConfigured' => self::has_credentials( $metadata->getId(), $credentials ), + 'models' => self::get_models_for_provider( $class_name, $metadata->getId(), $credentials ), + ); + } + + return $providers; + } + + /** + * Builds a fallback initials string for providers without a brand override. + * + * @param string $name Provider display name. + * @return string + */ + private static function get_initials( string $name ): string { + $parts = preg_split( '/\s+/', trim( $name ) ); + if ( empty( $parts ) ) { + $fallback = substr( $name, 0, 2 ); + return strtoupper( false !== $fallback ? $fallback : '' ); + } + + $initials = ''; + foreach ( $parts as $part ) { + $char = substr( $part, 0, 1 ); + $initials .= strtoupper( false !== $char ? $char : '' ); + if ( strlen( $initials ) >= 2 ) { + break; + } + } + + $result = substr( $initials, 0, 2 ); + return false !== $result ? $result : ''; + } + + /** + * Retrieves model metadata for a provider. + * + * @param string $provider_class Provider class name. + * @param string $provider_id Provider identifier. + * @param array $credentials Provider credentials. + * @return array> + */ + private static function get_models_for_provider( string $provider_class, string $provider_id, array $credentials ): array { + if ( ! method_exists( $provider_class, 'modelMetadataDirectory' ) ) { + return array(); + } + + $cache_key = self::get_models_cache_key( $provider_id, $credentials[ $provider_id ] ?? '' ); + if ( $cache_key ) { + $cached = get_transient( $cache_key ); + if ( false !== $cached && is_array( $cached ) ) { + return $cached; + } + } + + try { + $directory = $provider_class::modelMetadataDirectory(); + $metadata = $directory->listModelMetadata(); + } catch ( \Throwable $error ) { + return array(); + } + + $models = array(); + + foreach ( $metadata as $model_metadata ) { + if ( ! $model_metadata instanceof ModelMetadata ) { + continue; + } + + $models[] = array( + 'id' => $model_metadata->getId(), + 'name' => $model_metadata->getName(), + 'capabilities' => array_map( + static function ( CapabilityEnum $capability ): string { + return $capability->value; + }, + $model_metadata->getSupportedCapabilities() + ), + ); + } + + if ( $cache_key ) { + set_transient( $cache_key, $models, self::MODEL_CACHE_TTL ); + } + + return $models; + } + + /** + * Determines whether stored credentials exist for a provider. + * + * @param string $provider_id Provider identifier. + * @param array $credentials Raw credentials map. + * @return bool + */ + private static function has_credentials( string $provider_id, array $credentials ): bool { + if ( 'ollama' === $provider_id ) { + return true; + } + + if ( ! isset( $credentials[ $provider_id ] ) ) { + return false; + } + + $value = $credentials[ $provider_id ]; + if ( is_array( $value ) ) { + $value = wp_json_encode( $value ); + } + + return is_string( $value ) && '' !== trim( $value ); + } + + /** + * Builds a cache key for provider models. + * + * @param string $provider_id Provider identifier. + * @param string|array $credential Credential value. + * @return string|null + */ + private static function get_models_cache_key( string $provider_id, $credential ): ?string { + if ( '' === $provider_id ) { + return null; + } + + if ( is_array( $credential ) ) { + $credential = wp_json_encode( $credential ); + } + + return 'ai_provider_models_' . md5( $provider_id . '|' . (string) $credential ); + } + + /** + * Defines manual branding overrides per provider ID. + * + * @return array> + */ + private static function get_branding_overrides(): array { + /* translators: %s: provider name (e.g., "OpenAI", "Anthropic"). */ + $link_template = esc_html__( 'Create and manage your %s API keys in these account settings.', 'ai' ); + + return array( + 'anthropic' => array( + 'icon' => 'anthropic', + 'initials' => 'An', + 'color' => '#111111', + 'url' => 'https://console.anthropic.com/settings/keys', + 'tooltip' => sprintf( $link_template, 'Anthropic' ), + ), + 'cohere' => array( + 'color' => '#6f2cff', + 'url' => 'https://dashboard.cohere.com/api-keys', + 'tooltip' => sprintf( $link_template, 'Cohere' ), + ), + 'cloudflare' => array( + 'icon' => 'cloudflare', + 'color' => '#f3801a', + 'url' => 'https://dash.cloudflare.com/profile/api-tokens', + 'tooltip' => sprintf( $link_template, 'Cloudflare Workers AI' ), + ), + 'deepseek' => array( + 'icon' => 'deepseek', + 'color' => '#0f172a', + 'url' => 'https://platform.deepseek.com/api_keys', + 'tooltip' => sprintf( $link_template, 'DeepSeek' ), + ), + 'fal' => array( + 'icon' => 'fal', + 'color' => '#0ea5e9', + 'url' => 'https://fal.ai/dashboard/keys', + 'tooltip' => sprintf( $link_template, 'Fal.ai' ), + ), + 'fal-ai' => array( + 'icon' => 'fal-ai', + 'color' => '#0ea5e9', + 'url' => 'https://fal.ai/dashboard/keys', + 'tooltip' => sprintf( $link_template, 'Fal.ai' ), + ), + 'grok' => array( + 'icon' => 'grok', + 'color' => '#ff6f00', + 'url' => 'https://console.x.ai/api-keys', + 'tooltip' => sprintf( $link_template, 'Grok' ), + ), + 'groq' => array( + 'icon' => 'groq', + 'color' => '#f43f5e', + 'url' => 'https://console.groq.com/keys', + 'tooltip' => sprintf( $link_template, 'Groq' ), + ), + 'google' => array( + 'icon' => 'google', + 'color' => '#4285f4', + 'url' => 'https://aistudio.google.com/app/api-keys', + 'tooltip' => sprintf( $link_template, 'Google' ), + ), + 'huggingface' => array( + 'icon' => 'huggingface', + 'color' => '#ffbe3c', + 'url' => 'https://huggingface.co/settings/tokens', + 'tooltip' => sprintf( $link_template, 'Hugging Face' ), + ), + 'openai' => array( + 'icon' => 'openai', + 'color' => '#10a37f', + 'url' => 'https://platform.openai.com/api-keys', + 'tooltip' => sprintf( $link_template, 'OpenAI' ), + ), + 'openrouter' => array( + 'icon' => 'openrouter', + 'color' => '#0f172a', + 'url' => 'https://openrouter.ai/settings/keys', + 'tooltip' => sprintf( $link_template, 'OpenRouter' ), + ), + 'ollama' => array( + 'icon' => 'ollama', + 'color' => '#111111', + 'tooltip' => esc_html__( 'Local Ollama instances at http://localhost:11434 do not require an API key. If you are calling https://ollama.com/api, create a key from your ollama.com account (for example via the dashboard or the `ollama signin` command) and paste it here.', 'ai' ), + 'keepDescription' => true, + ), + 'xai' => array( + 'icon' => 'xai', + 'color' => '#000000', + 'url' => 'https://console.x.ai/api-keys', + 'tooltip' => sprintf( $link_template, 'xAI' ), + ), + ); + } +} diff --git a/includes/Experiment_Loader.php b/includes/Experiment_Loader.php index 20f49cea..514138f9 100644 --- a/includes/Experiment_Loader.php +++ b/includes/Experiment_Loader.php @@ -107,6 +107,7 @@ private function get_default_experiments(): array { \WordPress\AI\Experiments\Abilities_Explorer\Abilities_Explorer::class, \WordPress\AI\Experiments\Excerpt_Generation\Excerpt_Generation::class, \WordPress\AI\Experiments\Alt_Text_Generation\Alt_Text_Generation::class, + \WordPress\AI\Experiments\Extended_Providers\Extended_Providers::class, \WordPress\AI\Experiments\Image_Generation\Image_Generation::class, \WordPress\AI\Experiments\Review_Notes\Review_Notes::class, \WordPress\AI\Experiments\Summarization\Summarization::class, diff --git a/includes/Experiments/Extended_Providers/Extended_Providers.php b/includes/Experiments/Extended_Providers/Extended_Providers.php new file mode 100644 index 00000000..ef080f51 --- /dev/null +++ b/includes/Experiments/Extended_Providers/Extended_Providers.php @@ -0,0 +1,832 @@ + + */ + // phpcs:ignore SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition.DisallowedMultiConstantDefinition -- False positive with ::class array. + private const DEFAULT_PROVIDER_CLASSES = array( + CloudflareWorkersAiProvider::class, + CohereProvider::class, + DeepSeekProvider::class, + FalAiProvider::class, + GrokProvider::class, + GroqProvider::class, + HuggingFaceProvider::class, + OllamaProvider::class, + OpenRouterProvider::class, + ); + + /** + * Field name for provider selection setting. + * + * @var string + */ + private const FIELD_PROVIDERS = 'providers'; + + /** + * {@inheritDoc} + */ + protected function load_experiment_metadata(): array { + return array( + 'id' => 'extended-providers', + 'label' => __( 'Extended Providers', 'ai' ), + 'description' => __( 'Registers additional AI providers for experimentation without affecting the core set.', 'ai' ), + 'category' => '', + ); + } + + /** + * Provider metadata for connectors integration. + * + * Maps provider ID => [ label, description, helpUrl, helpLabel ]. + * + * @var array + */ + // phpcs:ignore SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition.DisallowedMultiConstantDefinition -- False positive with array constant. + private const CONNECTOR_META = array( + 'cloudflare' => array( + 'label' => 'Cloudflare Workers AI', + 'description' => "Run AI models on Cloudflare\u{2019}s global edge network.", + 'helpUrl' => 'https://dash.cloudflare.com/', + 'helpLabel' => 'dash.cloudflare.com', + ), + 'cohere' => array( + 'label' => 'Cohere', + 'description' => 'Enterprise-grade language models for text generation and embeddings.', + 'helpUrl' => 'https://dashboard.cohere.com/', + 'helpLabel' => 'dashboard.cohere.com', + ), + 'deepseek' => array( + 'label' => 'DeepSeek', + 'description' => 'Advanced reasoning and code generation with DeepSeek models.', + 'helpUrl' => 'https://platform.deepseek.com/', + 'helpLabel' => 'platform.deepseek.com', + ), + 'fal' => array( + 'label' => 'Fal.ai', + 'description' => 'Fast image generation and media models.', + 'helpUrl' => 'https://fal.ai/dashboard/', + 'helpLabel' => 'fal.ai', + ), + 'grok' => array( + 'label' => 'Grok (xAI)', + 'description' => "Text generation with xAI\u{2019}s Grok models.", + 'helpUrl' => 'https://console.x.ai/', + 'helpLabel' => 'console.x.ai', + ), + 'groq' => array( + 'label' => 'Groq', + 'description' => 'Ultra-fast inference for open-source language models.', + 'helpUrl' => 'https://console.groq.com/', + 'helpLabel' => 'console.groq.com', + ), + 'huggingface' => array( + 'label' => 'Hugging Face', + 'description' => 'Access thousands of open-source models via the Inference API.', + 'helpUrl' => 'https://huggingface.co/settings/tokens', + 'helpLabel' => 'huggingface.co', + ), + 'ollama' => array( + 'label' => 'Ollama', + 'description' => 'Run large language models locally on your own hardware.', + 'helpUrl' => 'https://ollama.com/', + 'helpLabel' => 'ollama.com', + 'type' => 'endpoint', + ), + 'openrouter' => array( + 'label' => 'OpenRouter', + 'description' => 'Unified API gateway for hundreds of AI models.', + 'helpUrl' => 'https://openrouter.ai/keys', + 'helpLabel' => 'openrouter.ai', + ), + ); + + /** + * {@inheritDoc} + */ + public function register(): void { + // Register providers immediately so they're available when + // the WP AI Client collects provider metadata for the credentials screen. + $this->register_providers(); + + // Add extended provider models to the preferred models list so that + // experiments like title generation can use them. + add_filter( 'ai_experiments_preferred_models_for_text_generation', array( $this, 'add_extended_model_preferences' ) ); + + if ( ! $this->is_enabled() ) { + return; + } + + // Always pass keys to the registry so text generation works, + // regardless of whether the Connectors screen exists. + $this->apply_endpoint_provider_urls(); + add_action( 'init', array( $this, 'maybe_pass_keys_to_registry' ), 22 ); + + // Connectors-specific UI and settings only on WP 7.0+. + if ( ! $this->is_connectors_supported() ) { + return; + } + + $this->register_extra_connector_settings(); + + // Register API key settings AFTER core's init:20 + // so we only fill in what core didn't handle (beta2 vs trunk differences). + add_action( 'init', array( $this, 'maybe_register_api_key_settings' ), 21 ); + + add_action( 'admin_enqueue_scripts', array( $this, 'maybe_enqueue_connectors_script' ) ); + } + + /** + * Checks whether the WP 7.0 Connectors screen is available. + * + * @return bool + */ + private function is_connectors_supported(): bool { + // Trunk uses _wp_connectors_get_connector_settings; beta2 uses _wp_connectors_get_provider_settings. + return function_exists( '_wp_connectors_get_connector_settings' ) + || function_exists( '_wp_connectors_get_provider_settings' ); + } + + /** + * Well-known text generation models for each extended provider. + * + * These are added to the preferred models list so that AI experiments + * (title generation, summarization, etc.) can use extended providers. + * + * @var array> + */ + // phpcs:ignore SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition.DisallowedMultiConstantDefinition -- False positive with array constant. + private const TEXT_GENERATION_MODELS = array( + 'cohere' => array( 'command-r-08-2024', 'command-a-reasoning-08-2025', 'command-r7b-12-2024' ), + 'deepseek' => array( 'deepseek-chat', 'deepseek-reasoner' ), + 'grok' => array( 'grok-2', 'grok-3-mini' ), + 'groq' => array( 'llama-3.3-70b-versatile', 'llama-3.1-8b-instant' ), + 'huggingface' => array( 'meta-llama/Llama-3.3-70B-Instruct' ), + 'openrouter' => array( 'openai/gpt-4o-mini', 'anthropic/claude-3.5-haiku' ), + ); + + /** + * Adds extended provider models to the preferred text generation models list. + * + * @param array $models The current preferred models. + * @return array The filtered preferred models. + */ + public function add_extended_model_preferences( array $models ): array { + $enabled_ids = $this->get_enabled_provider_ids(); + + foreach ( self::TEXT_GENERATION_MODELS as $provider_id => $model_ids ) { + if ( ! in_array( $provider_id, $enabled_ids, true ) ) { + continue; + } + + foreach ( $model_ids as $model_id ) { + $models[] = array( $provider_id, $model_id ); + } + } + + return $models; + } + + /** + * Returns the provider IDs that are currently enabled in the experiment. + * + * @return string[] + */ + private function get_enabled_provider_ids(): array { + $provider_classes = $this->filter_enabled_provider_classes( + $this->get_provider_classes() + ); + + $ids = array(); + foreach ( $provider_classes as $class_name ) { + if ( ! class_exists( $class_name ) || ! method_exists( $class_name, 'metadata' ) ) { + continue; + } + + try { + $ids[] = $class_name::metadata()->getId(); + } catch ( \Throwable $t ) { + continue; + } + } + + return $ids; + } + + /** + * Registers non-standard connector settings that core doesn't handle. + * + * Core's `_wp_register_default_connector_settings()` (init:20) auto-registers + * `connectors_ai_{id}_api_key` settings, mask filters, and key passing for all + * registered providers. We only register settings core doesn't know about: + * - Cloudflare Account ID (extra field alongside the API key) + * - Ollama endpoint URL (endpoint-based, no API key) + */ + private function register_extra_connector_settings(): void { + $enabled_ids = $this->get_enabled_provider_ids(); + + // Cloudflare needs an Account ID in addition to the API key that core handles. + if ( in_array( 'cloudflare', $enabled_ids, true ) ) { + register_setting( + 'connectors', + 'ai_cloudflare_account_id', + array( + 'type' => 'string', + 'label' => __( 'Cloudflare Account ID', 'ai' ), + 'description' => __( 'Cloudflare account ID for Workers AI API requests.', 'ai' ), + 'default' => '', + 'show_in_rest' => true, + 'sanitize_callback' => 'sanitize_text_field', + ) + ); + } + + // Ollama is endpoint-based (local provider, no API key). + if ( ! in_array( 'ollama', $enabled_ids, true ) ) { + return; + } + + register_setting( + 'connectors', + 'ai_ollama_endpoint', + array( + 'type' => 'string', + 'label' => __( 'Ollama Endpoint URL', 'ai' ), + 'description' => __( 'Endpoint URL for the Ollama provider.', 'ai' ), + 'default' => '', + 'show_in_rest' => true, + 'sanitize_callback' => 'sanitize_url', + ) + ); + } + + /** + * Applies endpoint URLs for providers that use a custom base URL instead of API keys. + */ + private function apply_endpoint_provider_urls(): void { + foreach ( $this->get_enabled_provider_ids() as $provider_id ) { + $meta = self::CONNECTOR_META[ $provider_id ] ?? array(); + $type = $meta['type'] ?? 'api_key'; + + if ( 'endpoint' !== $type ) { + continue; + } + + $endpoint = (string) get_option( "ai_{$provider_id}_endpoint", '' ); + if ( '' === $endpoint ) { + continue; + } + + add_filter( + "ai_{$provider_id}_base_url", + static function () use ( $endpoint ): string { + return rtrim( $endpoint, '/' ) . '/api'; + } + ); + } + } + + /** + * Registers API key settings for extended providers that core didn't handle. + * + * Runs at init:21 (after core's init:20) so we can check which settings + * core already registered and only fill in the gaps. This avoids duplicate + * mask filters that would break key retrieval. + */ + public function maybe_register_api_key_settings(): void { + foreach ( $this->get_enabled_provider_ids() as $provider_id ) { + $meta = self::CONNECTOR_META[ $provider_id ] ?? array(); + $type = $meta['type'] ?? 'api_key'; + + if ( 'endpoint' === $type ) { + continue; + } + + $setting_name = "connectors_ai_{$provider_id}_api_key"; + + // Skip if core already registered this setting (trunk behavior). + $registered = get_registered_settings(); + if ( isset( $registered[ $setting_name ] ) ) { + continue; + } + + register_setting( + 'connectors', + $setting_name, + array( + 'type' => 'string', + 'label' => sprintf( + /* translators: %s: AI provider name. */ + __( '%s API Key', 'ai' ), + $meta['label'] ?? ucwords( $provider_id ) + ), + 'description' => sprintf( + /* translators: %s: AI provider name. */ + __( 'API key for the %s AI provider.', 'ai' ), + $meta['label'] ?? ucwords( $provider_id ) + ), + 'default' => '', + 'show_in_rest' => true, + 'sanitize_callback' => static function ( $value ): string { + return sanitize_text_field( (string) $value ); + }, + ) + ); + + // Add mask filter (only one instance since we checked core didn't register). + if ( ! function_exists( '_wp_connectors_mask_api_key' ) ) { + continue; + } + + // @phpstan-ignore-next-line -- Function name string is a valid callable for add_filter. + add_filter( "option_{$setting_name}", '_wp_connectors_mask_api_key' ); + } + } + + /** + * Passes stored API keys to the AI client registry for providers that core didn't handle. + * + * Runs at init:22 (after core's init:20 and our settings registration at init:21). + */ + public function maybe_pass_keys_to_registry(): void { + if ( ! class_exists( AiClient::class ) ) { + return; + } + + try { + $registry = AiClient::defaultRegistry(); + } catch ( \Throwable $t ) { + return; + } + + foreach ( $this->get_enabled_provider_ids() as $provider_id ) { + $meta = self::CONNECTOR_META[ $provider_id ] ?? array(); + $type = $meta['type'] ?? 'api_key'; + + if ( 'endpoint' === $type ) { + continue; + } + + // Skip if already configured (core handled it at init:20). + if ( ! $registry->hasProvider( $provider_id ) ) { + continue; + } + + try { + if ( $registry->isProviderConfigured( $provider_id ) ) { + continue; + } + } catch ( \Throwable $t ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- isProviderConfigured may throw; continue to try setting key. + unset( $t ); + } + + $setting_name = "connectors_ai_{$provider_id}_api_key"; + + // Read unmasked value. + if ( function_exists( '_wp_connectors_get_real_api_key' ) && function_exists( '_wp_connectors_mask_api_key' ) ) { + $api_key = _wp_connectors_get_real_api_key( $setting_name, '_wp_connectors_mask_api_key' ); + } else { + $api_key = (string) get_option( $setting_name, '' ); + } + + if ( '' === $api_key ) { + continue; + } + + try { + $registry->setProviderRequestAuthentication( + $provider_id, + new \WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication( $api_key ) + ); + } catch ( \Throwable $t ) { + continue; + } + } + } + + /** + * Enqueues the connectors script module on the Connectors admin page. + * + * @param string $hook_suffix Admin page hook suffix. + */ + public function maybe_enqueue_connectors_script( string $hook_suffix ): void { + if ( 'settings_page_connectors-wp-admin' !== $hook_suffix ) { + return; + } + + if ( ! function_exists( 'wp_register_script_module' ) ) { + return; + } + + $script_path = AI_EXPERIMENTS_DIR . 'assets/js/connectors-extended.js'; + if ( ! file_exists( $script_path ) ) { + return; + } + + $module_id = 'ai-experiments/connectors-extended'; + $deps = array( + array( + 'id' => '@wordpress/connectors', + 'import' => 'static', + ), + ); + + wp_register_script_module( + $module_id, + AI_EXPERIMENTS_PLUGIN_URL . 'assets/js/connectors-extended.js', + $deps, + (string) filemtime( $script_path ) + ); + + wp_enqueue_script_module( $module_id ); + + // Pass provider data to JS. + $provider_data = array(); + foreach ( $this->get_enabled_provider_ids() as $provider_id ) { + if ( ! isset( self::CONNECTOR_META[ $provider_id ] ) ) { + continue; + } + + $meta = self::CONNECTOR_META[ $provider_id ]; + $type = $meta['type'] ?? 'api_key'; + + $entry = array( + 'id' => $provider_id, + 'label' => $meta['label'], + 'description' => $meta['description'], + 'helpUrl' => $meta['helpUrl'], + 'helpLabel' => $meta['helpLabel'], + 'type' => $type, + ); + + if ( 'endpoint' === $type ) { + $entry['settingName'] = "ai_{$provider_id}_endpoint"; + } else { + $entry['settingName'] = "connectors_ai_{$provider_id}_api_key"; + } + + $provider_data[] = $entry; + } + + // Output data as a global before the module loads. + add_action( + 'admin_print_footer_scripts', + static function () use ( $provider_data ): void { + printf( + '', + wp_json_encode( $provider_data ) + ); + }, + 1 + ); + } + + /** + * Registers any provider classes supplied via filters. + */ + public function register_providers(): void { + if ( ! $this->is_enabled() ) { + return; + } + + if ( ! class_exists( AiClient::class ) ) { + return; + } + + $provider_classes = $this->filter_enabled_provider_classes( + $this->get_provider_classes() + ); + + if ( empty( $provider_classes ) ) { + return; + } + + $registry = AiClient::defaultRegistry(); + + foreach ( $provider_classes as $class_name ) { + if ( '' === $class_name ) { + continue; + } + + if ( ! class_exists( $class_name ) ) { + _doing_it_wrong( + __METHOD__, + esc_html( + sprintf( + /* translators: %s: provider class name. */ + __( 'Extended Providers experiment could not load "%s". Make sure the class is autoloadable.', 'ai' ), + $class_name + ) + ), + '0.1.0' + ); + continue; + } + + if ( $registry->hasProvider( $class_name ) ) { + continue; + } + + try { + /** @var class-string<\WordPress\AiClient\Providers\Contracts\ProviderInterface> $class_name */ + $registry->registerProvider( $class_name ); + } catch ( \Throwable $t ) { + _doing_it_wrong( + __METHOD__, + esc_html( + sprintf( + /* translators: 1: provider class, 2: error message. */ + __( 'Failed to register provider "%1$s": %2$s', 'ai' ), + $class_name, + $t->getMessage() + ) + ), + '0.1.0' + ); + } + } + } + + /** + * {@inheritDoc} + */ + public function register_settings(): void { + register_setting( + Settings_Registration::OPTION_GROUP, + $this->get_provider_selection_option_name(), + array( + 'type' => 'array', + 'default' => array(), + 'sanitize_callback' => array( $this, 'sanitize_provider_selection' ), + ) + ); + } + + /** + * {@inheritDoc} + */ + public function render_settings_fields(): void { + $provider_classes = $this->get_provider_classes(); + + if ( empty( $provider_classes ) ) { + echo '

' . esc_html__( 'No providers are currently registered for this experiment.', 'ai' ) . '

'; + return; + } + + $selection = $this->get_provider_selection(); + $option_name = $this->get_provider_selection_option_name(); + ?> +
+

+ +

+ + is_provider_selected( $class_name, $selection ); + ?> +
+ " value="0" /> + " + value="1" + + /> + +
+ +
+ + */ + public function get_entry_points(): array { + return array( + array( + 'label' => __( 'Credentials', 'ai' ), + 'url' => admin_url( 'options-general.php?page=wp-ai-client' ), + 'type' => 'dashboard', + ), + ); + } + + /** + * Returns the provider class list after filters have been applied. + * + * @return array + */ + private function get_provider_classes(): array { + $defaults = apply_filters( 'ai_extended_provider_default_classes', self::DEFAULT_PROVIDER_CLASSES ); + + /** + * Filters the provider class list registered by the Extended Providers experiment. + * + * @since 0.1.0 + * + * @param array $classes Provider class names. + * @param \WordPress\AI\Abstracts\Abstract_Experiment $experiment Experiment instance. + */ + $providers = apply_filters( 'ai_extended_provider_classes', (array) $defaults, $this ); + + return array_values( + array_filter( + array_map( + static function ( $class_name ) { + return is_string( $class_name ) ? trim( $class_name ) : ''; // @phpstan-ignore function.alreadyNarrowedType + }, + (array) $providers + ) + ) + ); + } + + /** + * Filters provider classes based on the admin selection. + * + * @param array $provider_classes Provider classes. + * + * @return array + */ + private function filter_enabled_provider_classes( array $provider_classes ): array { + $selection = $this->get_provider_selection(); + + if ( empty( $selection ) ) { + return $provider_classes; + } + + return array_values( + array_filter( + $provider_classes, + static function ( string $class_name ) use ( $selection ): bool { + return ! isset( $selection[ $class_name ] ) || true === $selection[ $class_name ]; + } + ) + ); + } + + /** + * Gets the stored provider selection map. + * + * @return array + */ + private function get_provider_selection(): array { + $selection = get_option( $this->get_provider_selection_option_name(), array() ); + + if ( ! is_array( $selection ) ) { + return array(); + } + + $sanitized = array(); + foreach ( $selection as $class => $enabled ) { + if ( ! is_string( $class ) || '' === $class ) { + continue; + } + + $sanitized[ $class ] = (bool) $enabled; + } + + return $sanitized; + } + + /** + * Determines if a provider should be registered. + * + * @param string $class_name Provider class name. + * @param array $selection Selection map. + * + * @return bool + */ + private function is_provider_selected( string $class_name, array $selection ): bool { + if ( empty( $selection ) ) { + return true; + } + + return $selection[ $class_name ] ?? true; + } + + /** + * Returns the option name that stores provider selection. + */ + private function get_provider_selection_option_name(): string { + return $this->get_field_option_name( self::FIELD_PROVIDERS ); + } + + /** + * Sanitizes the provider selection payload from the settings form. + * + * @param mixed $value Submitted value. + * + * @return array + */ + public function sanitize_provider_selection( $value ): array { + if ( ! is_array( $value ) ) { + return array(); + } + + $sanitized = array(); + + foreach ( $value as $class => $enabled ) { + if ( ! is_string( $class ) || '' === $class ) { + continue; + } + + // @phpstan-ignore-next-line -- Template type T in rest_sanitize_boolean cannot be resolved statically. + $sanitized[ $class ] = rest_sanitize_boolean( $enabled ); + } + + return $sanitized; + } + + /** + * Returns a human-friendly label for a provider class. + * + * @param string $class_name Provider class name. + * + * @return string + */ + private function get_provider_label( string $class_name ): string { + if ( class_exists( $class_name ) && method_exists( $class_name, 'metadata' ) ) { + try { + /** @var \WordPress\AiClient\Providers\DTO\ProviderMetadata $metadata */ + $metadata = $class_name::metadata(); + return $metadata->getName(); + } catch ( \Throwable $t ) { + // Fallback to class name below. + unset( $t ); + } + } + + return $class_name; + } +} diff --git a/includes/Providers/Cloudflare/CloudflareWorkersAiModelMetadataDirectory.php b/includes/Providers/Cloudflare/CloudflareWorkersAiModelMetadataDirectory.php new file mode 100644 index 00000000..b8fc35f4 --- /dev/null +++ b/includes/Providers/Cloudflare/CloudflareWorkersAiModelMetadataDirectory.php @@ -0,0 +1,79 @@ + + */ + private $catalogue = array( + array( + 'id' => '@cf/meta/llama-3-8b-instruct', + 'name' => 'Meta Llama 3 8B (Cloudflare)', + ), + array( + 'id' => '@cf/meta/llama-3-70b-instruct', + 'name' => 'Meta Llama 3 70B (Cloudflare)', + ), + array( + 'id' => '@cf/mistral/mistral-7b-instruct-v0.2', + 'name' => 'Mistral 7B Instruct (Cloudflare)', + ), + ); + + /** + * {@inheritDoc} + */ + protected function sendListModelsRequest(): array { + $capabilities = array( + CapabilityEnum::textGeneration(), + CapabilityEnum::chatHistory(), + ); + + $options = array( + 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::customOptions() ), + ); + + $map = array(); + foreach ( $this->catalogue as $model ) { + $map[ $model['id'] ] = new ModelMetadata( + $model['id'], + $model['name'], + $capabilities, + $options + ); + } + + return $map; + } +} diff --git a/includes/Providers/Cloudflare/CloudflareWorkersAiProvider.php b/includes/Providers/Cloudflare/CloudflareWorkersAiProvider.php new file mode 100644 index 00000000..e0a0fe9c --- /dev/null +++ b/includes/Providers/Cloudflare/CloudflareWorkersAiProvider.php @@ -0,0 +1,137 @@ +getSupportedCapabilities() as $capability ) { + if ( $capability->isTextGeneration() ) { + return new CloudflareWorkersAiTextGenerationModel( $model_metadata, $provider_metadata ); + } + } + + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. + throw new RuntimeException( + 'Unsupported Cloudflare Workers AI model capabilities: ' . implode( + ', ', + array_map( + static function ( $capability ) { + return $capability->value; + }, + $model_metadata->getSupportedCapabilities() + ) + ) + ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + /** + * {@inheritDoc} + */ + protected static function createProviderMetadata(): ProviderMetadata { + return new ProviderMetadata( + 'cloudflare', + 'Cloudflare Workers AI', + ProviderTypeEnum::cloud(), + null, + RequestAuthenticationMethod::from( 'api_key' ) + ); + } + + /** + * {@inheritDoc} + */ + protected static function createProviderAvailability(): ProviderAvailabilityInterface { + return new ListModelsApiBasedProviderAvailability( + static::modelMetadataDirectory() + ); + } + + /** + * {@inheritDoc} + */ + protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface { + return new CloudflareWorkersAiModelMetadataDirectory(); + } +} diff --git a/includes/Providers/Cloudflare/CloudflareWorkersAiTextGenerationModel.php b/includes/Providers/Cloudflare/CloudflareWorkersAiTextGenerationModel.php new file mode 100644 index 00000000..00fd3a83 --- /dev/null +++ b/includes/Providers/Cloudflare/CloudflareWorkersAiTextGenerationModel.php @@ -0,0 +1,216 @@ +metadata()->getId() ), + array( 'Content-Type' => 'application/json' ), + $this->buildPayload( $prompt ) + ); + $request = $this->getRequestAuthentication()->authenticateRequest( $request ); + $response = $this->getHttpTransporter()->send( $request ); + $this->throwIfNotSuccessful( $response ); + + return $this->parseResponse( $response ); + } + + /** + * {@inheritDoc} + */ + /** @phpstan-ignore missingType.iterableValue */ + public function streamGenerateTextResult( array $prompt ): \Generator { + throw ResponseException::fromInvalidData( 'Cloudflare Workers AI', 'stream', 'Streaming is not implemented.' ); + } + + /** + * Builds the Cloudflare payload. + * + * @param list<\WordPress\AiClient\Messages\DTO\Message> $prompt Prompt messages. + * + * @return array + */ + private function buildPayload( array $prompt ): array { + $config = $this->getConfig(); + $messages = $this->convertPromptToMessages( $prompt ); + + if ( empty( $messages ) ) { + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. + throw new InvalidArgumentException( + __( 'Cloudflare Workers AI chat requests require at least one user message.', 'ai' ) + ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + $payload = array( + 'messages' => $messages, + 'stream' => false, + ); + + if ( null !== $config->getSystemInstruction() ) { + array_unshift( + $payload['messages'], + array( + 'role' => 'system', + 'content' => $config->getSystemInstruction(), + ) + ); + } + + if ( null !== $config->getTemperature() ) { + $payload['temperature'] = (float) $config->getTemperature(); + } + if ( null !== $config->getTopP() ) { + $payload['top_p'] = (float) $config->getTopP(); + } + if ( null !== $config->getMaxTokens() ) { + $payload['max_output_tokens'] = (int) $config->getMaxTokens(); + } + if ( $config->getStopSequences() ) { + $payload['stop_sequences'] = $config->getStopSequences(); + } + + foreach ( $config->getCustomOptions() as $key => $value ) { + if ( isset( $payload[ $key ] ) ) { + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. + throw new InvalidArgumentException( + sprintf( + /* translators: %s: custom option key. */ + __( 'The custom option "%s" conflicts with an existing Cloudflare Workers AI parameter.', 'ai' ), + $key + ) + ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + $payload[ $key ] = $value; + } + + return $payload; + } + + /** + * Converts the WP AI Client prompt into Cloudflare message objects. + * + * @param list<\WordPress\AiClient\Messages\DTO\Message> $prompt Prompt messages. + * + * @return list + */ + private function convertPromptToMessages( array $prompt ): array { + $messages = array(); + + foreach ( $prompt as $message ) { + $text = $this->extractTextFromMessage( $message ); + if ( '' === $text ) { + continue; + } + + $role = $message->getRole()->isModel() ? 'assistant' : 'user'; + $messages[] = array( + 'role' => $role, + 'content' => $text, + ); + } + + return $messages; + } + + /** + * Extracts text from a message. + * + * @param \WordPress\AiClient\Messages\DTO\Message $message Message instance. + * + * @return string + */ + private function extractTextFromMessage( Message $message ): string { + foreach ( $message->getParts() as $part ) { + if ( null !== $part->getText() ) { + return $part->getText(); + } + } + + return ''; + } + + /** + * Parses the Workers AI response to a WP AI result. + * + * @param \WordPress\AiClient\Providers\Http\DTO\Response $response HTTP response. + * + * @return \WordPress\AiClient\Results\DTO\GenerativeAiResult + */ + private function parseResponse( Response $response ): GenerativeAiResult { + $data = $response->getData() ?? array(); + if ( ! isset( $data['result']['response'] ) || ! is_string( $data['result']['response'] ) ) { + throw ResponseException::fromMissingData( 'Cloudflare Workers AI', 'result.response' ); + } + + $message = new Message( + MessageRoleEnum::model(), + array( new MessagePart( $data['result']['response'] ) ) + ); + + $candidate = new Candidate( $message, FinishReasonEnum::stop() ); + $prompt_tokens = (int) ( $data['result']['input_tokens'] ?? 0 ); + $output_tokens = (int) ( $data['result']['output_tokens'] ?? 0 ); + + return new GenerativeAiResult( + $data['result']['id'] ?? '', + array( $candidate ), + new TokenUsage( $prompt_tokens, $output_tokens, $prompt_tokens + $output_tokens ), + $this->providerMetadata(), + $this->metadata(), + $data + ); + } + + /** + * Ensures Workers AI returned a successful response. + * + * @param \WordPress\AiClient\Providers\Http\DTO\Response $response HTTP response. + * + * @return void + */ + protected function throwIfNotSuccessful( Response $response ): void { + ResponseUtil::throwIfNotSuccessful( $response ); + } +} diff --git a/includes/Providers/Cohere/CohereModelMetadataDirectory.php b/includes/Providers/Cohere/CohereModelMetadataDirectory.php new file mode 100644 index 00000000..45365027 --- /dev/null +++ b/includes/Providers/Cohere/CohereModelMetadataDirectory.php @@ -0,0 +1,113 @@ +getRequestAuthentication()->authenticateRequest( $request ); + $response = $this->getHttpTransporter()->send( $request ); + ResponseUtil::throwIfNotSuccessful( $response ); + + return $this->parseResponseToModelMetadataMap( $response ); + } + + /** + * Parses Cohere's `/models` response. + * + * @param \WordPress\AiClient\Providers\Http\DTO\Response $response Cohere response. + * + * @return array + */ + private function parseResponseToModelMetadataMap( Response $response ): array { + $data = $response->getData() ?? array(); + if ( ! isset( $data['models'] ) || ! is_array( $data['models'] ) ) { + throw ResponseException::fromMissingData( 'Cohere', 'models' ); + } + + $capabilities = array( + CapabilityEnum::textGeneration(), + CapabilityEnum::chatHistory(), + ); + $options = $this->getTextOptions(); + + $metadata = array(); + foreach ( $data['models'] as $model ) { + if ( ! is_array( $model ) || empty( $model['name'] ) ) { + continue; + } + + $endpoints = $model['endpoints'] ?? array(); + if ( ! is_array( $endpoints ) || ! in_array( 'chat', $endpoints, true ) ) { + continue; + } + + $model_id = (string) $model['name']; + $model_name = isset( $model['display_name'] ) && is_string( $model['display_name'] ) + ? $model['display_name'] + : $model_id; + + $metadata[ $model_id ] = new ModelMetadata( + $model_id, + $model_name, + $capabilities, + $options // @phpstan-ignore argument.type + ); + } + + return $metadata; + } + + /** + * Returns baseline Cohere chat options. + * + * @return array + */ + private function getTextOptions(): array { + return array( + 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::presencePenalty() ), + new SupportedOption( OptionEnum::frequencyPenalty() ), + new SupportedOption( OptionEnum::stopSequences() ), + new SupportedOption( OptionEnum::customOptions() ), + new SupportedOption( OptionEnum::inputModalities(), array( array( ModalityEnum::text() ) ) ), + new SupportedOption( OptionEnum::outputModalities(), array( array( ModalityEnum::text() ) ) ), + ); + } +} diff --git a/includes/Providers/Cohere/CohereProvider.php b/includes/Providers/Cohere/CohereProvider.php new file mode 100644 index 00000000..021ea80a --- /dev/null +++ b/includes/Providers/Cohere/CohereProvider.php @@ -0,0 +1,93 @@ +getSupportedCapabilities() as $capability ) { + if ( $capability->isTextGeneration() ) { + return new CohereTextGenerationModel( $model_metadata, $provider_metadata ); + } + } + + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. + throw new RuntimeException( + 'Unsupported Cohere model capabilities: ' . implode( + ', ', + array_map( + static function ( $capability ) { + return $capability->value; + }, + $model_metadata->getSupportedCapabilities() + ) + ) + ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + /** + * {@inheritDoc} + */ + protected static function createProviderMetadata(): ProviderMetadata { + return new ProviderMetadata( + 'cohere', + 'Cohere', + ProviderTypeEnum::cloud(), + null, + RequestAuthenticationMethod::from( 'api_key' ) + ); + } + + /** + * {@inheritDoc} + */ + protected static function createProviderAvailability(): ProviderAvailabilityInterface { + return new ListModelsApiBasedProviderAvailability( + static::modelMetadataDirectory() + ); + } + + /** + * {@inheritDoc} + */ + protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface { + return new CohereModelMetadataDirectory(); + } +} diff --git a/includes/Providers/Cohere/CohereTextGenerationModel.php b/includes/Providers/Cohere/CohereTextGenerationModel.php new file mode 100644 index 00000000..8a41a70c --- /dev/null +++ b/includes/Providers/Cohere/CohereTextGenerationModel.php @@ -0,0 +1,354 @@ +buildPayload( $prompt ); + + $request = new Request( + HttpMethodEnum::POST(), + CohereProvider::url( 'chat' ), + array( 'Content-Type' => 'application/json' ), + $payload + ); + + $request = $this->getRequestAuthentication()->authenticateRequest( $request ); + $http_transport = $this->getHttpTransporter(); + $response = $http_transport->send( $request ); + $this->throwIfNotSuccessful( $response ); + + return $this->parseResponseToResult( $response ); + } + + /** + * {@inheritDoc} + */ + /** @phpstan-ignore missingType.iterableValue */ + public function streamGenerateTextResult( array $prompt ): \Generator { + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. + throw ResponseException::fromInvalidData( + $this->providerMetadata()->getName(), + 'stream', + __( 'Streaming is not yet implemented for the Cohere provider.', 'ai' ) + ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + /** + * Builds the Cohere `/chat` payload. + * + * @param list<\WordPress\AiClient\Messages\DTO\Message> $prompt Prompt messages. + * + * @return array + */ + private function buildPayload( array $prompt ): array { + $config = $this->getConfig(); + $messages = $this->convertPromptToMessages( $prompt ); + $system_text = $config->getSystemInstruction(); + + if ( empty( $messages ) ) { + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. + throw new InvalidArgumentException( + __( 'Cohere chat requests require at least one user message.', 'ai' ) + ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + $current_message = $this->extractLatestUserMessage( $messages ); + $chat_history = $this->convertMessagesToChatHistory( $messages ); + + $payload = array( + 'model' => $this->metadata()->getId(), + 'message' => $current_message, + ); + + if ( $system_text ) { + $payload['preamble'] = $system_text; + } + + if ( ! empty( $chat_history ) ) { + $payload['chat_history'] = $chat_history; + } + + if ( null !== $config->getCandidateCount() ) { + $payload['response_count'] = (int) $config->getCandidateCount(); + } + if ( null !== $config->getMaxTokens() ) { + $payload['max_tokens'] = (int) $config->getMaxTokens(); + } + if ( null !== $config->getTemperature() ) { + $payload['temperature'] = (float) $config->getTemperature(); + } + if ( null !== $config->getTopP() ) { + $payload['top_p'] = (float) $config->getTopP(); + } + if ( null !== $config->getTopK() ) { + $payload['top_k'] = (int) $config->getTopK(); + } + if ( $config->getStopSequences() ) { + $payload['stop_sequences'] = $config->getStopSequences(); + } + + foreach ( $config->getCustomOptions() as $key => $value ) { + if ( isset( $payload[ $key ] ) ) { + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. + throw new InvalidArgumentException( + sprintf( + /* translators: %s: custom option key. */ + __( 'The custom option "%s" conflicts with an existing Cohere parameter.', 'ai' ), + $key + ) + ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + $payload[ $key ] = $value; + } + + return $payload; + } + + /** + * Converts the WP AI Client prompt into Cohere's messages array. + * + * @param list<\WordPress\AiClient\Messages\DTO\Message> $prompt Prompt messages. + * + * @return list + */ + private function convertPromptToMessages( array $prompt ): array { + $messages = array(); + + foreach ( $prompt as $message ) { + $text = $this->extractTextFromMessage( $message ); + if ( '' === $text ) { + continue; + } + + $role = $message->getRole()->isModel() ? 'assistant' : 'user'; + + $messages[] = array( + 'role' => $role, + 'content' => $text, + ); + } + + return $messages; + } + + /** + * Extracts the first text fragment from a message. + * + * @param \WordPress\AiClient\Messages\DTO\Message $message Prompt message. + * + * @return string + */ + private function extractTextFromMessage( Message $message ): string { + foreach ( $message->getParts() as $part ) { + if ( null !== $part->getText() ) { + return $part->getText(); + } + } + + return ''; + } + + /** + * Converts Cohere API responses to standard results. + * + * @param \WordPress\AiClient\Providers\Http\DTO\Response $response Cohere response. + * + * @return \WordPress\AiClient\Results\DTO\GenerativeAiResult + */ + private function parseResponseToResult( Response $response ): GenerativeAiResult { + $data = $response->getData() ?? array(); + + $text_candidates = $this->extractTextCandidates( $data ); + if ( empty( $text_candidates ) ) { + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. + throw ResponseException::fromMissingData( $this->providerMetadata()->getName(), 'text' ); + } + + $candidates = array_map( + static function ( string $text ): Candidate { + $message = new Message( + MessageRoleEnum::model(), + array( new MessagePart( $text ) ) + ); + return new Candidate( $message, FinishReasonEnum::stop() ); + }, + $text_candidates + ); + + $usage = $data['meta']['billed_units'] ?? array(); + $input_tokens = (int) ( $usage['input_tokens'] ?? 0 ); + $output_tokens = (int) ( $usage['output_tokens'] ?? 0 ); + $token_usage = new TokenUsage( + $input_tokens, + $output_tokens, + $input_tokens + $output_tokens + ); + + $additional = $data; + unset( $additional['text'], $additional['response'], $additional['generations'] ); + + return new GenerativeAiResult( + $data['generation_id'] ?? ( $data['id'] ?? '' ), + $candidates, + $token_usage, + $this->providerMetadata(), + $this->metadata(), + $additional + ); + } + + /** + * Normalizes Cohere text containers into strings. + * + * @param array $data Cohere response data. + * + * @return list + */ + private function extractTextCandidates( array $data ): array { + $candidates = array(); + + if ( isset( $data['message'] ) && is_array( $data['message'] ) ) { + $content = $data['message']['content'] ?? array(); + if ( is_array( $content ) ) { + foreach ( $content as $block ) { + if ( ! isset( $block['text'] ) || ! is_string( $block['text'] ) ) { + continue; + } + + $candidates[] = $block['text']; + } + } + } + + if ( isset( $data['text'] ) && is_string( $data['text'] ) ) { + $candidates[] = $data['text']; + } + + if ( isset( $data['response'] ) && is_array( $data['response'] ) ) { + foreach ( $data['response'] as $entry ) { + if ( ! isset( $entry['message'] ) || ! is_string( $entry['message'] ) ) { + continue; + } + + $candidates[] = $entry['message']; + } + } + + if ( isset( $data['generations'] ) && is_array( $data['generations'] ) ) { + foreach ( $data['generations'] as $generation ) { + if ( ! isset( $generation['text'] ) || ! is_string( $generation['text'] ) ) { + continue; + } + + $candidates[] = $generation['text']; + } + } + + return $candidates; + } + + /** + * Ensures Cohere returned a successful response. + * + * @param \WordPress\AiClient\Providers\Http\DTO\Response $response Cohere response. + * + * @return void + */ + protected function throwIfNotSuccessful( Response $response ): void { + ResponseUtil::throwIfNotSuccessful( $response ); + } + + /** + * Extracts the most recent user utterance for Cohere's `message` field. + * + * @param array $messages Normalized message list. + * + * @return string + */ + private function extractLatestUserMessage( array &$messages ): string { + for ( $index = count( $messages ) - 1; $index >= 0; $index-- ) { + if ( 'user' !== $messages[ $index ]['role'] ) { + continue; + } + + $content = $messages[ $index ]['content']; + unset( $messages[ $index ] ); + + return $content; + } + + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. + throw new InvalidArgumentException( + __( 'Cohere chat requests require at least one user message.', 'ai' ) + ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + /** + * Converts remaining messages into Cohere `chat_history` entries. + * + * @param array $messages Normalized message list. + * + * @return array + */ + private function convertMessagesToChatHistory( array $messages ): array { + $history = array(); + + foreach ( array_values( $messages ) as $message ) { + if ( 'system' === $message['role'] ) { + continue; + } + + $role = 'user' === $message['role'] ? 'USER' : 'CHATBOT'; + + $history[] = array( + 'role' => $role, + 'message' => $message['content'], + ); + } + + return $history; + } +} diff --git a/includes/Providers/DeepSeek/DeepSeekModelMetadataDirectory.php b/includes/Providers/DeepSeek/DeepSeekModelMetadataDirectory.php new file mode 100644 index 00000000..72bad124 --- /dev/null +++ b/includes/Providers/DeepSeek/DeepSeekModelMetadataDirectory.php @@ -0,0 +1,89 @@ +getData() ?? array(); + if ( ! isset( $data['data'] ) || ! is_array( $data['data'] ) ) { + throw ResponseException::fromMissingData( 'DeepSeek', 'data' ); + } + + $capabilities = array( + CapabilityEnum::textGeneration(), + CapabilityEnum::chatHistory(), + ); + + $options = array( + 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::functionDeclarations() ), + new SupportedOption( OptionEnum::customOptions() ), + new SupportedOption( OptionEnum::inputModalities(), array( array( ModalityEnum::text() ) ) ), + new SupportedOption( OptionEnum::outputModalities(), array( array( ModalityEnum::text() ) ) ), + ); + + return array_values( + array_map( + static function ( array $model ) use ( $capabilities, $options ): ModelMetadata { + $model_id = (string) $model['id']; + return new ModelMetadata( + $model_id, + $model['id'], + $capabilities, + $options + ); + }, + $data['data'] + ) + ); + } +} diff --git a/includes/Providers/DeepSeek/DeepSeekProvider.php b/includes/Providers/DeepSeek/DeepSeekProvider.php new file mode 100644 index 00000000..c4bb122d --- /dev/null +++ b/includes/Providers/DeepSeek/DeepSeekProvider.php @@ -0,0 +1,93 @@ +getSupportedCapabilities() as $capability ) { + if ( $capability->isTextGeneration() ) { + return new DeepSeekTextGenerationModel( $model_metadata, $provider_metadata ); + } + } + + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. + throw new RuntimeException( + 'Unsupported DeepSeek model capabilities: ' . implode( + ', ', + array_map( + static function ( $capability ) { + return $capability->value; + }, + $model_metadata->getSupportedCapabilities() + ) + ) + ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + /** + * {@inheritDoc} + */ + protected static function createProviderMetadata(): ProviderMetadata { + return new ProviderMetadata( + 'deepseek', + 'DeepSeek', + ProviderTypeEnum::cloud(), + null, + RequestAuthenticationMethod::from( 'api_key' ) + ); + } + + /** + * {@inheritDoc} + */ + protected static function createProviderAvailability(): ProviderAvailabilityInterface { + return new ListModelsApiBasedProviderAvailability( + static::modelMetadataDirectory() + ); + } + + /** + * {@inheritDoc} + */ + protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface { + return new DeepSeekModelMetadataDirectory(); + } +} diff --git a/includes/Providers/DeepSeek/DeepSeekTextGenerationModel.php b/includes/Providers/DeepSeek/DeepSeekTextGenerationModel.php new file mode 100644 index 00000000..7309e773 --- /dev/null +++ b/includes/Providers/DeepSeek/DeepSeekTextGenerationModel.php @@ -0,0 +1,37 @@ +getHttpTransporter(); + $request = $this->createRequest( + HttpMethodEnum::POST(), + $this->metadata()->getId(), + array( 'Content-Type' => 'application/json' ), + $this->buildPayload( $prompt ) + ); + + $request = $this->getRequestAuthentication()->authenticateRequest( $request ); + $request = $this->ensureFalAuthorizationHeader( $request ); + $response = $http_transporter->send( $request ); + $this->throwIfNotSuccessful( $response ); + + return $this->parseResponseToResult( $response ); + } + + /** + * Builds the HTTP request for the synchronous `fal.run` endpoint. + * + * @param \WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum $method HTTP method. + * @param string $model_path Model identifier. + * @param array> $headers Headers. + * @param array|null $data Payload. + * + * @return \WordPress\AiClient\Providers\Http\DTO\Request + */ + protected function createRequest( + HttpMethodEnum $method, + string $model_path, + array $headers = array(), + ?array $data = null + ): Request { + return new Request( + $method, + FalAiProvider::url( $model_path ), + $headers, + $data + ); + } + + /** + * Builds the Fal.ai payload from the prompt. + * + * @param list<\WordPress\AiClient\Messages\DTO\Message> $prompt Prompt messages. + * + * @return array + */ + private function buildPayload( array $prompt ): array { + return array( + 'prompt' => $this->preparePromptText( $prompt ), + ); + } + + /** + * Converts Fal.ai responses to a GenerativeAiResult. + * + * @param \WordPress\AiClient\Providers\Http\DTO\Response $response Fal.ai response. + * + * @return \WordPress\AiClient\Results\DTO\GenerativeAiResult + */ + private function parseResponseToResult( Response $response ): GenerativeAiResult { + $response_data = $response->getData() ?? array(); + if ( ! isset( $response_data['images'] ) || ! is_array( $response_data['images'] ) ) { + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. + throw ResponseException::fromMissingData( $this->providerMetadata()->getName(), 'images' ); + } + + $candidates = array(); + foreach ( $response_data['images'] as $index => $image_data ) { + if ( ! is_array( $image_data ) || empty( $image_data['url'] ) ) { + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. + throw ResponseException::fromInvalidData( + $this->providerMetadata()->getName(), + "images[{$index}]", + 'Each image must include a URL.' + ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + $mime_type = isset( $image_data['content_type'] ) && is_string( $image_data['content_type'] ) + ? $image_data['content_type'] + : 'image/png'; + + $file = new File( (string) $image_data['url'], $mime_type ); + $message = new Message( + MessageRoleEnum::model(), + array( new MessagePart( $file ) ) + ); + $candidates[] = new Candidate( $message, FinishReasonEnum::stop() ); + } + + $additional = $response_data; + unset( $additional['images'] ); + + return new GenerativeAiResult( + $additional['request_id'] ?? '', + $candidates, + new TokenUsage( 0, 0, 0 ), + $this->providerMetadata(), + $this->metadata(), + $additional + ); + } + + /** + * Normalizes the prompt into a single user string. + * + * @param list<\WordPress\AiClient\Messages\DTO\Message> $messages Prompt messages. + * + * @return string + */ + private function preparePromptText( array $messages ): string { + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. + if ( count( $messages ) !== 1 ) { + throw new InvalidArgumentException( + __( 'Fal.ai models require a single user prompt.', 'ai' ) + ); + } + + $message = $messages[0]; + if ( ! $message->getRole()->isUser() ) { + throw new InvalidArgumentException( + __( 'Fal.ai image prompts must originate from the user role.', 'ai' ) + ); + } + + foreach ( $message->getParts() as $part ) { + $text = $part->getText(); + if ( is_string( $text ) && '' !== trim( $text ) ) { + return $text; + } + } + + throw new InvalidArgumentException( + __( 'Fal.ai image prompts must include text content.', 'ai' ) + ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + /** + * Throws an exception if the response indicates failure. + * + * @param \WordPress\AiClient\Providers\Http\DTO\Response $response Fal.ai response. + * + * @return void + */ + protected function throwIfNotSuccessful( Response $response ): void { + ResponseUtil::throwIfNotSuccessful( $response ); + } + + /** + * Converts Bearer auth headers into Fal.ai `Key` headers. + * + * @param \WordPress\AiClient\Providers\Http\DTO\Request $request Authenticated request. + * + * @return \WordPress\AiClient\Providers\Http\DTO\Request + */ + private function ensureFalAuthorizationHeader( Request $request ): Request { + $authorization = $request->getHeader( 'Authorization' ); + if ( empty( $authorization ) || ! is_string( $authorization[0] ?? null ) ) { + return $request; + } + + $value = $authorization[0]; + if ( 0 !== strpos( $value, 'Bearer ' ) ) { + return $request; + } + + $token = trim( substr( $value, 7 ) ); + if ( '' === $token ) { + return $request; + } + + return $request->withHeader( 'Authorization', 'Key ' . $token ); + } +} diff --git a/includes/Providers/FalAi/FalAiModelMetadataDirectory.php b/includes/Providers/FalAi/FalAiModelMetadataDirectory.php new file mode 100644 index 00000000..e742779b --- /dev/null +++ b/includes/Providers/FalAi/FalAiModelMetadataDirectory.php @@ -0,0 +1,118 @@ +> + */ + private $catalogue = array( + // FLUX.2 models. + array( + 'id' => 'fal-ai/flux-2', + 'name' => 'FLUX.2 Dev', + 'mime' => 'image/jpeg', + ), + array( + 'id' => 'fal-ai/flux-2-pro', + 'name' => 'FLUX.2 Pro', + 'mime' => 'image/jpeg', + ), + array( + 'id' => 'fal-ai/flux-2-flex', + 'name' => 'FLUX.2 Flex', + 'mime' => 'image/jpeg', + ), + // FLUX.1 models. + array( + 'id' => 'fal-ai/flux/dev', + 'name' => 'FLUX.1 Dev', + 'mime' => 'image/jpeg', + ), + array( + 'id' => 'fal-ai/flux/schnell', + 'name' => 'FLUX.1 Schnell', + 'mime' => 'image/jpeg', + ), + // Other models. + array( + 'id' => 'fal-ai/fast-sdxl', + 'name' => 'Fast SDXL', + 'mime' => 'image/png', + ), + ); + + /** + * {@inheritDoc} + */ + protected function sendListModelsRequest(): array { + $capabilities = array( CapabilityEnum::imageGeneration() ); + $options = $this->get_default_options(); + $metadata_map = array(); + + foreach ( $this->catalogue as $model ) { + $metadata_map[ $model['id'] ] = new ModelMetadata( + $model['id'], + $model['name'], + $capabilities, + $this->merge_options_with_mime( $options, $model['mime'] ) // @phpstan-ignore argument.type + ); + } + + return $metadata_map; + } + + /** + * Returns baseline supported options. + * + * @return array + */ + private function get_default_options(): array { + return array( + new SupportedOption( OptionEnum::inputModalities(), array( array( ModalityEnum::text() ) ) ), + new SupportedOption( OptionEnum::outputModalities(), array( array( ModalityEnum::image() ) ) ), + new SupportedOption( OptionEnum::outputFileType(), array( FileTypeEnum::remote(), FileTypeEnum::inline() ) ), + new SupportedOption( OptionEnum::customOptions() ), + ); + } + + /** + * Adds MIME-specific option metadata. + * + * @param array $options Base option list. + * @param string $mime_type MIME string. + * + * @return array + */ + private function merge_options_with_mime( array $options, string $mime_type ): array { + $mime_option = new SupportedOption( OptionEnum::outputMimeType(), array( $mime_type ) ); + + return array_merge( $options, array( $mime_option ) ); + } +} diff --git a/includes/Providers/FalAi/FalAiProvider.php b/includes/Providers/FalAi/FalAiProvider.php new file mode 100644 index 00000000..4dd60d15 --- /dev/null +++ b/includes/Providers/FalAi/FalAiProvider.php @@ -0,0 +1,106 @@ +getSupportedCapabilities() as $capability ) { + if ( $capability->isImageGeneration() ) { + return new FalAiImageGenerationModel( $model_metadata, $provider_metadata ); + } + } + + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. + throw new RuntimeException( + 'Unsupported Fal.ai model capabilities: ' . implode( + ', ', + array_map( + static function ( $capability ) { + return $capability->value; + }, + $model_metadata->getSupportedCapabilities() + ) + ) + ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + /** + * {@inheritDoc} + */ + protected static function createProviderMetadata(): ProviderMetadata { + return new ProviderMetadata( + 'fal', + 'Fal.ai', + ProviderTypeEnum::cloud(), + null, + RequestAuthenticationMethod::from( 'api_key' ) + ); + } + + /** + * {@inheritDoc} + */ + protected static function createProviderAvailability(): ProviderAvailabilityInterface { + return new ListModelsApiBasedProviderAvailability( + static::modelMetadataDirectory() + ); + } + + /** + * {@inheritDoc} + */ + protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface { + return new FalAiModelMetadataDirectory(); + } +} diff --git a/includes/Providers/Grok/GrokModelMetadataDirectory.php b/includes/Providers/Grok/GrokModelMetadataDirectory.php new file mode 100644 index 00000000..c515acc1 --- /dev/null +++ b/includes/Providers/Grok/GrokModelMetadataDirectory.php @@ -0,0 +1,223 @@ +getData() ?? array(); + $models_data = array(); + + if ( isset( $response_data['data'] ) && is_array( $response_data['data'] ) ) { + $models_data = $response_data['data']; + } elseif ( isset( $response_data['models'] ) && is_array( $response_data['models'] ) ) { + $models_data = $response_data['models']; + } + + if ( empty( $models_data ) ) { + throw ResponseException::fromMissingData( 'Grok', 'data' ); + } + + $metadata = array(); + foreach ( $models_data as $model_data ) { + if ( ! is_array( $model_data ) || empty( $model_data['id'] ) ) { + continue; + } + + $model_id = (string) $model_data['id']; + $metadata[] = new ModelMetadata( + $model_id, + $this->format_model_name( $model_id ), + $this->determine_capabilities( $model_id ), // @phpstan-ignore argument.type + $this->determine_supported_options( $model_id ) // @phpstan-ignore argument.type + ); + } + + return $metadata; + } + + /** + * Returns a human friendly label for a Grok model. + * + * @param string $model_id Model identifier. + * + * @return string + */ + private function format_model_name( string $model_id ): string { + $label = str_replace( array( '-', '_' ), ' ', $model_id ); + return ucwords( $label ); + } + + /** + * Determines the supported capabilities for a given model identifier. + * + * @param string $model_id Model identifier. + * + * @return array + */ + private function determine_capabilities( string $model_id ): array { + foreach ( self::IMAGE_MODEL_KEYWORDS as $keyword ) { + if ( false !== strpos( $model_id, $keyword ) ) { + return array( CapabilityEnum::imageGeneration() ); + } + } + + return array( + CapabilityEnum::textGeneration(), + CapabilityEnum::chatHistory(), + ); + } + + /** + * Determines the supported options for a given model identifier. + * + * @param string $model_id Model identifier. + * + * @return array + */ + private function determine_supported_options( string $model_id ): array { + foreach ( self::IMAGE_MODEL_KEYWORDS as $keyword ) { + if ( false !== strpos( $model_id, $keyword ) ) { + return $this->get_image_options(); + } + } + + $is_multimodal = $this->has_keyword( $model_id, self::MULTIMODAL_KEYWORDS ); + return $this->get_text_options( $is_multimodal ); + } + + /** + * Checks whether a model identifier contains any keyword. + * + * @param string $model_id Model identifier. + * @param array $keywords Keywords to scan for. + * + * @return bool + */ + private function has_keyword( string $model_id, array $keywords ): bool { + foreach ( $keywords as $keyword ) { + if ( false !== strpos( $model_id, $keyword ) ) { + return true; + } + } + return false; + } + + /** + * Returns base supported options for Grok chat models. + * + * @param bool $supports_multimodal Whether the model supports image inputs. + * + * @return array + */ + private function get_text_options( bool $supports_multimodal ): array { + $options = array( + 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::functionDeclarations() ), + new SupportedOption( OptionEnum::outputMimeType(), array( 'text/plain', 'application/json' ) ), + new SupportedOption( OptionEnum::outputSchema() ), + new SupportedOption( OptionEnum::customOptions() ), + ); + + $input_modalities = array( + array( ModalityEnum::text() ), + ); + + if ( $supports_multimodal ) { + $input_modalities[] = array( ModalityEnum::text(), ModalityEnum::image() ); + } + + $options[] = new SupportedOption( OptionEnum::inputModalities(), $input_modalities ); + $options[] = new SupportedOption( OptionEnum::outputModalities(), array( array( ModalityEnum::text() ) ) ); + + return $options; + } + + /** + * Returns supported options for Grok image generators. + * + * @return array + */ + private function get_image_options(): array { + return array( + new SupportedOption( OptionEnum::inputModalities(), array( array( ModalityEnum::text() ) ) ), + new SupportedOption( OptionEnum::outputModalities(), array( array( ModalityEnum::image() ) ) ), + new SupportedOption( OptionEnum::candidateCount() ), + new SupportedOption( OptionEnum::outputMimeType(), array( 'image/png', 'image/jpeg', 'image/webp' ) ), + new SupportedOption( OptionEnum::outputFileType(), array( FileTypeEnum::inline() ) ), + new SupportedOption( + OptionEnum::outputMediaOrientation(), + array( + MediaOrientationEnum::square(), + MediaOrientationEnum::landscape(), + MediaOrientationEnum::portrait(), + ) + ), + new SupportedOption( OptionEnum::customOptions() ), + ); + } +} diff --git a/includes/Providers/Grok/GrokProvider.php b/includes/Providers/Grok/GrokProvider.php new file mode 100644 index 00000000..da148672 --- /dev/null +++ b/includes/Providers/Grok/GrokProvider.php @@ -0,0 +1,93 @@ +getSupportedCapabilities() as $capability ) { + if ( $capability->isTextGeneration() ) { + return new GrokTextGenerationModel( $model_metadata, $provider_metadata ); + } + } + + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. + throw new RuntimeException( + 'Unsupported Grok model capabilities: ' . implode( + ', ', + array_map( + static function ( $capability ) { + return $capability->value; + }, + $model_metadata->getSupportedCapabilities() + ) + ) + ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + /** + * {@inheritDoc} + */ + protected static function createProviderMetadata(): ProviderMetadata { + return new ProviderMetadata( + 'grok', + 'Grok (xAI)', + ProviderTypeEnum::cloud(), + null, + RequestAuthenticationMethod::from( 'api_key' ) + ); + } + + /** + * {@inheritDoc} + */ + protected static function createProviderAvailability(): ProviderAvailabilityInterface { + return new ListModelsApiBasedProviderAvailability( + static::modelMetadataDirectory() + ); + } + + /** + * {@inheritDoc} + */ + protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface { + return new GrokModelMetadataDirectory(); + } +} diff --git a/includes/Providers/Grok/GrokTextGenerationModel.php b/includes/Providers/Grok/GrokTextGenerationModel.php new file mode 100644 index 00000000..faa3ff8a --- /dev/null +++ b/includes/Providers/Grok/GrokTextGenerationModel.php @@ -0,0 +1,37 @@ +getData() ?? array(); + if ( ! isset( $response_data['data'] ) || ! is_array( $response_data['data'] ) ) { + throw ResponseException::fromMissingData( 'Groq', 'data' ); + } + + $capabilities = array( + CapabilityEnum::textGeneration(), + CapabilityEnum::chatHistory(), + ); + $options = $this->get_text_options(); + $models_metadata = array(); + + foreach ( $response_data['data'] as $model_data ) { + if ( ! is_array( $model_data ) || empty( $model_data['id'] ) ) { + continue; + } + + $model_id = (string) $model_data['id']; + $model_name = isset( $model_data['name'] ) && is_string( $model_data['name'] ) + ? $model_data['name'] + : $model_id; + + $models_metadata[] = new ModelMetadata( + $model_id, + $model_name, + $capabilities, + $options // @phpstan-ignore argument.type + ); + } + + return $models_metadata; + } + + /** + * Returns supported options for Groq chat models. + * + * @return array + */ + private function get_text_options(): array { + return array( + 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::functionDeclarations() ), + new SupportedOption( OptionEnum::outputMimeType(), array( 'text/plain', 'application/json' ) ), + new SupportedOption( OptionEnum::outputSchema() ), + new SupportedOption( OptionEnum::customOptions() ), + new SupportedOption( + OptionEnum::inputModalities(), + array( + array( ModalityEnum::text() ), + ) + ), + new SupportedOption( + OptionEnum::outputModalities(), + array( + array( ModalityEnum::text() ), + ) + ), + ); + } +} diff --git a/includes/Providers/Groq/GroqProvider.php b/includes/Providers/Groq/GroqProvider.php new file mode 100644 index 00000000..c882d058 --- /dev/null +++ b/includes/Providers/Groq/GroqProvider.php @@ -0,0 +1,93 @@ +getSupportedCapabilities() as $capability ) { + if ( $capability->isTextGeneration() ) { + return new GroqTextGenerationModel( $model_metadata, $provider_metadata ); + } + } + + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. + throw new RuntimeException( + 'Unsupported Groq model capabilities: ' . implode( + ', ', + array_map( + static function ( $capability ) { + return $capability->value; + }, + $model_metadata->getSupportedCapabilities() + ) + ) + ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + /** + * {@inheritDoc} + */ + protected static function createProviderMetadata(): ProviderMetadata { + return new ProviderMetadata( + 'groq', + 'Groq', + ProviderTypeEnum::cloud(), + null, + RequestAuthenticationMethod::from( 'api_key' ) + ); + } + + /** + * {@inheritDoc} + */ + protected static function createProviderAvailability(): ProviderAvailabilityInterface { + return new ListModelsApiBasedProviderAvailability( + static::modelMetadataDirectory() + ); + } + + /** + * {@inheritDoc} + */ + protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface { + return new GroqModelMetadataDirectory(); + } +} diff --git a/includes/Providers/Groq/GroqTextGenerationModel.php b/includes/Providers/Groq/GroqTextGenerationModel.php new file mode 100644 index 00000000..a12d2605 --- /dev/null +++ b/includes/Providers/Groq/GroqTextGenerationModel.php @@ -0,0 +1,37 @@ +getData() ?? array(); + if ( ! isset( $data['data'] ) || ! is_array( $data['data'] ) ) { + throw ResponseException::fromMissingData( 'Hugging Face', 'data' ); + } + + $capabilities = array( + CapabilityEnum::textGeneration(), + CapabilityEnum::chatHistory(), + ); + $options = $this->getTextOptions(); + + $models = array(); + foreach ( $data['data'] as $model ) { + if ( ! is_array( $model ) || empty( $model['id'] ) ) { + continue; + } + + $models[] = new ModelMetadata( + $model['id'], + $model['id'], + $capabilities, + $options // @phpstan-ignore argument.type + ); + } + + return $models; + } + + /** + * Returns supported options for Hugging Face chat models. + * + * @return array + */ + private function getTextOptions(): array { + return array( + 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::customOptions() ), + new SupportedOption( OptionEnum::inputModalities(), array( array( ModalityEnum::text() ) ) ), + new SupportedOption( OptionEnum::outputModalities(), array( array( ModalityEnum::text() ) ) ), + ); + } +} diff --git a/includes/Providers/HuggingFace/HuggingFaceProvider.php b/includes/Providers/HuggingFace/HuggingFaceProvider.php new file mode 100644 index 00000000..3b2b6636 --- /dev/null +++ b/includes/Providers/HuggingFace/HuggingFaceProvider.php @@ -0,0 +1,93 @@ +getSupportedCapabilities() as $capability ) { + if ( $capability->isTextGeneration() ) { + return new HuggingFaceTextGenerationModel( $model_metadata, $provider_metadata ); + } + } + + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. + throw new RuntimeException( + 'Unsupported Hugging Face model capabilities: ' . implode( + ', ', + array_map( + static function ( $capability ) { + return $capability->value; + }, + $model_metadata->getSupportedCapabilities() + ) + ) + ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + /** + * {@inheritDoc} + */ + protected static function createProviderMetadata(): ProviderMetadata { + return new ProviderMetadata( + 'huggingface', + 'Hugging Face', + ProviderTypeEnum::cloud(), + null, + RequestAuthenticationMethod::from( 'api_key' ) + ); + } + + /** + * {@inheritDoc} + */ + protected static function createProviderAvailability(): ProviderAvailabilityInterface { + return new ListModelsApiBasedProviderAvailability( + static::modelMetadataDirectory() + ); + } + + /** + * {@inheritDoc} + */ + protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface { + return new HuggingFaceModelMetadataDirectory(); + } +} diff --git a/includes/Providers/HuggingFace/HuggingFaceTextGenerationModel.php b/includes/Providers/HuggingFace/HuggingFaceTextGenerationModel.php new file mode 100644 index 00000000..f57a2826 --- /dev/null +++ b/includes/Providers/HuggingFace/HuggingFaceTextGenerationModel.php @@ -0,0 +1,37 @@ +getHttpTransporter()->send( $request ); + $this->throwIfNotSuccessful( $response ); // @phpstan-ignore method.notFound + + return $this->parseResponse( $response ); + } + + /** + * Parses Ollama tags response. + * + * @param \WordPress\AiClient\Providers\Http\DTO\Response $response Ollama response. + * + * @return array + */ + private function parseResponse( Response $response ): array { + $data = $response->getData() ?? array(); + if ( ! isset( $data['models'] ) || ! is_array( $data['models'] ) ) { + throw ResponseException::fromMissingData( 'Ollama', 'models' ); + } + + $capabilities = array( + CapabilityEnum::textGeneration(), + CapabilityEnum::chatHistory(), + ); + + $options = array( + new SupportedOption( OptionEnum::systemInstruction() ), + new SupportedOption( OptionEnum::maxTokens() ), + new SupportedOption( OptionEnum::temperature() ), + new SupportedOption( OptionEnum::topP() ), + new SupportedOption( OptionEnum::customOptions() ), + ); + + $map = array(); + foreach ( $data['models'] as $model ) { + if ( ! isset( $model['name'] ) ) { + continue; + } + + $id = (string) $model['name']; + $name = isset( $model['details']['family'] ) ? $model['details']['family'] . ' (' . $id . ')' : $id; + + $map[ $id ] = new ModelMetadata( + $id, + $name, + $capabilities, + $options + ); + } + + return $map; + } +} diff --git a/includes/Providers/Ollama/OllamaProvider.php b/includes/Providers/Ollama/OllamaProvider.php new file mode 100644 index 00000000..5ee17f88 --- /dev/null +++ b/includes/Providers/Ollama/OllamaProvider.php @@ -0,0 +1,106 @@ +getSupportedCapabilities() as $capability ) { + if ( $capability->isTextGeneration() ) { + return new OllamaTextGenerationModel( $model_metadata, $provider_metadata ); + } + } + + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. + throw new RuntimeException( + 'Unsupported Ollama model capabilities: ' . implode( + ', ', + array_map( + static function ( $capability ) { + return $capability->value; + }, + $model_metadata->getSupportedCapabilities() + ) + ) + ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + /** + * {@inheritDoc} + */ + protected static function createProviderMetadata(): ProviderMetadata { + return new ProviderMetadata( + 'ollama', + 'Ollama', + ProviderTypeEnum::client() + ); + } + + /** + * {@inheritDoc} + */ + protected static function createProviderAvailability(): ProviderAvailabilityInterface { + return new ListModelsApiBasedProviderAvailability( + static::modelMetadataDirectory() + ); + } + + /** + * {@inheritDoc} + */ + protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface { + return new OllamaModelMetadataDirectory(); + } +} diff --git a/includes/Providers/Ollama/OllamaTextGenerationModel.php b/includes/Providers/Ollama/OllamaTextGenerationModel.php new file mode 100644 index 00000000..3675fa5b --- /dev/null +++ b/includes/Providers/Ollama/OllamaTextGenerationModel.php @@ -0,0 +1,193 @@ + 'application/json' ), + $this->buildPayload( $prompt ) + ); + + $response = $this->getHttpTransporter()->send( $request ); + $this->throwIfNotSuccessful( $response ); + + return $this->parseResponse( $response ); + } + + /** + * {@inheritDoc} + */ + /** @phpstan-ignore missingType.iterableValue */ + public function streamGenerateTextResult( array $prompt ): \Generator { + throw ResponseException::fromInvalidData( 'Ollama', 'stream', 'Streaming not implemented.' ); + } + + /** + * Builds the request payload. + * + * @param list<\WordPress\AiClient\Messages\DTO\Message> $prompt Prompt messages. + * + * @return array + */ + private function buildPayload( array $prompt ): array { + $config = $this->getConfig(); + $messages = $this->convertPromptToMessages( $prompt ); + + if ( empty( $messages ) ) { + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. + throw new InvalidArgumentException( + __( 'Ollama chat requests require at least one user message.', 'ai' ) + ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + $payload = array( + 'model' => $this->metadata()->getId(), + 'messages' => $messages, + 'stream' => false, + ); + + if ( null !== $config->getTemperature() ) { + $payload['options']['temperature'] = (float) $config->getTemperature(); + } + if ( null !== $config->getTopP() ) { + $payload['options']['top_p'] = (float) $config->getTopP(); + } + if ( null !== $config->getTopK() ) { + $payload['options']['top_k'] = (float) $config->getTopK(); + } + + foreach ( $config->getCustomOptions() as $key => $value ) { + $payload['options'][ $key ] = $value; + } + + return $payload; + } + + /** + * Converts prompt messages to Ollama format. + * + * @param list<\WordPress\AiClient\Messages\DTO\Message> $prompt Prompt messages. + * + * @return list + */ + private function convertPromptToMessages( array $prompt ): array { + $messages = array(); + + foreach ( $prompt as $message ) { + $text = $this->extractTextFromMessage( $message ); + if ( '' === $text ) { + continue; + } + + $role = $message->getRole()->isModel() ? 'assistant' : 'user'; + $messages[] = array( + 'role' => $role, + 'content' => $text, + ); + } + + return $messages; + } + + /** + * Extracts first text part from a message. + * + * @param \WordPress\AiClient\Messages\DTO\Message $message Message instance. + * + * @return string + */ + private function extractTextFromMessage( Message $message ): string { + foreach ( $message->getParts() as $part ) { + if ( null !== $part->getText() ) { + return $part->getText(); + } + } + + return ''; + } + + /** + * Converts Ollama response to a GenerativeAiResult. + * + * @param \WordPress\AiClient\Providers\Http\DTO\Response $response Response instance. + * + * @return \WordPress\AiClient\Results\DTO\GenerativeAiResult + */ + private function parseResponse( Response $response ): GenerativeAiResult { + $data = $response->getData() ?? array(); + if ( ! isset( $data['message']['content'] ) || ! is_string( $data['message']['content'] ) ) { + throw ResponseException::fromMissingData( 'Ollama', 'message.content' ); + } + + $message = new Message( + MessageRoleEnum::model(), + array( new MessagePart( $data['message']['content'] ) ) + ); + + $candidate = new Candidate( $message, FinishReasonEnum::stop() ); + + $prompt_tokens = (int) ( $data['prompt_eval_count'] ?? 0 ); + $output_tokens = (int) ( $data['eval_count'] ?? 0 ); + + return new GenerativeAiResult( + $data['id'] ?? '', + array( $candidate ), + new TokenUsage( $prompt_tokens, $output_tokens, $prompt_tokens + $output_tokens ), + $this->providerMetadata(), + $this->metadata(), + $data + ); + } + + /** + * Validates response success. + * + * @param \WordPress\AiClient\Providers\Http\DTO\Response $response Response instance. + * + * @return void + */ + protected function throwIfNotSuccessful( Response $response ): void { + ResponseUtil::throwIfNotSuccessful( $response ); + } +} diff --git a/includes/Providers/OpenRouter/OpenRouterModelMetadataDirectory.php b/includes/Providers/OpenRouter/OpenRouterModelMetadataDirectory.php new file mode 100644 index 00000000..0966bdee --- /dev/null +++ b/includes/Providers/OpenRouter/OpenRouterModelMetadataDirectory.php @@ -0,0 +1,95 @@ +getData() ?? array(); + if ( ! isset( $data['data'] ) || ! is_array( $data['data'] ) ) { + throw ResponseException::fromMissingData( 'OpenRouter', 'data' ); + } + + $options = $this->getTextOptions(); + $capabilities = array( + CapabilityEnum::textGeneration(), + CapabilityEnum::chatHistory(), + ); + + $models = array(); + foreach ( $data['data'] as $model ) { + if ( ! is_array( $model ) || empty( $model['id'] ) ) { + continue; + } + + $models[] = new ModelMetadata( + $model['id'], + $model['name'] ?? $model['id'], + $capabilities, + $options // @phpstan-ignore argument.type + ); + } + + return $models; + } + + /** + * Returns supported options for OpenRouter chat models. + * + * @return array + */ + private function getTextOptions(): array { + return array( + 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::functionDeclarations() ), + new SupportedOption( OptionEnum::customOptions() ), + ); + } +} diff --git a/includes/Providers/OpenRouter/OpenRouterProvider.php b/includes/Providers/OpenRouter/OpenRouterProvider.php new file mode 100644 index 00000000..730d2b09 --- /dev/null +++ b/includes/Providers/OpenRouter/OpenRouterProvider.php @@ -0,0 +1,93 @@ +getSupportedCapabilities() as $capability ) { + if ( $capability->isTextGeneration() ) { + return new OpenRouterTextGenerationModel( $model_metadata, $provider_metadata ); + } + } + + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are for developers. + throw new RuntimeException( + 'Unsupported OpenRouter model capabilities: ' . implode( + ', ', + array_map( + static function ( $capability ) { + return $capability->value; + }, + $model_metadata->getSupportedCapabilities() + ) + ) + ); + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + /** + * {@inheritDoc} + */ + protected static function createProviderMetadata(): ProviderMetadata { + return new ProviderMetadata( + 'openrouter', + 'OpenRouter', + ProviderTypeEnum::cloud(), + null, + RequestAuthenticationMethod::from( 'api_key' ) + ); + } + + /** + * {@inheritDoc} + */ + protected static function createProviderAvailability(): ProviderAvailabilityInterface { + return new ListModelsApiBasedProviderAvailability( + static::modelMetadataDirectory() + ); + } + + /** + * {@inheritDoc} + */ + protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface { + return new OpenRouterModelMetadataDirectory(); + } +} diff --git a/includes/Providers/OpenRouter/OpenRouterTextGenerationModel.php b/includes/Providers/OpenRouter/OpenRouterTextGenerationModel.php new file mode 100644 index 00000000..6c7a276e --- /dev/null +++ b/includes/Providers/OpenRouter/OpenRouterTextGenerationModel.php @@ -0,0 +1,44 @@ + 'application/json', + ), + $headers + ); + + return new Request( + $method, + OpenRouterProvider::url( $path ), + $headers, + $data + ); + } +} diff --git a/includes/Services/AI_Service.php b/includes/Services/AI_Service.php index 9fb2ec45..762ae733 100644 --- a/includes/Services/AI_Service.php +++ b/includes/Services/AI_Service.php @@ -11,10 +11,9 @@ namespace WordPress\AI\Services; -use WordPress\AI_Client\AI_Client; -use WordPress\AI_Client\Builders\Prompt_Builder_With_WP_Error; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; +use function WordPress\AI\ai_client_prompt_with_wp_error; use function WordPress\AI\get_preferred_models_for_text_generation; /** @@ -121,10 +120,10 @@ private function __construct() {} * @type bool $logprobs Whether to return log probabilities. * @type int $top_logprobs Top log probabilities to return. * } - * @return \WordPress\AI_Client\Builders\Prompt_Builder_With_WP_Error The prompt builder instance. + * @return mixed Prompt builder instance. */ - public function create_textgen_prompt( ?string $prompt = null, array $options = array() ): Prompt_Builder_With_WP_Error { - $builder = AI_Client::prompt_with_wp_error( $prompt ); + public function create_textgen_prompt( ?string $prompt = null, array $options = array() ) { + $builder = ai_client_prompt_with_wp_error( $prompt ); // Apply default model preferences. $models = get_preferred_models_for_text_generation(); diff --git a/includes/Settings/Settings_Page.php b/includes/Settings/Settings_Page.php index 07ce1de8..837048d8 100644 --- a/includes/Settings/Settings_Page.php +++ b/includes/Settings/Settings_Page.php @@ -15,6 +15,7 @@ use WordPress\AI\Experiment_Category; use WordPress\AI\Experiment_Registry; +use function WordPress\AI\get_ai_provider_api_key_variable_names; use function WordPress\AI\has_ai_credentials; use function WordPress\AI\has_valid_ai_credentials; @@ -116,6 +117,45 @@ public function enqueue_styles(): void { Asset_Loader::enqueue_style( 'experiments-settings', 'admin/settings' ); } + /** + * Returns the AI credentials settings URL if available. + * + * @since 0.1.0 + * + * @return string|null The URL to the credentials settings screen, or null. + */ + private function get_ai_credentials_settings_url(): ?string { + return \WordPress\AI\get_ai_credentials_settings_url(); + } + + /** + * Returns fallback help text for configuring AI credentials without a UI screen. + * + * @since 0.1.0 + * + * @return string Human-readable setup help. + */ + private function get_ai_credentials_configuration_help(): string { + $variable_names = get_ai_provider_api_key_variable_names(); + $example_names = array_slice( $variable_names, 0, 3 ); + + if ( empty( $example_names ) ) { + return __( + 'Set provider API keys via environment variables or constants in wp-config.php.', + 'ai' + ); + } + + return sprintf( + /* translators: %s: Comma-separated credential variable names. */ + __( + 'Set provider API keys via environment variables or constants in wp-config.php, for example: %s.', + 'ai' + ), + implode( ', ', $example_names ) + ); + } + /** * Renders the settings page. * @@ -134,23 +174,49 @@ public function render_page(): void {

here.', 'ai' ), - admin_url( 'options-general.php?page=wp-ai-client' ) - ); + $credentials_settings_url = $this->get_ai_credentials_settings_url(); + + if ( is_string( $credentials_settings_url ) && '' !== $credentials_settings_url ) { + if ( ! has_ai_credentials() ) { + $warning_message = sprintf( + /* translators: 1: Link to the AI credentials settings page. */ + __( 'AI credentials are not configured yet. AI features may not work until you add one or more credentials here.', 'ai' ), + esc_url( $credentials_settings_url ) + ); + } else { + $warning_message = sprintf( + /* translators: 1: Link to the AI credentials settings page. */ + __( 'AI credentials appear invalid. AI features may fail until you update them here.', 'ai' ), + esc_url( $credentials_settings_url ) + ); + } } else { - $error_message = sprintf( - /* translators: 1: Link to the AI credentials settings page. */ - __( 'Most experiments require valid AI credentials to function properly. Please review the AI credentials you have set to ensure they are valid.', 'ai' ), - admin_url( 'options-general.php?page=wp-ai-client' ) - ); + $configuration_help = $this->get_ai_credentials_configuration_help(); + + if ( ! has_ai_credentials() ) { + $warning_message = sprintf( + /* translators: %s: Credential setup guidance. */ + __( + 'AI credentials are not configured yet. AI features may not work until credentials are added. This WordPress build does not currently expose an AI credentials settings screen. %s', + 'ai' + ), + $configuration_help + ); + } else { + $warning_message = sprintf( + /* translators: %s: Credential setup guidance. */ + __( + 'AI credentials appear invalid. AI features may fail until credentials are corrected. This WordPress build does not currently expose an AI credentials settings screen. %s', + 'ai' + ), + $configuration_help + ); + } } - wp_admin_notice( $error_message, array( 'type' => 'error' ) ); + wp_admin_notice( $warning_message, array( 'type' => 'warning' ) ); } ?> diff --git a/includes/bootstrap.php b/includes/bootstrap.php index 12324738..e75b22a5 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -12,6 +12,7 @@ namespace WordPress\AI; use WordPress\AI\Abilities\Utilities\Posts; +use WordPress\AI\Admin\Provider_Credentials_UI; use WordPress\AI\Settings\Settings_Page; use WordPress\AI\Settings\Settings_Registration; use WordPress\AI_Client\AI_Client; @@ -47,7 +48,7 @@ /** * Displays an admin notice for version requirement failures. * - * @since 0.1.0 + * @since x.x.x * * @param string $message The error message to display. */ @@ -65,7 +66,7 @@ function version_notice( string $message ): void { /** * Checks if the PHP version meets the minimum requirement. * - * @since 0.1.0 + * @since x.x.x * * @return bool True if PHP version is sufficient, false otherwise. */ @@ -140,10 +141,502 @@ function display_composer_notice(): void { hasProperty( 'psr4_map' ) ) { + $psr4_property = $reflection->getProperty( 'psr4_map' ); + if ( PHP_VERSION_ID < 80100 ) { + $psr4_property->setAccessible( true ); + } + $psr4_map = $psr4_property->getValue( $jetpack_autoloader_loader ); + + if ( is_array( $psr4_map ) ) { + foreach ( $psr4_map as $prefix => $data ) { + if ( ! is_array( $data ) || ! isset( $data['path'] ) || ! is_array( $data['path'] ) ) { + continue; + } + + $data['path'] = array_values( + array_filter( + $data['path'], + static function ( $path ) use ( $path_uses_bundled_package ): bool { + return ! $path_uses_bundled_package( $path ); + } + ) + ); + + if ( empty( $data['path'] ) ) { + unset( $psr4_map[ $prefix ] ); + continue; + } + + $psr4_map[ $prefix ] = $data; + } + + $psr4_property->setValue( $jetpack_autoloader_loader, $psr4_map ); + } + } + + if ( $reflection->hasProperty( 'classmap' ) ) { + $classmap_property = $reflection->getProperty( 'classmap' ); + if ( PHP_VERSION_ID < 80100 ) { + $classmap_property->setAccessible( true ); + } + $classmap = $classmap_property->getValue( $jetpack_autoloader_loader ); + + if ( is_array( $classmap ) ) { + foreach ( $classmap as $class => $data ) { + if ( ! is_array( $data ) || ! isset( $data['path'] ) ) { + continue; + } + + if ( ! $path_uses_bundled_package( $data['path'] ) ) { + continue; + } + + unset( $classmap[ $class ] ); + } + + $classmap_property->setValue( $jetpack_autoloader_loader, $classmap ); + } + } + + if ( $reflection->hasProperty( 'filemap' ) ) { + $filemap_property = $reflection->getProperty( 'filemap' ); + if ( PHP_VERSION_ID < 80100 ) { + $filemap_property->setAccessible( true ); + } + $filemap = $filemap_property->getValue( $jetpack_autoloader_loader ); + + if ( is_array( $filemap ) ) { + foreach ( $filemap as $identifier => $data ) { + if ( ! is_array( $data ) || ! isset( $data['path'] ) ) { + continue; + } + + if ( ! $path_uses_bundled_package( $data['path'] ) ) { + continue; + } + + unset( $filemap[ $identifier ] ); + } + + $filemap_property->setValue( $jetpack_autoloader_loader, $filemap ); + } + } + } catch ( \Throwable $t ) { + _doing_it_wrong( + __NAMESPACE__ . '\maybe_disable_bundled_wp_ai_client_packages', + sprintf( + /* translators: %s: Error message. */ + esc_html__( 'Could not adjust bundled WP AI Client autoloading: %s', 'ai' ), + esc_html( $t->getMessage() ) + ), + '0.1.0' + ); + } +} + +/** + * Registers available provider implementation classes with the default AI registry. + * + * This ensures provider classes are registered even when core ships the client API + * but does not automatically register built-in providers. + * + * @since x.x.x + * + * @return void + */ +function maybe_register_available_ai_client_providers(): void { + if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) { + return; + } + + try { + $registry = \WordPress\AiClient\AiClient::defaultRegistry(); + } catch ( \Throwable $t ) { + return; + } + + $provider_candidates = array( + 'anthropic' => array( + '\WordPress\AnthropicAiProvider\Provider\AnthropicProvider', + '\WordPress\AiClient\ProviderImplementations\Anthropic\AnthropicProvider', + ), + 'google' => array( + '\WordPress\GoogleAiProvider\Provider\GoogleProvider', + '\WordPress\AiClient\ProviderImplementations\Google\GoogleProvider', + ), + 'openai' => array( + '\WordPress\OpenAiAiProvider\Provider\OpenAiProvider', + '\WordPress\AiClient\ProviderImplementations\OpenAi\OpenAiProvider', + ), + ); + + /** + * Filters provider implementation class name candidates by provider ID. + * + * @since x.x.x + * + * @param array|string> $provider_candidates Provider class candidates keyed by provider ID. + * @return array|string> Filtered provider class candidates. + */ + $provider_candidates = (array) apply_filters( 'ai_experiments_ai_client_provider_classes', $provider_candidates ); + + foreach ( $provider_candidates as $provider_id => $candidate_classes ) { + try { + if ( is_string( $provider_id ) && '' !== $provider_id && $registry->hasProvider( $provider_id ) ) { + continue; + } + } catch ( \Throwable $t ) { + continue; + } + + if ( is_string( $candidate_classes ) && '' !== $candidate_classes ) { + $candidate_classes = array( $candidate_classes ); + } + + if ( ! is_array( $candidate_classes ) ) { + continue; + } + + $provider_class = ''; + foreach ( $candidate_classes as $candidate_class_raw ) { + $candidate_class = trim( $candidate_class_raw ); + if ( '' === $candidate_class ) { + continue; + } + + if ( ! class_exists( $candidate_class ) ) { + continue; + } + + $provider_class = $candidate_class; + break; + } + + if ( '' === $provider_class ) { + continue; + } + + if ( ! is_subclass_of( $provider_class, '\WordPress\AiClient\Providers\Contracts\ProviderInterface' ) ) { + continue; + } + + try { + if ( $registry->hasProvider( $provider_class ) ) { + continue; + } + + $registry->registerProvider( $provider_class ); + } catch ( \Throwable $t ) { + _doing_it_wrong( + __NAMESPACE__ . '\maybe_register_available_ai_client_providers', + sprintf( + /* translators: 1: Provider class name. 2: Error message. */ + esc_html__( 'Could not register AI provider class %1$s: %2$s', 'ai' ), + esc_html( $provider_class ), + esc_html( $t->getMessage() ) + ), + '0.3.1' + ); + } + } +} + +/** + * Applies legacy option-based credentials to core AI providers when needed. + * + * WordPress 6.9 with bundled WP AI Client stores credentials in the + * `wp_ai_client_provider_credentials` option. WordPress 7.0 core currently + * resolves provider credentials from environment variables/constants by default. + * This bridge preserves existing installs by re-applying saved option values. + * + * @since x.x.x + * + * @return void + */ +function maybe_apply_option_credentials_to_core_ai_client(): void { + if ( should_use_bundled_wp_ai_client() ) { + return; + } + + /** + * Filters whether to apply option-based credentials to the core AI client. + * + * @since x.x.x + * + * @param bool $should_apply True to apply option credentials to core providers. + * @return bool Whether to apply option-based credentials. + */ + $should_apply = apply_filters( 'ai_experiments_apply_option_credentials_to_core_ai_client', true ); + if ( ! $should_apply ) { + return; + } + + if ( + ! class_exists( '\WordPress\AiClient\AiClient' ) || + ! class_exists( '\WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication' ) || + ! interface_exists( '\WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface' ) + ) { + return; + } + + $credentials = get_option( 'wp_ai_client_provider_credentials', array() ); + if ( ! is_array( $credentials ) || empty( $credentials ) ) { + return; + } + + try { + $registry = \WordPress\AiClient\AiClient::defaultRegistry(); + + foreach ( $credentials as $provider_id => $api_key ) { + if ( ! is_string( $provider_id ) || '' === $provider_id ) { + continue; + } + + if ( ! is_string( $api_key ) || '' === trim( $api_key ) ) { + continue; + } + + if ( ! $registry->hasProvider( $provider_id ) ) { + continue; + } + + $authentication_class = '\WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication'; + + try { + $provider_class = $registry->getProviderClassName( $provider_id ); + if ( + class_exists( $provider_class ) && + method_exists( $provider_class, 'metadata' ) + ) { + $authentication_method = $provider_class::metadata()->getAuthenticationMethod(); + if ( null !== $authentication_method ) { + $candidate_authentication_class = $authentication_method->getImplementationClass(); + if ( + is_string( $candidate_authentication_class ) && + class_exists( $candidate_authentication_class ) && + is_subclass_of( $candidate_authentication_class, '\WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface' ) + ) { + $authentication_class = $candidate_authentication_class; + } + } + } + } catch ( \Throwable $t ) { + $authentication_class = '\WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication'; + } + + $api_key = trim( $api_key ); + $authentication_instance = null; + + if ( is_subclass_of( $authentication_class, '\WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication' ) ) { + $authentication_instance = $authentication_class::fromArray( + array( + 'apiKey' => $api_key, + ) + ); + } + + if ( ! $authentication_instance instanceof \WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface ) { + $authentication_instance = new \WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication( $api_key ); + } + + $registry->setProviderRequestAuthentication( $provider_id, $authentication_instance ); + } + } catch ( \Throwable $t ) { + _doing_it_wrong( + __NAMESPACE__ . '\maybe_apply_option_credentials_to_core_ai_client', + sprintf( + /* translators: %s: Error message. */ + esc_html__( 'Could not apply saved AI credentials to core providers: %s', 'ai' ), + esc_html( $t->getMessage() ) + ), + '0.3.1' + ); + } +} + +/** + * Removes invalid AI client capability callbacks from the `user_has_cap` hook. + * + * Plugin Check currently ships an older wp-ai-client package, which can load a + * Capabilities_Manager class without newer methods expected by wp-ai-client + * 0.3+. If that class is loaded first, this callback becomes invalid and can + * fatally error when capabilities are evaluated. + * + * @since x.x.x + * + * @return void + */ +function maybe_remove_invalid_ai_client_capability_callbacks(): void { + $capabilities_manager_class = (string) apply_filters( + 'ai_experiments_ai_client_capabilities_manager_class', + '\WordPress\AI_Client\Capabilities\Capabilities_Manager' + ); + + if ( '' === $capabilities_manager_class ) { + return; + } + + $callback = array( + ltrim( $capabilities_manager_class, '\\' ), + 'grant_list_ai_providers_models_to_administrators', + ); + + if ( ! has_filter( 'user_has_cap', $callback ) ) { + return; + } + + remove_filter( 'user_has_cap', $callback, 10 ); + + if ( ! class_exists( $callback[0] ) || ! method_exists( $callback[0], $callback[1] ) ) { + return; + } + + add_filter( 'user_has_cap', $callback ); +} + +/** + * Returns the AI credentials settings URL if available. + * + * @since x.x.x + * + * @return string|null The URL to the credentials settings screen, or null. + */ +function get_ai_credentials_settings_url(): ?string { + /** + * Filters the AI credentials settings URL. + * + * @since x.x.x + * + * @param string|null $url URL to the credentials settings screen, or null if unavailable. + * @return string|null The URL to use, or null if unavailable. + */ + $url = apply_filters( 'ai_experiments_credentials_settings_url', null ); + if ( is_string( $url ) && '' !== $url ) { + return $url; + } + + $settings_slug = 'wp-ai-client'; + + // If any plugin or core registered the AI credentials screen, use it. + global $_parent_pages, $submenu; + if ( + ( + is_array( $_parent_pages ) && + isset( $_parent_pages[ $settings_slug ] ) + ) || + ( + is_array( $submenu ) && + isset( $submenu['options-general.php'] ) && + is_array( $submenu['options-general.php'] ) && + in_array( + $settings_slug, + array_map( + static function ( $submenu_item ): string { + return isset( $submenu_item[2] ) && is_string( $submenu_item[2] ) ? $submenu_item[2] : ''; + }, + $submenu['options-general.php'] + ), + true + ) + ) + ) { + return admin_url( 'options-general.php?page=' . $settings_slug ); + } + + // The bundled package registers this settings page slug. + if ( should_use_bundled_wp_ai_client() ) { + return admin_url( 'options-general.php?page=' . $settings_slug ); + } + + return null; +} + /** * Adds action links to the plugin list table. * - * This adds "Experiments" and "Credentials" links to + * This adds "Experiments" and (when available) "Credentials" links to * the plugin's action links on the Plugins page. * * @since 0.1.1 @@ -157,14 +650,17 @@ function plugin_action_links( array $links ): array { admin_url( 'options-general.php?page=ai-experiments' ), esc_html__( 'Experiments', 'ai' ) ); + array_unshift( $links, $experiments_link ); - $credentials_link = sprintf( - '%2$s', - admin_url( 'options-general.php?page=wp-ai-client' ), - esc_html__( 'Credentials', 'ai' ) - ); - - array_unshift( $links, $credentials_link, $experiments_link ); + $credentials_url = get_ai_credentials_settings_url(); + if ( is_string( $credentials_url ) && '' !== $credentials_url ) { + $credentials_link = sprintf( + '%2$s', + esc_url( $credentials_url ), + esc_html__( 'Credentials', 'ai' ) + ); + array_unshift( $links, $credentials_link ); + } return $links; } @@ -193,6 +689,7 @@ function load(): void { return; } require_once AI_EXPERIMENTS_PLUGIN_DIR . 'vendor/autoload_packages.php'; + maybe_disable_bundled_wp_ai_client_packages(); $loaded = true; @@ -210,9 +707,20 @@ function load(): void { */ function initialize_experiments(): void { try { - // Initialize the WP AI Client. - AI_Client::init(); + // Ensure default providers are registered across core and bundled client combinations. + maybe_register_available_ai_client_providers(); + + // Initialize bundled WP AI Client when not relying on core. + if ( should_use_bundled_wp_ai_client() && class_exists( AI_Client::class ) ) { + AI_Client::init(); + } + maybe_remove_invalid_ai_client_capability_callbacks(); + + maybe_apply_option_credentials_to_core_ai_client(); + + // Initialize experiments so extended providers are registered + // before the WP AI Client collects provider metadata. $registry = new Experiment_Registry(); $loader = new Experiment_Loader( $registry ); $loader->register_default_experiments(); @@ -226,6 +734,9 @@ function initialize_experiments(): void { if ( is_admin() ) { $settings_page = new Settings_Page( $registry ); $settings_page->init(); + + // Initialize enhanced provider credentials UI. + Provider_Credentials_UI::init(); } // Register our post-related WordPress Abilities. diff --git a/includes/helpers.php b/includes/helpers.php index 9946ed71..9e95f9ed 100644 --- a/includes/helpers.php +++ b/includes/helpers.php @@ -28,7 +28,7 @@ /** * Normalizes the content by cleaning it and removing unwanted HTML tags. * - * @since 0.1.0 + * @since x.x.x * * @param string $content The content to normalize. * @return string The normalized content. @@ -77,7 +77,7 @@ function normalize_content( string $content ): string { /** * Returns the context for the given post ID. * - * @since 0.1.0 + * @since x.x.x * * @param int $post_id The ID of the post to get the context for. * @return array The context for the given post ID. @@ -295,6 +295,182 @@ function get_preferred_vision_models(): array { return (array) apply_filters( 'ai_experiments_preferred_vision_models', $preferred_models ); } +/** + * Returns a prompt builder with WP_Error error handling. + * + * Uses the core AI Client API when available, otherwise falls back to the + * bundled WP AI Client package. + * + * @since x.x.x + * + * @param mixed $prompt Optional initial prompt content. + * @return mixed Prompt builder instance. + */ +function ai_client_prompt_with_wp_error( $prompt = null ) { + if ( ! should_use_bundled_wp_ai_client() && function_exists( 'wp_ai_client_prompt' ) ) { + return wp_ai_client_prompt( $prompt ); + } + + if ( class_exists( AI_Client::class ) ) { + return AI_Client::prompt_with_wp_error( $prompt ); + } + + throw new \RuntimeException( 'No AI Client prompt builder is available.' ); +} + +/** + * Returns a prompt builder. + * + * Uses the core AI Client API when available, otherwise falls back to the + * bundled WP AI Client package. + * + * @since x.x.x + * + * @param mixed $prompt Optional initial prompt content. + * @return mixed Prompt builder instance. + */ +function ai_client_prompt( $prompt = null ) { + if ( ! should_use_bundled_wp_ai_client() && function_exists( 'wp_ai_client_prompt' ) ) { + return wp_ai_client_prompt( $prompt ); + } + + if ( class_exists( AI_Client::class ) ) { + return AI_Client::prompt( $prompt ); + } + + throw new \RuntimeException( 'No AI Client prompt builder is available.' ); +} + +/** + * Converts a value to CONSTANT_CASE. + * + * @since 0.1.0 + * + * @param string $value Value to convert. + * @return string CONSTANT_CASE value. + */ +function to_constant_case( string $value ): string { + $value = str_replace( '-', '_', $value ); + $value = preg_replace( '/([a-z])([A-Z])/', '$1_$2', $value ); + return strtoupper( (string) $value ); +} + +/** + * Returns registered provider IDs for the AI client. + * + * @since 0.1.0 + * + * @return array Provider IDs. + */ +function get_ai_provider_ids(): array { + $provider_ids = array(); + + if ( class_exists( '\WordPress\AiClient\AiClient' ) ) { + try { + $registry = \WordPress\AiClient\AiClient::defaultRegistry(); + $provider_ids = array_filter( + $registry->getRegisteredProviderIds(), + static function ( string $provider_id ): bool { + return '' !== $provider_id; + } + ); + } catch ( Throwable $t ) { + $provider_ids = array(); + } + } + + $stored_credentials = get_option( 'wp_ai_client_provider_credentials', array() ); + if ( is_array( $stored_credentials ) ) { + $provider_ids = array_merge( $provider_ids, array_keys( $stored_credentials ) ); + } + + $provider_ids = array_values( + array_unique( + array_filter( + $provider_ids, + static function ( $provider_id ): bool { + return is_string( $provider_id ) && '' !== $provider_id; + } + ) + ) + ); + + // Fall back to known cloud providers if IDs cannot be resolved at runtime. + if ( empty( $provider_ids ) ) { + $provider_ids = array( 'openai', 'anthropic', 'google' ); + } + + /** + * Filters AI provider IDs used for credential discovery. + * + * @since x.x.x + * @hook ai_experiments_provider_ids + * + * @param array $provider_ids Provider IDs. + * @return array Filtered provider IDs. + */ + return (array) apply_filters( 'ai_experiments_provider_ids', $provider_ids ); +} + +/** + * Returns provider API key environment variable / constant names. + * + * The default format follows the core/provider registry convention: + * `_API_KEY`. + * + * @since x.x.x + * + * @return array Environment variable / constant names. + */ +function get_ai_provider_api_key_variable_names(): array { + $variable_names = array_map( + static function ( string $provider_id ): string { + return to_constant_case( $provider_id ) . '_API_KEY'; + }, + get_ai_provider_ids() + ); + + $variable_names = array_values( array_unique( $variable_names ) ); + + /** + * Filters provider API key variable names used for credential discovery. + * + * @since x.x.x + * @hook ai_experiments_provider_api_key_variable_names + * + * @param array $variable_names Variable names. + * @return array Filtered variable names. + */ + return (array) apply_filters( 'ai_experiments_provider_api_key_variable_names', $variable_names ); +} + +/** + * Checks whether any provider API key exists in env vars or constants. + * + * @since x.x.x + * + * @return bool True if an API key variable is set, otherwise false. + */ +function has_ai_credentials_in_environment(): bool { + foreach ( get_ai_provider_api_key_variable_names() as $variable_name ) { + $env_value = getenv( $variable_name ); + if ( false !== $env_value && '' !== trim( (string) $env_value ) ) { + return true; + } + + if ( ! defined( $variable_name ) ) { + continue; + } + + $constant_value = constant( $variable_name ); + if ( is_scalar( $constant_value ) && '' !== trim( (string) $constant_value ) ) { + return true; + } + } + + return false; +} + /** * Checks if we have AI credentials set. * @@ -305,20 +481,23 @@ function get_preferred_vision_models(): array { function has_ai_credentials(): bool { $credentials = get_option( 'wp_ai_client_provider_credentials', array() ); - // If there are no credentials, return false. - if ( ! is_array( $credentials ) || empty( $credentials ) ) { - return false; + if ( is_array( $credentials ) && ! empty( $credentials ) ) { + // If all of the AI keys are empty, return false; otherwise, return true. + $has_option_credentials = ! empty( + array_filter( + $credentials, + static function ( $api_key ): bool { + return is_string( $api_key ) && '' !== trim( $api_key ); + } + ) + ); + + if ( $has_option_credentials ) { + return true; + } } - // If all of the AI keys are empty, return false; otherwise, return true. - return ! empty( - array_filter( - $credentials, - static function ( $api_key ): bool { - return is_string( $api_key ) && '' !== $api_key; - } - ) - ); + return has_ai_credentials_in_environment(); } /** @@ -351,7 +530,7 @@ function has_valid_ai_credentials(): bool { // See if we have credentials that give us access to generate text. try { - return AI_Client::prompt( 'Test' )->is_supported_for_text_generation(); + return ai_client_prompt( 'Test' )->is_supported_for_text_generation(); } catch ( Throwable $t ) { return false; } diff --git a/src/admin/_common.scss b/src/admin/_common.scss new file mode 100644 index 00000000..1094ab4d --- /dev/null +++ b/src/admin/_common.scss @@ -0,0 +1,150 @@ +/** + * Common Admin Page Styles + * + * Shared styles for AI admin pages including page headers with icons. + * + * @package WordPress\AI + */ + +/* Remove default .wrap top margin/padding for AI pages */ +.wrap.ai-mcp-server, +.wrap.ai-request-logs, +.wrap.ai-experiments-page { + margin-top: 0; + padding-top: 0; +} + +/* Force AI admin screens to use white page backgrounds */ +$ai-admin-white-pages: ( + 'settings_page_ai-request-logs', + 'settings_page_wp-ai-client', + 'settings_page_ai-experiments', + 'toplevel_page_ai-mcp' +); + +@each $page-class in $ai-admin-white-pages { + body.#{$page-class}, + body.#{$page-class} #wpwrap, + body.#{$page-class} #wpcontent, + body.#{$page-class} #wpbody, + body.#{$page-class} #wpbody-content { + background-color: #fff; + } +} + +/* Full-width page header (privacy-style) */ +.ai-admin-header { + background: #fff; + border-bottom: 1px solid #dcdcde; + margin: 0 0 1.5rem; + padding: 16px 20px; + + // Extend to full width by pulling out of .wrap padding + .wrap > & { + margin-left: -20px; + margin-right: -20px; + + @media screen and (max-width: 782px) { + margin-left: -10px; + margin-right: -10px; + padding-left: 10px; + padding-right: 10px; + } + } +} + +/* Card border-radius standardization (4px) */ +.ai-mcp-server, +.ai-request-logs { + .components-card { + border-radius: 4px; + } +} + +.ai-admin-header__inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + max-width: 1400px; +} + +.ai-admin-header__left { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.ai-admin-header__right { + display: flex; + align-items: center; + gap: 1rem; + + // Header toggle needs proper alignment + .components-toggle-control { + margin: 0; + + .components-base-control__field { + margin-bottom: 0; + } + } +} + +.ai-admin-header__icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 36px; + height: 36px; + padding: 6px; + background: #f6f7f7; + border-radius: 8px; + color: #1d2327; + + svg { + width: 100%; + height: 100%; + } +} + +.ai-admin-header__title { + h1 { + margin: 0; + padding: 0; + font-size: 23px; + font-weight: 600; + line-height: 1.3; + } +} + +/* Legacy page header with icon (for inline use) */ +.ai-page-header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1.5rem; + + h1 { + margin: 0; + padding: 0; + } +} + +.ai-page-header__icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 36px; + height: 36px; + padding: 6px; + background: #fff; + border-radius: 8px; + color: #1d2327; + + svg { + width: 100%; + height: 100%; + } +} diff --git a/src/admin/components/ProviderTooltipContent.tsx b/src/admin/components/ProviderTooltipContent.tsx new file mode 100644 index 00000000..49f48a9e --- /dev/null +++ b/src/admin/components/ProviderTooltipContent.tsx @@ -0,0 +1,81 @@ +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import type { ProviderMetadata } from '../types/providers'; + +interface ProviderTooltipContentProps { + metadata: ProviderMetadata; + activeModel?: string | null; +} + +const ProviderTooltipContent: React.FC< ProviderTooltipContentProps > = ( { + metadata, + activeModel, +} ) => { + const topModels = metadata.models?.slice( 0, 4 ) ?? []; + + return ( +
+
+
+ { metadata.name } + + { metadata.type === 'client' + ? __( 'Local', 'ai' ) + : __( 'Cloud', 'ai' ) } + +
+ { activeModel && ( + + { sprintf( + /* translators: %s: AI model name. */ + __( 'Requested model: %s', 'ai' ), + activeModel + ) } + + ) } + { metadata.tooltip && ( +

{ metadata.tooltip }

+ ) } + { topModels.length > 0 && ( +
+ + { __( 'Available models', 'ai' ) } + +
    + { topModels.map( ( model ) => ( +
  • + { model.name } + { model.capabilities?.length > 0 && ( + + { model.capabilities.join( ', ' ) } + + ) } +
  • + ) ) } +
+
+ ) } +
+ { metadata.url && ( + + ) } +
+ ); +}; + +export default ProviderTooltipContent; diff --git a/src/admin/components/icons/AiIcon.tsx b/src/admin/components/icons/AiIcon.tsx new file mode 100644 index 00000000..f1daf850 --- /dev/null +++ b/src/admin/components/icons/AiIcon.tsx @@ -0,0 +1,26 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * AI Icon - Cauldron with sparkles design used for AI features. + */ +const AiIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + + + + + +); + +export default AiIcon; diff --git a/src/admin/components/icons/AnthropicIcon.tsx b/src/admin/components/icons/AnthropicIcon.tsx new file mode 100644 index 00000000..d64f80bd --- /dev/null +++ b/src/admin/components/icons/AnthropicIcon.tsx @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * Anthropic (Claude) Icon + */ +const AnthropicIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + +); + +export default AnthropicIcon; diff --git a/src/admin/components/icons/CloudflareIcon.tsx b/src/admin/components/icons/CloudflareIcon.tsx new file mode 100644 index 00000000..804c6573 --- /dev/null +++ b/src/admin/components/icons/CloudflareIcon.tsx @@ -0,0 +1,29 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * Cloudflare Icon + */ +const CloudflareIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + + +); + +export default CloudflareIcon; diff --git a/src/admin/components/icons/DeepSeekIcon.tsx b/src/admin/components/icons/DeepSeekIcon.tsx new file mode 100644 index 00000000..effcfe19 --- /dev/null +++ b/src/admin/components/icons/DeepSeekIcon.tsx @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * DeepSeek Icon + */ +const DeepSeekIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + +); + +export default DeepSeekIcon; diff --git a/src/admin/components/icons/FalIcon.tsx b/src/admin/components/icons/FalIcon.tsx new file mode 100644 index 00000000..e5f43adb --- /dev/null +++ b/src/admin/components/icons/FalIcon.tsx @@ -0,0 +1,26 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * Fal.ai Icon + */ +const FalIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + +); + +export default FalIcon; diff --git a/src/admin/components/icons/GoogleIcon.tsx b/src/admin/components/icons/GoogleIcon.tsx new file mode 100644 index 00000000..5ec6b0ee --- /dev/null +++ b/src/admin/components/icons/GoogleIcon.tsx @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * Google Icon + */ +const GoogleIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + +); + +export default GoogleIcon; diff --git a/src/admin/components/icons/GrokIcon.tsx b/src/admin/components/icons/GrokIcon.tsx new file mode 100644 index 00000000..e6fcb836 --- /dev/null +++ b/src/admin/components/icons/GrokIcon.tsx @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * Grok Icon + */ +const GrokIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + +); + +export default GrokIcon; diff --git a/src/admin/components/icons/GroqIcon.tsx b/src/admin/components/icons/GroqIcon.tsx new file mode 100644 index 00000000..4f87cfd2 --- /dev/null +++ b/src/admin/components/icons/GroqIcon.tsx @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * Groq Icon + */ +const GroqIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + +); + +export default GroqIcon; diff --git a/src/admin/components/icons/HuggingFaceIcon.tsx b/src/admin/components/icons/HuggingFaceIcon.tsx new file mode 100644 index 00000000..ba89ab6d --- /dev/null +++ b/src/admin/components/icons/HuggingFaceIcon.tsx @@ -0,0 +1,45 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * Hugging Face Icon + */ +const HuggingFaceIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + + + + + + +); + +export default HuggingFaceIcon; diff --git a/src/admin/components/icons/McpIcon.tsx b/src/admin/components/icons/McpIcon.tsx new file mode 100644 index 00000000..abe8496a --- /dev/null +++ b/src/admin/components/icons/McpIcon.tsx @@ -0,0 +1,25 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * MCP Icon - Model Context Protocol logo. + */ +const McpIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + Model Context Protocol + + + +); + +export default McpIcon; diff --git a/src/admin/components/icons/OllamaIcon.tsx b/src/admin/components/icons/OllamaIcon.tsx new file mode 100644 index 00000000..eb7211df --- /dev/null +++ b/src/admin/components/icons/OllamaIcon.tsx @@ -0,0 +1,25 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * Ollama Icon + */ +const OllamaIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + +); + +export default OllamaIcon; diff --git a/src/admin/components/icons/OpenAiIcon.tsx b/src/admin/components/icons/OpenAiIcon.tsx new file mode 100644 index 00000000..6d4803c3 --- /dev/null +++ b/src/admin/components/icons/OpenAiIcon.tsx @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * OpenAI Icon + */ +const OpenAiIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + +); + +export default OpenAiIcon; diff --git a/src/admin/components/icons/OpenRouterIcon.tsx b/src/admin/components/icons/OpenRouterIcon.tsx new file mode 100644 index 00000000..1d769c21 --- /dev/null +++ b/src/admin/components/icons/OpenRouterIcon.tsx @@ -0,0 +1,25 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * OpenRouter Icon + */ +const OpenRouterIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + +); + +export default OpenRouterIcon; diff --git a/src/admin/components/icons/XaiIcon.tsx b/src/admin/components/icons/XaiIcon.tsx new file mode 100644 index 00000000..aeca554c --- /dev/null +++ b/src/admin/components/icons/XaiIcon.tsx @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import type { SVGProps } from 'react'; + +/** + * xAI Icon + */ +const XaiIcon = ( props: SVGProps< SVGSVGElement > ) => ( + + + +); + +export default XaiIcon; diff --git a/src/admin/components/icons/index.ts b/src/admin/components/icons/index.ts new file mode 100644 index 00000000..a950307d --- /dev/null +++ b/src/admin/components/icons/index.ts @@ -0,0 +1,14 @@ +export { default as AiIcon } from './AiIcon'; +export { default as AnthropicIcon } from './AnthropicIcon'; +export { default as CloudflareIcon } from './CloudflareIcon'; +export { default as DeepSeekIcon } from './DeepSeekIcon'; +export { default as FalIcon } from './FalIcon'; +export { default as GoogleIcon } from './GoogleIcon'; +export { default as GroqIcon } from './GroqIcon'; +export { default as GrokIcon } from './GrokIcon'; +export { default as HuggingFaceIcon } from './HuggingFaceIcon'; +export { default as McpIcon } from './McpIcon'; +export { default as OpenAiIcon } from './OpenAiIcon'; +export { default as OpenRouterIcon } from './OpenRouterIcon'; +export { default as OllamaIcon } from './OllamaIcon'; +export { default as XaiIcon } from './XaiIcon'; diff --git a/src/admin/components/provider-icons.tsx b/src/admin/components/provider-icons.tsx new file mode 100644 index 00000000..c7580348 --- /dev/null +++ b/src/admin/components/provider-icons.tsx @@ -0,0 +1,56 @@ +/** + * External dependencies + */ +import type { ComponentType, SVGProps } from 'react'; + +/** + * Internal dependencies + */ +import { + AiIcon, + AnthropicIcon, + CloudflareIcon, + DeepSeekIcon, + FalIcon, + GoogleIcon, + GrokIcon, + GroqIcon, + HuggingFaceIcon, + OllamaIcon, + OpenAiIcon, + OpenRouterIcon, + XaiIcon, +} from './icons'; + +const ICON_COMPONENTS: Record< string, ComponentType< SVGProps< SVGSVGElement > > > = + Object.freeze( { + anthropic: AnthropicIcon, + openai: OpenAiIcon, + google: GoogleIcon, + fal: FalIcon, + 'fal-ai': FalIcon, + deepseek: DeepSeekIcon, + cloudflare: CloudflareIcon, + huggingface: HuggingFaceIcon, + ollama: OllamaIcon, + openrouter: OpenRouterIcon, + groq: GroqIcon, + grok: GrokIcon, + xai: XaiIcon, + default: AiIcon, + } ); + +export const getProviderIconComponent = ( + iconKey?: string, + fallbackKey?: string +): ComponentType< SVGProps< SVGSVGElement > > => { + const normalized = + ( iconKey || fallbackKey || '' ).toLowerCase().replace( /\s+/g, '' ); + + return ( + ICON_COMPONENTS[ normalized ] || + ICON_COMPONENTS[ iconKey || '' ] || + ICON_COMPONENTS[ fallbackKey || '' ] || + ICON_COMPONENTS.default + ); +}; diff --git a/src/admin/provider-credentials/index.tsx b/src/admin/provider-credentials/index.tsx new file mode 100644 index 00000000..081cbfa6 --- /dev/null +++ b/src/admin/provider-credentials/index.tsx @@ -0,0 +1,254 @@ +/** + * WordPress dependencies + */ +import domReady from '@wordpress/dom-ready'; +import { Popover } from '@wordpress/components'; +import { useRef, useState, useEffect } from '@wordpress/element'; +import * as React from 'react'; + +/** + * External dependencies + */ +import { createRoot } from 'react-dom/client'; + +/** + * Internal dependencies + */ +import { getProviderIconComponent } from '../components/provider-icons'; +import ProviderTooltipContent from '../components/ProviderTooltipContent'; +import type { ProviderMetadata, ProviderMetadataMap } from '../types/providers'; +import './style.scss'; + +declare global { + interface Window { + aiProviderCredentialsConfig?: { + providers?: ProviderMetadataMap; + cloudflareAccountId?: string; + }; + } +} + +const ProviderBadge: React.FC< { + providerId: string; + config: ProviderMetadata; + labelElement?: HTMLElement | null; +} > = ( { providerId, config, labelElement } ) => { + const [ isOpen, setIsOpen ] = useState( false ); + const triggerRef = useRef< HTMLDivElement | null >( null ); + const closeTimeout = useRef< ReturnType< typeof setTimeout > | null >( null ); + + const clearCloseTimeout = () => { + if ( closeTimeout.current ) { + clearTimeout( closeTimeout.current ); + closeTimeout.current = null; + } + }; + + const open = () => { + clearCloseTimeout(); + setIsOpen( true ); + }; + + const scheduleClose = () => { + clearCloseTimeout(); + closeTimeout.current = setTimeout( () => setIsOpen( false ), 120 ); + }; + + const close = () => { + clearCloseTimeout(); + setIsOpen( false ); + }; + + // Attach event listeners to the label element so it also triggers the popover + React.useEffect( () => { + if ( ! labelElement ) return; + + const handleMouseEnter = () => open(); + const handleMouseLeave = () => scheduleClose(); + const handleClick = () => open(); + + labelElement.addEventListener( 'mouseenter', handleMouseEnter ); + labelElement.addEventListener( 'mouseleave', handleMouseLeave ); + labelElement.addEventListener( 'click', handleClick ); + labelElement.style.cursor = 'pointer'; + + return () => { + labelElement.removeEventListener( 'mouseenter', handleMouseEnter ); + labelElement.removeEventListener( 'mouseleave', handleMouseLeave ); + labelElement.removeEventListener( 'click', handleClick ); + }; + }, [ labelElement ] ); + + const IconComponent = getProviderIconComponent( + config.icon || providerId, + providerId + ); + const icon = ( + + + + ); + + return ( +
+ + { isOpen && triggerRef.current && ( + +
+ +
+
+ ) } +
+ ); +}; + +const injectCloudflareAccountField = ( + row: HTMLTableRowElement | null, + currentValue: string +) => { + if ( ! row ) { + return; + } + + const targetCell = row.querySelector< HTMLTableCellElement >( 'td' ); + if ( ! targetCell ) { + return; + } + + if ( targetCell.querySelector( '.ai-provider-credentials__cloudflare-account' ) ) { + return; + } + + const wrapper = document.createElement( 'div' ); + wrapper.className = 'ai-provider-credentials__cloudflare-account'; + + const label = document.createElement( 'label' ); + label.htmlFor = 'ai-cloudflare-account-id'; + label.textContent = 'Account ID'; + + const input = document.createElement( 'input' ); + input.type = 'text'; + input.id = 'ai-cloudflare-account-id'; + input.name = 'ai_cloudflare_account_id'; + input.className = 'regular-text'; + input.value = currentValue ?? ''; + input.placeholder = 'Enter your Cloudflare account ID'; + + const helpText = document.createElement( 'p' ); + helpText.className = 'description'; + helpText.textContent = + 'Find this under Workers AI → Overview in the Cloudflare dashboard.'; + + wrapper.appendChild( label ); + wrapper.appendChild( input ); + wrapper.appendChild( helpText ); + + targetCell.appendChild( wrapper ); +}; + +const enhanceProviderRows = ( + providers: ProviderMetadataMap, + cloudflareAccountId: string +) => { + const inputs = document.querySelectorAll( + 'input[id^="wp-ai-client-provider-api-key-"]' + ); + + inputs.forEach( ( input ) => { + const providerId = input.id.replace( + 'wp-ai-client-provider-api-key-', + '' + ); + const config = providers?.[ providerId ]; + + if ( ! config ) { + return; + } + + const row = input.closest( 'tr' ); + const header = row?.querySelector< HTMLElement >( 'th' ); + if ( ! header ) { + return; + } + + const label = + header.querySelector< HTMLElement >( 'label' ) || + header.querySelector< HTMLElement >( '.ai-provider-credentials__name' ) || + header.firstElementChild || + header; + + label.classList.add( 'ai-provider-credentials__name' ); + + const wrapper = document.createElement( 'div' ); + wrapper.className = 'ai-provider-credentials__label-wrapper'; + + const iconHost = document.createElement( 'span' ); + iconHost.className = 'ai-provider-credentials__icon-host'; + wrapper.appendChild( iconHost ); + wrapper.appendChild( label ); + + header.innerHTML = ''; + header.appendChild( wrapper ); + + const description = row?.querySelector< HTMLElement >( 'p.description' ); + if ( description ) { + if ( config.keepDescription && config.tooltip ) { + description.textContent = config.tooltip; + } else if ( ! config.keepDescription ) { + description.remove(); + } + } + + const root = createRoot( iconHost ); + root.render( + + ); + + if ( providerId === 'cloudflare' ) { + injectCloudflareAccountField( row, cloudflareAccountId ); + } + } ); +}; + +domReady( () => { + const providers = + window.aiProviderCredentialsConfig?.providers ?? undefined; + const cloudflareAccountId = + window.aiProviderCredentialsConfig?.cloudflareAccountId ?? ''; + if ( providers ) { + enhanceProviderRows( providers, cloudflareAccountId ); + } +} ); diff --git a/src/admin/provider-credentials/style.scss b/src/admin/provider-credentials/style.scss new file mode 100644 index 00000000..5f542b7e --- /dev/null +++ b/src/admin/provider-credentials/style.scss @@ -0,0 +1,186 @@ +@use '../common'; + +.ai-provider-credentials__label-wrapper { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; +} + +.ai-provider-credentials__icon-host { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.ai-provider-credentials__trigger { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.ai-provider-credentials__icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: none; + background: transparent; + cursor: pointer; +} + +.ai-provider-credentials__icon { + --ai-provider-icon-color: #1d2327; + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + color: #1d2327; + transition: color 0.15s ease; +} + +.ai-provider-credentials__icon svg { + width: 100%; + height: 100%; + fill: currentColor; +} + +.ai-provider-credentials__trigger:hover .ai-provider-credentials__icon, +.ai-provider-credentials__trigger:focus-within .ai-provider-credentials__icon, +.ai-provider-credentials__trigger[aria-expanded="true"] .ai-provider-credentials__icon { + color: var(--ai-provider-icon-color); +} + +.ai-provider-credentials__name { + font-weight: 600; +} + +.ai-provider-tooltip { + display: flex; + flex-direction: column; + max-width: 300px; +} + +.ai-provider-tooltip__header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} + +.ai-provider-tooltip__name { + font-size: 14px; + font-weight: 600; + color: #1d2327; +} + +.ai-provider-tooltip__badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + font-size: 11px; + font-weight: 500; + color: #50575e; + background: #f0f0f0; + border-radius: 10px; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.ai-provider-tooltip__section-title { + display: block; + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #757575; + margin-bottom: 6px; +} + +.ai-provider-tooltip__models { + margin-top: 8px; +} + +.ai-provider-tooltip__models ul { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.ai-provider-tooltip__models li { + display: flex; + flex-direction: column; + gap: 1px; + padding: 6px 8px; + background: #f6f7f7; + border-radius: 4px; +} + +.ai-provider-tooltip__model-name { + font-size: 12px; + font-weight: 500; + color: #1d2327; +} + +.ai-provider-tooltip__capabilities { + font-size: 11px; + color: #757575; +} + +.ai-provider-tooltip__link { + font-size: 11px; + color: var(--wp-components-color-foreground-muted, #50575e); +} + +.ai-provider-tooltip__hint { + font-size: 11px; + color: var(--wp-components-color-foreground-muted, #50575e); + line-height: 1.4; +} + +.ai-provider-credentials__popover .components-popover__content { + padding: 0; + background: #fff; + box-shadow: 0 16px 32px rgba(0, 0, 0, 0.14); + border: 1px solid rgba(0, 0, 0, 0.08); + min-width: 260px; + max-width: 360px; + border-radius: 4px; + overflow: hidden; +} + +.ai-provider-tooltip__body { + padding: 12px 16px; +} + +.ai-provider-tooltip__footer { + padding: 10px 16px; + border-top: 1px solid rgba(0, 0, 0, 0.08); + background: #f9f9f9; +} + +.ai-provider-credentials__cloudflare-account { + margin-top: 12px; + + label { + display: block; + font-weight: 600; + margin-bottom: 4px; + color: #1d2327; + } + + input { + width: 100%; + max-width: 24rem; + margin-bottom: 4px; + } + + .description { + margin: 0; + color: #5c5f62; + } +} diff --git a/tests/Integration/Includes/BootstrapTest.php b/tests/Integration/Includes/BootstrapTest.php new file mode 100644 index 00000000..292faecb --- /dev/null +++ b/tests/Integration/Includes/BootstrapTest.php @@ -0,0 +1,135 @@ +original_credentials_option = get_option( 'wp_ai_client_provider_credentials', null ); + } + + /** + * Tears down test fixture. + * + * @since x.x.x + */ + public function tearDown(): void { + remove_all_filters( 'ai_experiments_credentials_settings_url' ); + + if ( null === $this->original_credentials_option ) { + delete_option( 'wp_ai_client_provider_credentials' ); + } else { + update_option( 'wp_ai_client_provider_credentials', $this->original_credentials_option ); + } + + parent::tearDown(); + } + + /** + * Tests that credentials settings URL can be provided by filter. + * + * @since x.x.x + */ + public function test_get_ai_credentials_settings_url_uses_filter(): void { + add_filter( + 'ai_experiments_credentials_settings_url', + static function (): string { + return 'https://example.com/credentials'; + } + ); + + $this->assertSame( + 'https://example.com/credentials', + \WordPress\AI\get_ai_credentials_settings_url() + ); + } + + /** + * Tests that core provider registration runs without requiring bundled mode. + * + * @since x.x.x + */ + public function test_maybe_register_available_ai_client_providers(): void { + if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) { + $this->markTestSkipped( 'AI Client is not available.' ); + } + + \WordPress\AI\maybe_register_available_ai_client_providers(); + + $provider_ids = \WordPress\AiClient\AiClient::defaultRegistry()->getRegisteredProviderIds(); + $this->assertIsArray( $provider_ids ); + } + + /** + * Tests that saved option credentials are applied to the AI client registry. + * + * @since x.x.x + */ + public function test_maybe_apply_option_credentials_to_core_ai_client(): void { + if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) { + $this->markTestSkipped( 'AI Client is not available.' ); + } + + if ( \WordPress\AI\should_use_bundled_wp_ai_client() ) { + $this->markTestSkipped( 'Bundled WP AI Client mode does not use the core credential bridge.' ); + } + + \WordPress\AI\maybe_register_available_ai_client_providers(); + + $registry = \WordPress\AiClient\AiClient::defaultRegistry(); + $provider_ids = $registry->getRegisteredProviderIds(); + if ( ! in_array( 'openai', $provider_ids, true ) ) { + $this->markTestSkipped( 'OpenAI provider is not registered in this environment.' ); + } + + update_option( + 'wp_ai_client_provider_credentials', + array( + 'openai' => 'sk-bootstrap-test-key', + ) + ); + + \WordPress\AI\maybe_apply_option_credentials_to_core_ai_client(); + + $authentication = $registry->getProviderRequestAuthentication( 'openai' ); + if ( null === $authentication ) { + $this->markTestSkipped( 'Core registry did not retain provider authentication in this test environment.' ); + } + + $this->assertTrue( + $authentication instanceof \WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface + ); + + if ( ! method_exists( $authentication, 'getApiKey' ) ) { + return; + } + + $this->assertSame( 'sk-bootstrap-test-key', $authentication->getApiKey() ); + } +} diff --git a/tests/Integration/Includes/HelpersTest.php b/tests/Integration/Includes/HelpersTest.php index af5976ca..6b5409c9 100644 --- a/tests/Integration/Includes/HelpersTest.php +++ b/tests/Integration/Includes/HelpersTest.php @@ -575,4 +575,60 @@ function( $models ) { remove_all_filters( 'ai_experiments_preferred_vision_models' ); } + + /** + * Test that environment credentials are detected via provider variable filter. + * + * @since 0.3.1 + */ + public function test_has_ai_credentials_in_environment_with_filtered_variable_names() { + $constant_name = 'AI_EXPERIMENTS_TEST_ENV_KEY'; + if ( ! defined( $constant_name ) ) { + define( $constant_name, 'unit-test-key' ); + } + + add_filter( + 'ai_experiments_provider_api_key_variable_names', + static function () use ( $constant_name ): array { + return array( $constant_name ); + } + ); + + $this->assertTrue( \WordPress\AI\has_ai_credentials_in_environment() ); + + remove_all_filters( 'ai_experiments_provider_api_key_variable_names' ); + } + + /** + * Test that has_ai_credentials() falls back to environment credentials. + * + * @since 0.3.1 + */ + public function test_has_ai_credentials_falls_back_to_environment() { + $constant_name = 'AI_EXPERIMENTS_TEST_FALLBACK_KEY'; + if ( ! defined( $constant_name ) ) { + define( $constant_name, 'fallback-key' ); + } + + $original = get_option( 'wp_ai_client_provider_credentials', null ); + + add_filter( + 'ai_experiments_provider_api_key_variable_names', + static function () use ( $constant_name ): array { + return array( $constant_name ); + } + ); + + update_option( 'wp_ai_client_provider_credentials', array() ); + + $this->assertTrue( \WordPress\AI\has_ai_credentials() ); + + remove_all_filters( 'ai_experiments_provider_api_key_variable_names' ); + + if ( null === $original ) { + delete_option( 'wp_ai_client_provider_credentials' ); + } else { + update_option( 'wp_ai_client_provider_credentials', $original ); + } + } } diff --git a/tests/Integration/Includes/Services/AI_ServiceTest.php b/tests/Integration/Includes/Services/AI_ServiceTest.php index 2ef24331..d6ea4162 100644 --- a/tests/Integration/Includes/Services/AI_ServiceTest.php +++ b/tests/Integration/Includes/Services/AI_ServiceTest.php @@ -9,7 +9,6 @@ use WP_UnitTestCase; use WordPress\AI\Services\AI_Service; -use WordPress\AI_Client\Builders\Prompt_Builder_With_WP_Error; use function WordPress\AI\get_ai_service; @@ -78,10 +77,14 @@ public function test_get_ai_service_helper_returns_instance(): void { public function test_create_textgen_prompt_returns_builder(): void { $builder = $this->service->create_textgen_prompt( 'Test prompt' ); - $this->assertInstanceOf( - Prompt_Builder_With_WP_Error::class, - $builder, - 'Should return Prompt_Builder_With_WP_Error instance' + $acceptable_classes = array( + 'WordPress\\AI_Client\\Builders\\Prompt_Builder_With_WP_Error', + 'WP_AI_Client_Prompt_Builder', + ); + + $this->assertTrue( + is_object( $builder ) && in_array( get_class( $builder ), $acceptable_classes, true ), + 'Should return a supported prompt builder object' ); } @@ -100,10 +103,14 @@ public function test_create_textgen_prompt_with_options(): void { ) ); - $this->assertInstanceOf( - Prompt_Builder_With_WP_Error::class, - $builder, - 'Should return Prompt_Builder_With_WP_Error instance with options applied' + $acceptable_classes = array( + 'WordPress\\AI_Client\\Builders\\Prompt_Builder_With_WP_Error', + 'WP_AI_Client_Prompt_Builder', + ); + + $this->assertTrue( + is_object( $builder ) && in_array( get_class( $builder ), $acceptable_classes, true ), + 'Should return a supported prompt builder object with options applied' ); } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 352b49e9..02f2687b 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -7,6 +7,108 @@ define( 'TESTS_REPO_ROOT_DIR', dirname( __DIR__ ) ); +if ( ! defined( 'WP_TESTS_PHPUNIT_POLYFILLS_PATH' ) ) { + define( 'WP_TESTS_PHPUNIT_POLYFILLS_PATH', TESTS_REPO_ROOT_DIR . '/vendor/yoast/phpunit-polyfills' ); +} + +/** + * Preloads core AI client contracts to avoid test bootstrap interface conflicts. + * + * PHPUnit is invoked through Composer, which registers the plugin's Composer + * autoloader before WordPress core loads. On WordPress trunk/7.0, core ships a + * scoped AI client contract for `ClientWithOptionsInterface`. If that interface + * is first loaded from Composer dependencies, signature mismatch fatals can + * occur when core classes are declared. + * + * @return void + */ +function wp_ai_maybe_preload_core_ai_client_contracts(): void { + $core_roots = array( + '/var/www/html/wp-includes', + TESTS_REPO_ROOT_DIR . '/../../../../wp-includes', + TESTS_REPO_ROOT_DIR . '/../../../../../wp-includes', + ); + + $core_autoload_path = ''; + foreach ( $core_roots as $core_root ) { + $autoload_path = rtrim( $core_root, '/\\' ) . '/php-ai-client/autoload.php'; + if ( ! file_exists( $autoload_path ) ) { + continue; + } + + $core_autoload_path = $autoload_path; + break; + } + + if ( '' === $core_autoload_path ) { + return; + } + + require_once $core_autoload_path; + + // Move the core AI client autoloader ahead of Composer's autoloader for tests. + $autoloaders = spl_autoload_functions(); + if ( ! is_array( $autoloaders ) ) { + return; + } + + foreach ( $autoloaders as $autoloader ) { + if ( ! $autoloader instanceof \Closure ) { + continue; + } + + $reflection = new \ReflectionFunction( $autoloader ); + $file_name = $reflection->getFileName(); + if ( ! is_string( $file_name ) ) { + continue; + } + + $normalized_file_name = str_replace( '\\', '/', $file_name ); + $normalized_core_autoload_path = str_replace( '\\', '/', $core_autoload_path ); + if ( false === strpos( $normalized_file_name, $normalized_core_autoload_path ) ) { + continue; + } + + spl_autoload_unregister( $autoloader ); + spl_autoload_register( $autoloader, true, true ); + break; + } + + // Preload key classes/contracts from core to avoid mixed-version declarations. + $symbols_to_preload = array( + array( + 'type' => 'class', + 'name' => '\WordPress\AiClient\AiClient', + ), + array( + 'type' => 'interface', + 'name' => '\WordPress\AiClient\Providers\Http\Contracts\ClientWithOptionsInterface', + ), + array( + 'type' => 'class', + 'name' => '\WordPress\AiClient\Providers\Http\Abstracts\AbstractClientDiscoveryStrategy', + ), + ); + + foreach ( $symbols_to_preload as $symbol ) { + if ( ! isset( $symbol['type'], $symbol['name'] ) || ! is_string( $symbol['type'] ) || ! is_string( $symbol['name'] ) ) { + continue; + } + + if ( 'interface' === $symbol['type'] ) { + interface_exists( $symbol['name'] ); + continue; + } + + if ( 'class' === $symbol['type'] ) { + class_exists( $symbol['name'] ); + continue; + } + } +} + +wp_ai_maybe_preload_core_ai_client_contracts(); + /** * Check if WordPress core has the Abilities API (e.g., in trunk). * @@ -38,10 +140,9 @@ function wp_ai_has_core_abilities_api(): bool { require_once TESTS_REPO_ROOT_DIR . '/vendor/wordpress/abilities-api/includes/abilities-api/class-wp-ability.php'; } -// Load Composer dependencies if applicable. -if ( file_exists( TESTS_REPO_ROOT_DIR . '/vendor/autoload.php' ) ) { - require_once TESTS_REPO_ROOT_DIR . '/vendor/autoload.php'; -} +// Do not load Composer's regular autoloader in this bootstrap. +// The plugin itself loads Jetpack autoloader from ai.php, and loading Composer +// here can preload conflicting classes when core provides AI client packages. // Load Abilities API bootstrap for functions. // Only load from vendor if WordPress core doesn't already include it. diff --git a/webpack.config.js b/webpack.config.js index bb929257..57ce981f 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -54,6 +54,11 @@ module.exports = { 'src/experiments/title-generation', 'index.tsx' ), + 'admin/provider-credentials': path.resolve( + process.cwd(), + 'src/admin/provider-credentials', + 'index.tsx' + ), 'experiments/alt-text-generation': path.resolve( process.cwd(), 'src/experiments/alt-text-generation',