diff --git a/lib/experimental/connectors/default-connectors.php b/lib/experimental/connectors/default-connectors.php index 1cfbff02241aeb..255f9763f943cd 100644 --- a/lib/experimental/connectors/default-connectors.php +++ b/lib/experimental/connectors/default-connectors.php @@ -5,6 +5,49 @@ * @package gutenberg */ +/** + * Determines the source of an API key for a given provider. + * + * Checks in order: environment variable, PHP constant, database. + * Uses the same naming convention as the WP AI Client ProviderRegistry. + * + * @access private + * + * @param string $provider_id The provider ID (e.g., 'openai', 'anthropic', 'google'). + * @return string The key source: 'env', 'constant', 'database', or 'none'. + */ +function _gutenberg_get_api_key_source( string $provider_id ): string { + // Convert provider ID to CONSTANT_CASE for env var name. + // e.g., 'openai' -> 'OPENAI', 'anthropic' -> 'ANTHROPIC'. + $constant_case_id = strtoupper( + preg_replace( '/([a-z])([A-Z])/', '$1_$2', str_replace( '-', '_', $provider_id ) ) + ); + $env_var_name = "{$constant_case_id}_API_KEY"; + + // Check environment variable first. + $env_value = getenv( $env_var_name ); + if ( false !== $env_value && '' !== $env_value ) { + return 'env'; + } + + // Check PHP constant. + if ( defined( $env_var_name ) ) { + $const_value = constant( $env_var_name ); + if ( is_string( $const_value ) && '' !== $const_value ) { + return 'constant'; + } + } + + // Check database. + $setting_name = "connectors_ai_{$provider_id}_api_key"; + $db_value = get_option( $setting_name, '' ); + if ( '' !== $db_value ) { + return 'database'; + } + + return 'none'; +} + /** * Masks an API key, showing only the last 4 characters. * @@ -280,72 +323,7 @@ function _gutenberg_get_connector_settings(): array { return $connectors; } -/** - * Validates connector API keys in the REST response when explicitly requested. - * - * Runs on `rest_post_dispatch` for `/wp/v2/settings` requests that include connector - * fields via `_fields`. For each requested connector field, it validates the unmasked - * key against the provider and replaces the response value with `invalid_key` if - * validation fails. - * - * @access private - * - * @param WP_REST_Response $response The response object. - * @param WP_REST_Server $server The server instance. - * @param WP_REST_Request $request The request object. - * @return WP_REST_Response The potentially modified response. - */ -function _gutenberg_validate_connector_keys_in_rest( WP_REST_Response $response, WP_REST_Server $server, WP_REST_Request $request ): WP_REST_Response { - if ( '/wp/v2/settings' !== $request->get_route() ) { - return $response; - } - - if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) { - return $response; - } - - $fields = $request->get_param( '_fields' ); - if ( ! $fields ) { - return $response; - } - - if ( is_array( $fields ) ) { - $requested = $fields; - } else { - $requested = array_map( 'trim', explode( ',', $fields ) ); - } - - $data = $response->get_data(); - if ( ! is_array( $data ) ) { - return $response; - } - - foreach ( _gutenberg_get_connector_settings() as $connector_id => $connector_data ) { - $auth = $connector_data['authentication']; - if ( 'ai_provider' !== $connector_data['type'] || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { - continue; - } - - $setting_name = $auth['setting_name']; - if ( ! in_array( $setting_name, $requested, true ) ) { - continue; - } - - $real_key = _gutenberg_get_real_api_key( $setting_name, '_gutenberg_mask_api_key' ); - if ( '' === $real_key ) { - continue; - } - - if ( true !== _gutenberg_is_ai_api_key_valid( $real_key, $connector_id ) ) { - $data[ $setting_name ] = 'invalid_key'; - } - } - - $response->set_data( $data ); - return $response; -} remove_filter( 'rest_post_dispatch', '_wp_connectors_validate_keys_in_rest', 10 ); -add_filter( 'rest_post_dispatch', '_gutenberg_validate_connector_keys_in_rest', 10, 3 ); /** * Registers default connector settings and mask/sanitize filters. @@ -420,6 +398,12 @@ function _gutenberg_pass_default_connector_keys_to_ai_client(): void { continue; } + // Skip if the key is already provided via env var or constant. + $key_source = _gutenberg_get_api_key_source( $connector_id ); + if ( 'env' === $key_source || 'constant' === $key_source ) { + continue; + } + $api_key = _gutenberg_get_real_api_key( $auth['setting_name'], '_gutenberg_mask_api_key' ); if ( '' === $api_key || ! $registry->hasProvider( $connector_id ) ) { continue; @@ -450,6 +434,7 @@ function _gutenberg_get_connector_script_module_data( array $data ): array { return $data; } + $registry = \WordPress\AiClient\AiClient::defaultRegistry(); $connectors = array(); foreach ( _gutenberg_get_connector_settings() as $connector_id => $connector_data ) { $auth = $connector_data['authentication']; @@ -458,6 +443,12 @@ function _gutenberg_get_connector_script_module_data( array $data ): array { if ( 'api_key' === $auth['method'] ) { $auth_out['settingName'] = $auth['setting_name'] ?? ''; $auth_out['credentialsUrl'] = $auth['credentials_url'] ?? null; + $auth_out['keySource'] = _gutenberg_get_api_key_source( $connector_id ); + try { + $auth_out['isConnected'] = $registry->hasProvider( $connector_id ) && $registry->isProviderConfigured( $connector_id ); + } catch ( Exception $e ) { + $auth_out['isConnected'] = false; + } } $connector_out = array( diff --git a/packages/connectors/src/connector-item.tsx b/packages/connectors/src/connector-item.tsx index f719539bbe65f0..7950a44ced4b2c 100644 --- a/packages/connectors/src/connector-item.tsx +++ b/packages/connectors/src/connector-item.tsx @@ -59,6 +59,8 @@ export function ConnectorItem( { ); } +export type ApiKeySource = 'env' | 'constant' | 'database' | 'none'; + export interface DefaultConnectorSettingsProps { onSave?: ( apiKey: string ) => void | Promise< void >; onRemove?: () => void; @@ -66,6 +68,7 @@ export interface DefaultConnectorSettingsProps { helpUrl?: string; helpLabel?: string; readOnly?: boolean; + keySource?: ApiKeySource; } /** @@ -78,6 +81,7 @@ export interface DefaultConnectorSettingsProps { * @param props.helpUrl - URL to documentation for obtaining an API key. * @param props.helpLabel - Custom label for the help link. Defaults to the URL without protocol. * @param props.readOnly - Whether the form is in read-only mode. + * @param props.keySource - The source of the API key: 'env', 'constant', 'database', or 'none'. */ export function DefaultConnectorSettings( { onSave, @@ -86,6 +90,7 @@ export function DefaultConnectorSettings( { helpUrl, helpLabel, readOnly = false, + keySource, }: DefaultConnectorSettingsProps ) { const [ apiKey, setApiKey ] = useState( initialValue ); const [ isSaving, setIsSaving ] = useState( false ); @@ -110,7 +115,20 @@ export function DefaultConnectorSettings( { ) : undefined; + const isExternallyConfigured = + keySource === 'env' || keySource === 'constant'; + const getHelp = () => { + if ( isExternallyConfigured ) { + if ( keySource === 'env' ) { + return __( + 'This API key is configured using an environment variable.' + ); + } + if ( keySource === 'constant' ) { + return __( 'This API key is configured as a constant.' ); + } + } if ( readOnly ) { return helpUrl ? createInterpolateElement( @@ -182,9 +200,11 @@ export function DefaultConnectorSettings( { help={ getHelp() } /> { readOnly ? ( - + onRemove && ( + + ) ) : (