PluginProbe
King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder / 51.1.79
King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder v51.1.79
51.1.86 51.1.84 51.1.85 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 All 40 releases
king-addons / includes / AI_Provider.php

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

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