Si vous avez déjà retrouvé votre clé OpenAI copiée en clair dans un snippet “rapide” ou, pire, dans un dépôt Git public, vous avez touché le vrai problème : une clé API n’est pas un réglage, c’est un secret. Sur WordPress 6.9.4 (avril 2026), la bonne approche consiste à stocker ce secret hors de la base de données, hors de l’admin, et à l’utiliser côté serveur via wp_remote_post() avec du cache et des garde-fous.

Le besoin / Le cas d’usage

Une clé API OpenAI sert à authentifier votre site WordPress quand il appelle l’API OpenAI (génération de texte, résumé, extraction de mots-clés, classification, etc.). Le problème vient de la tentation classique : coller la clé “quelque part” dans un plugin de snippets, dans un champ ACF, dans functions.php, ou dans du JavaScript.

Dans mon expérience, les fuites arrivent surtout de trois façons :

  • La clé est en dur dans un fichier versionné (GitHub, sauvegarde partagée, zip envoyé à un prestataire).
  • La clé est stockée en base (option WordPress) et exportée dans un dump SQL ou visible à un admin “trop large”.
  • La clé est exposée côté navigateur (appel fetch depuis JS, ou clé injectée dans une page).

À la fin, vous saurez mettre en place une intégration IA minimale mais saine : clé dans wp-config.php, appel OpenAI via wp_remote_post(), cache via Transients API, et une petite couche de sécurité (timeouts, validation, nettoyage des réponses, limitation de débit).

Résumé rapide

  • Stockez la clé OpenAI dans wp-config.php avec define() (ou, mieux, via variable d’environnement si vous avez la main).
  • N’exposez jamais la clé côté client (pas de JavaScript, pas de shortcode qui l’affiche).
  • Appelez OpenAI avec wp_remote_post() (API HTTP WordPress), timeout court et gestion d’erreurs.
  • Mettez un cache (get_transient()/set_transient()) pour réduire les coûts et stabiliser le site.
  • Nettoyez les sorties IA (sanitize_text_field(), wp_kses_post()) avant affichage.
  • Ajoutez une limitation (rate limiting) et des nonces si l’utilisateur déclenche une requête.

Quand utiliser l’IA pour ça

Stocker une clé en sécurité n’est pas “optionnel” dès que vous faites l’un de ces cas d’usage côté WordPress :

  • Générer des résumés d’articles à la publication (metadescription, extrait, TL;DR).
  • Produire des variantes de titres et intertitres pour l’éditeur.
  • Créer des FAQ à partir du contenu d’une page.
  • Classer des contenus (catégories suggérées, tags, tonalité) via une requête IA.
  • Assistance interne (outil privé pour votre équipe) via une page d’admin.

Le point commun : l’appel doit partir du serveur (PHP) vers l’API. Donc la clé doit rester côté serveur.

Quand ne PAS utiliser l’IA

J’ai souvent vu des sites payer des appels IA pour des problèmes que WordPress résout déjà, gratuitement et plus vite.

  • Recherche interne simple : utilisez la recherche native, ou un moteur dédié (Elastic/OpenSearch) plutôt que des embeddings “à la volée”.
  • Réécriture systématique à chaque affichage de page : c’est un anti-pattern. Faites-le à la publication et stockez le résultat.
  • Traduction “en direct” à chaque visite : trop coûteux, trop lent. Utilisez un plugin de traduction ou un flux de traduction hors-ligne.
  • Contenu sensible (santé, juridique, données perso) sans cadre RGPD et sans validation humaine : risque légal et réputationnel.

Si votre besoin peut être couvert par une requête SQL, un champ personnalisé, une taxonomie, ou un simple template, vous gagnerez en stabilité.

Prérequis

Versions et environnement

  • WordPress : 6.9.4+.
  • PHP : 8.1+ (recommandé). Un PHP trop ancien déclenche des erreurs fatales sur du code moderne (typage, fonctions, libs).
  • HTTPS : indispensable en production.

Comprendre ce qu’est une API (et ce que vous faites réellement)

Une API (Application Programming Interface) est un service accessible via des requêtes HTTP. Concrètement, votre WordPress envoie une requête POST vers une URL OpenAI, avec :

  • un en-tête Authorization: Bearer VOTRE_CLE (c’est là que la clé doit rester secrète),
  • un corps JSON (votre prompt, le modèle, etc.),
  • puis reçoit une réponse JSON (texte généré, usage tokens, erreurs).

Dans WordPress, l’API HTTP se manipule via wp_remote_post() et wp_remote_get(). Référence officielle : WordPress HTTP API.

Clé OpenAI et avertissement coûts

Vous devez disposer d’une clé API OpenAI et d’un compte facturable. Une requête peut coûter quelques fractions de centime à plusieurs centimes selon le modèle et la longueur. Multipliez par le trafic, et la facture grimpe vite si vous appelez l’IA à chaque chargement de page.

Où coller la clé (et où ne pas la coller)

  • À faire : collez la clé dans wp-config.php via define(), idéalement en la lisant depuis une variable d’environnement.
  • À éviter : functions.php, un plugin de snippets, un champ dans l’admin, un fichier JS, un shortcode, un builder (Divi/Elementor/Avada) qui injecte du code.

Exemple recommandé dans wp-config.php

Ouvrez wp-config.php (à la racine de votre WordPress) et ajoutez ceci au-dessus de la ligne /* That's all, stop editing! */.

<?php
// ... votre wp-config.php ...

/**
 * Clé API OpenAI (secret).
 * Astuce: si vous pouvez, préférez une variable d'environnement (plus sûr en déploiement).
 */
if ( ! defined( 'OPENAI_API_KEY' ) ) {
	$openai_key = getenv( 'OPENAI_API_KEY' ); // Retourne false si non défini
	if ( $openai_key ) {
		define( 'OPENAI_API_KEY', $openai_key );
	}
}

/**
 * Fallback (moins idéal) : définir en dur si vous n'avez pas d'accès aux variables d'environnement.
 * Ne commitez jamais ce fichier dans un dépôt public si vous faites ça.
 */
/*
if ( ! defined( 'OPENAI_API_KEY' ) ) {
	define( 'OPENAI_API_KEY', 'sk-proj-xxxxxxxxxxxxxxxxxxxxxxxx' );
}
*/

Pourquoi wp-config.php ? Parce que ce fichier est chargé très tôt, avant la plupart des plugins, et il n’est pas censé être modifiable par l’admin. C’est une barrière pratique contre les fuites accidentelles.

Où coller le code PHP “fonctionnel”

Pour le code d’intégration IA, vous avez trois options (du plus fiable au plus “rapide”) :

  • mu-plugin (recommandé) : fichier dans wp-content/mu-plugins/, chargé automatiquement. Idéal pour du code “infrastructure”.
  • plugin custom : un plugin que vous créez, activable/désactivable.
  • functions.php d’un thème enfant : possible, mais si vous changez de thème, vous perdez l’intégration. J’ai vu ce piège sur des migrations Divi → Gutenberg.

Référence sur les mu-plugins : Must-Use Plugins.

Architecture de la solution

Voici le flux “propre” que je recommande pour WordPress 6.9.4 :

WordPress (PHP) → vérifie que OPENAI_API_KEY existe → construit la requête → wp_remote_post() vers OpenAI → parse JSON → nettoie/sanitize → met en cache (Transient) → renvoie le texte → affichage (shortcode ou bloc) ou stockage (meta).

Ce qui se passe en coulisses, étape par étape

  1. Entrée : un prompt (ex. “résume ce texte en 3 points”).
  2. Validation : longueur max, caractères autorisés, contexte (éviter d’envoyer des données perso).
  3. Cache : on calcule une clé de cache (hash du prompt + modèle). Si on a déjà une réponse, on ne paye pas une deuxième fois.
  4. Appel HTTP : wp_remote_post() avec timeout court, en-têtes et JSON.
  5. Traitement : gestion des erreurs HTTP/JSON, extraction du texte, nettoyage.
  6. Sortie : renvoyer une chaîne prête à être affichée (ou stockée).

Le code complet — étape par étape

On va construire un mini “client” OpenAI côté WordPress, puis un shortcode de test. Vous pourrez ensuite l’accrocher à un hook (action) de publication ou à une page d’admin.

Vocabulaire rapide :

  • Hook : point d’accroche dans WordPress. Il existe des actions (déclenchent du code) et des filtres (modifient une valeur). Référence : Hooks (actions & filters).
  • Transient : cache clé/valeur avec expiration. Référence : Transients API.

Étape 1 — Créer un mu-plugin

Créez le dossier wp-content/mu-plugins/ s’il n’existe pas, puis un fichier :

  • wp-content/mu-plugins/openai-key-and-client.php

Étape 2 — Vérifier la présence de la clé et définir une fonction utilitaire

<?php
/**
 * Plugin Name: OpenAI (clé + client minimal)
 * Description: Stockage via wp-config.php + appels OpenAI via wp_remote_post() avec cache et sécurité.
 * Author: Votre Nom
 * Version: 1.0.0
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Retourne la clé API OpenAI depuis la constante.
 * Ne loggez jamais la clé.
 */
function bpcab_openai_get_api_key(): string {
	if ( defined( 'OPENAI_API_KEY' ) && is_string( OPENAI_API_KEY ) && OPENAI_API_KEY !== '' ) {
		return OPENAI_API_KEY;
	}
	return '';
}

Piège fréquent : coller ce code dans functions.php d’un thème parent. Une mise à jour de thème l’écrase. Faites-le dans un mu-plugin ou un plugin.

Étape 3 — Écrire un appel OpenAI robuste avec wp_remote_post()

On utilise l’API “Responses” (recommandée par OpenAI depuis un moment pour unifier les usages). Si OpenAI fait évoluer les endpoints, gardez ce code isolé dans une fonction : vous ne modifierez qu’un endroit.

Docs officielles OpenAI : OpenAI API Reference.

/**
 * Appel OpenAI (endpoint Responses) avec cache Transient.
 *
 * @param string $prompt  Le texte envoyé au modèle.
 * @param array  $args    Options: model, max_output_tokens, temperature, cache_ttl.
 * @return array{ok:bool, text:string, error:string, cached:bool}
 */
function bpcab_openai_generate_text( string $prompt, array $args = array() ): array {
	$api_key = bpcab_openai_get_api_key();
	if ( $api_key === '' ) {
		return array(
			'ok'     => false,
			'text'   => '',
			'error'  => 'Clé API OpenAI manquante. Définissez OPENAI_API_KEY dans wp-config.php.',
			'cached' => false,
		);
	}

	$defaults = array(
		'model'             => 'gpt-4.1-mini',
		'max_output_tokens' => 250,
		'temperature'       => 0.2,
		'cache_ttl'         => 6 * HOUR_IN_SECONDS,
		'timeout'           => 20, // secondes
	);
	$args = wp_parse_args( $args, $defaults );

	// Validation basique côté serveur (évite les abus et les factures surprises).
	$prompt = wp_strip_all_tags( $prompt );
	$prompt = trim( $prompt );

	if ( $prompt === '' ) {
		return array(
			'ok'     => false,
			'text'   => '',
			'error'  => 'Prompt vide.',
			'cached' => false,
		);
	}

	// Limite simple: évitez d'envoyer des romans par erreur.
	if ( strlen( $prompt ) > 8000 ) {
		return array(
			'ok'     => false,
			'text'   => '',
			'error'  => 'Prompt trop long (limite locale). Réduisez la taille du texte envoyé.',
			'cached' => false,
		);
	}

	// Cache: clé stable basée sur le prompt + paramètres.
	$cache_key_raw = wp_json_encode(
		array(
			'prompt' => $prompt,
			'model'  => $args['model'],
			'max'    => (int) $args['max_output_tokens'],
			'temp'   => (float) $args['temperature'],
		)
	);

	// Transient keys: 172 chars max environ, on hash.
	$cache_key = 'bpcab_openai_' . md5( $cache_key_raw );

	$cached = get_transient( $cache_key );
	if ( is_array( $cached ) && isset( $cached['text'] ) ) {
		return array(
			'ok'     => true,
			'text'   => (string) $cached['text'],
			'error'  => '',
			'cached' => true,
		);
	}

	$endpoint = 'https://api.openai.com/v1/responses';

	$body = array(
		'model' => (string) $args['model'],
		'input' => $prompt,
		'temperature' => (float) $args['temperature'],
		'max_output_tokens' => (int) $args['max_output_tokens'],
	);

	$request_args = array(
		'method'  => 'POST',
		'timeout' => (int) $args['timeout'],
		'headers' => array(
			'Authorization' => 'Bearer ' . $api_key,
			'Content-Type'  => 'application/json; charset=utf-8',
		),
		'body'    => wp_json_encode( $body ),
	);

	$response = wp_remote_post( $endpoint, $request_args );

	if ( is_wp_error( $response ) ) {
		return array(
			'ok'     => false,
			'text'   => '',
			'error'  => 'Erreur HTTP (WordPress): ' . $response->get_error_message(),
			'cached' => false,
		);
	}

	$status = (int) wp_remote_retrieve_response_code( $response );
	$raw    = (string) wp_remote_retrieve_body( $response );

	if ( $status < 200 || $status >= 300 ) {
		// Ne renvoyez pas tout le body en front (peut contenir des détails). Gardez-le pour les logs.
		error_log( '[OpenAI] HTTP ' . $status . ' Body: ' . $raw );

		return array(
			'ok'     => false,
			'text'   => '',
			'error'  => 'OpenAI a renvoyé une erreur HTTP ' . $status . '. Vérifiez vos logs.',
			'cached' => false,
		);
	}

	$data = json_decode( $raw, true );
	if ( ! is_array( $data ) ) {
		error_log( '[OpenAI] JSON invalide: ' . $raw );
		return array(
			'ok'     => false,
			'text'   => '',
			'error'  => 'Réponse JSON invalide.',
			'cached' => false,
		);
	}

	/**
	 * Extraction du texte.
	 * Les structures peuvent évoluer. On reste défensif.
	 */
	$text = '';

	// Cas courant: output[0].content[0].text (selon formats).
	if ( isset( $data['output'][0]['content'][0]['text'] ) && is_string( $data['output'][0]['content'][0]['text'] ) ) {
		$text = $data['output'][0]['content'][0]['text'];
	}

	// Fallback: si l'API renvoie un champ différent.
	if ( $text === '' && isset( $data['text'] ) && is_string( $data['text'] ) ) {
		$text = $data['text'];
	}

	$text = trim( $text );

	if ( $text === '' ) {
		error_log( '[OpenAI] Réponse sans texte exploitable: ' . $raw );
		return array(
			'ok'     => false,
			'text'   => '',
			'error'  => 'Réponse reçue, mais aucun texte exploitable.',
			'cached' => false,
		);
	}

	/**
	 * Nettoyage des sorties IA:
	 * - si vous affichez en texte brut: sanitize_text_field()
	 * - si vous autorisez un peu de HTML (gras, liens): wp_kses_post()
	 *
	 * Ici on choisit wp_kses_post() pour autoriser un HTML basique sans scripts.
	 */
	$safe_text = wp_kses_post( $text );

	set_transient(
		$cache_key,
		array(
			'text' => $safe_text,
		),
		(int) $args['cache_ttl']
	);

	return array(
		'ok'     => true,
		'text'   => $safe_text,
		'error'  => '',
		'cached' => false,
	);
}

Deux remarques issues du terrain :

  • Un timeout trop long (60s+) finit par saturer PHP-FPM sur un site qui a du trafic. 15–25 secondes est un bon départ.
  • La plupart des “ça marche pas” viennent d’un JSON mal formé (virgule manquante) ou d’un point-virgule oublié en copiant le code. Si vous voyez un écran blanc, activez le debug (section débogage plus bas).

Étape 4 — Ajouter un rate limiting simple (anti-abus)

Si vous exposez un formulaire ou un shortcode public, quelqu’un peut marteler l’endpoint et vous faire payer. Voici une limitation très simple par IP (ce n’est pas parfait, mais ça évite le pire).

/**
 * Limitation très simple: X requêtes par fenêtre de temps et par IP.
 *
 * @param string $bucket Nom logique (ex: 'shortcode').
 * @param int    $limit  Nombre max de requêtes.
 * @param int    $window Fenêtre en secondes.
 * @return bool True si autorisé, false si bloqué.
 */
function bpcab_rate_limit_allow( string $bucket, int $limit = 10, int $window = 300 ): bool {
	$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? (string) $_SERVER['REMOTE_ADDR'] : '0.0.0.0';
	$ip = preg_replace( '/[^0-9a-fA-F:.]/', '', $ip ); // Nettoyage minimal

	$key = 'bpcab_rl_' . md5( $bucket . '|' . $ip );

	$state = get_transient( $key );
	if ( ! is_array( $state ) ) {
		$state = array(
			'count' => 0,
		);
	}

	$state['count'] = (int) $state['count'] + 1;

	set_transient( $key, $state, $window );

	return ( (int) $state['count'] <= $limit );
}

Étape 5 — Un shortcode de test (utile avec Divi / Elementor / Avada)

Les page builders aiment les shortcodes, parce que vous pouvez les insérer dans un module texte. Ici, on crée [openai_tldr] qui résume le contenu d’un article (ou un texte fourni).

Note sécurité : on ne veut pas que n’importe qui déclenche des appels coûteux. Donc on limite aux utilisateurs connectés qui peuvent éditer des posts.

/**
 * Shortcode: [openai_tldr text="..."] ou sans attribut pour résumer le contenu du post courant.
 *
 * Compatible Divi 5 / Elementor / Avada: insérez le shortcode dans un module texte.
 */
function bpcab_shortcode_openai_tldr( $atts ): string {
	if ( ! is_user_logged_in() || ! current_user_can( 'edit_posts' ) ) {
		return '';
	}

	if ( ! bpcab_rate_limit_allow( 'shortcode_tldr', 6, 300 ) ) {
		return '<p><strong>Trop de requêtes.</strong> Réessayez dans quelques minutes.</p>';
	}

	$atts = shortcode_atts(
		array(
			'text' => '',
		),
		(array) $atts,
		'openai_tldr'
	);

	$text = (string) $atts['text'];

	if ( $text === '' ) {
		$post = get_post();
		if ( $post instanceof WP_Post ) {
			$text = (string) $post->post_content;
		}
	}

	$text = wp_strip_all_tags( $text );
	$text = trim( $text );

	if ( $text === '' ) {
		return '<p>Aucun texte à résumer.</p>';
	}

	$prompt = "Résume le texte suivant en 3 puces courtes, en français, sans inventer d'informations.nnTexte:n" . $text;

	$result = bpcab_openai_generate_text(
		$prompt,
		array(
			'max_output_tokens' => 180,
			'temperature'       => 0.2,
			'cache_ttl'         => 12 * HOUR_IN_SECONDS,
		)
	);

	if ( ! $result['ok'] ) {
		return '<p><strong>Erreur:</strong> ' . esc_html( $result['error'] ) . '</p>';
	}

	$meta = $result['cached'] ? ' (cache)' : '';
	return '<div class="openai-tldr"><p><strong>TL;DR' . esc_html( $meta ) . ':</strong></p><div>' . $result['text'] . '</div></div>';
}
add_shortcode( 'openai_tldr', 'bpcab_shortcode_openai_tldr' );

Piège fréquent avec les builders : vous collez le shortcode dans un module qui “nettoie” le contenu et supprime les crochets. Dans Divi/Elementor/Avada, utilisez un module texte standard, pas un module “code” qui peut filtrer.

Le code assemblé complet

Copiez-collez ce fichier complet dans wp-content/mu-plugins/openai-key-and-client.php. Puis vérifiez que votre clé est bien définie dans wp-config.php.

<?php
/**
 * Plugin Name: OpenAI (clé + client minimal)
 * Description: Stockage via wp-config.php + appels OpenAI via wp_remote_post() avec cache, rate limit et sécurité.
 * Author: Votre Nom
 * Version: 1.0.0
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Retourne la clé API OpenAI depuis la constante.
 */
function bpcab_openai_get_api_key(): string {
	if ( defined( 'OPENAI_API_KEY' ) && is_string( OPENAI_API_KEY ) && OPENAI_API_KEY !== '' ) {
		return OPENAI_API_KEY;
	}
	return '';
}

/**
 * Limitation très simple: X requêtes par fenêtre de temps et par IP.
 */
function bpcab_rate_limit_allow( string $bucket, int $limit = 10, int $window = 300 ): bool {
	$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? (string) $_SERVER['REMOTE_ADDR'] : '0.0.0.0';
	$ip = preg_replace( '/[^0-9a-fA-F:.]/', '', $ip );

	$key = 'bpcab_rl_' . md5( $bucket . '|' . $ip );

	$state = get_transient( $key );
	if ( ! is_array( $state ) ) {
		$state = array(
			'count' => 0,
		);
	}

	$state['count'] = (int) $state['count'] + 1;

	set_transient( $key, $state, $window );

	return ( (int) $state['count'] <= $limit );
}

/**
 * Appel OpenAI (endpoint Responses) avec cache Transient.
 *
 * @return array{ok:bool, text:string, error:string, cached:bool}
 */
function bpcab_openai_generate_text( string $prompt, array $args = array() ): array {
	$api_key = bpcab_openai_get_api_key();
	if ( $api_key === '' ) {
		return array(
			'ok'     => false,
			'text'   => '',
			'error'  => 'Clé API OpenAI manquante. Définissez OPENAI_API_KEY dans wp-config.php.',
			'cached' => false,
		);
	}

	$defaults = array(
		'model'             => 'gpt-4.1-mini',
		'max_output_tokens' => 250,
		'temperature'       => 0.2,
		'cache_ttl'         => 6 * HOUR_IN_SECONDS,
		'timeout'           => 20,
	);
	$args = wp_parse_args( $args, $defaults );

	$prompt = wp_strip_all_tags( $prompt );
	$prompt = trim( $prompt );

	if ( $prompt === '' ) {
		return array(
			'ok'     => false,
			'text'   => '',
			'error'  => 'Prompt vide.',
			'cached' => false,
		);
	}

	if ( strlen( $prompt ) > 8000 ) {
		return array(
			'ok'     => false,
			'text'   => '',
			'error'  => 'Prompt trop long (limite locale).',
			'cached' => false,
		);
	}

	$cache_key_raw = wp_json_encode(
		array(
			'prompt' => $prompt,
			'model'  => $args['model'],
			'max'    => (int) $args['max_output_tokens'],
			'temp'   => (float) $args['temperature'],
		)
	);
	$cache_key = 'bpcab_openai_' . md5( $cache_key_raw );

	$cached = get_transient( $cache_key );
	if ( is_array( $cached ) && isset( $cached['text'] ) ) {
		return array(
			'ok'     => true,
			'text'   => (string) $cached['text'],
			'error'  => '',
			'cached' => true,
		);
	}

	$endpoint = 'https://api.openai.com/v1/responses';

	$body = array(
		'model'             => (string) $args['model'],
		'input'             => $prompt,
		'temperature'       => (float) $args['temperature'],
		'max_output_tokens' => (int) $args['max_output_tokens'],
	);

	$request_args = array(
		'method'  => 'POST',
		'timeout' => (int) $args['timeout'],
		'headers' => array(
			'Authorization' => 'Bearer ' . $api_key,
			'Content-Type'  => 'application/json; charset=utf-8',
		),
		'body'    => wp_json_encode( $body ),
	);

	$response = wp_remote_post( $endpoint, $request_args );

	if ( is_wp_error( $response ) ) {
		return array(
			'ok'     => false,
			'text'   => '',
			'error'  => 'Erreur HTTP (WordPress): ' . $response->get_error_message(),
			'cached' => false,
		);
	}

	$status = (int) wp_remote_retrieve_response_code( $response );
	$raw    = (string) wp_remote_retrieve_body( $response );

	if ( $status < 200 || $status >= 300 ) {
		error_log( '[OpenAI] HTTP ' . $status . ' Body: ' . $raw );
		return array(
			'ok'     => false,
			'text'   => '',
			'error'  => 'OpenAI a renvoyé une erreur HTTP ' . $status . '.',
			'cached' => false,
		);
	}

	$data = json_decode( $raw, true );
	if ( ! is_array( $data ) ) {
		error_log( '[OpenAI] JSON invalide: ' . $raw );
		return array(
			'ok'     => false,
			'text'   => '',
			'error'  => 'Réponse JSON invalide.',
			'cached' => false,
		);
	}

	$text = '';
	if ( isset( $data['output'][0]['content'][0]['text'] ) && is_string( $data['output'][0]['content'][0]['text'] ) ) {
		$text = $data['output'][0]['content'][0]['text'];
	}
	if ( $text === '' && isset( $data['text'] ) && is_string( $data['text'] ) ) {
		$text = $data['text'];
	}

	$text = trim( $text );
	if ( $text === '' ) {
		error_log( '[OpenAI] Réponse sans texte exploitable: ' . $raw );
		return array(
			'ok'     => false,
			'text'   => '',
			'error'  => 'Réponse reçue, mais aucun texte exploitable.',
			'cached' => false,
		);
	}

	$safe_text = wp_kses_post( $text );

	set_transient(
		$cache_key,
		array(
			'text' => $safe_text,
		),
		(int) $args['cache_ttl']
	);

	return array(
		'ok'     => true,
		'text'   => $safe_text,
		'error'  => '',
		'cached' => false,
	);
}

/**
 * Shortcode: [openai_tldr text="..."] ou sans attribut pour résumer le contenu du post courant.
 */
function bpcab_shortcode_openai_tldr( $atts ): string {
	if ( ! is_user_logged_in() || ! current_user_can( 'edit_posts' ) ) {
		return '';
	}

	if ( ! bpcab_rate_limit_allow( 'shortcode_tldr', 6, 300 ) ) {
		return '<p><strong>Trop de requêtes.</strong> Réessayez dans quelques minutes.</p>';
	}

	$atts = shortcode_atts(
		array(
			'text' => '',
		),
		(array) $atts,
		'openai_tldr'
	);

	$text = (string) $atts['text'];

	if ( $text === '' ) {
		$post = get_post();
		if ( $post instanceof WP_Post ) {
			$text = (string) $post->post_content;
		}
	}

	$text = wp_strip_all_tags( $text );
	$text = trim( $text );

	if ( $text === '' ) {
		return '<p>Aucun texte à résumer.</p>';
	}

	$prompt = "Résume le texte suivant en 3 puces courtes, en français, sans inventer d'informations.nnTexte:n" . $text;

	$result = bpcab_openai_generate_text(
		$prompt,
		array(
			'max_output_tokens' => 180,
			'temperature'       => 0.2,
			'cache_ttl'         => 12 * HOUR_IN_SECONDS,
		)
	);

	if ( ! $result['ok'] ) {
		return '<p><strong>Erreur:</strong> ' . esc_html( $result['error'] ) . '</p>';
	}

	$meta = $result['cached'] ? ' (cache)' : '';
	return '<div class="openai-tldr"><p><strong>TL;DR' . esc_html( $meta ) . ':</strong></p><div>' . $result['text'] . '</div></div>';
}
add_shortcode( 'openai_tldr', 'bpcab_shortcode_openai_tldr' );

Explication du code

Pourquoi une constante dans wp-config.php

Une constante (via define()) est disponible globalement, sans requête SQL, et surtout hors de l’interface admin. C’est simple, et ça réduit fortement les fuites.

Si vous avez un pipeline de déploiement, la meilleure version est : variable d’environnement → lue par getenv() → constante. Ça vous évite d’avoir des secrets dans des fichiers.

Pourquoi wp_remote_post() (et pas curl direct)

wp_remote_post() passe par l’API HTTP WordPress, qui gère les transports (cURL, streams), proxy, SSL, et s’intègre bien avec l’écosystème. Référence : wp_remote_post().

Pourquoi un cache Transient

Le cache réduit :

  • la facture OpenAI,
  • la latence (réponse instantanée),
  • les appels répétés quand un builder recharge l’aperçu plusieurs fois (j’ai vu Elementor déclencher des rendus multiples sur certaines pages).

Le transient est suffisant pour un débutant. Si vous avez Redis/Memcached, WordPress peut stocker les transients en mémoire via un object-cache drop-in.

Pourquoi nettoyer la réponse IA

Une IA peut renvoyer du HTML, des liens, ou du contenu inattendu. Même si OpenAI ne renvoie pas de script volontairement, vous ne voulez pas afficher du HTML non filtré.

  • sanitize_text_field() si vous voulez du texte brut.
  • wp_kses_post() si vous acceptez un HTML minimal (balises “post”). Référence : wp_kses_post().

Pourquoi limiter aux utilisateurs “edit_posts”

Un shortcode public qui appelle l’IA, c’est un distributeur de tokens. Ici, on limite aux utilisateurs connectés qui peuvent éditer des contenus. C’est un garde-fou basique mais très efficace pour commencer.

Coûts API et optimisation

Les prix OpenAI varient selon les modèles et changent dans le temps. Prenez l’habitude de vérifier la page officielle avant d’estimer. Source : OpenAI Pricing.

Estimation simple (ordre de grandeur)

Un TL;DR typique peut consommer, selon votre prompt et la taille du texte :

  • Entrée : 500 à 3000 tokens (si vous envoyez tout l’article, ça monte vite),
  • Sortie : 100 à 250 tokens.

Si vous faites 200 résumés/mois et que chaque résumé coûte quelques millièmes à quelques centimes, vous êtes généralement dans une fourchette “raisonnable”. Si vous déclenchez l’IA à chaque page vue, vous passez vite à des centaines/milliers de requêtes par jour.

Optimisations concrètes

  • Cache long pour les contenus “figés” (12h, 24h, 7 jours).
  • Réduisez l’entrée : envoyez l’extrait, ou les 1500 premiers caractères, ou une version nettoyée.
  • Modèle plus petit pour les tâches simples (résumé, extraction). Gardez les gros modèles pour les tâches difficiles.
  • Batch (si votre cas d’usage s’y prête) : générer 20 résumés en une opération planifiée (cron) plutôt que 20 fois en front.

Variantes et cas d’usage avancés

Variante 1 — Générer le TL;DR à la publication (au lieu d’un shortcode)

Pour éviter les appels en front, accrochez-vous à une action WordPress lors de la sauvegarde. Exemple avec save_post (attention aux autosaves et révisions).

Référence hook : save_post.

/**
 * Génère un TL;DR et le stocke en meta lors de la publication.
 */
function bpcab_generate_tldr_on_publish( int $post_id, WP_Post $post, bool $update ): void {
	// Éviter autosave/révisions.
	if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
		return;
	}

	// Limiter au type "post" (à adapter).
	if ( $post->post_type !== 'post' ) {
		return;
	}

	// Éviter de tourner sur chaque petite mise à jour si vous le souhaitez.
	// Exemple: ne générer que si la meta est vide.
	$existing = get_post_meta( $post_id, '_bpcab_tldr', true );
	if ( is_string( $existing ) && $existing !== '' ) {
		return;
	}

	$content = wp_strip_all_tags( (string) $post->post_content );
	$content = trim( $content );

	if ( $content === '' ) {
		return;
	}

	$prompt = "Résume le texte suivant en 3 puces courtes, en français.nnTexte:n" . $content;

	$result = bpcab_openai_generate_text(
		$prompt,
		array(
			'max_output_tokens' => 180,
			'temperature'       => 0.2,
			'cache_ttl'         => 30 * DAY_IN_SECONDS,
		)
	);

	if ( $result['ok'] ) {
		// Stocker une version sûre.
		update_post_meta( $post_id, '_bpcab_tldr', wp_kses_post( $result['text'] ) );
	}
}
add_action( 'save_post', 'bpcab_generate_tldr_on_publish', 10, 3 );

Avantage : coût maîtrisé, affichage instantané. Inconvénient : la sauvegarde peut être plus lente (timeout si vous avez un hébergement fragile). Dans ce cas, passez par une tâche cron.

Variante 2 — Afficher la meta TL;DR dans Divi 5 / Elementor / Avada

  • Divi 5 : utilisez un module “Texte” et insérez un shortcode qui lit la meta.
  • Elementor : widget “Shortcode” ou “Texte”.
  • Avada : élément “Code Block” (en mode contenu) ou “Text”, selon votre setup.

Exemple de shortcode “lecture meta” (sans appel IA) :

/**
 * Shortcode: [tldr_meta]
 */
function bpcab_shortcode_tldr_meta(): string {
	$post = get_post();
	if ( ! ( $post instanceof WP_Post ) ) {
		return '';
	}

	$tldr = get_post_meta( $post->ID, '_bpcab_tldr', true );
	if ( ! is_string( $tldr ) || $tldr === '' ) {
		return '';
	}

	return '<div class="tldr-meta">' . wp_kses_post( $tldr ) . '</div>';
}
add_shortcode( 'tldr_meta', 'bpcab_shortcode_tldr_meta' );

Variante 3 — Remplacer OpenAI par un autre provider sans toucher à wp-config.php

Si vous testez Anthropic ou Google, gardez le même principe : clé en constante, appel via wp_remote_post(), cache transients, nettoyage. Docs Anthropic : Anthropic Docs.

Sécurité et bonnes pratiques

Ne jamais exposer la clé côté client

Ne faites pas :

  • un fetch()</code JavaScript vers OpenAI depuis le navigateur,
  • une variable JS injectée avec la clé,
  • un endpoint REST WordPress qui renvoie la clé “pour tester”.

La clé doit rester côté serveur. Si vous avez besoin d’un bouton dans l’admin, faites un endpoint REST serveur qui appelle OpenAI, et protégez-le avec permissions + nonce.

Valider les entrées utilisateur

Si un utilisateur peut fournir le prompt (formulaire), appliquez :

  • limite de taille,
  • nettoyage (sanitize_text_field() ou wp_strip_all_tags()),
  • contrôle de capacité (current_user_can()),
  • nonce si action via POST (référence : WordPress Nonces).

Limiter le débit (rate limiting)

Le rate limiting du code est volontairement simple. Pour un site pro :

  • limitez par utilisateur (user ID) plutôt que par IP,
  • ajoutez un plafond global par jour,
  • loggez les abus (sans jamais logguer la clé).

RGPD / données personnelles

Si vous envoyez à l’API : emails, noms, messages de contact, ou tout contenu pouvant identifier une personne, vous devez cadrer ça (base légale, information, sous-traitance, durée). Au minimum :

  • évitez d’envoyer des données perso quand ce n’est pas nécessaire,
  • anonymisez (remplacer emails/téléphones),
  • documentez le flux dans votre registre.

Ne modifiez jamais le core WordPress

Ça paraît évident, mais je le vois encore : ne collez pas votre clé dans un fichier du core. Une mise à jour WordPress écrase tout. Référence bonnes pratiques dev : Plugin Developer Handbook.

Comment tester et déboguer

1) Activer WP_DEBUG proprement

Dans wp-config.php, activez le debug en environnement de test (pas en prod ouverte) :

<?php
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
define( 'WP_DEBUG_DISPLAY', false );

Les erreurs iront dans wp-content/debug.log. Doc officielle : Debug WordPress.

2) Tester une requête simple

Créez une page de test, insérez [openai_tldr text="Ceci est un test. Résumez-moi."]. Si vous êtes connecté avec un compte éditeur/admin, vous devez voir une réponse.

3) Vérifier les erreurs HTTP et JSON

Si OpenAI renvoie une erreur HTTP, le code loggue le body complet via error_log(). Sur la plupart des hébergeurs, vous le verrez :

  • dans debug.log si WP_DEBUG_LOG est activé,
  • ou dans les logs serveur (panel d’hébergement).

4) Attention aux caches

Si vous utilisez un plugin de cache (page cache), vous pouvez croire que “ça ne change pas”. Ici, on met aussi en cache côté transients, donc c’est normal de revoir la même réponse.

  • Pour forcer un test, changez légèrement le texte du prompt.
  • Ou supprimez le transient via un outil (WP-CLI, ou un plugin de gestion de transients).

Si ça ne marche pas

Symptôme Cause probable Vérification Solution
Message “Clé API OpenAI manquante” Constante non définie / mauvaise position dans wp-config.php Rechercher OPENAI_API_KEY dans wp-config.php, au-dessus de “stop editing” Ajouter le define() au bon endroit, ou définir la variable d’environnement
Écran blanc / erreur fatale Point-virgule manquant, parenthèse oubliée, PHP trop ancien Consulter wp-content/debug.log Corriger la syntaxe, vérifier PHP 8.1+, désactiver le snippet fautif
Erreur HTTP 401/403 dans les logs Clé invalide, projet/permissions, clé révoquée Voir le body loggué (sans l’afficher au public) Régénérer la clé, vérifier le compte OpenAI et les droits
Erreur HTTP 429 Quota dépassé / rate limit provider Logs + dashboard OpenAI Ajouter cache plus long, réduire appels, vérifier facturation/quota
Erreur “Réponse JSON invalide” Proxy/pare-feu injecte du HTML, ou réponse tronquée Voir le body loggué Tester sans proxy, vérifier WAF, augmenter légèrement timeout
Le shortcode n’affiche rien Vous n’êtes pas connecté, ou pas la capacité edit_posts Tester avec un compte éditeur/admin Ajuster la condition (ou créer une page admin dédiée)

Erreurs réalistes que je vois souvent (et comment les éviter)

  • Code collé au mauvais endroit : un mu-plugin doit être dans wp-content/mu-plugins/, pas dans plugins/.
  • Hook inadapté : déclencher l’IA sur the_content (filtre d’affichage) = appel à chaque vue. Préférez save_post ou un cron.
  • Tester en production sans sauvegarde : une faute de syntaxe peut casser le site. Faites un backup et testez sur staging.
  • Conflit avec un plugin de snippets : certains plugins minifient/filtrent, ou chargent trop tard. Un mu-plugin est plus prévisible.
  • Confusion action vs filtre : add_action() pour exécuter, add_filter() pour transformer une valeur.

Ressources

FAQ

Est-ce que wp-config.php est “100% sécurisé” ?

Non. C’est plus sûr que l’admin ou la base, mais la sécurité dépend de votre serveur. Si quelqu’un a accès au système de fichiers, il peut lire wp-config.php. L’objectif est de réduire les fuites accidentelles (Git, export DB, JS).

Puis-je stocker la clé dans la base de données (options WordPress) ?

Je l’évite. Une option peut se retrouver dans un export SQL, être lisible par trop de rôles, ou être affichée par erreur. Pour un secret, préférez wp-config.php ou une variable d’environnement.

Pourquoi ne pas mettre la clé dans un plugin “Code Snippets” ?

Parce que beaucoup de sites finissent par exporter/importer ces snippets, ou donner l’accès admin à un prestataire. J’ai déjà vu des clés copiées dans un ticket support. Un mu-plugin + wp-config.php réduit ce risque.

Est-ce que je peux appeler OpenAI depuis JavaScript (front) ?

Non si vous devez utiliser votre clé secrète. Vous pouvez faire du JS qui appelle votre endpoint WordPress (REST), et cet endpoint appelle OpenAI côté serveur. La clé ne doit jamais quitter le serveur.

À quoi sert le cache transient si OpenAI répond vite ?

À réduire les coûts et à stabiliser votre site. Même une API rapide peut ralentir un rendu si elle est appelée 50 fois. Et certains builders déclenchent des rendus multiples lors de l’édition.

Combien de temps mettre en cache ?

Pour un résumé d’article publié, 24h à 30 jours est courant. Pour une aide “interactive” (prompt libre), 5 à 30 minutes suffit.

Que faire si OpenAI renvoie 429 (quota) ?

Augmentez le cache, réduisez la taille des prompts, utilisez un modèle moins coûteux, et vérifiez la facturation/quota côté OpenAI. Le 429 arrive aussi si plusieurs utilisateurs déclenchent des requêtes en même temps.

Pourquoi limiter le shortcode aux rôles éditeur/admin ?

Parce qu’un shortcode public peut être abusé. Vous pouvez élargir ensuite, mais commencez fermé. Si vous devez l’ouvrir, ajoutez nonce + rate limit par utilisateur + plafond global.

Comment éviter d’envoyer des données sensibles à l’API ?

Nettoyez le texte (supprimez emails/téléphones), n’envoyez que ce qui est nécessaire, et évitez d’envoyer des messages de formulaires bruts. Si vous traitez des données personnelles, documentez et informez.

Puis-je utiliser ce code avec Divi 5, Elementor ou Avada ?

Oui. Le point clé est que l’appel IA reste côté serveur. Utilisez le shortcode pour tester, puis passez à une génération à la publication (meta) pour un rendu stable dans vos templates.

Quel est le meilleur endroit pour mettre du code IA en production ?

Un plugin custom ou un mu-plugin. Évitez functions.php si vous prévoyez de changer de thème, et évitez les snippets “magiques” si vous n’avez pas un processus de revue et de versioning.