translation_error = null; } public function assertTranslationSucceeded() { if ($this->translation_error) { throw $this->translation_error; } } /** Shared by the picker and runtime; never silently replace a saved model. */ public static function isSupportedTranslationModel($model) { if (!is_string($model)) { return false; } // Fine-tuned IDs contain their base model before the next colon. $base = strpos($model, 'ft:') === 0 ? explode(':', $model)[1] : $model; $retired = ['gpt-4o-2024-05-13', 'gpt-4.1-nano', 'gpt-4.1-nano-2025-04-14', 'gpt-5-chat-latest', 'gpt-5.1-chat-latest', 'gpt-5.2-chat-latest', 'gpt-5.3-chat-latest']; if (in_array($base, $retired, true)) { return false; } // Only general text Chat Completions variants, excluding images, audio, // realtime, search, Codex, pro-only and other specialized endpoints. return (bool) preg_match('/^(?:gpt-4o(?:-mini)?|gpt-4\.1(?:-mini)?|gpt-5(?:\.\d+)?(?:-mini|-nano|-sol|-terra|-luna)?|o3)(?:-\d{4}-\d{2}-\d{2})?$/D', $base); } /** * Whether the selected model accepts a custom sampling temperature. * * Reasoning model families use their default temperature unless reasoning * is explicitly disabled. This client does not override reasoning effort, * so sending temperature 0 causes OpenAI to reject the request. * * @param string $model OpenAI model ID. * @return bool */ public static function supportsCustomTemperature($model) { $model = strtolower(trim((string) $model)); if (preg_match('/^gpt-5(?:[.-]|$)/', $model)) { return false; } if (preg_match('/^o\d(?:[.-]|$)/', $model)) { return false; } return true; } private function makeCall($endpoint, $data = null) { $options = get_option('polylai_translator_options'); $api_key = $options['openai_key']; $org_id = $options['openai_org']; $response = wp_remote_post("https://api.openai.com/v1$endpoint", [ 'timeout' => 100000, 'method' => 'POST', 'blocking' => true, 'body' => json_encode($data), // cannot use wp_json_encode here 'headers' => [ "Authorization" => "Bearer $api_key", "Content-Type" => "application/json", "OpenAI-Organization" => $org_id ] ]); // print_r($response['body']); if (is_wp_error($response)) { polylai_Utils::db_log('error', 'translate', $response->get_error_message()); throw new Exception(esc_html($response->get_error_message())); } else { $result = json_decode($response['body']); if (wp_remote_retrieve_response_code($response) >= 400) { throw new Exception(isset($result->error->message) ? $result->error->message : __('OpenAI request failed. Check the API key and selected model in PolylAI settings.', 'ai-translator-for-polylang')); } return $result; } } public static function listModels() { $options = get_option('polylai_translator_options'); $api_key = $options['openai_key']; // Set the request URL and headers $url = 'https://api.openai.com/v1/models'; $headers = [ 'Authorization' => 'Bearer ' . $api_key, 'Content-Type' => 'application/json' ]; // Make the request using wp_remote_get $response = wp_remote_get($url, [ 'headers' => $headers ]); // Check for errors if (is_wp_error($response)) { $data = ['data' => []]; return $data['error'] = $response->get_error_message(); } else { // Decode the response and print the list of models $body = wp_remote_retrieve_body($response); $data = json_decode($body, true); return $data; } } public function translate($text, $locale_from, $locale_to, $post_id, $plain_text = false) { $request = new polylai_TranslationRequest(); $request->text = $text; $request->locale_from = $locale_from; $request->locale_to = $locale_to; $request->post_id = $post_id; $request->plain_text = $plain_text; return $this->translateRequest($request); } /** * Summary of translate * @param polylai_TranslationRequest $request * @throws \Exception * @return mixed */ public function translateRequest($request) { polylai_Utils::db_log('debug', 'chatgpt', "translate ID:$request->post_id ($request->locale_to)"); $options = get_option('polylai_translator_options'); if ($request->custom_prompt) { $prompt = $request->custom_prompt; } else { $prompt = "Translate the following HTML code from $request->locale_from to $request->locale_to. You must be very careful not to alter the structure of the code! You must translate the texts, even those inside the html comments. Do not add any of your own comments to the answer. Do not write \"Certainly\" or \"Sure\" or similar phrases. [custom_prompt] You must only respond with the requested translation following the instructions I have provided.: \n\n[text]"; if ($request->plain_text) { $prompt = "Translate the following text from $request->locale_from to $request->locale_to. Do not add any of your own comments to the answer. Do not write \"Certainly\" or \"Sure\" or similar phrases. [custom_prompt] You must only respond with the requested translation following the instructions I have provided: \n\n[text]"; } // Add custom prompt if available $custom_prompt = isset($options['custom_prompt']) ? trim($options['custom_prompt']) : ''; if (empty($custom_prompt)) { $custom_prompt = ""; } $prompt = str_replace("[custom_prompt]", $custom_prompt, $prompt); } $prompt = str_replace('[text]', $request->text, $prompt); error_log($prompt); $data = array(); $data["model"] = isset($options['openai_model']) ? $options['openai_model'] : null; if (empty($data["model"])) { $data["model"] = "gpt-4o"; } $data["messages"] = []; // o1 models uses "developer" role instead of "system" if (strpos($data["model"], "o1") < 0) { $data["messages"][] = ["role" => "system", "content" => "You are a professional translator."]; } $data["messages"][] = ["role" => "user", "content" => $prompt]; if (self::supportsCustomTemperature($data["model"])) { $data["temperature"] = 0; } try { $this->assertTranslationSucceeded(); if (!self::isSupportedTranslationModel($data['model'])) { throw new Exception(__('The selected OpenAI model is retired or unsupported for translation. Choose a supported OpenAI model in PolylAI settings.', 'ai-translator-for-polylang')); } polylai_Utils::db_log('debug', 'chatgpt', "before call"); $result = $this->makeCall('/chat/completions', $data); if (isset($result->error)) { polylai_Utils::db_log('debug', 'chatgpt', "error"); throw new Exception($result->error->message); } if (!isset($result->choices[0]->message->content) || !is_string($result->choices[0]->message->content) || (trim($request->text) !== '' && trim($result->choices[0]->message->content) === '') || (isset($result->choices[0]->finish_reason) && $result->choices[0]->finish_reason !== 'stop')) { throw new Exception(__('OpenAI returned an empty, invalid, or incomplete translation.', 'ai-translator-for-polylang')); } polylai_Utils::db_log('debug', 'chatgpt', "after call"); } catch (Exception $e) { polylai_Utils::db_log('error', 'translate', 'ChatGPT response error: ' . $e->getMessage(), $request->post_id, null, $request->text); $this->translation_error = $e; throw $e; } polylai_Utils::db_log('debug', 'chatgpt', "full response", $request->post_id, null, json_encode($result)); $content = $result->choices[0]->message->content; preg_match('/```html(.*?)```/s', $content, $matches); if (isset($matches[1])) { $content = trim($matches[1]); } preg_match('/```json(.*?)```/s', $content, $matches); if (isset($matches[1])) { $content = trim($matches[1]); } // Get token usage if available $total_tokens = isset($result->usage->total_tokens) ? $result->usage->total_tokens : 0; // Return both the content and token usage as an array return [ 'content' => $content, 'tokens' => $total_tokens ]; } }