PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.2.0
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.2.0
2.2.0 2.1.0 2.0.15 2.0.12 2.0.10 2.0.4 2.0.1 2.0.0 1.95.3 1.95.2 1.95 1.91.6 trunk 1.11 1.12 1.13 1.20 1.21 1.22 1.23 1.30 1.31 1.32 1.35 1.40 All 43 releases
← All changes | app/Services/AiService.php +235 -35 2.1.0 → 2.2.0 View file →
@@ -10,10 +10,10 @@
10 10 * Mirrors the FluentCRM AI implementation so the Fluent suite stays in sync:
11 11 * credentials are shared across the suite via the global `_fluent_ai_creds`
12 12 * option, while Fluent Boards keeps its own writing preferences.
13 13 *
14 - * Providers supported: OpenAI, Anthropic (Claude), Google Gemini and the
15 - * WordPress-native AI client. All provider calls are blocking wp_remote_post
14 + * Providers supported: OpenAI, Anthropic (Claude), Google Gemini, OpenRouter
15 + * and the WordPress-native AI client. All provider calls are blocking wp_remote_post
16 16 * requests (no streaming).
17 17 */
18 18 class AiService
19 19 {
@@ -36,18 +36,24 @@
36 36 private $legacySettingsOptionKey = 'ai_writing_settings';
37 37
38 38 private $providerModels = [
39 39 'wordpress' => ['wordpress'],
40 - 'open_ai' => ['auto', 'gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4.1', 'gpt-4o', 'gpt-4o-mini'],
41 - 'claude' => ['auto', 'claude-opus-4-7', 'claude-sonnet-4-6', 'claude-haiku-4-5-20251001', 'claude-opus-4-6'],
42 - 'gemini' => ['auto', 'gemini-3.5-flash', 'gemini-3.1-pro-preview', 'gemini-3-flash-preview', 'gemini-3.1-flash-lite', 'gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-2.5-flash-lite'],
40 + 'open_ai' => ['auto', 'gpt-6.1-sol', 'gpt-6-astra', 'gpt-6-luna', 'gpt-5.6-terra', 'gpt-5.6-sol', 'gpt-5.6-luna', 'gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-4.1', 'gpt-4o', 'gpt-4o-mini'],
41 + 'claude' => ['auto', 'claude-sonnet-5-5', 'claude-opus-5-5', 'claude-haiku-5-5', 'claude-fable-5-1', 'claude-sonnet-5', 'claude-opus-5', 'claude-haiku-4-5-20251001', 'claude-opus-4-8', 'claude-opus-4-7', 'claude-sonnet-4-6', 'claude-opus-4-6'],
42 + 'gemini' => ['auto', 'gemini-3.8-flash', 'gemini-3.6-flash', 'gemini-3.7-flash', 'gemini-3.5-flash', 'gemini-3.5-flash-lite', 'gemini-3.1-flash-lite', 'gemini-3.1-pro-preview', 'gemini-3-flash-preview', 'gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-2.5-flash-lite'],
43 + 'openrouter' => [],
43 44 ];
44 45
46 + /**
47 + * Auto picks the newest model first, then a widely available stable one.
48 + * Newer models are often gated by account tier, verification or region, so
49 + * fallback requires a typed 404 identifying the unavailable model.
50 + */
45 51 private $autoProviderModels = [
46 - 'open_ai' => 'gpt-5.4',
47 - 'claude' => 'claude-sonnet-4-6',
48 - 'gemini' => 'gemini-3.5-flash',
49 - 'wordpress' => 'wordpress',
52 + 'open_ai' => ['gpt-6.1-sol', 'gpt-5.6-terra'],
53 + 'claude' => ['claude-sonnet-5-5', 'claude-sonnet-5'],
54 + 'gemini' => ['gemini-3.8-flash', 'gemini-3.5-flash'],
55 + 'wordpress' => ['wordpress'],
50 56 ];
51 57
52 58 /**
53 59 * Actions the in-editor assistant can request.
@@ -121,8 +127,14 @@
121 127 $existingKey = (string) Arr::get($existing, 'api_key', '');
122 128 $existingProvider = (string) Arr::get($existing, 'provider', '');
123 129 $existingModel = (string) Arr::get($existing, 'model', '');
124 130
131 + if ($provider && $provider !== $existingProvider && $provider !== 'wordpress'
132 + && (!$apiKey || strpos($apiKey, '****') === 0)
133 + ) {
134 + return new \WP_Error('missing_api_key', __('Please enter an API key for the selected provider.', 'fluent-boards'));
135 + }
136 +
125 137 // Only a real, non-masked value replaces the stored key.
126 138 $plainApiKey = $existingKey;
127 139 if ($apiKey !== '' && strpos($apiKey, '****') !== 0) {
128 140 $plainApiKey = $apiKey;
@@ -131,16 +143,22 @@
131 143 // A blank submission keeps the stored provider/model rather than blanking them.
132 144 if (!$provider) {
133 145 $provider = $existingProvider;
134 146 }
147 + // A stored model only carries over when the provider is unchanged; another
148 + // provider's model would otherwise be saved against the new provider.
135 149 if (!$model) {
136 - $model = $existingModel ?: 'auto';
150 + $model = ($provider === $existingProvider && $existingModel) ? $existingModel : 'auto';
137 151 }
138 152
139 153 // Never persist a model the selected provider does not support, otherwise
140 154 // every later generation resolves it as-is and keeps calling the provider
141 155 // with an invalid model.
142 - if ($provider && !in_array($model, Arr::get($this->providerModels, $provider, []), true)) {
156 + if ($provider === 'openrouter') {
157 + if (!$model || $model === 'auto') {
158 + return new \WP_Error('invalid_model', __('Please select an OpenRouter model.', 'fluent-boards'));
159 + }
160 + } elseif ($provider && !in_array($model, Arr::get($this->providerModels, $provider, []), true)) {
143 161 return new \WP_Error('invalid_model', __('Invalid AI model selected for this provider.', 'fluent-boards'));
144 162 }
145 163
146 164 $credentials = [
@@ -174,11 +192,12 @@
174 192 /**
175 193 * Model dropdown options for a provider.
176 194 *
177 195 * @param string $provider
196 + * @param string $apiKey only used by OpenRouter; a masked value falls back to the stored key
178 197 * @return array|\WP_Error
179 198 */
180 - public function getModelOptions($provider)
199 + public function getModelOptions($provider, $apiKey = '')
181 200 {
182 201 $provider = $this->normalizeProvider($provider);
183 202
184 203 if (!$provider || !in_array($provider, array_keys($this->providerModels), true)) {
@@ -184,8 +203,21 @@
184 203 if (!$provider || !in_array($provider, array_keys($this->providerModels), true)) {
185 204 return new \WP_Error('invalid_provider', __('Invalid AI provider selected.', 'fluent-boards'));
186 205 }
187 206
207 + if ($provider === 'openrouter') {
208 + if (!$apiKey || strpos($apiKey, '****') === 0) {
209 + $credentials = $this->getSavedCredentials();
210 + $apiKey = $credentials['provider'] === $provider ? $credentials['api_key'] : '';
211 + }
212 +
213 + if (!$apiKey) {
214 + return new \WP_Error('missing_api_key', __('Please enter an OpenRouter API key first.', 'fluent-boards'));
215 + }
216 +
217 + return $this->getOpenRouterModels($apiKey);
218 + }
219 +
188 220 $models = [];
189 221 foreach (Arr::get($this->providerModels, $provider, []) as $model) {
190 222 $models[] = [
191 223 'value' => $model,
@@ -217,9 +249,10 @@
217 249 }
218 250
219 251 // Use the stored key when the field still shows the masked value.
220 252 if (!$apiKey || strpos($apiKey, '****') === 0) {
221 - $apiKey = Arr::get($this->getSavedSettings(), 'api_key', '');
253 + $credentials = $this->getSavedCredentials();
254 + $apiKey = $credentials['provider'] === $provider ? $credentials['api_key'] : '';
222 255 }
223 256
224 257 if ($provider !== 'wordpress' && !$apiKey) {
225 258 return new \WP_Error('missing_api_key', __('Please enter an API key.', 'fluent-boards'));
@@ -224,9 +257,9 @@
224 257 if ($provider !== 'wordpress' && !$apiKey) {
225 258 return new \WP_Error('missing_api_key', __('Please enter an API key.', 'fluent-boards'));
226 259 }
227 260
228 - $resolvedModel = $this->resolveModel($provider, $model);
261 + $resolvedModel = $this->resolveModels($provider, $model);
229 262 if (!$resolvedModel) {
230 263 return new \WP_Error('missing_model', __('AI model is not configured. Please set it in Settings.', 'fluent-boards'));
231 264 }
232 265
@@ -507,9 +540,9 @@
507 540 ];
508 541 }
509 542
510 543 /**
511 - * @return array{provider:string,model:string,api_key:string}|\WP_Error
544 + * @return array{provider:string,model:string[],api_key:string}|\WP_Error
512 545 */
513 546 private function validateGenerationConfig($settings)
514 547 {
515 548 if (Arr::get($settings, 'is_enabled') !== 'yes') {
@@ -527,9 +560,9 @@
527 560 if ($provider !== 'wordpress' && !$apiKey) {
528 561 return new \WP_Error('missing_api_key', __('AI API key is not configured. Please add it in Settings.', 'fluent-boards'));
529 562 }
530 563
531 - $resolvedModel = $this->resolveModel($provider, $model);
564 + $resolvedModel = $this->resolveModels($provider, $model);
532 565 if (!$resolvedModel) {
533 566 return new \WP_Error('missing_model', __('AI model is not configured. Please set it in Settings.', 'fluent-boards'));
534 567 }
535 568
@@ -615,10 +648,47 @@
615 648 /* -------------------------------------------------------------------------
616 649 * Provider dispatch
617 650 * ---------------------------------------------------------------------- */
618 651
619 - private function callProviderApi($provider, $model, $apiKey, $userPrompt, $systemPrompt = '', $timeout = 30)
652 + /**
653 + * @param string|string[] $models candidates in order; the next one is tried
654 + * only when the provider rejects the model
655 + */
656 + private function callProviderApi($provider, $models, $apiKey, $userPrompt, $systemPrompt = '', $timeout = 30)
620 657 {
658 + $result = null;
659 + foreach ((array) $models as $model) {
660 + $result = $this->callProviderModel($provider, $model, $apiKey, $userPrompt, $systemPrompt, $timeout);
661 + if (!$this->isModelUnavailableError($provider, $model, $result)) {
662 + return $result;
663 + }
664 + }
665 +
666 + return $result;
667 + }
668 +
669 + /** Fail closed: generic 404s and all auth, quota, permission and parameter errors do not retry. */
670 + private function isModelUnavailableError($provider, $model, $error)
671 + {
672 + if (!is_wp_error($error)) {
673 + return false;
674 + }
675 + $data = (array) $error->get_error_data();
676 + if ((int) Arr::get($data, 'status') !== 404) {
677 + return false;
678 + }
679 + $code = Arr::get($data, 'provider_error');
680 + if ($provider === 'open_ai') {
681 + return $code === 'model_not_found';
682 + }
683 + // Claude/Gemini use resource-wide codes, so also require the requested model ID.
684 + $expected = ['claude' => 'not_found_error', 'gemini' => 'NOT_FOUND'];
685 + return isset($expected[$provider]) && $code === $expected[$provider]
686 + && strpos($error->get_error_message(), $model) !== false;
687 + }
688 +
689 + private function callProviderModel($provider, $model, $apiKey, $userPrompt, $systemPrompt, $timeout)
690 + {
621 691 switch ($provider) {
622 692 case 'open_ai':
623 693 return $this->callOpenAi($model, $apiKey, $userPrompt, $systemPrompt, $timeout);
624 694 case 'claude':
@@ -624,8 +694,10 @@
624 694 case 'claude':
625 695 return $this->callClaude($model, $apiKey, $userPrompt, $systemPrompt, $timeout);
626 696 case 'gemini':
627 697 return $this->callGemini($model, $apiKey, $userPrompt, $systemPrompt, $timeout);
698 + case 'openrouter':
699 + return $this->callOpenRouter($model, $apiKey, $userPrompt, $systemPrompt, $timeout);
628 700 case 'wordpress':
629 701 return $this->callWordPress($model, $userPrompt, $systemPrompt, $timeout);
630 702 default:
631 703 return new \WP_Error('invalid_provider', __('Invalid AI provider.', 'fluent-boards'));
@@ -670,8 +742,21 @@
670 742 $messages[] = ['role' => 'system', 'content' => $systemPrompt];
671 743 }
672 744 $messages[] = ['role' => 'user', 'content' => $userPrompt];
673 745
746 + $data = [
747 + 'model' => $model,
748 + 'messages' => $messages,
749 + 'max_completion_tokens' => 2048,
750 + ];
751 + // Astra and 6.1 Sol require reasoning; reserve room for reasoning and text.
752 + if (in_array($model, ['gpt-6-astra', 'gpt-6.1-sol'], true)) {
753 + $data['reasoning_effort'] = 'low';
754 + $data['max_completion_tokens'] = 8192;
755 + } elseif ($model === 'gpt-6-luna') {
756 + $data['reasoning_effort'] = 'none';
757 + }
758 +
674 759 $response = wp_remote_post('https://api.openai.com/v1/chat/completions', [
675 760 'timeout' => $timeout,
676 761 'headers' => [
677 762 'Authorization' => 'Bearer ' . $apiKey,
@@ -676,17 +761,14 @@
676 761 'headers' => [
677 762 'Authorization' => 'Bearer ' . $apiKey,
678 763 'Content-Type' => 'application/json',
679 764 ],
680 - 'body' => wp_json_encode([
681 - 'model' => $model,
682 - 'messages' => $messages,
683 - 'max_completion_tokens' => 2048,
684 - ]),
765 + 'body' => wp_json_encode($data),
685 766 ]);
686 767
687 768 if (is_wp_error($response)) {
688 - return new \WP_Error('api_error', __('Failed to connect to OpenAI: ', 'fluent-boards') . $response->get_error_message());
769 + /* translators: %s is the error message returned by the AI provider */
770 + return new \WP_Error('api_error', sprintf(__('Failed to connect to OpenAI: %s', 'fluent-boards'), $response->get_error_message()));
689 771 }
690 772
691 773 $code = wp_remote_retrieve_response_code($response);
692 774 $body = json_decode(wp_remote_retrieve_body($response), true);
@@ -691,9 +773,9 @@
691 773 $code = wp_remote_retrieve_response_code($response);
692 774 $body = json_decode(wp_remote_retrieve_body($response), true);
693 775
694 776 if ($code !== 200) {
695 - return new \WP_Error('api_error', Arr::get($body, 'error.message', __('Unknown error from OpenAI.', 'fluent-boards')));
777 + return new \WP_Error('api_error', Arr::get($body, 'error.message', __('Unknown error from OpenAI.', 'fluent-boards')), ['status' => $code, 'provider_error' => Arr::get($body, 'error.code')]);
696 778 }
697 779
698 780 $content = Arr::get($body, 'choices.0.message.content', '');
699 781 if (empty($content)) {
@@ -712,8 +794,20 @@
712 794 ['role' => 'user', 'content' => $userPrompt],
713 795 ],
714 796 ];
715 797
798 + // These models think by default. Keep short Boards writing requests bounded.
799 + if ($model === 'claude-sonnet-5-5') {
800 + $data['thinking'] = ['type' => 'between_tools'];
801 + $data['output_config'] = ['effort' => 'low'];
802 + } elseif ($model === 'claude-haiku-5-5') {
803 + $data['thinking'] = ['type' => 'disabled'];
804 + $data['output_config'] = ['effort' => 'low'];
805 + } elseif (in_array($model, ['claude-opus-5-5', 'claude-fable-5-1'], true)) {
806 + $data['output_config'] = ['effort' => 'low'];
807 + $data['max_tokens'] = 8192;
808 + }
809 +
716 810 if ($systemPrompt) {
717 811 $data['system'] = $systemPrompt;
718 812 }
719 813
@@ -727,9 +821,10 @@
727 821 'body' => wp_json_encode($data),
728 822 ]);
729 823
730 824 if (is_wp_error($response)) {
731 - return new \WP_Error('api_error', __('Failed to connect to Claude: ', 'fluent-boards') . $response->get_error_message());
825 + /* translators: %s is the error message returned by the AI provider */
826 + return new \WP_Error('api_error', sprintf(__('Failed to connect to Claude: %s', 'fluent-boards'), $response->get_error_message()));
732 827 }
733 828
734 829 $code = wp_remote_retrieve_response_code($response);
735 830 $body = json_decode(wp_remote_retrieve_body($response), true);
@@ -734,12 +829,19 @@
734 829 $code = wp_remote_retrieve_response_code($response);
735 830 $body = json_decode(wp_remote_retrieve_body($response), true);
736 831
737 832 if ($code !== 200) {
738 - return new \WP_Error('api_error', Arr::get($body, 'error.message', __('Unknown error from Claude.', 'fluent-boards')));
833 + return new \WP_Error('api_error', Arr::get($body, 'error.message', __('Unknown error from Claude.', 'fluent-boards')), ['status' => $code, 'provider_error' => Arr::get($body, 'error.type')]);
739 834 }
740 835
741 - $content = Arr::get($body, 'content.0.text', '');
836 + // Thinking-enabled models can return non-text blocks before the answer.
837 + $textBlocks = [];
838 + foreach ((array) Arr::get($body, 'content', []) as $block) {
839 + if (Arr::get($block, 'type') === 'text') {
840 + $textBlocks[] = Arr::get($block, 'text', '');
841 + }
842 + }
843 + $content = implode("\n", $textBlocks);
742 844 if (empty($content)) {
743 845 return new \WP_Error('empty_response', __('No content generated. Please try again.', 'fluent-boards'));
744 846 }
745 847
@@ -758,8 +860,14 @@
758 860 'maxOutputTokens' => 2048,
759 861 ],
760 862 ];
761 863
864 + // Gemini 3.8 does not support minimal thinking; budget for low thinking plus text.
865 + if ($model === 'gemini-3.8-flash') {
866 + $data['generationConfig']['thinkingConfig'] = ['thinkingLevel' => 'low'];
867 + $data['generationConfig']['maxOutputTokens'] = 8192;
868 + }
869 +
762 870 if ($systemPrompt) {
763 871 $data['system_instruction'] = ['parts' => [['text' => $systemPrompt]]];
764 872 }
765 873
@@ -772,9 +880,10 @@
772 880 'body' => wp_json_encode($data),
773 881 ]);
774 882
775 883 if (is_wp_error($response)) {
776 - return new \WP_Error('api_error', __('Failed to connect to Gemini: ', 'fluent-boards') . $response->get_error_message());
884 + /* translators: %s is the error message returned by the AI provider */
885 + return new \WP_Error('api_error', sprintf(__('Failed to connect to Gemini: %s', 'fluent-boards'), $response->get_error_message()));
777 886 }
778 887
779 888 $code = wp_remote_retrieve_response_code($response);
780 889 $body = json_decode(wp_remote_retrieve_body($response), true);
@@ -779,9 +888,9 @@
779 888 $code = wp_remote_retrieve_response_code($response);
780 889 $body = json_decode(wp_remote_retrieve_body($response), true);
781 890
782 891 if ($code !== 200) {
783 - return new \WP_Error('api_error', Arr::get($body, 'error.message', __('Unknown error from Gemini.', 'fluent-boards')));
892 + return new \WP_Error('api_error', Arr::get($body, 'error.message', __('Unknown error from Gemini.', 'fluent-boards')), ['status' => $code, 'provider_error' => Arr::get($body, 'error.status')]);
784 893 }
785 894
786 895 $content = Arr::get($body, 'candidates.0.content.parts.0.text', '');
787 896 if (empty($content)) {
@@ -790,8 +899,86 @@
790 899
791 900 return $content;
792 901 }
793 902
903 + private function callOpenRouter($model, $apiKey, $userPrompt, $systemPrompt, $timeout)
904 + {
905 + $messages = [];
906 + if ($systemPrompt) {
907 + $messages[] = ['role' => 'system', 'content' => $systemPrompt];
908 + }
909 + $messages[] = ['role' => 'user', 'content' => $userPrompt];
910 +
911 + $response = wp_remote_post('https://openrouter.ai/api/v1/chat/completions', [
912 + 'timeout' => $timeout,
913 + 'headers' => [
914 + 'Authorization' => 'Bearer ' . $apiKey,
915 + 'Content-Type' => 'application/json',
916 + ],
917 + 'body' => wp_json_encode([
918 + 'model' => $model,
919 + 'messages' => $messages,
920 + 'max_completion_tokens' => 2048,
921 + ]),
922 + ]);
923 +
924 + if (is_wp_error($response)) {
925 + /* translators: %1$s: AI provider name, %2$s: error message returned by the provider */
926 + return new \WP_Error('api_error', sprintf(__('Failed to connect to %1$s: %2$s', 'fluent-boards'), 'OpenRouter', $response->get_error_message()));
927 + }
928 +
929 + $code = wp_remote_retrieve_response_code($response);
930 + $body = json_decode(wp_remote_retrieve_body($response), true);
931 + if ($code !== 200) {
932 + return new \WP_Error('api_error', Arr::get($body, 'error.message', __('Unknown error from OpenRouter.', 'fluent-boards')), ['status' => $code]);
933 + }
934 +
935 + $content = Arr::get($body, 'choices.0.message.content', '');
936 + if (empty($content)) {
937 + return new \WP_Error('empty_response', __('No content generated. Please try again.', 'fluent-boards'));
938 + }
939 +
940 + return $content;
941 + }
942 +
943 + private function getOpenRouterModels($apiKey)
944 + {
945 + $response = wp_remote_get('https://openrouter.ai/api/v1/models?output_modalities=text', [
946 + 'timeout' => 15,
947 + 'headers' => ['Authorization' => 'Bearer ' . $apiKey],
948 + ]);
949 +
950 + if (is_wp_error($response)) {
951 + /* translators: %s: error message returned by OpenRouter */
952 + return new \WP_Error('api_error', sprintf(__('Failed to load OpenRouter models: %s', 'fluent-boards'), $response->get_error_message()));
953 + }
954 +
955 + $code = wp_remote_retrieve_response_code($response);
956 + $body = json_decode(wp_remote_retrieve_body($response), true);
957 + if ($code !== 200) {
958 + return new \WP_Error('api_error', Arr::get($body, 'error.message', __('Could not load OpenRouter models.', 'fluent-boards')));
959 + }
960 +
961 + $models = [];
962 + foreach ((array) Arr::get($body, 'data', []) as $model) {
963 + $id = sanitize_text_field(Arr::get($model, 'id', ''));
964 + if (!$id) {
965 + continue;
966 + }
967 +
968 + $models[] = [
969 + 'value' => $id,
970 + 'label' => sanitize_text_field(Arr::get($model, 'name', $id)),
971 + ];
972 + }
973 +
974 + if (!$models) {
975 + return new \WP_Error('empty_models', __('OpenRouter returned no text models for this API key.', 'fluent-boards'));
976 + }
977 +
978 + return $models;
979 + }
980 +
794 981 /* -------------------------------------------------------------------------
795 982 * Storage helpers
796 983 * ---------------------------------------------------------------------- */
797 984
@@ -816,13 +1003,21 @@
816 1003 if (!is_array($credentials)) {
817 1004 $credentials = [];
818 1005 }
819 1006
820 - $model = sanitize_text_field(Arr::get($credentials, 'model', 'auto'));
1007 + $provider = $this->normalizeProvider(Arr::get($credentials, 'provider', ''));
1008 + $model = sanitize_text_field(Arr::get($credentials, 'model', 'auto')) ?: 'auto';
821 1009
1010 + // A retired model (removed from the list, or saved by another Fluent plugin
1011 + // that still offers it) falls back to Auto instead of failing every request.
1012 + $knownModels = Arr::get($this->providerModels, $provider, []);
1013 + if ($knownModels && !in_array($model, $knownModels, true)) {
1014 + $model = 'auto';
1015 + }
1016 +
822 1017 return [
823 - 'provider' => $this->normalizeProvider(Arr::get($credentials, 'provider', '')),
824 - 'model' => $model ?: 'auto',
1018 + 'provider' => $provider,
1019 + 'model' => $model,
825 1020 'api_key' => sanitize_text_field(Arr::get($credentials, 'api_key', '')),
826 1021 'created_by' => sanitize_text_field(Arr::get($credentials, 'created_by', '')),
827 1022 ];
828 1023 }
@@ -847,17 +1042,22 @@
847 1042 $provider = sanitize_key($provider);
848 1043 return $provider === 'openai' ? 'open_ai' : $provider;
849 1044 }
850 1045
851 - private function resolveModel($provider, $model)
1046 + /**
1047 + * Candidate models to try, in order. An explicit choice is used as-is.
1048 + *
1049 + * @return string[]
1050 + */
1051 + private function resolveModels($provider, $model)
852 1052 {
853 1053 $model = $model ?: 'auto';
854 1054
855 1055 if ($model !== 'auto') {
856 - return $model;
1056 + return [$model];
857 1057 }
858 1058
859 - return Arr::get($this->autoProviderModels, $provider, '');
1059 + return Arr::get($this->autoProviderModels, $provider, []);
860 1060 }
861 1061
862 1062 /**
863 1063 * Whether AI writing is enabled and configured (used to gate the editor UI).