api_key = (string) $api_key; } /** * Provider name used in error messages. * * Subclasses override this so the message matches the real provider. * * @return string */ protected function label() { return 'OpenAI'; } /** * Base URL of the API. * * @return string */ protected function endpoint() { return self::ENDPOINT; } /** * Authentication headers. * * @return array */ protected function auth_headers() { return array( 'Authorization' => 'Bearer ' . $this->api_key ); } /** * Provider ID. * * @return string */ public function get_id() { return 'openai'; } /** * Display name. * * @return string */ public function get_label() { return 'OpenAI'; } /** * Default chat model. * * @return string */ public function get_default_chat_model() { return 'gpt-4o-mini'; } /** * Default embedding model. * * @return string */ public function get_default_embedding_model() { return 'text-embedding-3-small'; } /** * Vector dimensions. * * @return int */ public function get_embedding_dimensions() { return 1536; } /** * Confirms that the API key is accepted. * * OpenAI answers `/models` with 401 unless a valid key is sent, so listing models is a * real credential check here. Providers whose catalogue is public must override this. * * @return true|WP_Error */ public function verify_credentials() { if ( '' === $this->api_key ) { return new WP_Error( 'mybot_missing_key', sprintf( /* translators: %s: provider name */ __( 'No API key configured for %s.', 'aihouse' ), $this->label() ) ); } $result = MYBOT_HTTP::get_json( $this->endpoint() . '/models', $this->auth_headers(), 12 ); if ( is_wp_error( $result ) ) { return $result; } return true; } /** * Lists the models this provider offers. * * The OpenAI-compatible `/models` endpoint answers with the same shape everywhere — * `{ data: [ { id, … } ] }` — so OpenRouter reuses this implementation and only replaces * the parts that differ (pricing, and the free/paid split). * * @param string $filter One of 'all', 'free' or 'paid'. * @param bool $force Ignore the cache and ask the provider again. * @return array|WP_Error */ public function list_models( $filter = 'all', $force = false ) { $filter = MYBOT_LLM_Factory::clean_model_filter( $filter ); $cache_key = MYBOT_LLM_Factory::models_cache_key( $this->get_id(), $filter ); // There is no free tier here: every model in this catalogue is billed per token. // Answering with the paid list would label billed models as free. if ( 'free' === $filter ) { return array(); } if ( ! $force ) { $cached = get_transient( $cache_key ); if ( is_array( $cached ) ) { return $cached; } } $result = MYBOT_HTTP::get_json( $this->endpoint() . '/models', $this->auth_headers(), 12 ); if ( is_wp_error( $result ) ) { return $result; } $rows = ( isset( $result['data'] ) && is_array( $result['data'] ) ) ? $result['data'] : array(); $models = array(); foreach ( $rows as $row ) { if ( ! isset( $row['id'] ) ) { continue; } $id = (string) $row['id']; $models[] = array( 'id' => $id, 'name' => isset( $row['name'] ) ? (string) $row['name'] : $id, 'free' => false, 'context' => isset( $row['context_length'] ) ? (int) $row['context_length'] : 0, ); } usort( $models, function ( $a, $b ) { return strcasecmp( $a['name'], $b['name'] ); } ); set_transient( $cache_key, $models, 6 * HOUR_IN_SECONDS ); return $models; } /** * Calls the chat completions endpoint. * * @param array $messages Messages. * @param array $args Arguments. * @return array|WP_Error */ public function chat( $messages, $args = array() ) { if ( '' === $this->api_key ) { return new WP_Error( 'mybot_missing_key', sprintf( /* translators: %s: provider name */ __( 'No API key configured for %s.', 'aihouse' ), $this->label() ) ); } $args = wp_parse_args( $args, array( 'model' => $this->get_default_chat_model(), 'temperature' => 0.3, 'max_tokens' => 500, 'timeout' => 8, ) ); $body = array( 'model' => $args['model'], 'messages' => $this->format_messages( $messages ), 'temperature' => (float) $args['temperature'], 'max_tokens' => (int) $args['max_tokens'], ); $response = MYBOT_HTTP::post_json( $this->endpoint() . '/chat/completions', $body, $this->auth_headers(), (int) $args['timeout'] ); if ( is_wp_error( $response ) ) { return $response; } $choice = isset( $response['choices'][0] ) && is_array( $response['choices'][0] ) ? $response['choices'][0] : array(); $message = isset( $choice['message'] ) && is_array( $choice['message'] ) ? $choice['message'] : array(); $content = isset( $message['content'] ) ? (string) $message['content'] : ''; if ( '' === trim( $content ) ) { return new WP_Error( 'mybot_empty_completion', $this->explain_empty_reply( $choice, $message, $args ) ); } return array( 'content' => trim( $content ), 'model' => isset( $response['model'] ) ? (string) $response['model'] : $args['model'], 'tokens_in' => isset( $response['usage']['prompt_tokens'] ) ? (int) $response['usage']['prompt_tokens'] : 0, 'tokens_out' => isset( $response['usage']['completion_tokens'] ) ? (int) $response['usage']['completion_tokens'] : 0, ); } /** * Explains a reply that carried no visible text. * * A reasoning model thinks before it writes, and can spend the whole token budget on its * reasoning field while `content` stays empty. That used to surface as the useless message * "returned an empty reply", which sent the reader looking at keys and settings instead of * at the model. The finish reason and the reasoning field are what say which of the two * happened, so both are read before answering. * * The model ID and the token budget are named in the answer: the reader was left comparing * this line against the "Settings in use" line above it to find out which model was at fault, * and a message that requires cross-referencing is half a message. * * @param array $choice First choice of the response. * @param array $message Message object inside that choice. * @param array $args Arguments the call was made with: model, max_tokens, timeout. * @return string */ protected function explain_empty_reply( $choice, $message, $args = array() ) { $finish = isset( $choice['finish_reason'] ) ? (string) $choice['finish_reason'] : ''; $reasoning = ''; $model = isset( $args['model'] ) ? (string) $args['model'] : $this->get_default_chat_model(); $budget = isset( $args['max_tokens'] ) ? (int) $args['max_tokens'] : 0; foreach ( array( 'reasoning', 'reasoning_content' ) as $field ) { if ( isset( $message[ $field ] ) && is_string( $message[ $field ] ) && '' !== trim( $message[ $field ] ) ) { $reasoning = (string) $message[ $field ]; break; } } if ( '' !== $reasoning ) { return sprintf( /* translators: 1: provider name, 2: token budget, 3: model ID */ __( '%1$s returned reasoning text but no answer: this is a reasoning model and it spent the whole %2$d-token budget on thinking. Model in use: %3$s. Choose a plain "instruct" or "chat" model, or raise Max tokens — and keep in mind that a reasoning model is slower, so the request timeout matters too.', 'aihouse' ), $this->label(), $budget, $model ); } if ( 'length' === $finish ) { return sprintf( /* translators: 1: provider name, 2: token budget, 3: model ID */ __( '%1$s hit the token limit of %2$d tokens before writing an answer (model: %3$s). Raise Max tokens, or choose a model that answers directly.', 'aihouse' ), $this->label(), $budget, $model ); } if ( '' !== $finish && 'stop' !== $finish ) { return sprintf( /* translators: 1: provider name, 2: finish reason */ __( '%1$s returned no text (finish reason: %2$s). Check that the model ID exists and is a chat model.', 'aihouse' ), $this->label(), $finish ); } return sprintf( /* translators: %s: provider name */ __( '%s returned an empty reply.', 'aihouse' ), $this->label() ); } /** * Generates embeddings. * * @param array $texts List of text chunks. * @param string $model Model. * @return array|WP_Error */ public function embed( $texts, $model = '' ) { if ( '' === $this->api_key ) { return new WP_Error( 'mybot_missing_key', sprintf( /* translators: %s: provider name */ __( 'No API key configured for %s.', 'aihouse' ), $this->label() ) ); } /* * Blank entries are dropped but every surviving text keeps a note of the position it came * from, because that position is what pairs a vector with its chunk. Re-indexing the payload * with array_values() would shift every chunk after the first blank one — the same silent * mispairing this method was fixed for in 1.5.5 (docs/08 muc 6.22). The payload itself must * still be a plain list, so the kept positions are remembered separately. */ $texts = array_filter( array_map( 'strval', (array) $texts ), 'strlen' ); if ( array() === $texts ) { return array(); } $dropped = array_keys( $texts ); $payload = array_values( $texts ); $model = '' !== $model ? $model : $this->get_default_embedding_model(); $response = MYBOT_HTTP::post_json( $this->endpoint() . '/embeddings', array( 'model' => $model, 'input' => $payload, ), $this->auth_headers(), 15, 1 ); if ( is_wp_error( $response ) ) { return $response; } if ( empty( $response['data'] ) || ! is_array( $response['data'] ) ) { return new WP_Error( 'mybot_empty_embedding', sprintf( /* translators: %s: provider name */ __( '%s returned no vectors.', 'aihouse' ), $this->label() ) ); } $vectors = array(); /* * Pair every vector with the index the API gave it, never with its position in the * response. * * The embeddings endpoint is allowed to return the list in a different order, and each * object carries an "index" field precisely so a caller can put it back. Pairing by * arrival order instead silently attached every chunk to another chunk's vector: the * indexing run reported 26/26 with no error, while every similarity score afterwards was * meaningless — on a real site that looked like "the closest chunk scored 0.06" * (docs/08 mục 6.22). Keys stay as the input positions, because the indexer reads * $vectors[ $position ] to know which chunk a vector belongs to. */ foreach ( $response['data'] as $position => $item ) { if ( ! isset( $item['embedding'] ) || ! is_array( $item['embedding'] ) ) { continue; } $index = isset( $item['index'] ) ? (int) $item['index'] : (int) $position; // The API numbers the texts that were sent; `$dropped` maps that back to the caller's // positions, so a blank entry in the batch cannot shift the pairing. $index = isset( $dropped[ $index ] ) ? (int) $dropped[ $index ] : $index; $vectors[ $index ] = array_map( 'floatval', $item['embedding'] ); } ksort( $vectors ); return $vectors; } /** * Whether a model can read an image. See VISION_PREFIXES for how honest this answer is. * * @param string $model Model ID. * @return bool */ public function supports_vision( $model = '' ) { return MYBOT_LLM_Factory::model_matches( $model, self::VISION_PREFIXES, 'openai', 'vision' ); } /** * Whether a model can read audio. False for every model here — see the interface docblock. * * @param string $model Model ID. * @return bool */ public function supports_audio( $model = '' ) { return false; } /** * Converts messages to the OpenAI format. * * Content goes through MYBOT_LLM_Content, which returns a **string** while the message is text * only — the exact shape 1.11.1 sent — and a list of parts as soon as there is an image. Casting * to string here instead would silently delete that image. * * @param array $messages Messages. * @return array */ protected function format_messages( $messages ) { $formatted = array(); foreach ( (array) $messages as $message ) { if ( ! isset( $message['content'] ) ) { continue; } $role = isset( $message['role'] ) ? $message['role'] : 'user'; if ( ! in_array( $role, array( 'system', 'user', 'assistant' ), true ) ) { $role = 'user'; } $formatted[] = array( 'role' => $role, 'content' => MYBOT_LLM_Content::to_openai( $message['content'] ), ); } return $formatted; } }