'gpt-5.6-luna', 'gpt-5-mini' => 'gpt-5-mini', ); } /** The model every Lite AI call sends: the Settings choice, else the Recommended default. */ public static function model() { $saved = trim((string) get_option(self::OPTION_MODEL, '')); return $saved !== '' ? $saved : self::default_model(); } /** * Recommended default: the first RECOMMENDED family on the account (exact id, else its newest * dated snapshot); the first live model when none is; the first curated model when the list * has never been fetched. Never makes an HTTP request. */ public static function default_model() { $ids = self::recommended_ids(); if ($ids) { return $ids[0]; } $live = self::known_live_ids(); if ($live) { return $live[0]; } $static = array_keys(self::static_models()); return $static[0]; } /** Recommended ids that exist on the account (curated ids while the list is unknown). */ public static function recommended_ids() { $live = self::known_live_ids(); if ($live === null) { return array_values(array_intersect(array_keys(self::static_models()), self::RECOMMENDED)); } $out = array(); foreach (self::RECOMMENDED as $prefix) { $matches = array(); foreach ($live as $id) { if (stripos($id, $prefix) === 0) { $matches[] = $id; } } if ($matches) { $out[] = in_array($prefix, $matches, true) ? $prefix : end($matches); } } return array_values(array_unique($out)); } /** * Model ids on the account from the last successful fetch (24h cache, else the last good * list); null when no fetch has ever succeeded. Never makes an HTTP request. */ public static function known_live_ids() { $cache = get_transient(self::TRANSIENT); if (is_array($cache) && !empty($cache['ids']) && is_array($cache['ids'])) { return array_values($cache['ids']); } $last = get_option(self::LAST_GOOD, null); return (is_array($last) && !empty($last)) ? array_values($last) : null; } /** * [id => label] for the Settings dropdown: the live list when known (else the curated list), * plus the saved model if the list lacks it. With $auto_fetch, a cold cache is warmed once * when an API key is saved (the Settings page, like Pro). */ public static function get_models($auto_fetch = false) { if ($auto_fetch && !is_array(get_transient(self::TRANSIENT))) { $key = trim((string) get_option('aeo_bob_lite_openai_key', '')); if ($key !== '') { self::refresh($key); } } $live = self::known_live_ids(); $models = array(); foreach ($live === null ? array_keys(self::static_models()) : $live as $id) { $models[$id] = $id; } $saved = trim((string) get_option(self::OPTION_MODEL, '')); if ($saved !== '' && !isset($models[$saved])) { /* translators: %s: model id */ $models[$saved] = sprintf(__('%s (saved - not in your model list)', 'aeo-bob-lite'), $saved); } return $models; } /** Fetch the live list with $api_key and cache it; a failed fetch keeps the previous list. Returns the ids ([] on failure). */ public static function refresh($api_key) { $ids = self::fetch_live($api_key); if (!empty($ids)) { set_transient(self::TRANSIENT, array('ids' => $ids, 'updated' => time()), self::CACHE_TTL); update_option(self::LAST_GOOD, $ids, false); } return $ids; } /** "5 minutes ago" for the cached list, or "Never". */ public static function last_refreshed() { $cache = get_transient(self::TRANSIENT); if (is_array($cache) && !empty($cache['updated'])) { /* translators: %s: human-readable time difference */ return sprintf(__('%s ago', 'aeo-bob-lite'), human_time_diff((int) $cache['updated'], time())); } return __('Never', 'aeo-bob-lite'); } private static function fetch_live($api_key) { if (trim((string) $api_key) === '') { return array(); } $response = wp_remote_get(self::MODELS_ENDPOINT, array( 'headers' => array('Authorization' => 'Bearer ' . $api_key, 'Accept' => 'application/json'), 'timeout' => self::HTTP_TIMEOUT, )); if (is_wp_error($response) || (int) wp_remote_retrieve_response_code($response) !== 200) { return array(); } $json = json_decode((string) wp_remote_retrieve_body($response), true); $ids = array(); if (is_array($json) && !empty($json['data']) && is_array($json['data'])) { foreach ($json['data'] as $m) { if (is_array($m) && !empty($m['id']) && is_string($m['id'])) { $ids[] = $m['id']; } } } return self::filter_text_models($ids); } /** * Keep chat/text models (Pro's OpenAI allowlist): gpt-*, o1/o3/o4, chatgpt-*; drop embeddings, * audio, image, moderation, realtime, search and the retired gpt-3.5 / early gpt-4 families. */ public static function filter_text_models(array $ids) { $kept = array(); foreach (array_unique(array_map('strval', $ids)) as $id) { if (preg_match('/^(gpt-|o1|o3|o4|chatgpt)/i', $id) && !preg_match('/embed|whisper|tts|audio|image|dall-e|moderation|realtime|transcribe|search|^gpt-3\.5|^gpt-4-03|^gpt-4-06|^gpt-4-32k/i', $id)) { $kept[] = $id; } } sort($kept); return $kept; } /** AJAX: Refresh Models (Settings). Uses the key typed in the field, else the saved key. */ public function ajax_refresh_models() { check_ajax_referer('aeo_bob_lite_refresh_models', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error(array('message' => __('Permission denied', 'aeo-bob-lite'))); } $key = isset($_POST['api_key']) ? trim(sanitize_text_field(wp_unslash($_POST['api_key']))) : ''; if ($key === '') { $key = trim((string) get_option('aeo_bob_lite_openai_key', '')); } if ($key === '') { wp_send_json_error(array('message' => __('Add your OpenAI API key first.', 'aeo-bob-lite'))); } $ids = self::refresh($key); if (empty($ids)) { wp_send_json_error(array('message' => __('Could not load the model list from OpenAI. Check the API key and try again; the previous list is kept.', 'aeo-bob-lite'))); } wp_send_json_success(array( 'models' => self::get_models(), 'recommended' => self::recommended_ids(), 'default' => self::default_model(), 'last_refreshed' => self::last_refreshed(), )); } // ------------------------------------------------------------------ request-parameter policy /** GPT-5/6 and o-series take the newer parameter contract (Pro aeo_bob_model_uses_new_openai_params). */ public static function uses_new_params($model) { return (bool) preg_match('/^(gpt-5|gpt-6|o1($|[-.])|o3($|[-.])|o4($|[-.]))/', strtolower(trim((string) $model))); } /** * A Chat Completions body for $model. $max_tokens is the expected output; models that reason * get it as max_completion_tokens plus reasoning headroom, older models as max_tokens. $temperature * is sent only to models that accept it. 0 = no token limit. */ public static function chat_body($model, array $messages, $max_tokens = 0, $temperature = null) { $body = array('model' => $model, 'messages' => $messages); $new = self::uses_new_params($model); if ($max_tokens > 0) { $body[$new ? 'max_completion_tokens' : 'max_tokens'] = $new ? $max_tokens + self::REASONING_HEADROOM : $max_tokens; } if ($temperature !== null && !$new) { $body['temperature'] = $temperature; } return $body; } /** Parameters $model must not receive: [param => replacement ('' = drop)], built-in + learned. */ public static function param_policy($model) { $policy = self::uses_new_params($model) ? array('temperature' => '', 'top_p' => '') : array(); $learned = get_option(self::REJECTIONS, array()); $key = strtolower(trim((string) $model)); if (is_array($learned) && !empty($learned[$key]) && is_array($learned[$key])) { $policy = array_merge($policy, $learned[$key]); } return $policy; } /** Remove (or rename) one parameter in a request body. */ public static function body_without_param(array $body, $param, $replacement = '') { if (array_key_exists($param, $body)) { if ($replacement !== '' && !array_key_exists($replacement, $body)) { $body[$replacement] = $body[$param]; } unset($body[$param]); } return $body; } /** Reasoning kept short on reasoning models (reasoning_effort "low"; filter aeo_bob_lite_reasoning_effort, '' = provider default), then the policy. */ public static function apply_param_policy($model, array $body) { if (self::uses_new_params($model) && !isset($body['reasoning_effort'])) { $effort = apply_filters('aeo_bob_lite_reasoning_effort', 'low', $model); if (!empty($effort)) { $body['reasoning_effort'] = $effort; } } foreach (self::param_policy($model) as $param => $replacement) { $body = self::body_without_param($body, $param, (string) $replacement); } return $body; } /** * Does this HTTP 400 say OpenAI rejects one of the parameters we sent? array(param, replacement, * message) or null. Only parameters present in the sent body qualify, never model/messages. */ public static function param_rejection($http_code, $response_body, array $sent_body) { if ((int) $http_code !== 400) { return null; } $data = json_decode((string) $response_body, true); $msg = ''; $named = ''; if (is_array($data) && isset($data['error'])) { if (is_array($data['error'])) { $msg = isset($data['error']['message']) ? (string) $data['error']['message'] : ''; $named = isset($data['error']['param']) ? (string) $data['error']['param'] : ''; } else { $msg = (string) $data['error']; } } else { $msg = (string) $response_body; } if (!preg_match('/deprecat|unsupported|not supported|does not support|not allowed|not permitted|no longer|unrecognized|unknown (parameter|field|name)|extra inputs/i', $msg)) { return null; } $candidates = array_values(array_diff(array_keys($sent_body), array('model', 'messages', 'stream'))); $param = ''; if ($named !== '' && in_array($named, $candidates, true)) { $param = $named; } else { foreach ($candidates as $c) { if (preg_match('/(? rename rather than lose the value. $replacement = ''; if (preg_match('/use\s+[\'"`]?([A-Za-z_]+)[\'"`]?\s+instead/i', $msg, $m) && $m[1] !== $param) { $replacement = $m[1]; } return array('param' => $param, 'replacement' => $replacement, 'message' => $msg); } public static function remember_param_rejection($model, $param, $replacement = '') { $learned = get_option(self::REJECTIONS, array()); if (!is_array($learned)) { $learned = array(); } $learned[strtolower(trim((string) $model))][$param] = (string) $replacement; update_option(self::REJECTIONS, $learned, false); } /** * The one POST to Chat Completions. $args are wp_remote_post() args without 'body' and * 'headers' (Authorization and Content-Type are added here). */ public static function post($api_key, array $body, array $args = array()) { $model = isset($body['model']) ? (string) $body['model'] : ''; $body = self::apply_param_policy($model, $body); $args['headers'] = array( 'Authorization' => 'Bearer ' . $api_key, 'Content-Type' => 'application/json', ); $args['body'] = wp_json_encode($body); $response = wp_remote_post(self::CHAT_ENDPOINT, $args); if (is_wp_error($response) || (int) wp_remote_retrieve_response_code($response) !== 400) { return $response; } $fix = self::param_rejection(400, wp_remote_retrieve_body($response), $body); if (!$fix) { return $response; } self::remember_param_rejection($model, $fix['param'], $fix['replacement']); $args['body'] = wp_json_encode(self::body_without_param($body, $fix['param'], $fix['replacement'])); return wp_remote_post(self::CHAT_ENDPOINT, $args); } } AEO_Bob_Lite_OpenAI::get_instance();