* @author Maximum Software Inc * @license GPL-3.0-only * @link https://agenistrator.com/ * * SPDX-FileCopyrightText: 2025-2026 Alex Ruban * SPDX-FileCopyrightText: 2025-2026 Maximum Software Inc * SPDX-License-Identifier: GPL-3.0-only */ namespace Agenistrator; // Direct access prevention if ( ! defined( 'ABSPATH' ) ) { exit; } use Orhanerday\OpenAi\OpenAi; /** * LLM_Embedding_OpenAI class. */ class LLM_Embedding_OpenAI extends LLM_Embedding { /** * Get the OpenAI client instance from the provider * * @return OpenAi The OpenAI client */ private function get_client() { return $this->provider->get_client(); } /** * Generate embeddings for the provided texts using OpenAI's batch processing. * * This method uses OpenAI's native ability to process multiple texts in a single API request. * The API supports up to 2048 inputs per request. For larger batches, use this method * multiple times from the calling code. * * @param array $texts Array of text strings to embed. Each string should be within * the model's token limit (8,191 tokens for most models). * Maximum 2048 texts per call. * @return array Array of embedding vectors in the same order as input texts. * Each embedding is an array of floats (dimension depends on model). * Return array length always equals input array length. * @throws \Exception If API request fails or response format is invalid. * @since 1.0.0 */ public function generate_embeddings( $texts ) { if ( ! is_array( $texts ) ) { $texts = array( $texts ); } if ( empty( $texts ) ) { return array(); } // Validate input limits (OpenAI API limit) if ( count( $texts ) > 2048 ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output throw new \Exception( __( 'Too many texts provided. Maximum 2048 texts per call.', 'agenistrator' ) ); } try { $client = $this->get_client(); // Make single API request with all texts $response_json = $client->embeddings( array( 'model' => $this->model_id, 'input' => $texts, ) ); try { $response = String_Utils::decode_json( $response_json ); } catch ( \Throwable $e ) { throw new \Exception( String_Utils::replace_tags( __( 'Invalid API response: {error}', 'agenistrator' ), array( 'error' => $e->getMessage() ) ) ); } // Extract embeddings from the response if ( ! isset( $response['data'] ) || ! is_array( $response['data'] ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output throw new \Exception( __( 'Invalid response structure from OpenAI embeddings API', 'agenistrator' ) ); } $embeddings = array(); foreach ( $response['data'] as $item ) { if ( ! isset( $item['embedding'] ) || ! is_array( $item['embedding'] ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output throw new \Exception( __( 'Invalid embedding data in OpenAI response', 'agenistrator' ) ); } if ( ! isset( $item['index'] ) || $item['index'] < 0 || $item['index'] >= count( $texts ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output throw new \Exception( __( 'Invalid embedding index in OpenAI response', 'agenistrator' ) ); } $embeddings[ $item['index'] ] = $item['embedding']; } if ( empty( $embeddings ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output throw new \Exception( __( 'No embeddings returned from OpenAI API', 'agenistrator' ) ); } // Verify we got the expected number of embeddings if ( count( $embeddings ) !== count( $texts ) ) { throw new \Exception( String_Utils::replace_tags( __( 'Embedding count mismatch. Expected {expected}, got {actual}.', 'agenistrator' ), array( 'expected' => count( $texts ), 'actual' => count( $embeddings ), ) ) ); } // Detect and update cached dimension if needed $this->detect_and_cache_dimension( $embeddings ); return $embeddings; } catch ( \Throwable $e ) { // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output throw new \Exception( String_Utils::replace_tags( __( 'Embedding generation failed: {error}', 'agenistrator' ), array( 'error' => $e->getMessage() ) ) ); // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } } /** * Get the dimension of embeddings for this model * * Auto-detects dimension on first use by making a test API call, * then caches the result permanently in WordPress options. * * @return int Dimension size * @throws \Exception If dimension cannot be detected * @since 1.0.0 */ public function get_dimension() { $llm_manager = LLM_Manager::get_instance(); // Check if dimension is already cached $cached_dimension = $llm_manager->get_cached_embeddings_model_dimension( $this->model_id ); if ( null !== $cached_dimension ) { return $cached_dimension; } // Auto-detect dimension by making a test API call // generate_embeddings will call detect_and_cache_dimension which caches the result try { $this->generate_embeddings( array( 'test' ) ); // Retrieve the cached dimension that was just detected and cached $cached_dimension = $llm_manager->get_cached_embeddings_model_dimension( $this->model_id ); if ( null === $cached_dimension ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output throw new \Exception( __( 'Failed to cache dimension after detection', 'agenistrator' ) ); } return $cached_dimension; } catch ( \Throwable $e ) { // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output throw new \Exception( String_Utils::replace_tags( __( 'Failed to auto-detect embedding dimension for model "{model_id}": {error}', 'agenistrator' ), array( 'model_id' => $this->model_id, 'error' => $e->getMessage(), ) ) ); // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } } }