| @@ -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). |