*/ public const BUNDLED_MODELS = array( 'gpt-6-luna', 'gpt-5.6-terra', 'gpt-5.6-luna' ); /** * Transient holding the last listed models for an hour. * * The ai_client_ prefix makes the plugin's cache clears remove it too. * * @since 1.0.0 * * @var string */ public const CACHE_KEY = 'ai_client_chatgpt_models'; /** * Account authentication used for every request. * * @var ChatGptAccountAuthentication|null */ private $account_authentication; /** * {@inheritDoc} * * @since 0.1.0 * * @param HttpMethodEnum $method The HTTP method. * @param string $path The request path. * @param array $headers The request headers. * @param mixed $data The request data. * @return Request */ protected function createRequest( HttpMethodEnum $method, string $path, array $headers = array(), $data = null ): Request { return new Request( $method, ChatGptProvider::url( $path ), $headers, $data ); } /** * Returns the account authentication, creating it on first use. * * @since 0.1.0 * * @return RequestAuthenticationInterface The account authentication. */ public function getRequestAuthentication(): RequestAuthenticationInterface { if ( null === $this->account_authentication ) { $this->account_authentication = new ChatGptAccountAuthentication( new TokenStore() ); } return $this->account_authentication; } /** * Keeps an account authentication and ignores the registry's API key placeholder. * * @since 1.0.0 * * @param RequestAuthenticationInterface $request_authentication The authentication. * @return void */ public function setRequestAuthentication( RequestAuthenticationInterface $request_authentication ): void { if ( $request_authentication instanceof ChatGptAccountAuthentication ) { $this->account_authentication = $request_authentication; } } /** * Lists the models with the site's default model first. * * @since 1.1.0 * * @return array Models keyed by id. */ protected function sendListModelsRequest(): array { return ModelSettings::prefer_default( $this->list_models() ); } /** * Lists the account's models, or the bundled list when the request fails. * * Authentication runs first and is not caught, so a site that is not * connected reports the provider as not configured. A record that needs a * new sign-in lists the last known or bundled models without calling * OpenAI, so model selection succeeds and the request itself reports the * ended session. A listed result is kept for an hour in CACHE_KEY. * * @since 1.0.0 * * @return array Models keyed by id. */ private function list_models(): array { $store = new TokenStore(); $cached = self::cached_entries(); if ( null !== $store->get() && $store->needs_signin() ) { return self::metadata_map( $cached ?? self::bundled_entries() ); } $request = $this->getRequestAuthentication()->authenticateRequest( $this->createRequest( HttpMethodEnum::GET(), 'models' ) ); if ( null !== $cached ) { return self::metadata_map( $cached ); } $models = array(); try { $response = $this->getHttpTransporter()->send( $request ); $this->throwIfNotSuccessful( $response ); $listed = self::listed_entries( $response->getData() ); if ( array() !== $listed ) { set_transient( self::CACHE_KEY, $listed, HOUR_IN_SECONDS ); ModelSettings::record_efforts( $listed ); } $models = array_map( array( self::class, 'to_metadata' ), $listed ); } catch ( Throwable $e ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Diagnostic under WP_DEBUG only. error_log( 'ai-provider-for-chatgpt: listing models failed: ' . $e->getMessage() ); } } if ( array() === $models ) { return self::metadata_map( self::bundled_entries() ); } $map = array(); foreach ( $models as $model ) { $map[ $model->getId() ] = $model; } return $map; } /** * Returns the cached listed entries, or null when there are none. * * @since 1.0.0 * * @return list|null */ private static function cached_entries(): ?array { // The cache clears delete rows with SQL, so drop this request's copy first. wp_cache_delete( '_transient_' . self::CACHE_KEY, 'options' ); wp_cache_delete( '_transient_timeout_' . self::CACHE_KEY, 'options' ); $cached = get_transient( self::CACHE_KEY ); if ( ! is_array( $cached ) || array() === $cached ) { return null; } foreach ( $cached as $entry ) { if ( ! is_array( $entry ) || ! is_string( $entry['id'] ?? null ) || ! is_string( $entry['name'] ?? null ) || ! is_bool( $entry['image'] ?? null ) ) { return null; } } return array_values( $cached ); } /** * Builds model metadata keyed by id from normalised entries. * * @since 1.0.0 * * @param list $entries The entries. * @return array */ private static function metadata_map( array $entries ): array { $map = array(); foreach ( $entries as $entry ) { $map[ $entry['id'] ] = self::to_metadata( $entry ); } return $map; } /** * {@inheritDoc} * * @since 0.1.0 * * @param Response $response The /models response. * @return list Listed models in server order. */ protected function parseResponseToModelMetadataList( Response $response ): array { return array_map( array( self::class, 'to_metadata' ), self::listed_entries( $response->getData() ) ); } /** * Returns the listed entries of a /models body, in server order. * * @since 1.0.0 * * @param mixed $data The decoded body. * @return list|null, default_effort: string|null}> */ private static function listed_entries( $data ): array { return array_values( array_filter( self::normalise_entries( $data ), static function ( array $entry ): bool { return $entry['listed']; } ) ); } /** * Normalises a /models body to one entry per model. * * @since 1.0.0 * * @param mixed $data The decoded body. * @return list|null, default_effort: string|null}> */ public static function normalise_entries( $data ): array { $models = is_array( $data ) && isset( $data['models'] ) && is_array( $data['models'] ) ? $data['models'] : array(); $entries = array(); foreach ( $models as $model ) { if ( ! is_array( $model ) || ! isset( $model['slug'] ) || ! is_string( $model['slug'] ) || '' === $model['slug'] ) { continue; } $name = $model['display_name'] ?? ''; $modalities = $model['input_modalities'] ?? array(); $default = $model['default_reasoning_level'] ?? null; $entries[] = array( 'id' => $model['slug'], 'name' => is_string( $name ) && '' !== $name ? $name : $model['slug'], // A model without a visibility field is listed. 'listed' => 'list' === ( $model['visibility'] ?? 'list' ), 'image' => is_array( $modalities ) && in_array( 'image', $modalities, true ), 'efforts' => self::efforts( $model['supported_reasoning_levels'] ?? null ), 'default_effort' => is_string( $default ) && '' !== ModelSettings::sanitize_effort( $default ) ? $default : null, ); } return $entries; } /** * Returns the effort names of supported_reasoning_levels, or null when it is absent. * * @since 1.1.0 * * @param mixed $levels The supported_reasoning_levels value. * @return list|null */ private static function efforts( $levels ): ?array { if ( ! is_array( $levels ) ) { return null; } $efforts = array(); foreach ( $levels as $level ) { $effort = is_array( $level ) && is_string( $level['effort'] ?? null ) ? ModelSettings::sanitize_effort( $level['effort'] ) : ''; if ( '' !== $effort ) { $efforts[] = $effort; } } return $efforts; } /** * Returns the filtered fallback list as normalised entries. * * @since 1.0.0 * * @return list */ private static function bundled_entries(): array { /** * Filters the models listed when /v1/models cannot be read. * * @since 0.1.0 * * @param array $models Model ids. */ $bundled = apply_filters( 'halawa_chatgpt_bundled_models', self::BUNDLED_MODELS ); $entries = array(); foreach ( is_array( $bundled ) ? $bundled : array() as $entry ) { $id = is_array( $entry ) ? ( $entry['id'] ?? '' ) : $entry; $name = is_array( $entry ) ? ( $entry['model_family_display_name'] ?? $id ) : $id; if ( ! is_string( $id ) || '' === $id ) { continue; } $entries[] = array( 'id' => $id, 'name' => is_string( $name ) && '' !== $name ? $name : $id, 'listed' => true, 'image' => true, ); } return $entries; } /** * Builds model metadata from a normalised entry. * * @since 1.0.0 * * @param array{id: string, name: string, listed: bool, image: bool} $entry The entry. * @return ModelMetadata */ private static function to_metadata( array $entry ): ModelMetadata { $inputs = array( array( ModalityEnum::text() ) ); if ( $entry['image'] ) { $inputs[] = array( ModalityEnum::text(), ModalityEnum::image() ); } return new ModelMetadata( $entry['id'], $entry['name'], array( CapabilityEnum::textGeneration(), CapabilityEnum::chatHistory() ), array( new SupportedOption( OptionEnum::systemInstruction() ), new SupportedOption( OptionEnum::maxTokens() ), // Advertised so the SDK's model requirements still select these models; never sent. new SupportedOption( OptionEnum::candidateCount() ), new SupportedOption( OptionEnum::temperature() ), new SupportedOption( OptionEnum::topP() ), new SupportedOption( OptionEnum::stopSequences() ), new SupportedOption( OptionEnum::outputMimeType(), array( 'text/plain', 'application/json' ) ), new SupportedOption( OptionEnum::outputSchema() ), new SupportedOption( OptionEnum::functionDeclarations() ), new SupportedOption( OptionEnum::customOptions() ), new SupportedOption( OptionEnum::inputModalities(), $inputs ), new SupportedOption( OptionEnum::outputModalities(), array( array( ModalityEnum::text() ) ) ), ) ); } }