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 && (
+
+ )
) : (