PluginProbe
King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder / 51.1.82
King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder v51.1.82
51.1.83 51.1.82 51.1.81 51.1.79 51.1.78 51.1.77 51.1.76 51.1.74 51.1.75 51.1.65 51.1.64 51.1.63 trunk 51.1.14 51.1.2 51.1.35 51.1.36 51.1.37 51.1.38 51.1.39 51.1.44 51.1.45 51.1.46 51.1.47 51.1.49 All 37 releases
king-addons / includes / AI_Provider.php

AI_Provider.php in King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder 51.1.82, at includes/AI_Provider.php

934 lines 32.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Central abstraction over the AI providers King Addons can talk to.
5 *
6 * Every AI feature (text generation, rewriting, translation, alt text, auto
7 * tagging, post generation, image generation) goes through here so that adding
8 * a provider does not mean touching each call site.
9 *
10 * Both supported providers speak the OpenAI Chat Completions dialect, so the
11 * request/response bodies stay identical — only the base URL, the auth headers
12 * and the model list differ.
13 */
14
15 namespace King_Addons;
16
17 if (!defined('ABSPATH')) {
18 exit; // Exit if accessed directly.
19 }
20
21 final class AI_Provider
22 {
23 public const OPTION_NAME = 'king_addons_ai_options';
24
25 public const OPENAI = 'openai';
26 public const OPENROUTER = 'openrouter';
27
28 /** Legacy cache key, kept so existing installs do not lose their cached list. */
29 private const CACHE_OPENAI = 'king_addons_ai_models_cache';
30 private const CACHE_OPENROUTER = 'king_addons_ai_openrouter_models_cache';
31
32 private const BASE_OPENAI = 'https://api.openai.com/v1/';
33 private const BASE_OPENROUTER = 'https://openrouter.ai/api/v1/';
34
35 /**
36 * Fallbacks used when the model list cannot be fetched (no key yet, API down).
37 */
38 private const FALLBACK_OPENAI = ['gpt-4o-mini' => 'GPT-4o-mini', 'gpt-4.1-nano' => 'GPT-4.1-nano'];
39 private const FALLBACK_OPENROUTER = ['openai/gpt-4o-mini' => 'OpenAI: GPT-4o-mini'];
40
41 /**
42 * All providers with their human readable labels.
43 *
44 * @return array<string, string>
45 */
46 public static function getProviders(): array
47 {
48 return [
49 self::OPENAI => esc_html__('OpenAI', 'king-addons'),
50 self::OPENROUTER => esc_html__('OpenRouter', 'king-addons'),
51 ];
52 }
53
54 /**
55 * Provider to use when none has been chosen yet.
56 *
57 * New installs start on OpenRouter, because its free models let someone try
58 * the AI features without paying anything first. An install that already had
59 * an OpenAI key before the provider selector existed keeps OpenAI, so an
60 * update never silently points working features at a provider with no key.
61 */
62 public static function getDefaultProvider(): string
63 {
64 $options = get_option(self::OPTION_NAME, []);
65
66 if (is_array($options) && !empty($options['openai_api_key'])) {
67 return self::OPENAI;
68 }
69
70 return self::OPENROUTER;
71 }
72
73 /**
74 * Normalises an arbitrary value to a supported provider slug.
75 */
76 public static function normalizeProvider($provider): string
77 {
78 if ($provider === self::OPENROUTER) {
79 return self::OPENROUTER;
80 }
81
82 if ($provider === self::OPENAI) {
83 return self::OPENAI;
84 }
85
86 return self::getDefaultProvider();
87 }
88
89 /**
90 * The provider currently selected in AI Settings.
91 */
92 public static function getProvider(): string
93 {
94 $options = get_option(self::OPTION_NAME, []);
95 $stored = is_array($options) ? ($options['ai_provider'] ?? '') : '';
96
97 if ($stored === self::OPENAI || $stored === self::OPENROUTER) {
98 return $stored;
99 }
100
101 return self::getDefaultProvider();
102 }
103
104 public static function isOpenRouter(): bool
105 {
106 return self::getProvider() === self::OPENROUTER;
107 }
108
109 /**
110 * Human readable name of a provider.
111 */
112 public static function getLabel(?string $provider = null): string
113 {
114 $provider = self::normalizeProvider($provider ?? self::getProvider());
115 $providers = self::getProviders();
116 return $providers[$provider];
117 }
118
119 /**
120 * API key stored for a provider (defaults to the active one).
121 */
122 public static function getApiKey(?string $provider = null): string
123 {
124 $provider = self::normalizeProvider($provider ?? self::getProvider());
125 $options = get_option(self::OPTION_NAME, []);
126 if (!is_array($options)) {
127 return '';
128 }
129 $key = ($provider === self::OPENROUTER)
130 ? ($options['openrouter_api_key'] ?? '')
131 : ($options['openai_api_key'] ?? '');
132 return is_string($key) ? trim($key) : '';
133 }
134
135 /**
136 * Option key holding a model for the given provider and purpose.
137 *
138 * @param string $type text|vision|image
139 */
140 public static function getModelOptionKey(string $type, ?string $provider = null): string
141 {
142 $provider = self::normalizeProvider($provider ?? self::getProvider());
143 $prefix = ($provider === self::OPENROUTER) ? 'openrouter' : 'openai';
144 switch ($type) {
145 case 'vision':
146 return $prefix . '_vision_model';
147 case 'image':
148 return $prefix . '_image_model';
149 default:
150 return $prefix . '_model';
151 }
152 }
153
154 /**
155 * Default model used when nothing is configured yet.
156 *
157 * @param string $type text|vision|image
158 */
159 public static function getDefaultModel(string $type, ?string $provider = null): string
160 {
161 $provider = self::normalizeProvider($provider ?? self::getProvider());
162 if ($provider === self::OPENROUTER) {
163 return ($type === 'image') ? 'google/gemini-2.5-flash-image' : 'openai/gpt-4o-mini';
164 }
165 return ($type === 'image') ? 'gpt-image-1' : 'gpt-4o-mini';
166 }
167
168 /**
169 * Model configured for text generation.
170 */
171 public static function getTextModel(): string
172 {
173 return self::getModel('text');
174 }
175
176 /**
177 * Model configured for image recognition (vision), falling back to the text model.
178 */
179 public static function getVisionModel(): string
180 {
181 $model = self::getModel('vision');
182 return ($model !== '') ? $model : self::getModel('text');
183 }
184
185 /**
186 * Model configured for image generation.
187 */
188 public static function getImageModel(): string
189 {
190 $model = self::getModel('image');
191 return ($model !== '') ? $model : self::getDefaultModel('image');
192 }
193
194 /**
195 * Reads a stored model for the active provider.
196 *
197 * @param string $type text|vision|image
198 */
199 public static function getModel(string $type, ?string $provider = null): string
200 {
201 $options = get_option(self::OPTION_NAME, []);
202 if (!is_array($options)) {
203 return '';
204 }
205 $value = $options[self::getModelOptionKey($type, $provider)] ?? '';
206 return is_string($value) ? trim($value) : '';
207 }
208
209 /**
210 * Base API URL for a provider, with trailing slash.
211 */
212 public static function getBaseUrl(?string $provider = null): string
213 {
214 $provider = self::normalizeProvider($provider ?? self::getProvider());
215 return ($provider === self::OPENROUTER) ? self::BASE_OPENROUTER : self::BASE_OPENAI;
216 }
217
218 /**
219 * Chat Completions endpoint of a provider.
220 */
221 public static function getChatEndpoint(?string $provider = null): string
222 {
223 return self::getBaseUrl($provider) . 'chat/completions';
224 }
225
226 /**
227 * Image generation endpoint of a provider.
228 */
229 public static function getImagesEndpoint(?string $provider = null): string
230 {
231 return self::getBaseUrl($provider) . 'images/generations';
232 }
233
234 /**
235 * Request headers for a provider. OpenRouter asks integrations to identify
236 * themselves so requests are attributed to the site rather than anonymous.
237 *
238 * @return array<string, string>
239 */
240 public static function getHeaders(?string $provider = null, ?string $api_key = null): array
241 {
242 $provider = self::normalizeProvider($provider ?? self::getProvider());
243 $api_key = ($api_key !== null && $api_key !== '') ? $api_key : self::getApiKey($provider);
244
245 $headers = [
246 'Authorization' => 'Bearer ' . $api_key,
247 'Content-Type' => 'application/json',
248 ];
249
250 if ($provider === self::OPENROUTER) {
251 $headers['HTTP-Referer'] = home_url('/');
252 $headers['X-Title'] = 'King Addons';
253 }
254
255 return $headers;
256 }
257
258 /**
259 * Whether the given URL belongs to one of the AI providers.
260 */
261 public static function isProviderUrl(string $url): bool
262 {
263 return strpos($url, 'openai.com') !== false || strpos($url, 'openrouter.ai') !== false;
264 }
265
266 /**
267 * Link to the provider's usage dashboard.
268 */
269 public static function getDashboardUrl(?string $provider = null): string
270 {
271 return (self::normalizeProvider($provider ?? self::getProvider()) === self::OPENROUTER)
272 ? 'https://openrouter.ai/activity'
273 : 'https://platform.openai.com/usage';
274 }
275
276 /**
277 * Link to the provider's API key page.
278 */
279 public static function getApiKeysUrl(?string $provider = null): string
280 {
281 return (self::normalizeProvider($provider ?? self::getProvider()) === self::OPENROUTER)
282 ? 'https://openrouter.ai/keys'
283 : 'https://platform.openai.com/api-keys';
284 }
285
286 /**
287 * Classifies a provider failure so callers can tell a temporary hiccup from
288 * a dead end, and report the right thing to the user.
289 *
290 * Providers answer with very different bodies but fairly consistent status
291 * codes, so the status is what the decision is based on.
292 *
293 * @param \WP_Error|int $error Error from decodeResponse(), or a raw status code.
294 * @param string $message Message to carry through; taken from the error when omitted.
295 * @return array{code: string, retryable: bool, status: int, message: string}
296 */
297 public static function classifyError($error, string $message = ''): array
298 {
299 $status = 0;
300
301 if (is_wp_error($error)) {
302 $data = $error->get_error_data();
303 $status = (int) (is_array($data) ? ($data['status'] ?? 0) : 0);
304 if ($message === '') {
305 $message = $error->get_error_message();
306 }
307
308 // A transport failure never reached the provider, so it carries no
309 // status. Timeouts and dropped connections are exactly the kind of
310 // hiccup a retry fixes, so they must not fall through to "unknown".
311 if ($status === 0 && $error->get_error_code() === 'http_request_failed') {
312 return [
313 'code' => 'timeout',
314 'retryable' => true,
315 'status' => 0,
316 'message' => $message,
317 ];
318 }
319 } elseif (is_numeric($error)) {
320 $status = (int) $error;
321 }
322
323 switch ($status) {
324 case 401:
325 case 403:
326 $code = 'auth';
327 $retryable = false;
328 break;
329 case 402:
330 $code = 'credits';
331 $retryable = false;
332 break;
333 case 400:
334 case 404:
335 // Usually a model id the provider does not serve.
336 $code = 'model';
337 $retryable = false;
338 break;
339 case 429:
340 // Per-minute throttling and free-tier daily caps both land here.
341 $code = 'rate_limit';
342 $retryable = true;
343 break;
344 case 408:
345 case 409:
346 case 500:
347 case 502:
348 case 503:
349 case 504:
350 // The provider or the model behind it is briefly unavailable.
351 $code = 'upstream';
352 $retryable = true;
353 break;
354 default:
355 $code = 'unknown';
356 $retryable = false;
357 break;
358 }
359
360 // A daily cap is reported as 429 but waiting a few seconds will not
361 // clear it, so it must not be retried like ordinary throttling.
362 if ($code === 'rate_limit' && preg_match('/\b(daily limit|per day|limit_rpd|quota exceeded|out of credits)\b/i', $message)) {
363 $code = 'daily_limit';
364 $retryable = false;
365 }
366
367 return [
368 'code' => $code,
369 'retryable' => $retryable,
370 'status' => $status,
371 'message' => $message,
372 ];
373 }
374
375 /**
376 * HTTP status to answer an AJAX caller with, so the browser sees something
377 * closer to the truth than a blanket 500.
378 */
379 public static function getResponseStatus(array $classified): int
380 {
381 $status = (int) ($classified['status'] ?? 0);
382
383 // Pass through the statuses a client can act on; collapse the rest to
384 // 502, which says "the upstream failed", not "this site broke".
385 if (in_array($status, [400, 401, 402, 403, 404, 408, 429], true)) {
386 return $status;
387 }
388
389 return 502;
390 }
391
392 /**
393 * Applies provider specific defaults to a Chat Completions payload.
394 *
395 * King Addons sends deliberately small max_tokens budgets (50 for alt text,
396 * 80 for tags). Many models in OpenRouter's catalogue are reasoning models
397 * that would spend that entire budget thinking and return an empty message,
398 * so reasoning is turned off unless the caller asked for it.
399 *
400 * @param array $payload Chat Completions payload.
401 * @return array
402 */
403 public static function prepareChatPayload(array $payload, ?string $provider = null): array
404 {
405 $provider = self::normalizeProvider($provider ?? self::getProvider());
406
407 if ($provider === self::OPENROUTER && !isset($payload['reasoning'])) {
408 $payload['reasoning'] = ['enabled' => false];
409 }
410
411 return $payload;
412 }
413
414 /**
415 * Reads the assistant message out of a Chat Completions response.
416 *
417 * @param mixed $data Decoded response body.
418 * @return string|\WP_Error Message text, or the reason it is missing.
419 */
420 public static function extractMessageContent($data)
421 {
422 if (!is_array($data) || empty($data['choices'][0]) || !is_array($data['choices'][0])) {
423 return new \WP_Error('king_addons_ai_empty', self::extractErrorMessage($data));
424 }
425
426 $choice = $data['choices'][0];
427 $content = $choice['message']['content'] ?? null;
428
429 if (is_string($content) && trim($content) !== '') {
430 return trim($content);
431 }
432
433 // A model that spent its whole budget before answering needs a clearer
434 // message than "unexpected data" — the fix is a different model.
435 if (($choice['finish_reason'] ?? '') === 'length') {
436 return new \WP_Error('king_addons_ai_truncated', esc_html__('The model ran out of tokens before it produced an answer. Try a model that does not use extended reasoning.', 'king-addons'));
437 }
438
439 return new \WP_Error('king_addons_ai_empty', esc_html__('The model returned an empty response.', 'king-addons'));
440 }
441
442 /**
443 * Turns an error body into one readable line.
444 *
445 * OpenRouter often answers with a vague "Provider returned error" and puts
446 * the real cause in error.metadata, so surface that too.
447 *
448 * @param mixed $body Decoded response body.
449 * @param string $fallback Message to use when the body carries nothing useful.
450 */
451 public static function extractErrorMessage($body, string $fallback = ''): string
452 {
453 if ($fallback === '') {
454 $fallback = esc_html__('The AI provider could not complete the request.', 'king-addons');
455 }
456 if (!is_array($body) || !isset($body['error'])) {
457 return $fallback;
458 }
459
460 $error = $body['error'];
461 if (!is_array($error)) {
462 return is_scalar($error) ? (string) $error : $fallback;
463 }
464
465 $message = (isset($error['message']) && is_string($error['message']) && $error['message'] !== '')
466 ? $error['message']
467 : $fallback;
468
469 $parts = [];
470 if (!empty($error['metadata']['provider_name'])) {
471 $parts[] = (string) $error['metadata']['provider_name'];
472 }
473 if (!empty($error['metadata']['raw'])) {
474 $raw = $error['metadata']['raw'];
475 $parts[] = wp_strip_all_tags(substr(is_string($raw) ? $raw : (string) wp_json_encode($raw), 0, 400));
476 }
477 if (empty($parts) && !empty($error['code'])) {
478 $parts[] = 'code ' . $error['code'];
479 }
480
481 return $parts ? $message . '' . implode(': ', $parts) : $message;
482 }
483
484 /**
485 * A provider can answer HTTP 200 and still carry an error object, so both
486 * the status code and the body have to be checked.
487 *
488 * @param array|\WP_Error $response Raw wp_remote_* response.
489 * @return array|\WP_Error Decoded body or an error.
490 */
491 public static function decodeResponse($response, string $fallback = '')
492 {
493 if (is_wp_error($response)) {
494 return $response;
495 }
496
497 $code = (int) wp_remote_retrieve_response_code($response);
498 $raw = (string) wp_remote_retrieve_body($response);
499 $body = json_decode($raw, true);
500
501 if ($code < 200 || $code >= 300 || !is_array($body) || isset($body['error'])) {
502 $message = is_array($body)
503 ? self::extractErrorMessage($body, $fallback)
504 : wp_strip_all_tags(substr($raw, 0, 500));
505
506 if (trim($message) === '') {
507 /* translators: %d: HTTP status code */
508 $message = sprintf(esc_html__('The AI provider returned HTTP %d.', 'king-addons'), $code);
509 }
510
511 return new \WP_Error('king_addons_ai_provider', $message, ['status' => $code]);
512 }
513
514 return $body;
515 }
516
517 /* --------------------------------------------------------------------- */
518 /* Model list */
519 /* --------------------------------------------------------------------- */
520
521 /**
522 * Transient name holding the cached model list of a provider.
523 */
524 public static function getCacheKey(?string $provider = null): string
525 {
526 return (self::normalizeProvider($provider ?? self::getProvider()) === self::OPENROUTER)
527 ? self::CACHE_OPENROUTER
528 : self::CACHE_OPENAI;
529 }
530
531 /**
532 * Drops the cached model lists of every provider.
533 */
534 public static function clearModelsCache(): void
535 {
536 delete_transient(self::CACHE_OPENAI);
537 delete_transient(self::CACHE_OPENROUTER);
538 }
539
540 /**
541 * Fetches the model catalogue of a provider.
542 *
543 * @return array<int, array<string, mixed>>|\WP_Error List of model entries.
544 */
545 public static function fetchModels(?string $provider = null, ?string $api_key = null)
546 {
547 $provider = self::normalizeProvider($provider ?? self::getProvider());
548 $api_key = ($api_key !== null && $api_key !== '') ? $api_key : self::getApiKey($provider);
549
550 // OpenRouter serves its catalogue publicly; OpenAI needs the key.
551 if ($api_key === '' && $provider !== self::OPENROUTER) {
552 return new \WP_Error('missing_key', esc_html__('API key is required to fetch models.', 'king-addons'));
553 }
554
555 $args = ['timeout' => 20];
556 if ($api_key !== '') {
557 $args['headers'] = self::getHeaders($provider, $api_key);
558 unset($args['headers']['Content-Type']);
559 }
560
561 $body = self::decodeResponse(
562 wp_remote_get(self::getBaseUrl($provider) . 'models', $args),
563 esc_html__('Invalid response from API.', 'king-addons')
564 );
565
566 if (is_wp_error($body)) {
567 return $body;
568 }
569
570 if (empty($body['data']) || !is_array($body['data'])) {
571 return new \WP_Error('no_models', esc_html__('No models found via API.', 'king-addons'));
572 }
573
574 $models = ($provider === self::OPENROUTER)
575 ? self::parseOpenRouterModels($body['data'])
576 : self::parseOpenAiModels($body['data']);
577
578 if (empty($models)) {
579 return new \WP_Error('no_models', esc_html__('No models found via API.', 'king-addons'));
580 }
581
582 return self::sortModels($models);
583 }
584
585 /**
586 * OpenAI does not advertise per-model capabilities, so every model stays
587 * available for every purpose — same behaviour King Addons always had.
588 *
589 * @param array $data Raw `data` array from the API.
590 * @return array<int, array<string, mixed>>
591 */
592 private static function parseOpenAiModels(array $data): array
593 {
594 $models = [];
595 foreach ($data as $model) {
596 if (!is_array($model) || empty($model['id']) || !is_string($model['id'])) {
597 continue;
598 }
599 $models[] = [
600 'id' => $model['id'],
601 'label' => $model['id'],
602 'free' => false,
603 'text' => true,
604 'vision' => true,
605 'image' => false,
606 ];
607 }
608 return $models;
609 }
610
611 /**
612 * OpenRouter reports pricing and modalities, so the list can be split into
613 * free and paid models and filtered per purpose.
614 *
615 * @param array $data Raw `data` array from the API.
616 * @return array<int, array<string, mixed>>
617 */
618 private static function parseOpenRouterModels(array $data): array
619 {
620 $models = [];
621
622 foreach ($data as $model) {
623 if (!is_array($model) || empty($model['id']) || !is_string($model['id'])) {
624 continue;
625 }
626
627 $pricing = [];
628 $prices_known = true;
629 $raw_pricing = (isset($model['pricing']) && is_array($model['pricing'])) ? $model['pricing'] : [];
630 foreach ($raw_pricing as $metric => $price) {
631 if (is_numeric($price) && is_finite((float) $price)) {
632 $pricing[$metric] = (float) $price;
633 } else {
634 // Nested overrides and non-numeric values mean the price is
635 // not a flat, known number.
636 $prices_known = false;
637 }
638 }
639
640 // Missing prices and routed prices (-1) are not a promise of free
641 // usage. Every advertised charge must be exactly zero.
642 $free = $prices_known
643 && isset($pricing['prompt'], $pricing['completion'])
644 && 0.0 === $pricing['prompt']
645 && 0.0 === $pricing['completion'];
646 if ($free) {
647 foreach ($pricing as $price) {
648 if (0.0 !== $price) {
649 $free = false;
650 break;
651 }
652 }
653 }
654
655 $architecture = (isset($model['architecture']) && is_array($model['architecture'])) ? $model['architecture'] : [];
656 $inputs = isset($architecture['input_modalities']) ? (array) $architecture['input_modalities'] : ['text'];
657 $outputs = isset($architecture['output_modalities']) ? (array) $architecture['output_modalities'] : ['text'];
658
659 $models[] = [
660 'id' => $model['id'],
661 'label' => (isset($model['name']) && is_string($model['name']) && $model['name'] !== '')
662 ? $model['name']
663 : $model['id'],
664 'free' => $free,
665 'text' => in_array('text', $outputs, true),
666 'vision' => in_array('image', $inputs, true),
667 'image' => in_array('image', $outputs, true),
668 'context' => isset($model['context_length']) ? (int) $model['context_length'] : 0,
669 ];
670 }
671
672 return $models;
673 }
674
675 /**
676 * Free models first, then paid ones, each block alphabetical by label.
677 *
678 * @param array<int, array<string, mixed>> $models
679 * @return array<int, array<string, mixed>>
680 */
681 public static function sortModels(array $models): array
682 {
683 usort($models, static function ($left, $right) {
684 if (!empty($left['free']) !== !empty($right['free'])) {
685 return !empty($left['free']) ? -1 : 1;
686 }
687 $by_label = strcasecmp((string) $left['label'], (string) $right['label']);
688 return $by_label !== 0 ? $by_label : strcmp((string) $left['id'], (string) $right['id']);
689 });
690
691 return $models;
692 }
693
694 /**
695 * Model list of a provider, served from cache when available.
696 *
697 * @return array<int, array<string, mixed>>
698 */
699 public static function getModels(?string $provider = null): array
700 {
701 $provider = self::normalizeProvider($provider ?? self::getProvider());
702
703 $cached = get_transient(self::getCacheKey($provider));
704 if (is_array($cached) && !empty($cached)) {
705 return self::normalizeCachedModels($cached);
706 }
707
708 $fetched = self::fetchModels($provider);
709 if (!is_wp_error($fetched)) {
710 // OpenAI's list never changes without a key change, so it is kept
711 // until refreshed by hand. OpenRouter adds models constantly.
712 $ttl = ($provider === self::OPENROUTER) ? DAY_IN_SECONDS : 0;
713 set_transient(self::getCacheKey($provider), $fetched, $ttl);
714 return $fetched;
715 }
716
717 return self::getFallbackModels($provider);
718 }
719
720 /**
721 * Older installs cached a flat id => label map. Accept both shapes so an
722 * upgrade does not need the cache to be cleared first.
723 *
724 * @param array $cached
725 * @return array<int, array<string, mixed>>
726 */
727 private static function normalizeCachedModels(array $cached): array
728 {
729 $first = reset($cached);
730 if (is_array($first) && isset($first['id'])) {
731 return $cached;
732 }
733
734 $models = [];
735 foreach ($cached as $id => $label) {
736 if (!is_string($id) || $id === '') {
737 continue;
738 }
739 $models[] = [
740 'id' => $id,
741 'label' => is_string($label) ? $label : $id,
742 'free' => false,
743 'text' => true,
744 'vision' => true,
745 'image' => false,
746 ];
747 }
748 return $models;
749 }
750
751 /**
752 * @return array<int, array<string, mixed>>
753 */
754 private static function getFallbackModels(?string $provider = null): array
755 {
756 $provider = self::normalizeProvider($provider ?? self::getProvider());
757 $fallback = ($provider === self::OPENROUTER) ? self::FALLBACK_OPENROUTER : self::FALLBACK_OPENAI;
758
759 $models = [];
760 foreach ($fallback as $id => $label) {
761 $models[] = [
762 'id' => $id,
763 'label' => $label,
764 'free' => false,
765 'text' => true,
766 'vision' => true,
767 'image' => false,
768 ];
769 }
770 return $models;
771 }
772
773 /**
774 * Model list narrowed to a purpose.
775 *
776 * @param string $type text|vision|image
777 * @return array<int, array<string, mixed>>
778 */
779 public static function getModelsFor(string $type, ?string $provider = null): array
780 {
781 $provider = self::normalizeProvider($provider ?? self::getProvider());
782 $models = self::getModels($provider);
783
784 // OpenAI exposes no capability flags; its image models are a fixed pair.
785 if ($provider !== self::OPENROUTER) {
786 if ($type === 'image') {
787 return [
788 ['id' => 'dall-e-3', 'label' => esc_html__('DALL·E 3', 'king-addons'), 'free' => false],
789 ['id' => 'gpt-image-1', 'label' => esc_html__('GPT Image 1', 'king-addons'), 'free' => false],
790 ];
791 }
792 return $models;
793 }
794
795 $key = in_array($type, ['vision', 'image'], true) ? $type : 'text';
796 $filtered = array_values(array_filter($models, static function ($model) use ($key) {
797 return !empty($model[$key]);
798 }));
799
800 return !empty($filtered) ? $filtered : $models;
801 }
802
803 /**
804 * Renders a model <select>, grouping free models above paid ones.
805 *
806 * @param string $name Field name attribute.
807 * @param string $selected Currently selected model id.
808 * @param array $models Model entries from getModelsFor().
809 * @param array $attrs Extra attributes, e.g. ['class' => '...', 'id' => '...'].
810 */
811 public static function renderModelSelect(string $name, string $selected, array $models, array $attrs = []): void
812 {
813 $attr_html = '';
814 foreach ($attrs as $attr => $value) {
815 $attr_html .= sprintf(' %s="%s"', esc_attr($attr), esc_attr($value));
816 }
817
818 if (empty($models)) {
819 printf('<select name="%s"%s disabled>', esc_attr($name), $attr_html);
820 echo '<option value="">' . esc_html__('Could not fetch models. Check the API key?', 'king-addons') . '</option>';
821 echo '</select>';
822 return;
823 }
824
825 $free = array_values(array_filter($models, static function ($model) {
826 return !empty($model['free']);
827 }));
828 $paid = array_values(array_filter($models, static function ($model) {
829 return empty($model['free']);
830 }));
831
832 // The saved model may have been retired upstream or belong to a list
833 // filtered by capability — keep it selectable so saving does not
834 // silently switch the user to another model.
835 $known = wp_list_pluck($models, 'id');
836 $has_selected = ($selected === '' || in_array($selected, $known, true));
837
838 printf('<select name="%s"%s>', esc_attr($name), $attr_html);
839
840 if (!$has_selected) {
841 printf(
842 '<option value="%s" selected>%s</option>',
843 esc_attr($selected),
844 esc_html(sprintf(/* translators: %s: model id */ esc_html__('%s (saved)', 'king-addons'), $selected))
845 );
846 }
847
848 if (!empty($free) && !empty($paid)) {
849 echo '<optgroup label="' . esc_attr__('Free models', 'king-addons') . '">';
850 self::renderOptions($free, $selected);
851 echo '</optgroup>';
852 echo '<optgroup label="' . esc_attr__('Paid models', 'king-addons') . '">';
853 self::renderOptions($paid, $selected);
854 echo '</optgroup>';
855 } else {
856 self::renderOptions($models, $selected);
857 }
858
859 echo '</select>';
860 }
861
862 /**
863 * @param array<int, array<string, mixed>> $models
864 */
865 private static function renderOptions(array $models, string $selected): void
866 {
867 foreach ($models as $model) {
868 printf(
869 '<option value="%s" %s>%s</option>',
870 esc_attr($model['id']),
871 selected($selected, $model['id'], false),
872 esc_html($model['label'])
873 );
874 }
875 }
876
877 /**
878 * Verifies an API key against the provider.
879 *
880 * @return string|\WP_Error Success message, or the reason it failed.
881 */
882 public static function testConnection(?string $provider = null, ?string $api_key = null)
883 {
884 $provider = self::normalizeProvider($provider ?? self::getProvider());
885 $api_key = ($api_key !== null && $api_key !== '') ? $api_key : self::getApiKey($provider);
886
887 if ($api_key === '') {
888 return new \WP_Error('missing_key', esc_html__('Enter an API key first.', 'king-addons'));
889 }
890
891 $headers = self::getHeaders($provider, $api_key);
892 unset($headers['Content-Type']);
893
894 // OpenRouter has a dedicated key endpoint that also reports credit;
895 // OpenAI validates the key on any authenticated call.
896 $endpoint = ($provider === self::OPENROUTER)
897 ? self::BASE_OPENROUTER . 'key'
898 : self::BASE_OPENAI . 'models';
899
900 $body = self::decodeResponse(
901 wp_remote_get($endpoint, ['timeout' => 20, 'headers' => $headers]),
902 esc_html__('The API key was rejected.', 'king-addons')
903 );
904
905 if (is_wp_error($body)) {
906 return $body;
907 }
908
909 $message = sprintf(
910 /* translators: %s: provider name */
911 esc_html__('Connected to %s. The API key is valid.', 'king-addons'),
912 self::getLabel($provider)
913 );
914
915 if ($provider === self::OPENROUTER && isset($body['data']['limit_remaining']) && null !== $body['data']['limit_remaining']) {
916 $message .= ' ' . sprintf(
917 /* translators: %s: remaining credit, formatted */
918 esc_html__('Remaining credit: %s.', 'king-addons'),
919 '$' . number_format_i18n((float) $body['data']['limit_remaining'], 2)
920 );
921 }
922
923 if ($provider === self::OPENAI && !empty($body['data']) && is_array($body['data'])) {
924 $message .= ' ' . sprintf(
925 /* translators: %d: number of models */
926 esc_html__('%d models available.', 'king-addons'),
927 count($body['data'])
928 );
929 }
930
931 return $message;
932 }
933 }
934