PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.4
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.4
4.9.4 4.9.3 4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 All 202 releases
← All changes | includes/Core/WriteWithAI.php +339 -299 4.7.0 → 4.9.4 View file →
@@ -12,10 +12,13 @@
12 12 use WPDeveloper\BetterDocs\Core\PostType;
13 13
14 14 use WPDeveloper\BetterDocs\Utils\Helper;
15 15 use WPDeveloper\BetterDocs\Utils\AIHelper;
16 + use WPDeveloper\BetterDocs\AI\ProviderFactory;
17 + use WPDeveloper\BetterDocs\AI\ModelRegistry;
16 18 use WPDeveloper\BetterDocs\Utils\AIUsage;
17 19 use WPDeveloper\BetterDocs\REST\AIEdit;
20 + use WPDeveloper\BetterDocs\REST\WriteWithAI as RESTWriteWithAI;
18 21
19 22 class WriteWithAI extends Base {
20 23
21 24 public $settings;
@@ -49,11 +52,32 @@
49 52 if ( 'docs' !== $post_type ) {
50 53 return;
51 54 }
52 55
53 - $api_key = $this->get_api_key();
56 + $factory = new ProviderFactory( $this->settings );
57 + $api_key = $factory->api_key_for( $factory->active_platform() );
54 58 $has_key = ! empty( $api_key );
55 59
60 + // Resolve the *active* AI platform + model (multi-platform aware) so the
61 + // modal reflects the current Settings → AI selection instead of the legacy
62 + // OpenAI-only `write_with_ai_model` key.
63 + $active_platform = $factory->active_platform();
64 + $active_model = $factory->active_model();
65 + $platform_labels = ModelRegistry::platforms();
66 + $model_labels = ModelRegistry::models( $active_platform );
67 +
68 + // Glossary suggestions are existing-terms-only, and the glossaries taxonomy is only
69 + // registered by Pro (see BetterDocsPro\Core\GlossaryTaxonomy::register_glossaries_taxonomy).
70 + // So the "Suggest glossaries" control must require, on top of the two settings, that Pro is
71 + // active AND at least one glossary term exists — otherwise the modal advertises an offer that can never
72 + // return anything. Mirrors the Docs-AI-suite availability check in Core\DocsAISuite.
73 + $glossary_count = wp_count_terms( array( 'taxonomy' => 'glossaries', 'hide_empty' => false ) );
74 + $has_glossary_terms = ! is_wp_error( $glossary_count ) && (int) $glossary_count > 0;
75 + $glossary_suggestions_enabled = (bool) $this->settings->get( 'enable_glossaries', false )
76 + && (bool) $this->settings->get( 'show_glossary_suggestions', true )
77 + && betterdocs()->is_pro_active()
78 + && $has_glossary_terms;
79 +
56 80 // Write with AI — loads even without a key so the modal can show its
57 81 // "add an API key" banner (matches the legacy inline form behavior).
58 82 betterdocs()->assets->enqueue( 'betterdocs-write-with-ai', 'blocks/write-with-ai.js' );
59 83 betterdocs()->assets->enqueue( 'betterdocs-write-with-ai-style', 'blocks/write-with-ai-style.css' );
@@ -69,10 +93,23 @@
69 93 // Term-suggestion (Docs AI Suite) wiring reused by the Write-with-AI
70 94 // preview step. Endpoint + gate mirror REST\DocsAISuite / Core\DocsAISuite.
71 95 'rest_suggest_url' => esc_url_raw( rest_url( 'betterdocs/v1/ai-suggest-terms' ) ),
72 96 'suggest_terms_enabled' => (bool) $this->settings->get( 'enable_docs_ai_suite', true ),
73 - 'model' => $this->settings->get( 'write_with_ai_model', 'gpt-4o-mini' ),
97 + // Glossary suggestions follow the glossary feature AND real availability
98 + // (Pro active + at least one glossary term); see the computation above.
99 + 'glossary_suggestions_enabled' => $glossary_suggestions_enabled,
100 + 'platform' => $active_platform,
101 + 'platform_label' => isset( $platform_labels[ $active_platform ] ) ? $platform_labels[ $active_platform ] : ucfirst( (string) $active_platform ),
102 + 'model' => $active_model,
103 + 'model_label' => isset( $model_labels[ $active_model ] ) ? $model_labels[ $active_model ] : $active_model,
74 104 'max_token' => (int) $this->settings->get( 'ai_autowrite_max_token', 2500 ),
105 + // Attachment limits, resolved server-side so the modal can refuse
106 + // an oversize file before uploading it — and so it advertises the
107 + // host's real ceiling rather than ours when the host is smaller.
108 + 'max_upload_bytes' => RESTWriteWithAI::upload_cap( 'file' ),
109 + 'max_media_bytes' => RESTWriteWithAI::upload_cap( 'media' ),
110 + 'media_exts' => RESTWriteWithAI::media_exts(),
111 + 'supports_transcription' => $this->platform_supports( 'transcription', $active_platform ),
75 112 'settings_url' => esc_url( admin_url( 'admin.php?page=betterdocs-settings#betterdocs-ai' ) ),
76 113 'woo_active' => class_exists( 'WooCommerce' ),
77 114 'is_multilingual_active' => Helper::is_multilingual_active(),
78 115 'language_options' => Helper::get_active_languages(),
@@ -118,54 +155,21 @@
118 155 }
119 156
120 157 public function isValidAPIKey( $apiKey ) {
121 158 if ( empty( $apiKey ) ) {
122 - $api_response[ 'valid' ] = false;
123 - $api_response[ 'message' ] = 'Please Insert your <a href="/admin.php?page=betterdocs-settings#betterdocs-ai">OpenAI API Key</a> to use this Write with AI feature.';
124 -
125 - return $api_response;
159 + return array(
160 + 'valid' => false,
161 + 'message' => 'Please Insert your <a href="/admin.php?page=betterdocs-settings#betterdocs-ai">API Key</a> to use this Write with AI feature.'
162 + );
126 163 }
127 164
128 - $api_response = array();
129 -
130 - $response = wp_safe_remote_get(
131 - 'https://api.openai.com/v1/engines',
132 - array(
133 - 'headers' => array(
134 - 'Content-Type' => 'application/json',
135 - 'Authorization' => 'Bearer ' . $apiKey,
136 - ),
137 - 'timeout' => 15,
138 - )
139 - );
140 -
141 - if ( is_wp_error( $response ) ) {
142 - $api_response[ 'valid' ] = false;
143 - $api_response[ 'message' ] = $response->get_error_message();
144 - return $api_response;
145 - }
146 -
147 - $httpCode = (int) wp_remote_retrieve_response_code( $response );
148 - $body = wp_remote_retrieve_body( $response );
149 -
150 - if ( 200 === $httpCode ) {
151 - $api_response[ 'valid' ] = true;
152 - $api_response[ 'message' ] = 'Valid API Key';
153 - } else {
154 - $responseData = json_decode( $body, true );
155 - $messageData = ! empty( $responseData[ 'error' ] ) ? $responseData[ 'error' ] : array();
156 - $api_response[ 'valid' ] = false;
157 - $api_response[ 'message' ] = ! empty( $messageData[ 'message' ] ) ? $messageData[ 'message' ] : 'Invalid API Key';
158 - }
159 -
160 - // print_r($response);
161 -
162 - return $api_response;
165 + $factory = new ProviderFactory( $this->settings );
166 + return $factory->validate( $factory->active_platform(), $apiKey );
163 167 }
164 168
165 169 public function get_api_key() {
166 - $api_key = $this->settings->get( 'ai_autowrite_api_key', '' );
167 - return $api_key;
170 + $factory = new ProviderFactory( $this->settings );
171 + return $factory->api_key_for( $factory->active_platform() );
168 172 }
169 173
170 174 /**
171 175 * The built-in "Default" instruction body.
@@ -371,8 +375,240 @@
371 375
372 376 return apply_filters( 'betterdocs_write_with_ai_system_prompt', $prompt );
373 377 }
374 378
379 + /**
380 + * Build the system + user messages and chat options shared by the
381 + * Write-with-AI generator and the in-editor AI-Edit endpoint. Routes through
382 + * the active AI platform (ProviderFactory), so it works for every provider.
383 + *
384 + * @param string $prompt
385 + * @param int|null $max_tokens Optional cap override (e.g. a "large" doc).
386 + * @param array $extra_system Optional extra system messages.
387 + * @return array array( array $messages, array $options )
388 + */
389 + private function ai_request_args( $prompt, $max_tokens = null, $extra_system = array() ) {
390 + $messages = array_merge(
391 + array( array( 'role' => 'system', 'content' => $this->get_system_prompt() ) ),
392 + $this->normalize_extra_system( $extra_system ),
393 + array( array( 'role' => 'user', 'content' => $prompt ) )
394 + );
395 +
396 + return array( $messages, $this->ai_chat_options( $max_tokens ) );
397 + }
398 +
399 + /**
400 + * Assemble the chat options passed to the active provider. Document
401 + * generation is long-running on every platform: reasoning models (gpt-5*)
402 + * and larger non-OpenAI models (e.g. Claude Opus) routinely need well over
403 + * the old 50s default to return a full doc, which timed them out mid-
404 + * generation. Give every request a generous ceiling; fast models simply
405 + * finish early and are unaffected.
406 + *
407 + * @param int|null $max_tokens Optional token cap override.
408 + * @param float|null $temperature Optional sampling temperature.
409 + * @return array
410 + */
411 + private function ai_chat_options( $max_tokens = null, $temperature = null ) {
412 + $timeout = 300;
413 + if ( function_exists( 'set_time_limit' ) ) {
414 + set_time_limit( 300 );
415 + }
416 +
417 + $options = array(
418 + 'max_tokens' => null !== $max_tokens ? (int) $max_tokens : (int) $this->settings->get( 'ai_autowrite_max_token', 2500 ),
419 + 'context' => 'write_with_ai',
420 + 'timeout' => $timeout,
421 + );
422 +
423 + if ( null !== $temperature ) {
424 + $options['temperature'] = $temperature;
425 + }
426 +
427 + return $options;
428 + }
429 +
430 + public function generate_openai_response( $prompt, $keywords, $max_tokens = null, $extra_system = array() ) {
431 + try {
432 + list( $messages, $options ) = $this->ai_request_args( $prompt, $max_tokens, $extra_system );
433 +
434 + $result = ( new ProviderFactory( $this->settings ) )->make()->chat( $messages, $options );
435 +
436 + if ( is_wp_error( $result ) ) {
437 + return $result->get_error_message();
438 + }
439 +
440 + return $result['content'];
441 + } catch ( \Exception $error ) {
442 + return 'Error: ' . $error->getMessage();
443 + }
444 + }
445 +
446 + /**
447 + * Generate documentation from an uploaded image using a vision-capable model.
448 + *
449 + * Mirrors generate_openai_response() but sends the picture alongside the text
450 + * prompt as an OpenAI-format multimodal user message
451 + * (`content: [ {type:text}, {type:image_url} ]`). The OpenAI-compatible
452 + * provider forwards that message array to the wire verbatim, so no provider
453 + * change is needed. Only OpenAI vision models are wired — Claude and Gemini
454 + * use a different image envelope, so they are refused with a clear error
455 + * instead of being sent a payload they would reject.
456 + *
457 + * @param string $prompt Composed instruction prompt.
458 + * @param array $image { data_uri:string, mime:string }.
459 + * @param int|null $max_tokens Optional token cap.
460 + * @param array $extra_system Extra system messages (instruction sets).
461 + * @return string|\WP_Error Generated content, or WP_Error on guard/failure.
462 + */
463 + public function generate_vision_response( $prompt, $image, $max_tokens = null, $extra_system = array() ) {
464 + if ( empty( $image['data_uri'] ) ) {
465 + return new \WP_Error( 'ai_vision_no_image', __( 'No image data to send to the AI.', 'betterdocs' ) );
466 + }
467 +
468 + $factory = new ProviderFactory( $this->settings );
469 + $platform = $factory->active_platform();
470 + $model = $factory->active_model( $platform );
471 +
472 + if ( ! $this->platform_supports( 'vision', $platform, $model ) ) {
473 + return new \WP_Error(
474 + 'ai_no_vision',
475 + sprintf(
476 + /* translators: 1: AI platform id, 2: model name. */
477 + __( 'The configured AI model (%1$s / %2$s) can\'t read images. Switch to an OpenAI vision model such as GPT-4o or GPT-4o mini in BetterDocs → Settings → AI Content Suite, or upload a PDF/DOCX/TXT instead.', 'betterdocs' ),
478 + $platform,
479 + '' !== (string) $model ? $model : 'default'
480 + )
481 + );
482 + }
483 +
484 + try {
485 + $messages = array_merge(
486 + array( array( 'role' => 'system', 'content' => $this->get_system_prompt() ) ),
487 + $this->normalize_extra_system( $extra_system ),
488 + array(
489 + array(
490 + 'role' => 'user',
491 + 'content' => array(
492 + array( 'type' => 'text', 'text' => (string) $prompt ),
493 + array( 'type' => 'image_url', 'image_url' => array( 'url' => (string) $image['data_uri'] ) ),
494 + ),
495 + ),
496 + )
497 + );
498 +
499 + $result = $factory->make()->chat( $messages, $this->ai_chat_options( $max_tokens ) );
500 +
501 + if ( is_wp_error( $result ) ) {
502 + return $result;
503 + }
504 +
505 + return $result['content'];
506 + } catch ( \Exception $error ) {
507 + return new \WP_Error( 'ai_vision_failed', 'Error: ' . $error->getMessage() );
508 + }
509 + }
510 +
511 + /**
512 + * Transcribe an uploaded recording to plain text.
513 + *
514 + * Deliberately not a doc-generation call: this returns the raw transcript so
515 + * the author can correct it, and generation then runs through the ordinary
516 + * grounded from-source path. That keeps one generation path in the plugin
517 + * instead of a second, media-shaped one.
518 + *
519 + * @since 4.9.4
520 + *
521 + * @param array $file `[ 'path', 'filename', 'mime' ]` — a PHP temp upload.
522 + * @return string|\WP_Error Transcript text.
523 + */
524 + public function transcribe( $file ) {
525 + $factory = new ProviderFactory( $this->settings );
526 + $platform = $factory->active_platform();
527 +
528 + if ( ! $this->platform_supports( 'transcription', $platform ) ) {
529 + return new \WP_Error(
530 + 'ai_no_transcription',
531 + sprintf(
532 + /* translators: %s: AI platform id, e.g. "claude". */
533 + __( 'The configured AI platform (%s) can\'t read audio or video. Switch to OpenAI or Google Gemini in BetterDocs → Settings → AI Content Suite, or upload a text file instead.', 'betterdocs' ),
534 + $platform
535 + )
536 + );
537 + }
538 +
539 + // 300s, matching every other AI call here: a 25 MB recording can take a
540 + // minute or more to come back, well past the 50s provider default.
541 + $options = $this->ai_chat_options();
542 + $options['model'] = ModelRegistry::transcription_model( $platform );
543 +
544 + try {
545 + return $factory->make()->transcribe( $file, $options );
546 + } catch ( \Exception $error ) {
547 + return new \WP_Error( 'ai_transcribe_failed', 'Error: ' . $error->getMessage() );
548 + }
549 + }
550 +
551 + /**
552 + * Whether a platform + model can handle a given input capability.
553 + *
554 + * One entry point for every "can this provider read that file?" question, so
555 + * a new capability is a case here rather than another bespoke check beside
556 + * the call site.
557 + *
558 + * @since 4.9.4
559 + *
560 + * @param string $capability `vision` | `transcription`
561 + * @param string $platform
562 + * @param string $model Chat model. Ignored for transcription, which uses
563 + * its own model (see ModelRegistry).
564 + * @return bool
565 + */
566 + public function platform_supports( $capability, $platform, $model = '' ) {
567 + switch ( $capability ) {
568 + case 'vision':
569 + return $this->platform_supports_vision( $platform, $model );
570 +
571 + case 'transcription':
572 + // Presence in the transcription map is the whole test — the chat
573 + // model is irrelevant, since transcription runs as its own call
574 + // with its own model before any doc is written.
575 + return '' !== ModelRegistry::transcription_model( $platform );
576 + }
577 +
578 + return false;
579 + }
580 +
581 + /**
582 + * Whether the active platform + model can accept image input in the OpenAI
583 + * multimodal format. Deliberately conservative: only OpenAI vision model
584 + * families qualify, because Claude and Gemini require a different image
585 + * envelope this path does not build. gpt-3.5 (text-only) is excluded.
586 + *
587 + * @param string $platform
588 + * @param string $model
589 + * @return bool
590 + */
591 + protected function platform_supports_vision( $platform, $model ) {
592 + if ( 'openai' !== $platform ) {
593 + return false;
594 + }
595 +
596 + $model = strtolower( (string) $model );
597 +
598 + if ( '' === $model || false !== strpos( $model, 'gpt-3.5' ) ) {
599 + return false;
600 + }
601 +
602 + foreach ( array( 'gpt-4o', 'gpt-4.1', 'gpt-4-turbo', 'gpt-4-vision', 'chatgpt-4o', 'gpt-5', 'o1', 'o3', 'o4' ) as $family ) {
603 + if ( false !== strpos( $model, $family ) ) {
604 + return true;
605 + }
606 + }
607 +
608 + return false;
609 + }
610 +
375 611 public function get_outline_system_prompt() {
376 612 $prompt = <<<'PROMPT'
377 613 You are a Senior Technical Writer. Produce a documentation OUTLINE only — not the full article.
378 614
@@ -400,9 +636,9 @@
400 636
401 637 if ( empty( $result['success'] ) ) {
402 638 return array(
403 639 'success' => false,
404 - 'error' => isset( $result['error'] ) ? $result['error'] : 'OpenAI error',
640 + 'error' => isset( $result['error'] ) ? $result['error'] : 'AI error',
405 641 );
406 642 }
407 643
408 644 $outline = $this->parse_outline( (string) $result['content'] );
@@ -457,251 +693,96 @@
457 693
458 694 return $outline;
459 695 }
460 696
461 - public function generate_openai_response( $prompt, $keywords, $max_tokens = null, $extra_system = array() ) {
462 - try {
463 - $api_key = $this->settings->get( 'ai_autowrite_api_key', '' );
464 - // Caller may raise the cap (e.g. a "large" doc) above the saved default.
465 - $max_tokens = null !== $max_tokens ? (int) $max_tokens : $this->settings->get( 'ai_autowrite_max_token', 2500 );
466 - $model = $this->settings->get( 'write_with_ai_model', 'gpt-4o-mini' );
467 -
468 - $api_endpoint = 'https://api.openai.com/v1/chat/completions'; // Update the endpoint based on OpenAI API version
469 -
470 - $messages = array_merge(
471 - array(
472 - array(
473 - 'role' => 'system',
474 - 'content' => $this->get_system_prompt()
475 - )
476 - ),
477 - $this->normalize_extra_system( $extra_system ),
478 - array(
479 - array(
480 - 'role' => 'user',
481 - 'content' => $prompt
482 - )
483 - )
484 - );
485 -
486 - $request_body = AIHelper::build_openai_payload(
487 - $model,
488 - $messages,
489 - $max_tokens,
490 - null,
491 - 'write_with_ai'
492 - );
493 -
494 - $request_options = array(
495 - 'headers' => array(
496 - 'Content-Type' => 'application/json',
497 - 'Authorization' => 'Bearer ' . $api_key
498 - ),
499 - 'body' => json_encode( $request_body ),
500 - 'timeout' => 300
501 - );
502 -
503 - // GPT-5.5 reasoning can run well past the default limits; give PHP and
504 - // the HTTP call room to finish (still subject to server php-fpm/nginx limits).
505 - if ( function_exists( 'set_time_limit' ) ) {
506 - set_time_limit( 300 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- long-running AI generation needs an extended limit; still bounded by server fpm/nginx timeouts.
507 - }
508 -
509 - $response = wp_remote_post( $api_endpoint, $request_options );
510 -
511 - if ( is_wp_error( $response ) ) {
512 - return 'Error: ' . $response->get_error_message();
513 - } else {
514 - $body = wp_remote_retrieve_body( $response );
515 -
516 - $data = json_decode( $body, true );
517 -
518 - if ( ! empty( $data[ 'error' ] ) ) {
519 - return $data[ 'error' ][ 'message' ];
520 - }
521 -
522 - return $data[ 'choices' ][ 0 ][ 'message' ][ 'content' ]; // Update this line to get the assistant's message
523 - }
524 - } catch ( Exception $error ) {
525 - return 'Error: ' . $error->getMessage();
526 - }
527 - }
528 -
529 697 public function generate_openai_response_ai_edit( $prompt, $extra_system = array() ) {
530 - $api_key = $this->settings->get( 'ai_autowrite_api_key', '' );
531 - $max_tokens = $this->settings->get( 'ai_autowrite_max_token', 2500 );
532 - $model = $this->settings->get( 'write_with_ai_model', 'gpt-4o-mini' );
698 + $factory = new ProviderFactory( $this->settings );
699 + $model = $factory->active_model();
533 700
534 - $api_endpoint = 'https://api.openai.com/v1/chat/completions';
535 -
536 701 $messages = array_merge(
537 - array(
538 - array(
539 - 'role' => 'system',
540 - 'content' => $this->get_system_prompt()
541 - )
542 - ),
702 + array( array( 'role' => 'system', 'content' => $this->get_system_prompt() ) ),
543 703 $this->normalize_extra_system( $extra_system ),
544 - array(
545 - array(
546 - 'role' => 'user',
547 - 'content' => $prompt
548 - )
549 - )
704 + array( array( 'role' => 'user', 'content' => $prompt ) )
550 705 );
551 706
552 - $payload = AIHelper::build_openai_payload(
553 - $model,
554 - $messages,
555 - $max_tokens,
556 - null,
557 - 'write_with_ai'
558 - );
707 + $result = $factory->make()->chat( $messages, $this->ai_chat_options() );
559 708
560 - $request_options = array(
561 - 'headers' => array(
562 - 'Content-Type' => 'application/json',
563 - 'Authorization' => 'Bearer ' . $api_key
564 - ),
565 - 'body' => wp_json_encode( $payload ),
566 - 'timeout' => 300
567 - );
568 -
569 - // GPT-5.5 reasoning can run well past the default limits; give PHP and
570 - // the HTTP call room to finish (still subject to server php-fpm/nginx limits).
571 - if ( function_exists( 'set_time_limit' ) ) {
572 - set_time_limit( 300 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- long-running AI generation needs an extended limit; still bounded by server fpm/nginx timeouts.
573 - }
574 -
575 - $response = wp_remote_post( $api_endpoint, $request_options );
576 -
577 - if ( is_wp_error( $response ) ) {
709 + if ( is_wp_error( $result ) ) {
578 710 return array(
579 711 'success' => false,
580 - 'error' => $response->get_error_message(),
581 - 'model' => $model
712 + 'error' => $result->get_error_message(),
713 + 'model' => $model
582 714 );
583 715 }
584 716
585 - $body = wp_remote_retrieve_body( $response );
586 - $data = json_decode( $body, true );
717 + $usage = isset( $result['usage'] ) && is_array( $result['usage'] ) ? $result['usage'] : array();
587 718
588 - if ( ! empty( $data[ 'error' ] ) ) {
589 - return array(
590 - 'success' => false,
591 - 'error' => isset( $data[ 'error' ][ 'message' ] ) ? $data[ 'error' ][ 'message' ] : 'OpenAI error',
592 - 'model' => $model,
593 - 'raw' => $data
594 - );
595 - }
596 -
597 - $content = isset( $data[ 'choices' ][ 0 ][ 'message' ][ 'content' ] ) ? $data[ 'choices' ][ 0 ][ 'message' ][ 'content' ] : '';
598 - $usage = isset( $data[ 'usage' ] ) && is_array( $data[ 'usage' ] ) ? $data[ 'usage' ] : array();
599 -
600 719 return array(
601 - 'success' => true,
602 - 'content' => $content,
603 - 'model' => $model,
604 - 'prompt_tokens' => isset( $usage[ 'prompt_tokens' ] ) ? (int) $usage[ 'prompt_tokens' ] : null,
605 - 'completion_tokens' => isset( $usage[ 'completion_tokens' ] ) ? (int) $usage[ 'completion_tokens' ] : null,
606 - 'total_tokens' => isset( $usage[ 'total_tokens' ] ) ? (int) $usage[ 'total_tokens' ] : null,
607 - 'finish_reason' => isset( $data[ 'choices' ][ 0 ][ 'finish_reason' ] ) ? $data[ 'choices' ][ 0 ][ 'finish_reason' ] : null
720 + 'success' => true,
721 + 'content' => $result['content'],
722 + 'model' => isset( $result['model'] ) ? $result['model'] : $model,
723 + 'prompt_tokens' => isset( $usage['prompt_tokens'] ) ? $usage['prompt_tokens'] : null,
724 + 'completion_tokens' => isset( $usage['completion_tokens'] ) ? $usage['completion_tokens'] : null,
725 + 'total_tokens' => isset( $usage['total_tokens'] ) ? $usage['total_tokens'] : null,
726 + 'finish_reason' => isset( $result['finish_reason'] ) ? $result['finish_reason'] : null
608 727 );
609 728 }
610 729
611 730 /**
612 - * Generic chat completion using the Write-with-AI model/token/key settings.
731 + * Generic chat completion using the active Write-with-AI platform/model/token
732 + * settings. Shared by lightweight, plain-text generators (glossary definitions,
733 + * FAQ answers, outlines) that need the same dynamic model as Write with AI /
734 + * AI Edit but a caller-supplied system prompt instead of the doc-authoring HTML
735 + * prompt. Routes through ProviderFactory so every provider is supported.
613 736 *
614 - * Shared by lightweight, plain-text generators (glossary definitions, FAQ answers)
615 - * that need the same dynamic model as Write with AI / AI Edit but a caller-supplied
616 - * system prompt instead of the doc-authoring HTML prompt. Returns the same structured
617 - * array shape as {@see self::generate_openai_response_ai_edit()}.
618 - *
619 737 * @param string $user_prompt The user message.
620 738 * @param string $system_prompt Optional system message (omitted when empty).
739 + * @param array $extra_system Optional extra system messages.
621 740 * @return array { success:bool, content?:string, error?:string, model:string, *_tokens?:int }
622 741 */
623 742 public function generate_text( $user_prompt, $system_prompt = '', $extra_system = array() ) {
624 - $api_key = $this->settings->get( 'ai_autowrite_api_key', '' );
625 - $max_tokens = $this->settings->get( 'ai_autowrite_max_token', 2500 );
626 - $model = $this->settings->get( 'write_with_ai_model', 'gpt-4o-mini' );
743 + $factory = new ProviderFactory( $this->settings );
744 + $model = $factory->active_model();
627 745
628 746 $messages = array();
629 747 if ( $system_prompt !== '' ) {
630 - $messages[] = array(
631 - 'role' => 'system',
632 - 'content' => $system_prompt
633 - );
748 + $messages[] = array( 'role' => 'system', 'content' => $system_prompt );
634 749 }
635 750 foreach ( $this->normalize_extra_system( $extra_system ) as $extra ) {
636 751 $messages[] = $extra;
637 752 }
638 - $messages[] = array(
639 - 'role' => 'user',
640 - 'content' => $user_prompt
641 - );
753 + $messages[] = array( 'role' => 'user', 'content' => $user_prompt );
642 754
643 - $api_endpoint = 'https://api.openai.com/v1/chat/completions';
755 + $result = $factory->make()->chat( $messages, $this->ai_chat_options() );
644 756
645 - $payload = AIHelper::build_openai_payload( $model, $messages, $max_tokens, null, 'write_with_ai' );
646 -
647 - $request_options = array(
648 - 'headers' => array(
649 - 'Content-Type' => 'application/json',
650 - 'Authorization' => 'Bearer ' . $api_key
651 - ),
652 - 'body' => wp_json_encode( $payload ),
653 - 'timeout' => 300
654 - );
655 -
656 - // GPT-5.5 reasoning can run well past the default limits; give PHP and
657 - // the HTTP call room to finish (still subject to server php-fpm/nginx limits).
658 - if ( function_exists( 'set_time_limit' ) ) {
659 - set_time_limit( 300 );
660 - }
661 -
662 - $response = wp_remote_post( $api_endpoint, $request_options );
663 -
664 - if ( is_wp_error( $response ) ) {
757 + if ( is_wp_error( $result ) ) {
665 758 return array(
666 759 'success' => false,
667 - 'error' => $response->get_error_message(),
760 + 'error' => $result->get_error_message(),
668 761 'model' => $model
669 762 );
670 763 }
671 764
672 - $body = wp_remote_retrieve_body( $response );
673 - $data = json_decode( $body, true );
765 + $usage = isset( $result['usage'] ) && is_array( $result['usage'] ) ? $result['usage'] : array();
674 766
675 - if ( ! empty( $data[ 'error' ] ) ) {
676 - return array(
677 - 'success' => false,
678 - 'error' => isset( $data[ 'error' ][ 'message' ] ) ? $data[ 'error' ][ 'message' ] : 'OpenAI error',
679 - 'model' => $model,
680 - 'raw' => $data
681 - );
682 - }
683 -
684 - $content = isset( $data[ 'choices' ][ 0 ][ 'message' ][ 'content' ] ) ? $data[ 'choices' ][ 0 ][ 'message' ][ 'content' ] : '';
685 - $usage = isset( $data[ 'usage' ] ) && is_array( $data[ 'usage' ] ) ? $data[ 'usage' ] : array();
686 -
687 767 return array(
688 768 'success' => true,
689 - 'content' => $content,
690 - 'model' => $model,
691 - 'prompt_tokens' => isset( $usage[ 'prompt_tokens' ] ) ? (int) $usage[ 'prompt_tokens' ] : null,
692 - 'completion_tokens' => isset( $usage[ 'completion_tokens' ] ) ? (int) $usage[ 'completion_tokens' ] : null,
693 - 'total_tokens' => isset( $usage[ 'total_tokens' ] ) ? (int) $usage[ 'total_tokens' ] : null,
694 - 'finish_reason' => isset( $data[ 'choices' ][ 0 ][ 'finish_reason' ] ) ? $data[ 'choices' ][ 0 ][ 'finish_reason' ] : null
769 + 'content' => $result['content'],
770 + 'model' => isset( $result['model'] ) ? $result['model'] : $model,
771 + 'prompt_tokens' => isset( $usage['prompt_tokens'] ) ? $usage['prompt_tokens'] : null,
772 + 'completion_tokens' => isset( $usage['completion_tokens'] ) ? $usage['completion_tokens'] : null,
773 + 'total_tokens' => isset( $usage['total_tokens'] ) ? $usage['total_tokens'] : null,
774 + 'finish_reason' => isset( $result['finish_reason'] ) ? $result['finish_reason'] : null
695 775 );
696 776 }
697 777
698 778 /**
699 - * Generate an OpenAI chat completion with a caller-supplied system + user
700 - * prompt. Mirrors generate_openai_response_ai_edit() (same key/model/token
701 - * floor/timeout handling and return shape) but does NOT force the
702 - * documentation-writer system prompt, so callers such as the Docs AI Suite
703 - * (taxonomy suggestions, excerpts) can supply task-appropriate instructions.
779 + * Generate a chat completion with a caller-supplied system + user prompt.
780 + * Mirrors generate_openai_response_ai_edit() (same model/token floor/timeout
781 + * handling and return shape) but does NOT force the documentation-writer
782 + * system prompt, so callers such as the Docs AI Suite (taxonomy suggestions,
783 + * excerpts) can supply task-appropriate instructions. Routes through the active
784 + * provider via ProviderFactory.
704 785 *
705 786 * @param string $system_prompt System instruction for the model.
706 787 * @param string $user_prompt User message / content payload.
707 788 * @param float|null $temperature Optional sampling temperature (ignored for gpt-5*).
@@ -707,77 +788,36 @@
707 788 * @param float|null $temperature Optional sampling temperature (ignored for gpt-5*).
708 789 * @return array{success:bool,content?:string,error?:string,model:string,...}
709 790 */
710 791 public function generate_openai_response_raw( $system_prompt, $user_prompt, $temperature = null ) {
711 - $api_key = $this->settings->get( 'ai_autowrite_api_key', '' );
712 - $max_tokens = $this->settings->get( 'ai_autowrite_max_token', 2500 );
713 - $model = $this->settings->get( 'write_with_ai_model', 'gpt-4o-mini' );
792 + $factory = new ProviderFactory( $this->settings );
793 + $model = $factory->active_model();
714 794
715 - $api_endpoint = 'https://api.openai.com/v1/chat/completions';
716 -
717 - $payload = AIHelper::build_openai_payload(
718 - $model,
719 - array(
720 - array(
721 - 'role' => 'system',
722 - 'content' => $system_prompt
723 - ),
724 - array(
725 - 'role' => 'user',
726 - 'content' => $user_prompt
727 - )
728 - ),
729 - $max_tokens,
730 - $temperature,
731 - 'write_with_ai'
795 + $messages = array(
796 + array( 'role' => 'system', 'content' => $system_prompt ),
797 + array( 'role' => 'user', 'content' => $user_prompt )
732 798 );
733 799
734 - $request_options = array(
735 - 'headers' => array(
736 - 'Content-Type' => 'application/json',
737 - 'Authorization' => 'Bearer ' . $api_key
738 - ),
739 - 'body' => wp_json_encode( $payload ),
740 - 'timeout' => 300
741 - );
800 + $result = $factory->make()->chat( $messages, $this->ai_chat_options( null, $temperature ) );
742 801
743 - if ( function_exists( 'set_time_limit' ) ) {
744 - set_time_limit( 300 );
745 - }
746 -
747 - $response = wp_remote_post( $api_endpoint, $request_options );
748 -
749 - if ( is_wp_error( $response ) ) {
802 + if ( is_wp_error( $result ) ) {
750 803 return array(
751 804 'success' => false,
752 - 'error' => $response->get_error_message(),
753 - 'model' => $model
805 + 'error' => $result->get_error_message(),
806 + 'model' => $model
754 807 );
755 808 }
756 809
757 - $body = wp_remote_retrieve_body( $response );
758 - $data = json_decode( $body, true );
810 + $usage = isset( $result['usage'] ) && is_array( $result['usage'] ) ? $result['usage'] : array();
759 811
760 - if ( ! empty( $data[ 'error' ] ) ) {
761 - return array(
762 - 'success' => false,
763 - 'error' => isset( $data[ 'error' ][ 'message' ] ) ? $data[ 'error' ][ 'message' ] : 'OpenAI error',
764 - 'model' => $model,
765 - 'raw' => $data
766 - );
767 - }
768 -
769 - $content = isset( $data[ 'choices' ][ 0 ][ 'message' ][ 'content' ] ) ? $data[ 'choices' ][ 0 ][ 'message' ][ 'content' ] : '';
770 - $usage = isset( $data[ 'usage' ] ) && is_array( $data[ 'usage' ] ) ? $data[ 'usage' ] : array();
771 -
772 812 return array(
773 - 'success' => true,
774 - 'content' => $content,
775 - 'model' => $model,
776 - 'prompt_tokens' => isset( $usage[ 'prompt_tokens' ] ) ? (int) $usage[ 'prompt_tokens' ] : null,
777 - 'completion_tokens' => isset( $usage[ 'completion_tokens' ] ) ? (int) $usage[ 'completion_tokens' ] : null,
778 - 'total_tokens' => isset( $usage[ 'total_tokens' ] ) ? (int) $usage[ 'total_tokens' ] : null,
779 - 'finish_reason' => isset( $data[ 'choices' ][ 0 ][ 'finish_reason' ] ) ? $data[ 'choices' ][ 0 ][ 'finish_reason' ] : null
813 + 'success' => true,
814 + 'content' => $result['content'],
815 + 'model' => isset( $result['model'] ) ? $result['model'] : $model,
816 + 'prompt_tokens' => isset( $usage['prompt_tokens'] ) ? $usage['prompt_tokens'] : null,
817 + 'completion_tokens' => isset( $usage['completion_tokens'] ) ? $usage['completion_tokens'] : null,
818 + 'total_tokens' => isset( $usage['total_tokens'] ) ? $usage['total_tokens'] : null,
819 + 'finish_reason' => isset( $result['finish_reason'] ) ? $result['finish_reason'] : null
780 820 );
781 821 }
782 822
783 823 public function generate_openai_content_callback() {