PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.29.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.29.0
2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 All 50 releases
thinkrank / includes / ai / class-brand-visibility-providers.php

class-brand-visibility-providers.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 1.29.0, at includes/ai/class-brand-visibility-providers.php

292 lines 9.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Brand Visibility v2 — per-platform provider resolution.
4 *
5 * Measuring visibility means asking SEVERAL assistants the same question, so
6 * this feature can't be chained to the single site-wide AI provider the rest
7 * of ThinkRank uses. Each platform therefore gets its own optional key
8 * (`bv_key_<platform>`), falling back to the main provider key when the two
9 * happen to be the same vendor. A platform with no usable key is simply not
10 * offered in the wizard.
11 *
12 * Grounding honesty: plain Chat Completions answers reflect the model's
13 * training data, while the consumer apps people actually use often browse the
14 * live web. Perplexity's API is search-grounded; the others are not, and the
15 * UI labels them accordingly rather than implying all four measure the same
16 * thing.
17 *
18 * @package ThinkRank\AI
19 * @since 1.28.0
20 */
21
22 declare(strict_types=1);
23
24 namespace ThinkRank\AI;
25
26 use ThinkRank\Core\Settings;
27
28 if (!defined('ABSPATH')) {
29 exit;
30 }
31
32 /**
33 * Resolves platform slugs to configured AI clients.
34 */
35 final class Brand_Visibility_Providers {
36
37 /**
38 * Supported platforms.
39 *
40 * `grounded` drives the UI's honesty label. `main_provider` names the
41 * site-wide provider whose key this platform can borrow.
42 */
43 public const PLATFORMS = [
44 'chatgpt' => [
45 'label' => 'ChatGPT',
46 'vendor' => 'openai',
47 'main_provider' => 'openai',
48 'grounded' => false,
49 'default_model' => 'gpt-5-nano',
50 ],
51 'gemini' => [
52 'label' => 'Gemini',
53 'vendor' => 'gemini',
54 'main_provider' => 'gemini',
55 'grounded' => false,
56 'default_model' => Settings::DEFAULT_GEMINI_MODEL,
57 ],
58 'claude' => [
59 'label' => 'Claude',
60 'vendor' => 'claude',
61 'main_provider' => 'claude',
62 'grounded' => false,
63 'default_model' => Settings::DEFAULT_CLAUDE_MODEL,
64 ],
65 'perplexity' => [
66 'label' => 'Perplexity',
67 'vendor' => 'perplexity',
68 'main_provider' => '', // no site-wide equivalent — needs its own key
69 'grounded' => true,
70 'default_model' => Perplexity_Client::DEFAULT_MODEL,
71 ],
72 ];
73
74 /**
75 * Selectable models per platform.
76 *
77 * Deliberately a short, curated list rather than the vendor's full catalogue:
78 * Brand Visibility fires queries × platforms × samples calls per run, so the
79 * cheap/fast tiers are listed first and are the defaults. The whitelist also
80 * doubles as save-time validation — an unknown id is rejected rather than
81 * stored and failing later inside a queued run.
82 *
83 * @var array<string, array<int, array{value: string, label: string}>>
84 */
85 public const MODEL_CHOICES = [
86 'chatgpt' => [
87 ['value' => 'gpt-5-nano', 'label' => 'GPT-5 nano (fastest, lowest cost)'],
88 ['value' => 'gpt-5-mini', 'label' => 'GPT-5 mini (balanced)'],
89 ['value' => 'gpt-5', 'label' => 'GPT-5 (highest quality)'],
90 ['value' => 'gpt-4o', 'label' => 'GPT-4o (compatibility)'],
91 ],
92 'gemini' => [
93 ['value' => 'gemini-3.5-flash', 'label' => 'Gemini 3.5 Flash (recommended)'],
94 ['value' => 'gemini-3.1-flash-lite', 'label' => 'Gemini 3.1 Flash Lite (fastest)'],
95 ['value' => 'gemini-3.1-pro', 'label' => 'Gemini 3.1 Pro'],
96 ['value' => 'gemini-2.5-pro', 'label' => 'Gemini 2.5 Pro'],
97 ],
98 'claude' => [
99 ['value' => 'claude-haiku-4-5', 'label' => 'Claude Haiku 4.5 (fastest)'],
100 ['value' => 'claude-sonnet-5', 'label' => 'Claude Sonnet 5 (recommended)'],
101 ['value' => 'claude-opus-4-8', 'label' => 'Claude Opus 4.8 (most capable)'],
102 ],
103 'perplexity' => [
104 ['value' => 'sonar', 'label' => 'Sonar (search-grounded)'],
105 ['value' => 'sonar-pro', 'label' => 'Sonar Pro (deeper search)'],
106 ['value' => 'sonar-reasoning', 'label' => 'Sonar Reasoning'],
107 ],
108 ];
109
110 /**
111 * Settings accessor.
112 *
113 * @var Settings
114 */
115 private Settings $settings;
116
117 /**
118 * Constructor.
119 *
120 * @param Settings|null $settings Settings instance.
121 */
122 public function __construct(?Settings $settings = null) {
123 $this->settings = $settings ?? Settings::instance();
124 }
125
126 /**
127 * Option key holding a platform's dedicated API key.
128 *
129 * @param string $platform Platform slug.
130 * @return string
131 */
132 public static function key_option(string $platform): string {
133 return 'bv_key_' . $platform;
134 }
135
136 /**
137 * Option key holding a platform's chosen model.
138 *
139 * @param string $platform Platform slug.
140 * @return string
141 */
142 public static function model_option(string $platform): string {
143 return 'bv_model_' . $platform;
144 }
145
146 /**
147 * Whether a model id is offered for a platform.
148 *
149 * @param string $platform Platform slug.
150 * @param string $model Model id.
151 * @return bool
152 */
153 public static function is_valid_model(string $platform, string $model): bool {
154 foreach (self::MODEL_CHOICES[$platform] ?? [] as $choice) {
155 if ($choice['value'] === $model) {
156 return true;
157 }
158 }
159
160 return false;
161 }
162
163 /**
164 * The model a platform will actually use — its saved choice, else the
165 * platform default. A stored id that is no longer offered falls back to the
166 * default rather than failing mid-run.
167 *
168 * @param string $platform Platform slug.
169 * @return string Empty when the platform is unknown.
170 */
171 public function model_for(string $platform): string {
172 $config = self::PLATFORMS[$platform] ?? null;
173 if (null === $config) {
174 return '';
175 }
176
177 $saved = trim((string) $this->settings->get(self::model_option($platform), ''));
178
179 return self::is_valid_model($platform, $saved) ? $saved : (string) $config['default_model'];
180 }
181
182 /**
183 * Effective model for every platform, for the wizard's model pickers.
184 *
185 * @return array<string, string>
186 */
187 public function models(): array {
188 $out = [];
189
190 foreach (array_keys(self::PLATFORMS) as $slug) {
191 $out[$slug] = $this->model_for($slug);
192 }
193
194 return $out;
195 }
196
197 /**
198 * The API key to use for a platform: its own, else the site-wide key when
199 * the main provider is the same vendor.
200 *
201 * @param string $platform Platform slug.
202 * @return string Empty when the platform is unusable.
203 */
204 public function api_key_for(string $platform): string {
205 $config = self::PLATFORMS[$platform] ?? null;
206 if (null === $config) {
207 return '';
208 }
209
210 $own = trim((string) $this->settings->get(self::key_option($platform), ''));
211 if ('' !== $own) {
212 return $own;
213 }
214
215 // Borrow the site-wide key only when it belongs to the same vendor —
216 // an OpenAI key cannot talk to Anthropic.
217 $main_provider = (string) $this->settings->get('ai_provider', '');
218 if ('' !== $config['main_provider'] && $main_provider === $config['main_provider']) {
219 return trim((string) $this->settings->get('ai_api_key', ''));
220 }
221
222 return '';
223 }
224
225 /**
226 * Platforms with a usable key, for the wizard's platform picker.
227 *
228 * @return array<int, array{slug: string, label: string, grounded: bool, available: bool, uses_shared_key: bool}>
229 */
230 public function available_platforms(): array {
231 $out = [];
232
233 foreach (self::PLATFORMS as $slug => $config) {
234 $own_key = '' !== trim((string) $this->settings->get(self::key_option($slug), ''));
235
236 $out[] = [
237 'slug' => $slug,
238 'label' => $config['label'],
239 'grounded' => (bool) $config['grounded'],
240 'available' => '' !== $this->api_key_for($slug),
241 'uses_shared_key' => !$own_key && '' !== $this->api_key_for($slug),
242 ];
243 }
244
245 return $out;
246 }
247
248 /**
249 * Build a client for a platform.
250 *
251 * @param string $platform Platform slug.
252 * @return object A client exposing generate_completion(string, array): array.
253 * @throws \Exception When the platform is unknown or has no key.
254 */
255 public function client_for(string $platform) {
256 $config = self::PLATFORMS[$platform] ?? null;
257 if (null === $config) {
258 throw new \Exception(sprintf('Unknown Brand Visibility platform: %s', esc_html($platform)));
259 }
260
261 $api_key = $this->api_key_for($platform);
262 if ('' === $api_key) {
263 throw new \Exception(sprintf(
264 /* translators: %s: platform label, e.g. Claude. */
265 esc_html__('No API key configured for %s. Add one under Brand Visibility settings.', 'thinkrank'),
266 esc_html($config['label'])
267 ));
268 }
269
270 $model = $this->model_for($platform);
271
272 switch ($config['vendor']) {
273 case 'openai':
274 return '' !== $model
275 ? new OpenAI_Client($api_key, $model)
276 : new OpenAI_Client($api_key);
277 case 'claude':
278 return '' !== $model
279 ? new Claude_Client($api_key, $model)
280 : new Claude_Client($api_key);
281 case 'gemini':
282 return '' !== $model
283 ? new Gemini_Client($api_key, $model)
284 : new Gemini_Client($api_key);
285 case 'perplexity':
286 return new Perplexity_Client($api_key, $model);
287 }
288
289 throw new \Exception(sprintf('Unsupported vendor for platform: %s', esc_html($platform)));
290 }
291 }
292