* @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; } /** * LLM_Manager class. */ class LLM_Manager extends Manager { /** * Constructor - initializes the manager * @since 1.0.0 */ protected function __construct() { // Call parent constructor with option suffix parent::__construct( 'llm_settings' ); } /** * Get default configuration * * Returns default LLM settings values. * * @return array Default configuration * @since 1.0.0 */ protected function get_default_config() { // Return default configuration return array( 'chat_model' => '', 'embeddings_model' => '', 'embeddings_model_dimensions' => array(), // Cache for embeddings model dimensions keyed by model_id ); } /** * Uninstall plugin * * Clears model info cache transient and removes configuration. * @since 1.0.0 */ public function uninstall_plugin() { // Clear model info dev cache transient Model_Info::get_instance()->clear_cache(); // Call parent to delete option parent::uninstall_plugin(); } /** * Check if a chat model is selected * * @return bool Whether a chat model is selected and its provider exists * @since 1.0.0 */ public function is_chat_model_configured() { try { // Check if a chat model is selected $chat_model = $this->get_chat_model_id(); if ( empty( $chat_model ) ) { return false; } // Check if the provider for this model exists $provider = $this->get_provider_for_model( $chat_model ); return ( null !== $provider ); } catch ( \Throwable $e ) { return false; } } /** * Check if an embeddings model is selected * * @return bool Whether an embeddings model is selected and its provider exists * @since 1.0.0 */ public function is_embeddings_model_configured() { try { // Check if an embeddings model is selected $embeddings_model = $this->get_embeddings_model_id(); if ( empty( $embeddings_model ) ) { return false; } // Check if the provider for this model exists $provider = $this->get_provider_for_model( $embeddings_model ); return ( null !== $provider ); } catch ( \Throwable $e ) { return false; } } /** * Get provider for a namespaced model ID * * Parses the namespace prefix (e.g., 'openai/gpt-4o') to determine the provider type, * then searches only providers of that type. When multiple providers of the same type * support the model, one is selected randomly to distribute load. * * @param string $namespaced_model_id The namespaced model ID (e.g., 'openai/gpt-4o') * @return Provider_Type|null Provider instance or null if not found * @since 1.0.0 */ public function get_provider_for_model( $namespaced_model_id ) { // Extract provider type from namespace prefix (e.g., "openai/gpt-4o" or "openrouter/openai/gpt-4o"). $parts = explode( '/', $namespaced_model_id, 2 ); if ( count( $parts ) !== 2 || empty( $parts[0] ) || empty( $parts[1] ) ) { return null; } $type_id = $parts[0]; $raw_model_id = $parts[1]; $provider_manager = Provider_Manager::get_instance(); $providers = $provider_manager->get_config( array( 'providers' ), array() ); $matching = array(); foreach ( $providers as $provider_id => $config ) { // Skip disabled providers or wrong type. if ( empty( $config['enabled'] ) || ( $config['type'] ?? '' ) !== $type_id ) { continue; } try { $provider = $provider_manager->get_provider( $provider_id ); // Check if the provider has all required parameters set and has the model if ( $provider->is_configured() && $provider->has_model( $raw_model_id ) ) { $matching[] = $provider; } } catch ( \Throwable $e ) { continue; } } return empty( $matching ) ? null : $matching[ array_rand( $matching ) ]; } /** * Strip the namespace prefix from a namespaced model ID * * @param string $namespaced_model_id The namespaced model ID (e.g., 'openai/gpt-4o') * @return string The raw model ID (e.g., 'gpt-4o'), or original if no namespace * @since 1.0.0 */ public function strip_model_namespace( $namespaced_model_id ) { $parts = explode( '/', $namespaced_model_id, 2 ); return count( $parts ) === 2 ? $parts[1] : $namespaced_model_id; } /** * Create a namespaced model ID by prefixing with provider type * * @param Provider_Type $provider The provider instance * @param string $raw_model_id The raw model ID (e.g., 'gpt-4o') * @return string Namespaced model ID (e.g., 'openai/gpt-4o') * @since 1.0.0 */ public function namespace_model_id( $provider, $raw_model_id ) { return $provider->get_type_id() . '/' . $raw_model_id; } /** * Get a chat model instance using the configured model * * @return LLM_Chat Chat model instance * @throws \Exception If no chat model is configured or provider not found * @since 1.0.0 */ public function get_chat_model() { $namespaced_model_id = $this->get_chat_model_id(); if ( empty( $namespaced_model_id ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output throw new \Exception( __( 'No chat model configured', 'agenistrator' ) ); } $provider = $this->get_provider_for_model( $namespaced_model_id ); if ( ! $provider ) { // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages are not HTML output throw new \Exception( String_Utils::replace_tags( __( 'No provider found for chat model {model_id}', 'agenistrator' ), array( 'model_id' => $namespaced_model_id ) ) ); // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } // Strip namespace prefix before passing to provider API $raw_model_id = $this->strip_model_namespace( $namespaced_model_id ); return $provider->create_chat_model( $raw_model_id ); } /** * Get an embedding model instance using the configured model * * @return LLM_Embedding Embedding model instance * @throws \Exception If no embeddings model is configured or provider not found * @since 1.0.0 */ public function get_embedding_model() { $namespaced_model_id = $this->get_embeddings_model_id(); if ( empty( $namespaced_model_id ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output throw new \Exception( __( 'No embeddings model configured', 'agenistrator' ) ); } $provider = $this->get_provider_for_model( $namespaced_model_id ); if ( ! $provider ) { // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages are not HTML output throw new \Exception( String_Utils::replace_tags( __( 'No provider found for embeddings model {model_id}', 'agenistrator' ), array( 'model_id' => $namespaced_model_id ) ) ); // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } // Strip namespace prefix before passing to provider API $raw_model_id = $this->strip_model_namespace( $namespaced_model_id ); return $provider->create_embedding_model( $raw_model_id ); } /** * Initialize default chat model for a newly configured provider * * Sets default chat model if none is currently configured. * Embeddings model is not auto-set to avoid unexpected costs from * background embedding generation. * Should be called after creating and enabling a new provider. * * @param Provider_Type $provider The provider to get defaults from * @return void * @since 1.0.0 */ public function initialize_default_models_for_provider( $provider ) { // Set default chat model if none configured if ( empty( $this->get_chat_model_id() ) ) { $default_chat = $provider->get_default_chat_model(); if ( null !== $default_chat ) { $namespaced = $this->namespace_model_id( $provider, $default_chat ); $this->set_chat_model_id( $namespaced ); } } } /** * Get the configured chat model ID * * @return string The namespaced chat model ID (e.g., 'openai/gpt-4o') * @since 1.0.0 */ public function get_chat_model_id() { return $this->get_config( 'chat_model', '' ); } /** * Set the chat model ID * * @param string $model_id The namespaced model ID to set (e.g., 'openai/gpt-4o') * @return bool Whether the option was updated * @since 1.0.0 */ public function set_chat_model_id( $model_id ) { // Update the model ID in the config and save return $this->set_config_and_save( 'chat_model', $model_id ); } /** * Get the configured embeddings model ID * * @return string The namespaced embeddings model ID (e.g., 'openai/text-embedding-3-small') * @since 1.0.0 */ public function get_embeddings_model_id() { return $this->get_config( 'embeddings_model', '' ); } /** * Set the embeddings model ID * * Cancels any in-progress embeddings generation if the model is changing, * since embeddings generated with the old model would be incompatible. * Schedules new embeddings generation when a model is set. * * @param string $model_id The model ID to set * @return bool Whether the option was updated * @since 1.0.0 */ public function set_embeddings_model_id( $model_id ) { $old_model = $this->get_config( 'embeddings_model', '' ); $model_changed = $old_model !== $model_id; // Cancel any in-progress embeddings generation if model is changing if ( '' !== $old_model && $model_changed ) { Embeddings_Manager::get_instance()->cancel_embeddings_generation(); } $result = $this->set_config_and_save( 'embeddings_model', $model_id ); // Schedule embeddings regeneration if model is set and different from old if ( '' !== $model_id && $model_changed ) { try { Embeddings_Manager::get_instance()->schedule_sync_content_embeddings( true, false, false ); } catch ( \Throwable $e ) { // Log error but don't fail the model change // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Production error logging for embeddings scheduling failures error_log( 'Agenistrator: Failed to schedule embeddings regeneration: ' . $e->getMessage() ); } } return $result; } /** * Get cached dimension for an embeddings model * * @param string $model_id The embeddings model ID * @return int|null The cached dimension or null if not cached * @since 1.0.0 */ public function get_cached_embeddings_model_dimension( $model_id ) { $dimensions = $this->get_config( 'embeddings_model_dimensions', array() ); return $dimensions[ $model_id ] ?? null; } /** * Cache dimension for an embeddings model * * @param string $model_id The embeddings model ID * @param int $dimension The dimension to cache * @return bool Whether the cache was saved successfully * @since 1.0.0 */ public function cache_embeddings_model_dimension( $model_id, $dimension ) { $dimensions = $this->get_config( 'embeddings_model_dimensions', array() ); $dimensions[ $model_id ] = $dimension; return $this->set_config_and_save( 'embeddings_model_dimensions', $dimensions ); } }