PluginProbe
King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder / 51.1.83
King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder v51.1.83
51.1.84 51.1.85 51.1.83 51.1.82 51.1.81 51.1.79 51.1.78 51.1.77 51.1.76 51.1.74 51.1.75 51.1.65 51.1.64 51.1.63 trunk 51.1.14 51.1.2 51.1.35 51.1.36 51.1.37 51.1.38 51.1.39 51.1.44 51.1.45 51.1.46 All 39 releases
← All changes | includes/Admin.php +617 -281 51.1.7651.1.83 View file →
@@ -24,8 +24,9 @@
24 24 add_action('admin_menu', [$this, 'addUpgradeMenu'], 9999999999); // Highest priority to add at the very end
25 25
26 26 // Reorder submenu items after ALL entries are registered.
27 27 add_action('admin_menu', [$this, 'reorderKingAddonsSubmenu'], 1000000000);
28 + add_action('admin_menu', [$this, 'reorderWooBuilderSubmenu'], 1000000000);
28 29
29 30 add_action('admin_init', [$this, 'createSettings']);
30 31 add_action('admin_init', [$this, 'createAiSettings']);
31 32
@@ -90,31 +91,8 @@
90 91
91 92 // Get options for extension toggle checks (before any extension checks)
92 93 $options = get_option('king_addons_options', []);
93 94
94 - // Check Wishlist extension toggle
95 - $wishlist_enabled = (!isset($options['ext_wishlist']) || $options['ext_wishlist'] === 'enabled')
96 - && (defined('KING_ADDONS_EXT_WISHLIST') ? KING_ADDONS_EXT_WISHLIST : true);
97 - if ($wishlist_enabled) {
98 - add_submenu_page(
99 - 'king-addons',
100 - esc_html__('Wishlist', 'king-addons'),
101 - esc_html__('Wishlist', 'king-addons'),
102 - 'manage_options',
103 - 'king-addons-wishlist',
104 - [$this, 'renderWishlistPage']
105 - );
106 -
107 - add_submenu_page(
108 - 'king-addons',
109 - esc_html__('Wishlist Analytics', 'king-addons'),
110 - esc_html__('Wishlist Analytics', 'king-addons'),
111 - 'manage_options',
112 - 'king-addons-wishlist-analytics',
113 - [$this, 'renderWishlistAnalyticsPage']
114 - );
115 - }
116 -
117 95 // Check Cookie / Consent Bar extension toggle
118 96 $cookie_consent_enabled = !isset($options['ext_cookie-consent']) || $options['ext_cookie-consent'] === 'enabled';
119 97 if ($cookie_consent_enabled && (defined('KING_ADDONS_EXT_COOKIE_CONSENT') ? KING_ADDONS_EXT_COOKIE_CONSENT : true) && class_exists('King_Addons\Cookie_Consent')) {
120 98 add_submenu_page(
@@ -216,19 +194,38 @@
216 194 if ($popup_builder_enabled && (defined('KING_ADDONS_EXT_POPUP_BUILDER') ? KING_ADDONS_EXT_POPUP_BUILDER : true) && class_exists('King_Addons\Popup_Builder')) {
217 195 self::showPopupBuilder();
218 196 }
219 197
220 - // Check WooCommerce Builder extension toggle
221 - $woo_builder_enabled = !isset($options['ext_woo-builder']) || $options['ext_woo-builder'] === 'enabled';
222 - if (
223 - $woo_builder_enabled
224 - && (defined('KING_ADDONS_EXT_WOO_BUILDER') ? KING_ADDONS_EXT_WOO_BUILDER : true)
225 - && class_exists('WooCommerce')
226 - && function_exists('WC')
227 - ) {
198 + // WooCommerce Builder is a top-level menu. Store screens hang off it
199 + // when it exists; otherwise they stay under King Addons. Page slugs
200 + // do not change.
201 + if (king_addons_woo_builder_menu_is_active()) {
228 202 $this->showWooBuilder();
229 203 }
230 204
205 + $wishlist_enabled = (!isset($options['ext_wishlist']) || $options['ext_wishlist'] === 'enabled')
206 + && (defined('KING_ADDONS_EXT_WISHLIST') ? KING_ADDONS_EXT_WISHLIST : true);
207 + if ($wishlist_enabled) {
208 + $woo_parent = king_addons_woo_admin_parent_slug();
209 + add_submenu_page(
210 + $woo_parent,
211 + esc_html__('Wishlist', 'king-addons'),
212 + esc_html__('Wishlist', 'king-addons'),
213 + 'manage_options',
214 + 'king-addons-wishlist',
215 + [$this, 'renderWishlistPage']
216 + );
217 +
218 + add_submenu_page(
219 + $woo_parent,
220 + esc_html__('Wishlist Analytics', 'king-addons'),
221 + esc_html__('Wishlist Analytics', 'king-addons'),
222 + 'manage_options',
223 + 'king-addons-wishlist-analytics',
224 + [$this, 'renderWishlistAnalyticsPage']
225 + );
226 + }
227 +
231 228 $menu['54.8'] = array( '', 'read', 'separator-king-addons-bottom', '', 'wp-menu-separator elementor' );
232 229
233 230 }
234 231
@@ -287,8 +284,61 @@
287 284
288 285 $submenu['king-addons'] = $items;
289 286 }
290 287
288 + /**
289 + * WooCommerce Builder submenu:
290 + * 1) Dashboard (same slug as the top-level page)
291 + * 2) Wishlist
292 + * 3) Wishlist Analytics
293 + * 4) Free Shipping Bar
294 + * 5) Sticky Add To Cart
295 + * 6) My Account Endpoints
296 + */
297 + public function reorderWooBuilderSubmenu(): void
298 + {
299 + global $submenu;
300 +
301 + if (!is_array($submenu) || empty($submenu['king-addons-woo-builder']) || !is_array($submenu['king-addons-woo-builder'])) {
302 + return;
303 + }
304 +
305 + $priorityBySlug = [
306 + 'king-addons-woo-builder' => 0,
307 + 'king-addons-wishlist' => 1,
308 + 'king-addons-wishlist-analytics' => 2,
309 + 'king-addons-free-shipping-bar' => 3,
310 + 'king-addons-sticky-add-to-cart' => 4,
311 + 'king-addons-myaccount-endpoints' => 5,
312 + ];
313 +
314 + $items = $submenu['king-addons-woo-builder'];
315 +
316 + usort($items, static function ($a, $b) use ($priorityBySlug): int {
317 + $aSlug = isset($a[2]) ? (string) $a[2] : '';
318 + $bSlug = isset($b[2]) ? (string) $b[2] : '';
319 +
320 + $aPriority = array_key_exists($aSlug, $priorityBySlug) ? $priorityBySlug[$aSlug] : 9999;
321 + $bPriority = array_key_exists($bSlug, $priorityBySlug) ? $priorityBySlug[$bSlug] : 9999;
322 +
323 + if ($aPriority !== $bPriority) {
324 + return $aPriority <=> $bPriority;
325 + }
326 +
327 + $aLabel = isset($a[0]) ? wp_strip_all_tags((string) $a[0]) : '';
328 + $bLabel = isset($b[0]) ? wp_strip_all_tags((string) $b[0]) : '';
329 +
330 + $cmp = strcasecmp($aLabel, $bLabel);
331 + if ($cmp !== 0) {
332 + return $cmp;
333 + }
334 +
335 + return strcasecmp($aSlug, $bSlug);
336 + });
337 +
338 + $submenu['king-addons-woo-builder'] = $items;
339 + }
340 +
291 341 function addUpgradeMenu(): void
292 342 {
293 343 // Don't add menu if Freemius is showing opt-in/activation
294 344 $fs = king_addons_freemius();
@@ -343,8 +393,19 @@
343 393 [$this, 'renderWooBuilderPage'],
344 394 'dashicons-cart',
345 395 54.5
346 396 );
397 +
398 + // Same slug as the parent so the first submenu item is "Dashboard"
399 + // instead of repeating "WooCommerce Builder".
400 + add_submenu_page(
401 + 'king-addons-woo-builder',
402 + esc_html__('WooCommerce Builder', 'king-addons'),
403 + esc_html__('Dashboard', 'king-addons'),
404 + 'manage_options',
405 + 'king-addons-woo-builder',
406 + [$this, 'renderWooBuilderPage']
407 + );
347 408 }
348 409
349 410 /**
350 411 * Render WooCommerce Builder admin page.
@@ -660,8 +721,9 @@
660 721 'button_added_text' => sanitize_text_field($settings['button_added_text'] ?? $defaults['button_added_text']),
661 722 'button_display_mode' => in_array($settings['button_display_mode'] ?? 'icon_text', ['icon', 'icon_text'], true) ? $settings['button_display_mode'] : $defaults['button_display_mode'],
662 723 'button_position' => in_array($settings['button_position'] ?? 'after_add_to_cart', ['before_add_to_cart', 'after_add_to_cart'], true) ? $settings['button_position'] : $defaults['button_position'],
663 724 'show_in_archives' => !empty($settings['show_in_archives']),
725 + 'show_on_single' => !empty($settings['show_on_single']),
664 726 'wishlist_columns' => array_values(
665 727 array_intersect(
666 728 (array) ($settings['wishlist_columns'] ?? []),
667 729 ['image', 'title', 'price', 'stock', 'notes', 'add_to_cart', 'remove']
@@ -741,18 +803,56 @@
741 803 );
742 804
743 805 add_settings_section(
744 806 'king_addons_ai_openai_section',
745 - esc_html__('OpenAI API Settings', 'king-addons'),
807 + esc_html__('AI Provider Settings', 'king-addons'),
746 808 [$this, 'renderAiOpenaiSection'],
747 809 'king-addons-ai-settings'
748 810 );
749 811
750 812 add_settings_field(
813 + 'ai_provider',
814 + esc_html__('AI Provider', 'king-addons'),
815 + [$this, 'renderAiProviderField'],
816 + 'king-addons-ai-settings',
817 + 'king_addons_ai_openai_section'
818 + );
819 +
820 + // Provider specific rows. The row classes let the settings page show only
821 + // the fields belonging to the provider that is currently selected; the
822 + // inactive ones start hidden so they never flash before the script runs.
823 + $active_provider = AI_Provider::getProvider();
824 + $row_class = static function (string $provider) use ($active_provider): array {
825 + $classes = 'ka-ai-provider-row ka-ai-provider-' . $provider;
826 + if ($provider !== $active_provider) {
827 + $classes .= ' ka-ai-row-hidden';
828 + }
829 + return ['class' => $classes];
830 + };
831 +
832 + add_settings_field(
751 833 'openai_api_key',
752 834 esc_html__('OpenAI API Key', 'king-addons'),
753 835 [$this, 'renderAiApiKeyField'],
754 836 'king-addons-ai-settings',
837 + 'king_addons_ai_openai_section',
838 + $row_class(AI_Provider::OPENAI)
839 + );
840 +
841 + add_settings_field(
842 + 'openrouter_api_key',
843 + esc_html__('OpenRouter API Key', 'king-addons'),
844 + [$this, 'renderAiOpenRouterApiKeyField'],
845 + 'king-addons-ai-settings',
846 + 'king_addons_ai_openai_section',
847 + $row_class(AI_Provider::OPENROUTER)
848 + );
849 +
850 + add_settings_field(
851 + 'ai_connection_test',
852 + esc_html__('Connection', 'king-addons'),
853 + [$this, 'renderAiConnectionTestField'],
854 + 'king-addons-ai-settings',
755 855 'king_addons_ai_openai_section'
756 856 );
757 857
758 858 add_settings_field(
@@ -759,9 +859,10 @@
759 859 'openai_model',
760 860 esc_html__('OpenAI Model (for text generation)', 'king-addons'),
761 861 [$this, 'renderAiModelField'],
762 862 'king-addons-ai-settings',
763 - 'king_addons_ai_openai_section'
863 + 'king_addons_ai_openai_section',
864 + $row_class(AI_Provider::OPENAI)
764 865 );
765 866
766 867 add_settings_field(
767 868 'openai_vision_model',
@@ -767,9 +868,10 @@
767 868 'openai_vision_model',
768 869 esc_html__('OpenAI Model (for image recognition)', 'king-addons'),
769 870 [$this, 'renderAiVisionModelField'],
770 871 'king-addons-ai-settings',
771 - 'king_addons_ai_openai_section'
872 + 'king_addons_ai_openai_section',
873 + $row_class(AI_Provider::OPENAI)
772 874 );
773 875
774 876 // Add image model selector field
775 877 add_settings_field(
@@ -776,11 +878,39 @@
776 878 'openai_image_model',
777 879 esc_html__('OpenAI Image Model (for image generation)', 'king-addons'),
778 880 [$this, 'renderAiImageModelField'],
779 881 'king-addons-ai-settings',
780 - 'king_addons_ai_openai_section'
882 + 'king_addons_ai_openai_section',
883 + $row_class(AI_Provider::OPENAI)
781 884 );
782 885
886 + add_settings_field(
887 + 'openrouter_model',
888 + esc_html__('OpenRouter Model (for text generation)', 'king-addons'),
889 + [$this, 'renderAiOpenRouterModelField'],
890 + 'king-addons-ai-settings',
891 + 'king_addons_ai_openai_section',
892 + $row_class(AI_Provider::OPENROUTER)
893 + );
894 +
895 + add_settings_field(
896 + 'openrouter_vision_model',
897 + esc_html__('OpenRouter Model (for image recognition)', 'king-addons'),
898 + [$this, 'renderAiOpenRouterVisionModelField'],
899 + 'king-addons-ai-settings',
900 + 'king_addons_ai_openai_section',
901 + $row_class(AI_Provider::OPENROUTER)
902 + );
903 +
904 + add_settings_field(
905 + 'openrouter_image_model',
906 + esc_html__('OpenRouter Image Model (for image generation)', 'king-addons'),
907 + [$this, 'renderAiOpenRouterImageModelField'],
908 + 'king-addons-ai-settings',
909 + 'king_addons_ai_openai_section',
910 + $row_class(AI_Provider::OPENROUTER)
911 + );
912 +
783 913 // Global content language
784 914 add_settings_field(
785 915 'content_language_custom_enable',
786 916 esc_html__('Content Language', 'king-addons'),
@@ -893,8 +1023,11 @@
893 1023
894 1024 // AJAX handler for refreshing models.
895 1025 add_action('wp_ajax_king_addons_ai_refresh_models', [$this, 'handleAiRefreshModels']);
896 1026
1027 + // AJAX handler for testing the provider connection.
1028 + add_action('wp_ajax_king_addons_ai_test_connection', [$this, 'handleAiTestConnection']);
1029 +
897 1030 // AJAX handler for generating text via AI
898 1031 add_action('wp_ajax_king_addons_ai_generate_text', [$this, 'handleAiGenerateText']);
899 1032
900 1033 // AJAX handler to change text using AI based on user prompt and original text.
@@ -921,8 +1054,12 @@
921 1054 */
922 1055 public function sanitizeAiSettings(array $input): array
923 1056 {
924 1057 $sanitized = [];
1058 +
1059 + // An absent value resolves to the default rather than being pinned to OpenAI.
1060 + $sanitized['ai_provider'] = AI_Provider::normalizeProvider($input['ai_provider'] ?? '');
1061 +
925 1062 $sanitized['openai_api_key'] = isset($input['openai_api_key'])
926 1063 ? sanitize_text_field($input['openai_api_key'])
927 1064 : '';
928 1065 $sanitized['openai_model'] = isset($input['openai_model'])
@@ -934,8 +1071,29 @@
934 1071 $sanitized['openai_image_model'] = isset($input['openai_image_model'])
935 1072 ? sanitize_text_field($input['openai_image_model'])
936 1073 : 'gpt-image-1';
937 1074
1075 + // OpenRouter is configured independently, so switching providers back
1076 + // and forth keeps both sets of credentials and models intact.
1077 + $sanitized['openrouter_api_key'] = isset($input['openrouter_api_key'])
1078 + ? sanitize_text_field($input['openrouter_api_key'])
1079 + : '';
1080 + $sanitized['openrouter_model'] = isset($input['openrouter_model'])
1081 + ? sanitize_text_field($input['openrouter_model'])
1082 + : '';
1083 + $sanitized['openrouter_vision_model'] = isset($input['openrouter_vision_model'])
1084 + ? sanitize_text_field($input['openrouter_vision_model'])
1085 + : ($sanitized['openrouter_model'] ?: '');
1086 + $sanitized['openrouter_image_model'] = isset($input['openrouter_image_model'])
1087 + ? sanitize_text_field($input['openrouter_image_model'])
1088 + : '';
1089 +
1090 + // Content language is rendered by this section, so it has to survive the save.
1091 + $sanitized['content_language_custom_enable'] = !empty($input['content_language_custom_enable']);
1092 + $sanitized['content_language_custom'] = isset($input['content_language_custom'])
1093 + ? sanitize_text_field($input['content_language_custom'])
1094 + : '';
1095 +
938 1096 // Sanitize Daily Token Limit.
939 1097 if (isset($input['daily_token_limit'])) {
940 1098 $daily_limit = absint($input['daily_token_limit']);
941 1099 $sanitized['daily_token_limit'] = max(0, $daily_limit); // Ensure non-negative
@@ -964,9 +1122,9 @@
964 1122 }
965 1123 // Sanitize Image Detail Level
966 1124 $allowed_detail_levels = ['low', 'high'];
967 1125 $sanitized['ai_alt_text_image_detail_level'] = in_array(($input['ai_alt_text_image_detail_level'] ?? 'low'), $allowed_detail_levels, true)
968 - ? $input['ai_alt_text_image_detail_level']
1126 + ? ($input['ai_alt_text_image_detail_level'] ?? 'low')
969 1127 : 'low';
970 1128
971 1129 // Sanitize Auto Tagging settings.
972 1130 $sanitized['auto_tagging_max_tags'] = isset($input['auto_tagging_max_tags'])
@@ -989,13 +1147,21 @@
989 1147 || !empty($sanitized['enable_ai_alt_text_button'])
990 1148 || !empty($sanitized['enable_ai_page_translator'])
991 1149 );
992 1150
993 - if ($ai_requires_key && empty($sanitized['openai_api_key'])) {
1151 + $active_key = ($sanitized['ai_provider'] === AI_Provider::OPENROUTER)
1152 + ? $sanitized['openrouter_api_key']
1153 + : $sanitized['openai_api_key'];
1154 +
1155 + if ($ai_requires_key && empty($active_key)) {
994 1156 add_settings_error(
995 1157 'king_addons_ai',
996 1158 'king_addons_ai_missing_api_key',
997 - esc_html__('OpenAI API Key is required to enable AI features.', 'king-addons'),
1159 + sprintf(
1160 + /* translators: %s: provider name */
1161 + esc_html__('%s API Key is required to enable AI features.', 'king-addons'),
1162 + AI_Provider::getLabel($sanitized['ai_provider'])
1163 + ),
998 1164 'error'
999 1165 );
1000 1166 }
1001 1167
@@ -1008,9 +1174,9 @@
1008 1174 * @return void
1009 1175 */
1010 1176 public function clearAiModelsCache(): void
1011 1177 {
1012 - delete_transient('king_addons_ai_models_cache');
1178 + AI_Provider::clearModelsCache();
1013 1179 }
1014 1180
1015 1181 /**
1016 1182 * Renders the AI Settings page content.
@@ -1059,23 +1225,162 @@
1059 1225 'nonce' => wp_create_nonce('king_addons_ai_refresh_models_nonce'),
1060 1226 'refreshing_text' => esc_html__('Refreshing...', 'king-addons'),
1061 1227 'refreshed_text' => esc_html__('List updated.', 'king-addons'),
1062 1228 'error_text' => esc_html__('Error updating list.', 'king-addons'),
1229 + 'testing_text' => esc_html__('Testing connection...', 'king-addons'),
1230 + 'test_failed_text' => esc_html__('Connection failed.', 'king-addons'),
1231 + 'free_models_label' => esc_html__('Free models', 'king-addons'),
1232 + 'paid_models_label' => esc_html__('Paid models', 'king-addons'),
1063 1233 ]
1064 1234 );
1065 1235 }
1066 1236
1067 1237 /**
1068 - * Renders description for OpenAI API Settings section.
1238 + * Renders description for the AI Provider Settings section.
1069 1239 *
1070 1240 * @return void
1071 1241 */
1072 1242 public function renderAiOpenaiSection(): void
1073 1243 {
1074 - echo '<p>' . esc_html__('Enter your OpenAI API key and select the model for AI features.', 'king-addons') . '</p>';
1244 + echo '<p>' . esc_html__('Choose an AI provider, enter its API key and select the models used by the AI features.', 'king-addons') . '</p>';
1075 1245 }
1076 1246
1077 1247 /**
1248 + * Renders the AI provider selector.
1249 + *
1250 + * @return void
1251 + */
1252 + public function renderAiProviderField(): void
1253 + {
1254 + $selected = AI_Provider::getProvider();
1255 +
1256 + echo '<select name="king_addons_ai_options[ai_provider]" id="king-addons-ai-provider">';
1257 + foreach (AI_Provider::getProviders() as $provider => $label) {
1258 + printf(
1259 + '<option value="%s" %s>%s</option>',
1260 + esc_attr($provider),
1261 + selected($selected, $provider, false),
1262 + esc_html($label)
1263 + );
1264 + }
1265 + echo '</select>';
1266 +
1267 + echo '<p class="description">' . esc_html__('OpenAI talks to the OpenAI API directly. OpenRouter is a gateway to models from many vendors, including a number of free ones, through a single API key.', 'king-addons') . '</p>';
1268 + }
1269 +
1270 + /**
1271 + * Renders the OpenRouter API Key input field.
1272 + *
1273 + * @return void
1274 + */
1275 + public function renderAiOpenRouterApiKeyField(): void
1276 + {
1277 + $options = get_option('king_addons_ai_options', []);
1278 + $api_key = $options['openrouter_api_key'] ?? '';
1279 + printf(
1280 + '<input type="password" name="king_addons_ai_options[openrouter_api_key]" id="king-addons-openrouter-api-key" value="%s" class="regular-text" autocomplete="off" />',
1281 + esc_attr($api_key)
1282 + );
1283 + echo '<p class="description">';
1284 + printf(
1285 + /* translators: %1$s: opening link tag, %2$s: closing link tag */
1286 + esc_html__('Get your API key from %1$sOpenRouter Keys%2$s. Saving the key will attempt to fetch the available models.', 'king-addons'),
1287 + '<a href="https://openrouter.ai/keys" target="_blank" rel="noopener noreferrer">',
1288 + '</a>'
1289 + );
1290 + echo '</p>';
1291 + echo '<div class="ka-ai-notice ka-ai-notice-info">';
1292 + echo '<strong>' . esc_html__('Info:', 'king-addons') . '</strong> ';
1293 + echo esc_html__('Free models can be used without adding credit. Paid models, including every image generation model, require credit on your OpenRouter account.', 'king-addons');
1294 + echo '</div>';
1295 + echo '<div class="ka-ai-notice ka-ai-notice-info">';
1296 + echo '<strong class="ka-ai-notice-title">' . esc_html__('Useful OpenRouter Links:', 'king-addons') . '</strong>';
1297 + echo '<ul class="ka-ai-links-list">';
1298 + $links = [
1299 + 'Models & Pricing' => 'https://openrouter.ai/models',
1300 + 'API Keys' => 'https://openrouter.ai/keys',
1301 + 'Activity Dashboard' => 'https://openrouter.ai/activity',
1302 + 'Credits' => 'https://openrouter.ai/settings/credits',
1303 + 'Limits' => 'https://openrouter.ai/docs/api-reference/limits',
1304 + ];
1305 + foreach ($links as $label => $url) {
1306 + printf(
1307 + '<li><a href="%s" target="_blank" rel="noopener noreferrer">%s</a></li>',
1308 + esc_url($url),
1309 + esc_html($label)
1310 + );
1311 + }
1312 + echo '</ul></div>';
1313 + }
1314 +
1315 + /**
1316 + * Renders the "Test Connection" control.
1317 + *
1318 + * @return void
1319 + */
1320 + public function renderAiConnectionTestField(): void
1321 + {
1322 + echo '<button type="button" id="king-addons-ai-test-connection-button" class="button button-secondary">' . esc_html__('Test Connection', 'king-addons') . '</button>';
1323 + echo '<button type="button" id="king-addons-ai-refresh-models-button" class="button button-secondary" style="margin-left:10px; vertical-align:middle;">' . esc_html__('Refresh List', 'king-addons') . '</button>';
1324 + echo '<span class="spinner" id="king-addons-ai-test-connection-spinner" style="float:none; vertical-align:middle;"></span>';
1325 + echo '<span class="spinner" id="king-addons-ai-refresh-models-spinner" style="float:none; vertical-align:middle;"></span>';
1326 + echo '<span id="king-addons-ai-test-connection-status" style="margin-left:5px; vertical-align:middle;"></span>';
1327 + echo '<span id="king-addons-ai-refresh-models-status" style="margin-left:5px; vertical-align:middle;"></span>';
1328 + echo '<p class="description">' . esc_html__('Test Connection checks the API key currently typed above against the selected provider — it does not have to be saved first. Refresh List re-fetches that provider\'s model catalogue.', 'king-addons') . '</p>';
1329 + }
1330 +
1331 + /**
1332 + * Renders the OpenRouter text model dropdown.
1333 + *
1334 + * @return void
1335 + */
1336 + public function renderAiOpenRouterModelField(): void
1337 + {
1338 + $selected = AI_Provider::getModel('text', AI_Provider::OPENROUTER);
1339 + AI_Provider::renderModelSelect(
1340 + 'king_addons_ai_options[openrouter_model]',
1341 + $selected,
1342 + AI_Provider::getModelsFor('text', AI_Provider::OPENROUTER),
1343 + ['class' => 'ka-ai-model-select', 'data-ka-model-type' => 'text']
1344 + );
1345 + echo '<p class="description">' . esc_html__('Free models are listed first, then paid ones, each block in alphabetical order. Use the Refresh List button to pull the latest catalogue.', 'king-addons') . '</p>';
1346 + }
1347 +
1348 + /**
1349 + * Renders the OpenRouter vision model dropdown.
1350 + *
1351 + * @return void
1352 + */
1353 + public function renderAiOpenRouterVisionModelField(): void
1354 + {
1355 + $selected = AI_Provider::getModel('vision', AI_Provider::OPENROUTER);
1356 + AI_Provider::renderModelSelect(
1357 + 'king_addons_ai_options[openrouter_vision_model]',
1358 + $selected,
1359 + AI_Provider::getModelsFor('vision', AI_Provider::OPENROUTER),
1360 + ['class' => 'ka-ai-model-select', 'data-ka-model-type' => 'vision']
1361 + );
1362 + echo '<p class="description">' . esc_html__('Only models that accept image input are listed. Used for the Alt Text Generator and other vision requests.', 'king-addons') . '</p>';
1363 + }
1364 +
1365 + /**
1366 + * Renders the OpenRouter image generation model dropdown.
1367 + *
1368 + * @return void
1369 + */
1370 + public function renderAiOpenRouterImageModelField(): void
1371 + {
1372 + $selected = AI_Provider::getModel('image', AI_Provider::OPENROUTER);
1373 + AI_Provider::renderModelSelect(
1374 + 'king_addons_ai_options[openrouter_image_model]',
1375 + $selected,
1376 + AI_Provider::getModelsFor('image', AI_Provider::OPENROUTER),
1377 + ['class' => 'ka-ai-model-select', 'data-ka-model-type' => 'image']
1378 + );
1379 + echo '<p class="description">' . esc_html__('Only models that return images are listed. Image generation is never free — your OpenRouter account needs credit.', 'king-addons') . '</p>';
1380 + }
1381 +
1382 + /**
1078 1383 * Renders the OpenAI API Key input field.
1079 1384 *
1080 1385 * @return void
1081 1386 */
@@ -1128,31 +1433,15 @@
1128 1433 * @return void
1129 1434 */
1130 1435 public function renderAiModelField(): void
1131 1436 {
1132 - $options = get_option('king_addons_ai_options', []);
1133 - $selected = $options['openai_model'] ?? '';
1134 - $models = $this->getAiAvailableModels();
1135 - printf(
1136 - '<select name="king_addons_ai_options[openai_model]" %s>',
1137 - empty($models) ? 'disabled' : ''
1437 + $selected = AI_Provider::getModel('text', AI_Provider::OPENAI);
1438 + AI_Provider::renderModelSelect(
1439 + 'king_addons_ai_options[openai_model]',
1440 + $selected,
1441 + AI_Provider::getModelsFor('text', AI_Provider::OPENAI),
1442 + ['class' => 'ka-ai-model-select', 'data-ka-model-type' => 'text']
1138 1443 );
1139 - if (!empty($models)) {
1140 - foreach ($models as $id => $label) {
1141 - printf(
1142 - '<option value="%s" %s>%s</option>',
1143 - esc_attr($id),
1144 - selected($selected, $id, false),
1145 - esc_html($label)
1146 - );
1147 - }
1148 - } else {
1149 - echo '<option value="">' . esc_html__('Could not fetch models. Check API key?', 'king-addons') . '</option>';
1150 - }
1151 - echo '</select>';
1152 - echo '<button type="button" id="king-addons-ai-refresh-models-button" class="button button-secondary" style="margin-left:10px; vertical-align:middle;">' . esc_html__('Refresh List', 'king-addons') . '</button>';
1153 - echo '<span class="spinner" id="king-addons-ai-refresh-models-spinner" style="float:none; vertical-align:middle;"></span>';
1154 - echo '<span id="king-addons-ai-refresh-models-status" style="margin-left:5px; vertical-align:middle;"></span>';
1155 1444 echo '<p class="description">' . esc_html__('Select an available OpenAI model capable of processing text. We recommend GPT-4o-mini or GPT-4.1-nano for best results. The list of models is cached indefinitely until manually refreshed.', 'king-addons') . '</p>';
1156 1445 }
1157 1446
1158 1447 /**
@@ -1163,122 +1452,111 @@
1163 1452 public function renderAiVisionModelField(): void
1164 1453 {
1165 1454 $options = get_option('king_addons_ai_options', []);
1166 1455 $selected = $options['openai_vision_model'] ?? ($options['openai_model'] ?? 'gpt-4o-mini');
1167 - $models = $this->getAiAvailableModels();
1168 -
1169 - printf(
1170 - '<select name="king_addons_ai_options[openai_vision_model]" %s>',
1171 - empty($models) ? 'disabled' : ''
1456 + AI_Provider::renderModelSelect(
1457 + 'king_addons_ai_options[openai_vision_model]',
1458 + (string) $selected,
1459 + AI_Provider::getModelsFor('vision', AI_Provider::OPENAI),
1460 + ['class' => 'ka-ai-model-select', 'data-ka-model-type' => 'vision']
1172 1461 );
1173 -
1174 - if (!empty($models)) {
1175 - foreach ($models as $id => $label) {
1176 - printf(
1177 - '<option value="%s" %s>%s</option>',
1178 - esc_attr($id),
1179 - selected($selected, $id, false),
1180 - esc_html($label)
1181 - );
1182 - }
1183 - } else {
1184 - echo '<option value="">' . esc_html__('Could not fetch models. Check API key?', 'king-addons') . '</option>';
1185 - }
1186 -
1187 - echo '</select>';
1188 1462 echo '<p class="description">' . esc_html__('Select the default model used for AI image analysis tasks (Alt Text Generator and related vision requests).', 'king-addons') . '</p>';
1189 1463 }
1190 1464
1191 1465 /**
1192 - * Fetches the list of OpenAI models via API.
1466 + * Builds the model lists the settings page needs, keyed by purpose.
1193 1467 *
1194 - * @param string|null $api_key API key to use.
1195 - * @return array|\WP_Error Model list or error.
1468 + * @param string $provider Provider slug.
1469 + * @return array<string, array<int, array<string, mixed>>>
1196 1470 */
1197 - private function fetchAiOpenaiModels(?string $api_key)
1471 + private function getAiModelPayload(string $provider): array
1198 1472 {
1199 - if (empty($api_key)) {
1200 - return new \WP_Error('missing_key', esc_html__('API key is required to fetch models.', 'king-addons'));
1201 - }
1202 - $endpoint = 'https://api.openai.com/v1/models';
1203 - $response = wp_remote_get($endpoint, [
1204 - 'headers' => ['Authorization' => 'Bearer ' . $api_key],
1205 - 'timeout' => 20,
1206 - ]);
1207 - if (is_wp_error($response)) {
1208 - return $response;
1209 - }
1210 - $code = wp_remote_retrieve_response_code($response);
1211 - $body = wp_remote_retrieve_body($response);
1212 - $data = json_decode($body, true);
1213 - if ($code !== 200 || empty($data['data']) || !is_array($data['data'])) {
1214 - $message = $data['error']['message'] ?? esc_html__('Invalid response from API.', 'king-addons');
1215 - return new \WP_Error('api_error', $message, ['status' => $code]);
1216 - }
1217 - $list = [];
1218 - foreach ($data['data'] as $model) {
1219 - if (isset($model['id'])) {
1220 - $list[$model['id']] = $model['id'];
1221 - }
1222 - }
1223 - ksort($list);
1224 - if (empty($list)) {
1225 - return new \WP_Error('no_models', esc_html__('No models found via API.', 'king-addons'));
1226 - }
1227 - return $list;
1473 + return [
1474 + 'text' => AI_Provider::getModelsFor('text', $provider),
1475 + 'vision' => AI_Provider::getModelsFor('vision', $provider),
1476 + 'image' => AI_Provider::getModelsFor('image', $provider),
1477 + ];
1228 1478 }
1229 1479
1230 1480 /**
1231 - * Retrieves available models, using cache if possible.
1481 + * Handles AJAX request to refresh the model list of the selected provider.
1232 1482 *
1233 - * @return array Model list.
1483 + * @return void
1234 1484 */
1235 - private function getAiAvailableModels(): array
1485 + public function handleAiRefreshModels(): void
1236 1486 {
1237 - $cached = get_transient('king_addons_ai_models_cache');
1238 - if (false !== $cached && is_array($cached)) {
1239 - return $cached;
1487 + check_ajax_referer('king_addons_ai_refresh_models_nonce', 'nonce');
1488 + if (!current_user_can('manage_options')) {
1489 + wp_send_json_error(['message' => esc_html__('Permission denied.', 'king-addons')], 403);
1240 1490 }
1241 - $options = get_option('king_addons_ai_options', []);
1242 - $api_key = $options['openai_api_key'] ?? null;
1243 - $fetched = $this->fetchAiOpenaiModels($api_key);
1244 - if (!is_wp_error($fetched)) {
1245 - set_transient('king_addons_ai_models_cache', $fetched, 0);
1246 - return $fetched;
1491 +
1492 + $provider = AI_Provider::normalizeProvider(
1493 + isset($_POST['provider']) ? sanitize_text_field(wp_unslash($_POST['provider'])) : AI_Provider::getProvider()
1494 + );
1495 +
1496 + // Allow refreshing against a key that has been typed but not saved yet.
1497 + $posted_key = isset($_POST['api_key']) ? trim(sanitize_text_field(wp_unslash($_POST['api_key']))) : '';
1498 + $api_key = ($posted_key !== '') ? $posted_key : AI_Provider::getApiKey($provider);
1499 +
1500 + // OpenRouter publishes its catalogue without authentication.
1501 + if ($api_key === '' && $provider !== AI_Provider::OPENROUTER) {
1502 + wp_send_json_error(['message' => esc_html__('API key is not set.', 'king-addons')], 400);
1247 1503 }
1248 - return ['gpt-4o-mini' => 'GPT-4o-mini', 'gpt-4.1-nano' => 'GPT-4.1-nano'];
1504 +
1505 + $models = AI_Provider::fetchModels($provider, $api_key);
1506 + if (is_wp_error($models)) {
1507 + wp_send_json_error(['message' => $models->get_error_message()], 500);
1508 + }
1509 +
1510 + set_transient(
1511 + AI_Provider::getCacheKey($provider),
1512 + $models,
1513 + ($provider === AI_Provider::OPENROUTER) ? DAY_IN_SECONDS : 0
1514 + );
1515 +
1516 + wp_send_json_success([
1517 + 'provider' => $provider,
1518 + 'models' => $this->getAiModelPayload($provider),
1519 + ]);
1249 1520 }
1250 1521
1251 1522 /**
1252 - * Handles AJAX request to refresh model list.
1523 + * Handles AJAX request to verify the provider connection.
1253 1524 *
1254 1525 * @return void
1255 1526 */
1256 - public function handleAiRefreshModels(): void
1527 + public function handleAiTestConnection(): void
1257 1528 {
1258 1529 check_ajax_referer('king_addons_ai_refresh_models_nonce', 'nonce');
1259 1530 if (!current_user_can('manage_options')) {
1260 1531 wp_send_json_error(['message' => esc_html__('Permission denied.', 'king-addons')], 403);
1261 1532 }
1262 - $options = get_option('king_addons_ai_options', []);
1263 - $api_key = $options['openai_api_key'] ?? null;
1264 - if (empty($api_key)) {
1265 - wp_send_json_error(['message' => esc_html__('API key is not set.', 'king-addons')], 400);
1533 +
1534 + $provider = AI_Provider::normalizeProvider(
1535 + isset($_POST['provider']) ? sanitize_text_field(wp_unslash($_POST['provider'])) : AI_Provider::getProvider()
1536 + );
1537 +
1538 + // Test what is in the field right now, so a key can be verified before
1539 + // it is saved. An empty field falls back to the stored key.
1540 + $posted_key = isset($_POST['api_key']) ? trim(sanitize_text_field(wp_unslash($_POST['api_key']))) : '';
1541 + $stored_key = AI_Provider::getApiKey($provider);
1542 + $api_key = ($posted_key !== '') ? $posted_key : $stored_key;
1543 + $unsaved = ($posted_key !== '' && $posted_key !== $stored_key);
1544 +
1545 + $result = AI_Provider::testConnection($provider, $api_key);
1546 + if (is_wp_error($result)) {
1547 + wp_send_json_error(['message' => $result->get_error_message()], 400);
1266 1548 }
1267 - $this->clearAiModelsCache();
1268 - $models = $this->fetchAiOpenaiModels($api_key);
1269 - if (is_wp_error($models)) {
1270 - wp_send_json_error(['message' => $models->get_error_message()], 500);
1549 +
1550 + if ($unsaved) {
1551 + $result .= ' ' . esc_html__('Save the settings to start using it.', 'king-addons');
1271 1552 }
1272 - if (empty($models)) {
1273 - wp_send_json_error(['message' => esc_html__('No models returned by API.', 'king-addons')], 500);
1274 - }
1275 - set_transient('king_addons_ai_models_cache', $models, 0);
1276 - wp_send_json_success(['models' => $models]);
1553 +
1554 + wp_send_json_success(['message' => $result, 'unsaved' => $unsaved]);
1277 1555 }
1278 1556
1279 1557 /**
1280 - * AJAX handler to generate text using OpenAI.
1558 + * AJAX handler to generate text using the configured AI provider.
1281 1559 *
1282 1560 * @return void
1283 1561 */
1284 1562 public function handleAiGenerateText(): void
@@ -1296,10 +1574,10 @@
1296 1574 // Get editor type if provided
1297 1575 $editor_type = sanitize_text_field($_POST['editor_type'] ?? 'text');
1298 1576
1299 1577 $options = get_option('king_addons_ai_options', []);
1300 - $api_key = $options['openai_api_key'] ?? '';
1301 - $model = $options['openai_model'] ?? '';
1578 + $api_key = AI_Provider::getApiKey();
1579 + $model = AI_Provider::getTextModel();
1302 1580
1303 1581 if (empty($api_key) || empty($model)) {
1304 1582 wp_send_json_error(['message' => esc_html__('API key or model not set.', 'king-addons')], 400);
1305 1583 }
@@ -1313,9 +1591,13 @@
1313 1591 $current_usage = $this->getAiDailyUsage();
1314 1592
1315 1593 if ($daily_limit > 0 && $current_usage >= $daily_limit) {
1316 1594 wp_send_json_error([
1317 - 'message' => esc_html__('Daily token limit reached. Please try again tomorrow or increase the limit in AI Settings.', 'king-addons')
1595 + 'message' => esc_html__('Daily token limit reached. Please try again tomorrow or increase the limit in AI Settings.', 'king-addons'),
1596 + // This is the plugin's own cap, not the provider's, so the
1597 + // client must not retry it or blame the AI provider.
1598 + 'code' => 'local_limit',
1599 + 'retryable' => false,
1318 1600 ], 429);
1319 1601 }
1320 1602
1321 1603 // System instruction based on editor type
@@ -1325,9 +1607,9 @@
1325 1607 if ($editor_type === 'wysiwyg') {
1326 1608 $system_instruction = 'You are a helpful content assistant for a rich text editor. Provide content with proper HTML formatting. Use <p> tags for paragraphs with appropriate spacing between them. If relevant, use other HTML formatting like <strong>, <em>, <ul>, <ol>, etc. for better readability and structure. IMPORTANT: Do NOT wrap your HTML in code fences (``` or ```html). Respond ONLY with the actual HTML content.';
1327 1609 }
1328 1610
1329 - // Prepare request to OpenAI Chat Completions
1611 + // Prepare request to the provider's Chat Completions endpoint
1330 1612 $messages = [
1331 1613 ['role' => 'system', 'content' => $system_instruction],
1332 1614 ['role' => 'user', 'content' => $prompt]
1333 1615 ];
@@ -1337,20 +1619,17 @@
1337 1619 $messages[1]['content'] .= "\n\nOutput should be properly formatted HTML with <p> tags for paragraphs, maintaining good spacing and readability. Do NOT use code fences (```html or ```) in your response - provide just the clean HTML.";
1338 1620 }
1339 1621
1340 1622 $response = wp_remote_post(
1341 - 'https://api.openai.com/v1/chat/completions',
1623 + AI_Provider::getChatEndpoint(),
1342 1624 [
1343 - 'headers' => [
1344 - 'Authorization' => 'Bearer ' . $api_key,
1345 - 'Content-Type' => 'application/json',
1346 - ],
1347 - 'body' => wp_json_encode([
1625 + 'headers' => AI_Provider::getHeaders(),
1626 + 'body' => wp_json_encode(AI_Provider::prepareChatPayload([
1348 1627 'model' => $model,
1349 1628 'messages' => $messages,
1350 1629 'max_tokens' => 500,
1351 1630 'temperature' => 0.7, // Slight creativity for better content
1352 - ]),
1631 + ])),
1353 1632 'timeout' => 30,
1354 1633 ]
1355 1634 );
1356 1635
@@ -1361,9 +1640,9 @@
1361 1640 $code = wp_remote_retrieve_response_code($response);
1362 1641 $data = json_decode(wp_remote_retrieve_body($response), true);
1363 1642
1364 1643 if ($code !== 200 || empty($data['choices'][0]['message']['content'])) {
1365 - $error_msg = $data['error']['message'] ?? esc_html__('AI API error.', 'king-addons');
1644 + $error_msg = AI_Provider::extractErrorMessage($data, esc_html__('AI API error.', 'king-addons'));
1366 1645 wp_send_json_error(['message' => $error_msg], 500);
1367 1646 }
1368 1647
1369 1648 $generated = trim($data['choices'][0]['message']['content']);
@@ -1406,10 +1685,10 @@
1406 1685 $original = isset($_POST['original']) ? wp_kses_post(wp_unslash($_POST['original'])) : '';
1407 1686 $instruction_context = isset($_POST['instruction_context']) ? sanitize_textarea_field(wp_unslash($_POST['instruction_context'])) : '';
1408 1687
1409 1688 $options = get_option('king_addons_ai_options', []);
1410 - $api_key = $options['openai_api_key'] ?? '';
1411 - $model = $options['openai_model'] ?? '';
1689 + $api_key = AI_Provider::getApiKey();
1690 + $model = AI_Provider::getTextModel();
1412 1691
1413 1692 if (empty($api_key) || empty($model) || empty($prompt) || empty($original)) {
1414 1693 wp_send_json_error(['message' => esc_html__('Missing data for AI change.', 'king-addons')], 400);
1415 1694 }
@@ -1419,9 +1698,13 @@
1419 1698 $current_usage = $this->getAiDailyUsage();
1420 1699
1421 1700 if ($daily_limit > 0 && $current_usage >= $daily_limit) {
1422 1701 wp_send_json_error([
1423 - 'message' => esc_html__('Daily token limit reached. Please try again tomorrow or increase the limit in AI Settings.', 'king-addons')
1702 + 'message' => esc_html__('Daily token limit reached. Please try again tomorrow or increase the limit in AI Settings.', 'king-addons'),
1703 + // This is the plugin's own cap, not the provider's, so the
1704 + // client must not retry it or blame the AI provider.
1705 + 'code' => 'local_limit',
1706 + 'retryable' => false,
1424 1707 ], 429);
1425 1708 }
1426 1709
1427 1710 // Default instruction if none provided
@@ -1672,15 +1955,12 @@
1672 1955 $body['max_tokens'] = 15000;
1673 1956 }
1674 1957
1675 1958 $response = wp_remote_post(
1676 - 'https://api.openai.com/v1/chat/completions',
1959 + AI_Provider::getChatEndpoint(),
1677 1960 [
1678 - 'headers' => [
1679 - 'Authorization' => 'Bearer ' . $api_key,
1680 - 'Content-Type' => 'application/json',
1681 - ],
1682 - 'body' => wp_json_encode($body),
1961 + 'headers' => AI_Provider::getHeaders(),
1962 + 'body' => wp_json_encode(AI_Provider::prepareChatPayload($body)),
1683 1963 'timeout' => 30,
1684 1964 ]
1685 1965 );
1686 1966
@@ -1691,9 +1971,9 @@
1691 1971 $code = wp_remote_retrieve_response_code($response);
1692 1972 $data = json_decode(wp_remote_retrieve_body($response), true);
1693 1973
1694 1974 if ($code !== 200 || empty($data['choices'][0]['message']['content'])) {
1695 - $error_msg = $data['error']['message'] ?? esc_html__('AI change error.', 'king-addons');
1975 + $error_msg = AI_Provider::extractErrorMessage($data, esc_html__('AI change error.', 'king-addons'));
1696 1976 wp_send_json_error(['message' => $error_msg], 500);
1697 1977 }
1698 1978
1699 1979 $changed = trim($data['choices'][0]['message']['content']);
@@ -1869,10 +2149,10 @@
1869 2149 $options = get_option('king_addons_ai_options', []);
1870 2150 $daily_limit = isset($options['daily_token_limit']) ? intval($options['daily_token_limit']) : self::DEFAULT_DAILY_TOKEN_LIMIT;
1871 2151 $daily_used = $this->getAiDailyUsage();
1872 2152 // Check if API key and model are set
1873 - $api_key = $options['openai_api_key'] ?? '';
1874 - $model = $options['openai_model'] ?? '';
2153 + $api_key = AI_Provider::getApiKey();
2154 + $model = AI_Provider::getTextModel();
1875 2155 $api_key_valid = !empty($api_key) && !empty($model);
1876 2156
1877 2157 wp_send_json_success([
1878 2158 'daily_used' => $daily_used,
@@ -1897,10 +2177,10 @@
1897 2177 $options = get_option('king_addons_ai_options', []);
1898 2178 $daily_limit = isset($options['daily_token_limit']) ? intval($options['daily_token_limit']) : self::DEFAULT_DAILY_TOKEN_LIMIT;
1899 2179 $daily_used = $this->getAiDailyUsage();
1900 2180 // Check if API key and model are set
1901 - $api_key = $options['openai_api_key'] ?? '';
1902 - $model = $options['openai_model'] ?? '';
2181 + $api_key = AI_Provider::getApiKey();
2182 + $model = AI_Provider::getTextModel();
1903 2183 $api_key_valid = !empty($api_key) && !empty($model);
1904 2184
1905 2185 wp_send_json_success([
1906 2186 'daily_used' => $daily_used,
@@ -2058,9 +2338,9 @@
2058 2338 echo '</p>';
2059 2339 }
2060 2340
2061 2341 /**
2062 - * AJAX handler to generate images using OpenAI.
2342 + * AJAX handler to generate images using the configured AI provider.
2063 2343 *
2064 2344 * @return void
2065 2345 */
2066 2346 public function set_openai_curl_options($handle, $r, $url): void
@@ -2068,10 +2348,10 @@
2068 2348 if (!is_string($url) || $url === '') {
2069 2349 return;
2070 2350 }
2071 2351
2072 - // Apply these cURL options only to OpenAI requests to avoid affecting other outbound HTTP calls.
2073 - if (strpos($url, 'openai.com') === false) {
2352 + // Apply these cURL options only to AI provider requests to avoid affecting other outbound HTTP calls.
2353 + if (!AI_Provider::isProviderUrl($url)) {
2074 2354 return;
2075 2355 }
2076 2356
2077 2357 // Increase connect timeout to 60s, and total timeout to 5m.
@@ -2092,129 +2372,158 @@
2092 2372 $prompt = isset($_POST['prompt']) ? sanitize_textarea_field(wp_unslash($_POST['prompt'])) : '';
2093 2373 $quality = isset($_POST['quality']) ? sanitize_text_field(wp_unslash($_POST['quality'])) : '';
2094 2374 $size = isset($_POST['size']) ? sanitize_text_field(wp_unslash($_POST['size'])) : '';
2095 2375 // Model from frontend selector
2096 - $model = isset($_POST['model']) ? sanitize_text_field(wp_unslash($_POST['model'])) : 'dall-e-3';
2376 + $model = isset($_POST['model']) ? sanitize_text_field(wp_unslash($_POST['model'])) : '';
2377 + if ($model === '') {
2378 + $model = AI_Provider::getImageModel();
2379 + }
2097 2380
2098 - $options = get_option('king_addons_ai_options', []);
2099 - $api_key = $options['openai_api_key'] ?? '';
2381 + $api_key = AI_Provider::getApiKey();
2100 2382 if (empty($api_key)) {
2101 - wp_send_json_error(['message' => esc_html__('OpenAI API key is not set.', 'king-addons')], 400);
2383 + wp_send_json_error([
2384 + 'message' => sprintf(
2385 + /* translators: %s: provider name */
2386 + esc_html__('%s API key is not set.', 'king-addons'),
2387 + AI_Provider::getLabel()
2388 + )
2389 + ], 400);
2102 2390 }
2103 2391 if (empty($prompt)) {
2104 2392 wp_send_json_error(['message' => esc_html__('Please provide an image prompt.', 'king-addons')], 400);
2105 2393 }
2106 2394
2107 - // Build request body based on selected model
2395 + // Build the request body. OpenAI's image models take vendor specific
2396 + // quality/size/background options; OpenRouter fans out to many vendors
2397 + // that do not share that vocabulary, so only portable fields are sent.
2108 2398 $body = [
2109 2399 'model' => $model,
2110 2400 'prompt' => $prompt,
2111 - 'size' => $size,
2112 2401 ];
2113 - if ($model === 'dall-e-3') {
2114 - // DALL·E 3 parameters
2402 +
2403 + if (AI_Provider::isOpenRouter()) {
2115 2404 $body['n'] = 1;
2116 - $body['quality'] = ($quality === 'hd') ? 'hd' : 'standard';
2117 - } elseif ($model === 'gpt-image-1') {
2118 - // GPT Image 1 parameters
2119 - // Only include background when transparent is requested
2120 - if (!empty($_POST['background']) && 'transparent' === sanitize_text_field(wp_unslash($_POST['background']))) {
2121 - $body['background'] = 'transparent';
2405 + } else {
2406 + $body['size'] = $size;
2407 +
2408 + if ($model === 'dall-e-3') {
2409 + // DALL·E 3 parameters
2410 + $body['n'] = 1;
2411 + $body['quality'] = ($quality === 'hd') ? 'hd' : 'standard';
2412 + } elseif ($model === 'gpt-image-1') {
2413 + // GPT Image 1 parameters
2414 + // Only include background when transparent is requested
2415 + if (!empty($_POST['background']) && 'transparent' === sanitize_text_field(wp_unslash($_POST['background']))) {
2416 + $body['background'] = 'transparent';
2417 + }
2418 + $body['quality'] = in_array($quality, ['low', 'medium', 'high', 'auto'], true)
2419 + ? $quality
2420 + : 'auto';
2122 2421 }
2123 - $body['quality'] = in_array($quality, ['low', 'medium', 'high', 'auto'], true)
2124 - ? $quality
2125 - : 'auto';
2126 2422 }
2127 2423
2128 2424 add_action('http_api_curl', [$this, 'set_openai_curl_options'], 10, 3);
2129 2425
2130 - // Call OpenAI Image Generations API
2426 + // Call the provider's image generation API.
2131 2427 $response = wp_remote_post(
2132 - 'https://api.openai.com/v1/images/generations',
2428 + AI_Provider::getImagesEndpoint(),
2133 2429 [
2134 - 'headers' => [
2135 - 'Authorization' => 'Bearer ' . $api_key,
2136 - 'Content-Type' => 'application/json',
2137 - ],
2430 + 'headers' => AI_Provider::getHeaders(),
2138 2431 'body' => wp_json_encode($body),
2139 2432 'timeout' => 300,
2140 2433 ]
2141 2434 );
2142 - if (is_wp_error($response)) {
2143 - wp_send_json_error(['message' => $response->get_error_message()], 500);
2435 +
2436 + $data = AI_Provider::decodeResponse($response, esc_html__('AI image generation error.', 'king-addons'));
2437 + if (is_wp_error($data)) {
2438 + wp_send_json_error(['message' => $data->get_error_message()], 500);
2144 2439 }
2145 2440
2146 - $image_url = '';
2441 + // Depending on the model, an image comes back either as a hosted URL or
2442 + // as inline base64, so both shapes are accepted from either provider.
2443 + $item = (isset($data['data'][0]) && is_array($data['data'][0])) ? $data['data'][0] : [];
2444 + $base64 = '';
2445 + $remote_url = '';
2446 + $mime = 'image/png';
2147 2447
2148 - if ($model === 'gpt-image-1') {
2149 - // Grab and decode the base64
2448 + if (!empty($item['b64_json']) && is_string($item['b64_json'])) {
2449 + $base64 = $item['b64_json'];
2450 + if (!empty($item['media_type']) && is_string($item['media_type'])) {
2451 + $mime = $item['media_type'];
2452 + }
2453 + } elseif (!empty($item['url']) && is_string($item['url'])) {
2454 + $remote_url = $item['url'];
2455 + } elseif (!empty($item['image_url']['url']) && is_string($item['image_url']['url'])) {
2456 + $remote_url = $item['image_url']['url'];
2457 + }
2150 2458
2151 - $data = json_decode(wp_remote_retrieve_body($response), true);
2459 + // Some providers hand back a data: URI in the url field.
2460 + if ($remote_url !== '' && strpos($remote_url, 'data:') === 0 && preg_match('#^data:([^;,]+);base64,(.+)$#s', $remote_url, $matches)) {
2461 + $mime = $matches[1];
2462 + $base64 = $matches[2];
2463 + $remote_url = '';
2464 + }
2152 2465
2153 - $image_base64 = $data['data'][0]['b64_json'];
2466 + if ($base64 === '' && $remote_url === '') {
2467 + wp_send_json_error([
2468 + 'message' => sprintf(
2469 + /* translators: %s: model id */
2470 + esc_html__('The model %s did not return an image. Pick an image-capable model in AI Settings.', 'king-addons'),
2471 + $model
2472 + )
2473 + ], 500);
2474 + }
2154 2475
2155 - $bytes = base64_decode($image_base64);
2476 + require_once ABSPATH . 'wp-admin/includes/image.php';
2477 + require_once ABSPATH . 'wp-admin/includes/file.php';
2478 + require_once ABSPATH . 'wp-admin/includes/media.php';
2479 +
2480 + $filename = substr(sanitize_file_name($prompt), 0, 100);
2481 + if ($filename === '') {
2482 + $filename = 'ai-image';
2483 + }
2484 +
2485 + if ($base64 !== '') {
2486 + $bytes = base64_decode($base64, true);
2156 2487 if (!$bytes) {
2157 - wp_send_json_error(['message' => 'Invalid image data from API.'], 500);
2488 + wp_send_json_error(['message' => esc_html__('Invalid image data from API.', 'king-addons')], 500);
2158 2489 }
2159 2490
2491 + $extension = 'png';
2492 + $type_map = ['image/jpeg' => 'jpg', 'image/jpg' => 'jpg', 'image/webp' => 'webp', 'image/gif' => 'gif'];
2493 + if (isset($type_map[$mime])) {
2494 + $extension = $type_map[$mime];
2495 + }
2496 +
2160 2497 // Create a temp file and write it
2161 - $tmp = wp_tempnam('gpt-image-1.png');
2498 + $tmp = wp_tempnam($filename . '.' . $extension);
2162 2499 if (!$tmp || !file_put_contents($tmp, $bytes)) {
2163 - wp_send_json_error(['message' => 'Failed to write temp image file.'], 500);
2500 + wp_send_json_error(['message' => esc_html__('Failed to write temp image file.', 'king-addons')], 500);
2164 2501 }
2165 2502
2166 - // Prepare for sideload
2167 - $file = [
2168 - 'name' => substr(sanitize_file_name($prompt), 0, 100) . '.png',
2169 - 'tmp_name' => $tmp,
2170 - ];
2503 + // Sideload into the Media Library
2504 + $attachment_id = media_handle_sideload(
2505 + ['name' => $filename . '.' . $extension, 'tmp_name' => $tmp],
2506 + 0,
2507 + $prompt
2508 + );
2171 2509
2172 - // Make sure these are loaded
2173 - require_once ABSPATH . 'wp-admin/includes/image.php';
2174 - require_once ABSPATH . 'wp-admin/includes/file.php';
2175 - require_once ABSPATH . 'wp-admin/includes/media.php';
2176 -
2177 - // Sideload into the Media Library
2178 - $attach_id = media_handle_sideload($file, 0, $prompt);
2179 - if (is_wp_error($attach_id)) {
2180 - wp_send_json_error(['message' => $attach_id->get_error_message()], 500);
2510 + if (is_wp_error($attachment_id)) {
2511 + @unlink($tmp);
2512 + wp_send_json_error(['message' => $attachment_id->get_error_message()], 500);
2181 2513 }
2182 -
2183 - $url = wp_get_attachment_url($attach_id);
2184 - wp_send_json_success(['attachment_id' => $attach_id, 'url' => $url]);
2185 2514 } else {
2186 -
2187 -
2188 - $code = wp_remote_retrieve_response_code($response);
2189 - $data = json_decode(wp_remote_retrieve_body($response), true);
2190 - if ($code !== 200 || empty($data['data'][0]['url'])) {
2191 - $error_msg = $data['error']['message'] ?? esc_html__('AI image generation error.', 'king-addons');
2192 - wp_send_json_error(['message' => $error_msg], 500);
2193 - }
2194 -
2195 - // Sideload image into media library
2196 - require_once ABSPATH . 'wp-admin/includes/image.php';
2197 - require_once ABSPATH . 'wp-admin/includes/file.php';
2198 - require_once ABSPATH . 'wp-admin/includes/media.php';
2199 -
2200 - if ($model === 'dall-e-3') {
2201 - $image_url = esc_url_raw($data['data'][0]['url']);
2202 - }
2203 -
2204 - $attachment_id = media_sideload_image($image_url, 0, $prompt, 'id');
2205 -
2515 + $attachment_id = media_sideload_image(esc_url_raw($remote_url), 0, $prompt, 'id');
2206 2516 if (is_wp_error($attachment_id)) {
2207 2517 wp_send_json_error(['message' => $attachment_id->get_error_message()], 500);
2208 2518 }
2209 - $attachment_url = wp_get_attachment_url($attachment_id);
2519 + }
2210 2520
2211 - // Respond with attachment details
2212 - wp_send_json_success([
2213 - 'attachment_id' => $attachment_id,
2214 - 'url' => $attachment_url,
2215 - ]);
2216 - }
2521 + // Respond with attachment details
2522 + wp_send_json_success([
2523 + 'attachment_id' => $attachment_id,
2524 + 'url' => wp_get_attachment_url($attachment_id),
2525 + ]);
2217 2526 }
2218 2527
2219 2528 /**
2220 2529 * Renders the global content language field.
@@ -2278,9 +2587,9 @@
2278 2587 echo '<p class="description">' . esc_html__('When enabled, adds an AI Page Translator button to the Elementor editor top toolbar that allows you to translate entire pages with one click. Automatically detects and translates all text content in widgets including advanced repeater fields.', 'king-addons') . '</p>';
2279 2588 }
2280 2589
2281 2590 /**
2282 - * AJAX handler to translate text using OpenAI.
2591 + * AJAX handler to translate text using the configured AI provider.
2283 2592 *
2284 2593 * @return void
2285 2594 */
2286 2595 public function handleAiTranslateText(): void
@@ -2294,10 +2603,10 @@
2294 2603 $from_lang = isset($_POST['from_lang']) ? sanitize_text_field(wp_unslash($_POST['from_lang'])) : 'auto';
2295 2604 $to_lang = isset($_POST['to_lang']) ? sanitize_text_field(wp_unslash($_POST['to_lang'])) : 'en';
2296 2605
2297 2606 $options = get_option('king_addons_ai_options', []);
2298 - $api_key = $options['openai_api_key'] ?? '';
2299 - $model = $options['openai_model'] ?? '';
2607 + $api_key = AI_Provider::getApiKey();
2608 + $model = AI_Provider::getTextModel();
2300 2609
2301 2610 if (empty($api_key) || empty($model)) {
2302 2611 wp_send_json_error(['message' => esc_html__('API key or model not set.', 'king-addons')], 400);
2303 2612 }
@@ -2311,9 +2620,13 @@
2311 2620 $current_usage = $this->getAiDailyUsage();
2312 2621
2313 2622 if ($daily_limit > 0 && $current_usage >= $daily_limit) {
2314 2623 wp_send_json_error([
2315 - 'message' => esc_html__('Daily token limit reached. Please try again tomorrow or increase the limit in AI Settings.', 'king-addons')
2624 + 'message' => esc_html__('Daily token limit reached. Please try again tomorrow or increase the limit in AI Settings.', 'king-addons'),
2625 + // This is the plugin's own cap, not the provider's, so the
2626 + // client must not retry it or blame the AI provider.
2627 + 'code' => 'local_limit',
2628 + 'retryable' => false,
2316 2629 ], 429);
2317 2630 }
2318 2631
2319 2632 // Prepare translation prompt
@@ -2356,39 +2669,36 @@
2356 2669 ['role' => 'system', 'content' => $system_message],
2357 2670 ['role' => 'user', 'content' => $user_message]
2358 2671 ];
2359 2672
2360 - $response = wp_remote_post(
2361 - 'https://api.openai.com/v1/chat/completions',
2362 - [
2363 - 'headers' => [
2364 - 'Authorization' => 'Bearer ' . $api_key,
2365 - 'Content-Type' => 'application/json',
2366 - ],
2367 - 'body' => wp_json_encode([
2368 - 'model' => $model,
2369 - 'messages' => $messages,
2370 - 'max_tokens' => 1000,
2371 - 'temperature' => 0.3, // Lower temperature for more consistent translations
2372 - ]),
2373 - 'timeout' => 30,
2374 - ]
2673 + $data = AI_Provider::decodeResponse(
2674 + wp_remote_post(
2675 + AI_Provider::getChatEndpoint(),
2676 + [
2677 + 'headers' => AI_Provider::getHeaders(),
2678 + 'body' => wp_json_encode(AI_Provider::prepareChatPayload([
2679 + 'model' => $model,
2680 + 'messages' => $messages,
2681 + 'max_tokens' => 1000,
2682 + 'temperature' => 0.3, // Lower temperature for more consistent translations
2683 + ])),
2684 + 'timeout' => 60, // Free and reasoning models are routinely slower than 30s.
2685 + ]
2686 + ),
2687 + esc_html__('AI translation error.', 'king-addons')
2375 2688 );
2376 2689
2377 - if (is_wp_error($response)) {
2378 - wp_send_json_error(['message' => $response->get_error_message()], 500);
2690 + // The translator runs hundreds of these back to back, so it needs to
2691 + // know whether a failure is worth retrying or is a dead end.
2692 + if (is_wp_error($data)) {
2693 + $this->sendAiProviderError($data);
2379 2694 }
2380 2695
2381 - $code = wp_remote_retrieve_response_code($response);
2382 - $data = json_decode(wp_remote_retrieve_body($response), true);
2383 -
2384 - if ($code !== 200 || empty($data['choices'][0]['message']['content'])) {
2385 - $error_msg = $data['error']['message'] ?? esc_html__('AI translation error.', 'king-addons');
2386 - wp_send_json_error(['message' => $error_msg], 500);
2696 + $translated_text = AI_Provider::extractMessageContent($data);
2697 + if (is_wp_error($translated_text)) {
2698 + $this->sendAiProviderError($translated_text);
2387 2699 }
2388 2700
2389 - $translated_text = trim($data['choices'][0]['message']['content']);
2390 -
2391 2701 // Update token usage statistics if present in the response
2392 2702 if (isset($data['usage']['total_tokens'])) {
2393 2703 $this->incrementAiDailyUsage(intval($data['usage']['total_tokens']));
2394 2704 }
@@ -2400,8 +2710,34 @@
2400 2710 'daily_used' => $this->getAiDailyUsage(),
2401 2711 'daily_limit' => $daily_limit,
2402 2712 ]
2403 2713 ]);
2714 + }
2715 +
2716 + /**
2717 + * Ends an AJAX request with a classified provider error.
2718 + *
2719 + * Sends the closest meaningful HTTP status instead of a blanket 500, plus a
2720 + * machine readable code and a retryable flag so the caller can back off and
2721 + * retry transient failures rather than aborting a long run.
2722 + *
2723 + * @param \WP_Error $error Error from the provider layer.
2724 + * @return void
2725 + */
2726 + private function sendAiProviderError(\WP_Error $error): void
2727 + {
2728 + $classified = AI_Provider::classifyError($error);
2729 +
2730 + wp_send_json_error(
2731 + [
2732 + 'message' => $classified['message'],
2733 + 'code' => $classified['code'],
2734 + 'retryable' => $classified['retryable'],
2735 + 'provider' => AI_Provider::getProvider(),
2736 + 'provider_label' => AI_Provider::getLabel(),
2737 + ],
2738 + AI_Provider::getResponseStatus($classified)
2739 + );
2404 2740 }
2405 2741
2406 2742 /**
2407 2743 * Get language name by code