PluginProbe
AI / trunk
AI vtrunk
1.3.0 1.2.0 1.1.0 1.0.2 1.0.1 1.0.0 0.9.0 trunk 0.1.1 0.2.0 0.2.1 0.3.0 0.3.1 0.4.0 0.4.1 0.5.0 0.6.0 0.7.0 0.8.0
ai / includes / helpers.php

helpers.php in AI trunk, at includes/helpers.php

869 lines 24.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Helper functions for the AI plugin.
4 *
5 * @package WordPress\AI
6 */
7
8 declare( strict_types=1 );
9
10 namespace WordPress\AI;
11
12 use Throwable;
13 use WordPress\AI\Abilities\Utilities\Posts;
14 use WordPress\AI\Experiments\Summarization\Summarization;
15 use WordPress\AI\Logging\AI_Request_Log_Manager;
16 use WordPress\AI\Logging\Logging_Integration;
17 use WordPress\AI\Services\AI_Service;
18 use WordPress\AI\Services\Guidelines;
19 use WordPress\AiClient\AiClient;
20 use WordPress\AiClient\Builders\EmbeddingBuilder;
21 use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum;
22
23 /**
24 * Purposely using return instead of exit here.
25 *
26 * This file is loaded via the composer files directive.
27 * When tools like PHPCS and PHPStan run, they include
28 * our composer autoloader and that will then load this file,
29 * causing the script to exit and not function properly.
30 */
31 if ( ! defined( 'ABSPATH' ) ) {
32 return;
33 }
34
35 /**
36 * Normalizes the content by cleaning it and removing unwanted HTML tags.
37 *
38 * @since 0.1.0
39 *
40 * @param string $content The content to normalize.
41 * @return string The normalized content.
42 */
43 function normalize_content( string $content ): string {
44 /**
45 * Hook to filter content before cleaning it.
46 *
47 * @since 0.1.0
48 *
49 * @param string $post_content The post content.
50 *
51 * @return string The filtered Post content.
52 */
53 $content = (string) apply_filters( 'wpai_pre_normalize_content', $content );
54
55 // Strip HTML entities.
56 $content = preg_replace( '/&#?[a-z0-9]{2,8};/i', '', $content ) ?? $content;
57
58 // Replace HTML linebreaks with newlines.
59 $content = preg_replace( '#<br\s?/?>#', "\n\n", $content ) ?? $content;
60
61 // Remove linebreaks but replace with spaces to avoid sentences running together.
62 $content = str_replace( array( "\r", "\n" ), ' ', (string) $content );
63
64 // Strip all HTML tags.
65 $content = wp_strip_all_tags( (string) $content );
66
67 // Remove unrendered shortcode tags.
68 $content = preg_replace( '#\[.+\](.+)\[/.+\]#', '$1', $content ) ?? $content;
69
70 /**
71 * Filters the normalized content to allow for additional cleanup.
72 *
73 * @since 0.1.0
74 *
75 * @param string $content The normalized content.
76 *
77 * @return string The filtered normalized content.
78 */
79 $content = (string) apply_filters( 'wpai_normalize_content', (string) $content );
80
81 return trim( $content );
82 }
83
84 /**
85 * Counts characters excluding whitespace, with Unicode support.
86 *
87 * This approximately mirrors @wordpress/wordcount's
88 * `characters_excluding_spaces` strategy used in the editor.
89 *
90 * @since 1.1.0
91 *
92 * @param string $text The text to count characters in.
93 * @return int The number of non-whitespace characters.
94 */
95 function count_characters_excluding_spaces( string $text ): int {
96 if ( empty( $text ) ) {
97 return 0;
98 }
99
100 // Strip all HTML tags including comments.
101 $text = wp_strip_all_tags( $text );
102
103 // Normalize NBSP entities to whitespace.
104 $text = preg_replace( '/&nbsp;|&#160;/i', ' ', $text ) ?? $text;
105
106 // Transpose HTML entities to countable characters.
107 $text = preg_replace( '/&\S+?;/u', 'a', $text ) ?? $text;
108
109 /*
110 * Count non-whitespace code points using a class that mirrors JavaScript's
111 * \s semantics, so full-width CJK spaces and similar separators match the
112 * editor's @wordpress/wordcount result.
113 */
114 $whitespace = '\x{0009}\x{000A}\x{000B}\x{000C}\x{000D}\x{0020}\x{00A0}\x{1680}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}\x{FEFF}';
115 $count = preg_match_all( sprintf( '/[^%s]/u', $whitespace ), $text );
116
117 return is_int( $count ) ? $count : 0;
118 }
119
120 /**
121 * Returns the context for the given post ID.
122 *
123 * Reads the post details directly rather than through the get-post-details
124 * ability, so it works even when that ability is gated off. Because it does not
125 * go through WP_Ability::execute(), the ability's permission callback is NOT
126 * run. Callers are responsible for performing their own capability/permission
127 * checks before exposing this data.
128 *
129 * @since 0.1.0
130 *
131 * @param int $post_id The ID of the post to get the context for.
132 * @return array<string, string> The context for the given post ID.
133 */
134 function get_post_context( int $post_id ): array {
135 $context = array();
136
137 // Get the post details directly (not via the ability) so the context is
138 // available even when the get-post-details ability is gated off.
139 $details = Posts::get_post_details( $post_id );
140
141 if ( is_array( $details ) ) {
142 $context = array_merge( $context, $details );
143
144 if ( isset( $context['content'] ) ) {
145 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
146 $context['content'] = normalize_content( (string) apply_filters( 'the_content', $context['content'] ) );
147 }
148
149 if ( isset( $context['type'] ) ) {
150 $context['content_type'] = $context['type'];
151 unset( $context['type'] );
152 }
153
154 // Remove any empty context values.
155 $context = array_filter( $context );
156 }
157
158 // Get the post terms directly (not via the ability) so the context is
159 // available even when the get-post-terms ability is gated off.
160 $terms = Posts::get_post_terms( $post_id );
161
162 if ( $terms && ! is_wp_error( $terms ) ) {
163 $grouped_terms = array();
164
165 foreach ( $terms as $term ) {
166 $taxonomy = $term['taxonomy'] ?? '';
167 $name = $term['name'] ?? '';
168
169 if ( '' === $taxonomy || '' === $name ) {
170 continue;
171 }
172
173 $grouped_terms[ $taxonomy ][] = $name;
174 }
175
176 $context = array_merge(
177 $context,
178 array_map(
179 static fn( array $term_names ): string => implode( ', ', $term_names ),
180 $grouped_terms
181 )
182 );
183 }
184
185 return $context;
186 }
187
188 /**
189 * Returns the preferred models for text generation.
190 *
191 * @since 0.2.1
192 *
193 * @return array<int, array{string, string}> The preferred models for text generation.
194 */
195 function get_preferred_models_for_text_generation(): array {
196 $preferred_models = array(
197 array(
198 'anthropic',
199 'claude-sonnet-5',
200 ),
201 array(
202 'google',
203 'gemini-3.6-flash',
204 ),
205 array(
206 'google',
207 'gemini-3.5-flash-lite',
208 ),
209 array(
210 'openai',
211 'gpt-5.6-luna',
212 ),
213 array(
214 'openai',
215 'gpt-5.4-mini',
216 ),
217 );
218
219 /**
220 * Filters the preferred models for text generation.
221 *
222 * @since 0.2.1
223 *
224 * @param array<int, array{string, string}> $preferred_models The preferred models for text generation.
225 * @return array<int, array{string, string}> The filtered preferred models.
226 */
227 return (array) apply_filters( 'wpai_preferred_text_models', $preferred_models );
228 }
229
230 /**
231 * Gets the AI Service instance.
232 *
233 * Call `wp_ai_client_prompt()` directly instead. The prompt builder it returns
234 * exposes the full SDK API, so the only behavior this helper added was applying
235 * `get_preferred_models_for_text_generation()` by default:
236 *
237 * ```php
238 * $builder = wp_ai_client_prompt( 'Summarize this article...' );
239 *
240 * $models = WordPress\AI\get_preferred_models_for_text_generation();
241 * if ( ! empty( $models ) ) {
242 * $builder = $builder->using_model_preference( ...$models );
243 * }
244 * ```
245 *
246 * @since 0.2.1
247 * @deprecated 1.3.0 Use wp_ai_client_prompt() instead.
248 *
249 * @return \WordPress\AI\Services\AI_Service The AI Service instance.
250 */
251 function get_ai_service(): AI_Service {
252 _deprecated_function( __FUNCTION__, '1.3.0', 'wp_ai_client_prompt()' );
253
254 return AI_Service::get_instance();
255 }
256
257 /**
258 * Returns the preferred image models.
259 *
260 * @since 0.2.0
261 *
262 * @return array<int, array{string, string}> The preferred image models.
263 */
264 function get_preferred_image_models(): array {
265 $preferred_models = array(
266 array(
267 'google',
268 'gemini-3.1-flash-image-preview',
269 ),
270 array(
271 'google',
272 'gemini-3-pro-image-preview',
273 ),
274 array(
275 'google',
276 'gemini-2.5-flash-image',
277 ),
278 array(
279 'google',
280 'imagen-4.0-generate-001',
281 ),
282 array(
283 'openai',
284 'gpt-image-2',
285 ),
286 array(
287 'openai',
288 'gpt-image-1.5',
289 ),
290 );
291
292 /**
293 * Filters the preferred image models.
294 *
295 * @since 0.2.0
296 *
297 * @param array<int, array{string, string}> $preferred_models The preferred image models.
298 * @return array<int, array{string, string}> The filtered preferred image models.
299 */
300 return (array) apply_filters( 'wpai_preferred_image_models', $preferred_models );
301 }
302
303 /**
304 * Returns the preferred vision models.
305 *
306 * @since 0.3.0
307 *
308 * @return array<int, array{string, string}> The preferred vision models.
309 */
310 function get_preferred_vision_models(): array {
311 $preferred_models = array(
312 array(
313 'anthropic',
314 'claude-sonnet-5',
315 ),
316 array(
317 'google',
318 'gemini-3.6-flash',
319 ),
320 array(
321 'google',
322 'gemini-3.5-flash-lite',
323 ),
324 array(
325 'openai',
326 'gpt-5.6-luna',
327 ),
328 array(
329 'openai',
330 'gpt-5.4-mini',
331 ),
332 );
333
334 /**
335 * Filters the preferred vision models.
336 *
337 * @since 0.3.0
338 *
339 * @param array<int, array{string, string}> $preferred_models The preferred vision models.
340 * @return array<int, array{string, string}> The filtered preferred vision models.
341 */
342 return (array) apply_filters( 'wpai_preferred_vision_models', $preferred_models );
343 }
344
345 /**
346 * Returns the developer-mode provider/model config saved for a feature.
347 *
348 * @since 0.9.0
349 *
350 * @param string $feature_id The feature ID (e.g. 'excerpt-generation').
351 * @return array{provider: string, model: string} The saved provider and model, or empty strings if unset.
352 */
353 function get_feature_developer_model_config( string $feature_id ): array {
354 $option = get_option( "wpai_feature_{$feature_id}_field_developer", array() );
355 return array(
356 'provider' => is_array( $option ) ? ( $option['provider'] ?? '' ) : '',
357 'model' => is_array( $option ) ? ( $option['model'] ?? '' ) : '',
358 );
359 }
360
361 /**
362 * Retrieves guidelines, optionally filtered by category.
363 *
364 * @since 0.8.0
365 *
366 * @param string|null $category Optional. Guideline category to retrieve.
367 * @return array<string, string>|null Keyed array of guidelines, or null when unavailable.
368 */
369 function get_guidelines( ?string $category = null ): ?array {
370 return Guidelines::get_instance()->get_guidelines( $category );
371 }
372
373 /**
374 * Formats guidelines as an XML-tagged string for prompt injection.
375 *
376 * @since 0.8.0
377 *
378 * @param list<string> $categories Guideline category slugs to include.
379 * @param string|null $block_name Optional block name for block-specific guidelines.
380 * @return string Formatted guidelines XML string, or empty string.
381 */
382 function format_guidelines_for_prompt( array $categories, ?string $block_name = null ): string {
383 return Guidelines::get_instance()->format_for_prompt( $categories, $block_name );
384 }
385
386 /**
387 * Determines if a connector is configured.
388 *
389 * @since 1.0.1
390 *
391 * @param string $connector_id The connector ID.
392 * @return bool True if the connector is configured, false otherwise.
393 */
394 function is_connector_configured( string $connector_id ): bool {
395 $registry = AiClient::defaultRegistry();
396 return $registry->hasProvider( $connector_id ) && $registry->isProviderConfigured( $connector_id );
397 }
398
399 /**
400 * Determines if a connector has authentication in place.
401 *
402 * This checks for API-key credentials by source only (environment variable,
403 * PHP constant, or stored option) and does not make external API requests.
404 *
405 * @since 1.0.1
406 *
407 * @param string $connector_id The connector ID.
408 * @return bool True if connector authentication is present, false otherwise.
409 */
410 function has_connector_authentication( string $connector_id ): bool {
411 if ( ! wp_is_connector_registered( $connector_id ) ) {
412 return false;
413 }
414
415 $connector = wp_get_connector( $connector_id );
416 if ( ! is_array( $connector ) ) {
417 return false;
418 }
419
420 $auth = $connector['authentication'] ?? null;
421 if ( ! is_array( $auth ) || ( $auth['method'] ?? '' ) !== 'api_key' ) {
422 return false;
423 }
424
425 $setting_name = $auth['setting_name'] ?? '';
426 if ( ! is_string( $setting_name ) || '' === $setting_name ) {
427 return false;
428 }
429
430 return 'none' !== get_connector_api_key_source(
431 $setting_name,
432 $auth['env_var_name'] ?? '',
433 $auth['constant_name'] ?? ''
434 );
435 }
436
437 /**
438 * Determines the source of a connector API key.
439 *
440 * Checks in order: environment variable, PHP constant, database option.
441 *
442 * @since 1.0.1
443 *
444 * @param string $setting_name The option name for the API key.
445 * @param string $env_var_name Optional environment variable name.
446 * @param string $constant_name Optional PHP constant name.
447 * @return string The key source: 'env', 'constant', 'database', or 'none'.
448 */
449 function get_connector_api_key_source( string $setting_name, string $env_var_name = '', string $constant_name = '' ): string {
450 if ( '' !== $env_var_name ) {
451 $env_value = getenv( $env_var_name );
452 if ( false !== $env_value && '' !== $env_value ) {
453 return 'env';
454 }
455 }
456
457 if ( '' !== $constant_name && defined( $constant_name ) ) {
458 $const_value = constant( $constant_name );
459 if ( is_string( $const_value ) && '' !== $const_value ) {
460 return 'constant';
461 }
462 }
463
464 $db_value = get_option( $setting_name, '' );
465 if ( '' !== $db_value ) {
466 return 'database';
467 }
468
469 return 'none';
470 }
471
472 /**
473 * Checks if we have AI credentials set.
474 *
475 * @since 0.1.0
476 *
477 * @return bool True if we have AI credentials, false otherwise.
478 */
479 function has_ai_credentials(): bool {
480 $connectors = get_ai_connectors();
481 $has_credentials = false;
482
483 foreach ( $connectors as $connector_id => $connector_data ) {
484 $auth = $connector_data['authentication'];
485 if ( 'api_key' !== $auth['method'] ) {
486 continue;
487 }
488
489 if ( ! has_connector_authentication( $connector_id ) ) {
490 continue;
491 }
492
493 $has_credentials = true;
494 break;
495 }
496
497 /**
498 * Filters whether AI credentials are available.
499 *
500 * Allows third-party plugins to declare credential availability for
501 * connectors that do not rely on API key settings.
502 *
503 * @since 0.7.0
504 *
505 * @param bool $has_credentials Whether AI credentials are available.
506 * @param array $connectors The registered connectors.
507 */
508 return (bool) apply_filters( 'wpai_has_ai_credentials', $has_credentials, $connectors );
509 }
510
511 /**
512 * Checks whether any configured connector exposes an image-generation-capable model.
513 *
514 * @since 1.0.2
515 *
516 * @param bool $reset_cache Whether to bypass the static cache and recompute. Default false.
517 * @return bool True if at least one connector supports image generation.
518 */
519 function has_image_generation_support( bool $reset_cache = false ): bool {
520 static $result = null;
521
522 if ( ! $reset_cache && null !== $result ) {
523 return $result;
524 }
525
526 $connectors = array();
527 $has_support = false;
528
529 if ( class_exists( AiClient::class ) ) {
530 $registry = AiClient::defaultRegistry();
531 $connectors = get_ai_connectors();
532
533 foreach ( array_keys( $connectors ) as $connector_id ) {
534 if ( ! has_connector_authentication( $connector_id ) ) {
535 continue;
536 }
537
538 try {
539 $provider_class = $registry->getProviderClassName( $connector_id );
540
541 /** @var \WordPress\AiClient\Providers\Contracts\ProviderInterface $provider_class */
542 $models = $provider_class::modelMetadataDirectory()->listModelMetadata();
543
544 foreach ( $models as $model ) {
545 foreach ( $model->getSupportedCapabilities() as $capability ) {
546 if ( CapabilityEnum::IMAGE_GENERATION === $capability->value ) {
547 $has_support = true;
548 break 3;
549 }
550 }
551 }
552 } catch ( Throwable $e ) {
553 continue;
554 }
555 }
556 }
557
558 /**
559 * Filters whether image generation is supported.
560 *
561 * Allows third-party plugins to declare image generation support for
562 * connectors that do not rely on API key settings (e.g. OAuth), without
563 * triggering a live API request.
564 *
565 * @since 1.1.0
566 *
567 * @param bool $has_support Whether image generation is supported.
568 * @param array $connectors The registered connectors.
569 */
570 $result = (bool) apply_filters( 'wpai_has_image_generation_support', $has_support, $connectors );
571
572 return $result;
573 }
574
575 /**
576 * Returns provider availability data for script localization.
577 *
578 * @since 1.0.0
579 *
580 * @return array{hasProvider: bool, connectorsUrl: string} Provider availability data.
581 */
582 function get_provider_availability_data(): array {
583 return array(
584 'hasProvider' => has_ai_credentials(),
585 'connectorsUrl' => admin_url( 'options-connectors.php' ),
586 );
587 }
588
589 /**
590 * Checks if we have valid AI credentials.
591 *
592 * @since 0.1.0
593 *
594 * @return bool True if we have valid AI credentials, false otherwise.
595 */
596 function has_valid_ai_credentials(): bool {
597 // If we have no AI credentials, return false.
598 if ( ! has_ai_credentials() ) {
599 return false;
600 }
601
602 /**
603 * Filters whether valid AI credentials are available.
604 *
605 * Allows overriding the credentials check, useful for testing.
606 *
607 * @since 0.1.0
608 *
609 * @param bool|null $has_valid_credentials Whether valid credentials are available. Return null to use default check.
610 * @return bool|null True if valid credentials are available, false otherwise, or null to use default check.
611 */
612 $valid = apply_filters( 'wpai_pre_has_valid_credentials_check', null );
613 if ( null !== $valid ) {
614 return (bool) $valid;
615 }
616
617 // See if we have credentials that give us access to generate text.
618 try {
619 return wp_ai_client_prompt( 'Test' )->is_supported_for_text_generation();
620 } catch ( Throwable $t ) {
621 return false;
622 }
623 }
624
625 /**
626 * Returns the AI connectors.
627 *
628 * @since 0.9.0
629 *
630 * @param bool $active_only Whether to only return active connectors.
631 * @return array<string, array<string, mixed>> The AI connectors.
632 */
633 function get_ai_connectors( bool $active_only = true ): array {
634 $connectors = array();
635
636 foreach ( (array) wp_get_connectors() as $connector_id => $data ) {
637 if ( ! is_string( $connector_id ) || ! is_array( $data ) ) {
638 continue;
639 }
640
641 if ( ( $data['type'] ?? '' ) !== 'ai_provider' ) {
642 continue;
643 }
644
645 if ( $active_only && ! is_connector_plugin_active( $data ) ) {
646 continue;
647 }
648
649 $connectors[ $connector_id ] = $data;
650 }
651
652 return $connectors;
653 }
654
655 /**
656 * Checks whether the connector's related plugin is currently active.
657 *
658 * If plugin metadata is not provided for a connector, it is treated as active.
659 *
660 * @since 0.9.0
661 *
662 * @param array<string, mixed> $connector_data Connector metadata.
663 * @return bool True if the connector plugin is active or unknown, false if known inactive.
664 */
665 function is_connector_plugin_active( array $connector_data ): bool {
666 if ( empty( $connector_data['plugin'] ) || ! is_array( $connector_data['plugin'] ) ) {
667 return true;
668 }
669
670 $plugin_file = '';
671
672 if ( ! empty( $connector_data['plugin']['file'] ) && is_string( $connector_data['plugin']['file'] ) ) {
673 $plugin_file = $connector_data['plugin']['file'];
674 } elseif ( ! empty( $connector_data['plugin']['plugin_file'] ) && is_string( $connector_data['plugin']['plugin_file'] ) ) {
675 $plugin_file = $connector_data['plugin']['plugin_file'];
676 } elseif ( ! empty( $connector_data['plugin']['pluginFile'] ) && is_string( $connector_data['plugin']['pluginFile'] ) ) {
677 $plugin_file = $connector_data['plugin']['pluginFile'];
678 }
679
680 if ( '' === $plugin_file ) {
681 return true;
682 }
683
684 if ( ! function_exists( 'is_plugin_active' ) ) {
685 require_once ABSPATH . 'wp-admin/includes/plugin.php';
686 }
687
688 if ( is_plugin_active( $plugin_file ) ) {
689 return true;
690 }
691
692 return is_multisite() && function_exists( 'is_plugin_active_for_network' ) && is_plugin_active_for_network( $plugin_file );
693 }
694
695 /**
696 * Returns the minimum content length in characters required for a given feature.
697 *
698 * @since 1.1.0
699 *
700 * @param string $feature_id The feature identifier (e.g. 'content-resizing', 'content-classification', 'summarization').
701 * @param int $content_length The default minimum content length in characters for the feature.
702 * @return int The minimum content length in characters.
703 */
704 function get_min_content_length( string $feature_id, int $content_length = 250 ): int {
705 /**
706 * Filters the minimum content length required for a feature.
707 *
708 * @since 1.1.0
709 *
710 * @param int $content_length The minimum content length in characters for the feature.
711 * @param string $feature_id The feature identifier.
712 */
713 return (int) apply_filters( 'wpai_min_content_length', $content_length, $feature_id );
714 }
715
716 /**
717 * Gets the default request timeout used by a feature.
718 *
719 * @since 1.2.0
720 *
721 * @param string $feature_id The ID of the feature.
722 * @param int $default_timeout The default timeout in seconds.
723 * @return int The request timeout.
724 */
725 function get_default_request_timeout( string $feature_id, int $default_timeout = 30 ): int {
726 /**
727 * Filters the default request timeout for a feature.
728 *
729 * @since 1.2.0
730 *
731 * @param int $default_timeout The default timeout in seconds.
732 * @param string $feature_id The ID of the feature.
733 */
734 return (int) apply_filters( 'wpai_default_request_timeout', $default_timeout, $feature_id );
735 }
736
737 /**
738 * Returns the maximum number of items a single bulk action may process.
739 *
740 * @since x.x.x
741 *
742 * @param string $feature_id The feature identifier (e.g. 'summarization').
743 * @return int The maximum number of items to process, always at least 1.
744 */
745 function get_bulk_action_max_items( string $feature_id ): int {
746 /**
747 * Filters the maximum number of items a single bulk action may process.
748 *
749 * @since x.x.x
750 *
751 * @param int $max_items The maximum number of items per bulk run.
752 * @param string $feature_id The ID of the feature.
753 */
754 $max_items = (int) apply_filters( 'wpai_bulk_action_max_items', 100, $feature_id );
755
756 return max( 1, $max_items );
757 }
758
759 /**
760 * Determines whether a post type supports bulk AI actions for a given feature.
761 *
762 * @since 1.2.0
763 *
764 * @param string $post_type The post type slug to check.
765 * @param string $feature_id The feature identifier (e.g. 'summarization').
766 * @return bool True if the post type supports bulk AI actions for the feature.
767 */
768 function post_type_supports_bulk_action( string $post_type, string $feature_id ): bool {
769 $post_type_obj = get_post_type_object( $post_type );
770
771 // Check if the post type is registered and supports REST API and UI.
772 $base_supported = $post_type_obj
773 && ! empty( $post_type_obj->show_in_rest )
774 && ! empty( $post_type_obj->show_ui );
775
776 switch ( $feature_id ) {
777 case Summarization::get_id():
778 return $base_supported && 'attachment' !== $post_type;
779 default:
780 return $base_supported;
781 }
782 }
783
784 /**
785 * Records a request in the request log.
786 *
787 * @since 1.3.0
788 *
789 * @param array{
790 * type: string,
791 * operation: string,
792 * provider?: string,
793 * model?: string,
794 * duration_ms?: int,
795 * tokens_input?: int,
796 * tokens_output?: int,
797 * status: string,
798 * error_message?: string,
799 * user_id?: int,
800 * context?: array<string, mixed>
801 * } $data Log data. The `type` must be one of the values returned by
802 * {@see AI_Request_Log_Manager::get_types()}.
803 * @return string|false The log identifier on success, false when logging is inactive or the write failed.
804 */
805 function log_ai_request( array $data ) {
806 $log_manager = Logging_Integration::get_log_manager();
807
808 if ( ! $log_manager instanceof AI_Request_Log_Manager ) {
809 return false;
810 }
811
812 return $log_manager->log( $data );
813 }
814
815 /**
816 * Determines whether embedding generation is available in this environment.
817 *
818 * @since 1.3.0
819 *
820 * @return bool True if embeddings can be generated, false otherwise.
821 */
822 function supports_embedding_generation(): bool {
823 return class_exists( AiClient::class ) && class_exists( EmbeddingBuilder::class );
824 }
825
826 /**
827 * Generates embeddings for one or more text inputs.
828 *
829 * @since 1.3.0
830 *
831 * @param string|list<string> $input The text input, or a list of inputs for batch embedding.
832 * @param array<string, mixed> $args {
833 * Optional. Generation options.
834 *
835 * @type string $provider Connector/provider ID to use.
836 * @type list<string> $model_preference Ordered model preferences.
837 * @type int $dimensions Requested embedding vector dimensions.
838 * }
839 * @return \WordPress\AiClient\Results\DTO\EmbeddingResult|\WP_Error The result, or WP_Error on failure.
840 */
841 function generate_embeddings( $input, array $args = array() ) {
842 if ( ! supports_embedding_generation() ) {
843 return new \WP_Error(
844 'ai_embeddings_unsupported',
845 __( 'Embedding generation is not available in this environment.', 'ai' )
846 );
847 }
848
849 try {
850 $builder = new EmbeddingBuilder( AiClient::defaultRegistry(), $input );
851
852 if ( isset( $args['provider'] ) && is_string( $args['provider'] ) && '' !== $args['provider'] ) {
853 $builder->usingProvider( $args['provider'] );
854 }
855
856 if ( ! empty( $args['model_preference'] ) && is_array( $args['model_preference'] ) ) {
857 $builder->usingModelPreference( ...array_values( $args['model_preference'] ) );
858 }
859
860 if ( isset( $args['dimensions'] ) ) {
861 $builder->usingDimensions( (int) $args['dimensions'] );
862 }
863
864 return $builder->generateEmbeddingResult();
865 } catch ( Throwable $e ) {
866 return new \WP_Error( 'ai_embeddings_failed', $e->getMessage() );
867 }
868 }
869