diff --git a/inc/Abilities/SettingsAbilities.php b/inc/Abilities/SettingsAbilities.php index c5f39c2db..a68456b0f 100644 --- a/inc/Abilities/SettingsAbilities.php +++ b/inc/Abilities/SettingsAbilities.php @@ -110,6 +110,10 @@ private function registerUpdateSettings(): void { 'description' => 'Per-mode provider/model overrides keyed by mode id', ), 'max_turns' => array( 'type' => 'integer' ), + 'wp_ai_client_connect_timeout' => array( + 'type' => 'number', + 'description' => 'Connection timeout in seconds for wp-ai-client provider requests.', + ), 'disabled_tools' => array( 'type' => 'object' ), 'ai_provider_keys' => array( 'type' => 'object' ), 'queue_tuning' => array( @@ -353,6 +357,7 @@ public function executeGetSettings( array $input ): array { 'default_model' => $settings['default_model'] ?? '', 'mode_models' => $settings['mode_models'] ?? array(), 'max_turns' => $settings['max_turns'] ?? $defaults['max_turns'], + 'wp_ai_client_connect_timeout' => $settings['wp_ai_client_connect_timeout'] ?? $defaults['wp_ai_client_connect_timeout'], 'disabled_tools' => $settings['disabled_tools'] ?? array(), 'ai_provider_keys' => $masked_keys, 'queue_tuning' => wp_parse_args( $settings['queue_tuning'] ?? array(), $defaults['queue_tuning'] ), @@ -457,6 +462,11 @@ public function executeUpdateSettings( array $input ): array { $handled_keys[] = 'max_turns'; } + if ( isset( $input['wp_ai_client_connect_timeout'] ) && is_numeric( $input['wp_ai_client_connect_timeout'] ) ) { + $all_settings['wp_ai_client_connect_timeout'] = max( 0.0, min( 300.0, (float) $input['wp_ai_client_connect_timeout'] ) ); + $handled_keys[] = 'wp_ai_client_connect_timeout'; + } + if ( isset( $input['disabled_tools'] ) ) { $all_settings['disabled_tools'] = array(); foreach ( $input['disabled_tools'] as $tool_id => $disabled ) { diff --git a/inc/Core/PluginSettings.php b/inc/Core/PluginSettings.php index 10472041f..a24d90d46 100644 --- a/inc/Core/PluginSettings.php +++ b/inc/Core/PluginSettings.php @@ -21,6 +21,7 @@ class PluginSettings { public const DEFAULT_MAX_TURNS = 25; + public const DEFAULT_WP_AI_CLIENT_CONNECT_TIMEOUT = 30.0; private static ?array $cache = null; private static array $agent_model_cache = array(); @@ -54,12 +55,13 @@ public static function getDefaultQueueTuning(): array { /** * Get centralized plugin defaults used by backend and admin UI. * - * @return array{max_turns:int,queue_tuning:array{concurrent_batches:int,batch_size:int,time_limit:int,chunk_size:int,chunk_delay:int}} + * @return array{max_turns:int,wp_ai_client_connect_timeout:float,queue_tuning:array{concurrent_batches:int,batch_size:int,time_limit:int,chunk_size:int,chunk_delay:int}} */ public static function getDefaults(): array { return array( - 'max_turns' => self::DEFAULT_MAX_TURNS, - 'queue_tuning' => self::getDefaultQueueTuning(), + 'max_turns' => self::DEFAULT_MAX_TURNS, + 'wp_ai_client_connect_timeout' => self::DEFAULT_WP_AI_CLIENT_CONNECT_TIMEOUT, + 'queue_tuning' => self::getDefaultQueueTuning(), ); } diff --git a/inc/Engine/AI/RequestBuilder.php b/inc/Engine/AI/RequestBuilder.php index 01a7ae01c..c0561e70d 100644 --- a/inc/Engine/AI/RequestBuilder.php +++ b/inc/Engine/AI/RequestBuilder.php @@ -12,6 +12,7 @@ namespace DataMachine\Engine\AI; +use DataMachine\Core\PluginSettings; use DataMachine\Engine\AI\Directives\DirectivePolicyResolver; defined( 'ABSPATH' ) || exit; @@ -130,7 +131,9 @@ public static function build( } $result = null; + $request_options = null; $request_timeout = self::wpAiClientRequestTimeout( $mode, $provider, $model, $payload ); + $connect_timeout = self::wpAiClientConnectTimeout( $mode, $provider, $model, $payload, $request_timeout ); $timeout_filter = static function ( $default_timeout ) use ( $request_timeout ) { return max( (float) $default_timeout, $request_timeout ); }; @@ -169,15 +172,13 @@ public static function build( /** @var callable $model_resolver wp-ai-client exposes this through __call() in some versions. */ $model_resolver = array( $registry, 'getProviderModel' ); $model_instance = call_user_func( $model_resolver, $provider_id, $model, null ); - if ( - is_object( $model_instance ) - && method_exists( $model_instance, 'setRequestOptions' ) - && class_exists( '\WordPress\AiClient\Providers\Http\DTO\RequestOptions' ) - ) { + if ( class_exists( '\WordPress\AiClient\Providers\Http\DTO\RequestOptions' ) ) { $request_options = new \WordPress\AiClient\Providers\Http\DTO\RequestOptions(); $request_options->setTimeout( $request_timeout ); - $request_options->setConnectTimeout( min( 30.0, $request_timeout ) ); - $model_instance->setRequestOptions( $request_options ); + $request_options->setConnectTimeout( $connect_timeout ); + if ( is_object( $model_instance ) && method_exists( $model_instance, 'setRequestOptions' ) ) { + $model_instance->setRequestOptions( $request_options ); + } } // wp-ai-client refuses to construct a MessagePart from an empty @@ -187,6 +188,9 @@ public static function build( $builder = '' !== $prompt_context['prompt'] ? \wp_ai_client_prompt( $prompt_context['prompt'] ) : \wp_ai_client_prompt(); + if ( null !== $request_options && is_callable( array( $builder, 'using_request_options' ) ) ) { + $builder = $builder->using_request_options( $request_options ); + } $builder = $builder->using_provider( $provider_id ) ->using_model( $model_instance ); @@ -400,6 +404,43 @@ private static function wpAiClientRequestTimeout( string $mode, string $provider return max( 0.0, (float) $timeout ); } + /** + * Resolve the connection timeout Data Machine applies to wp-ai-client calls. + * + * @param string $mode Execution mode. + * @param string $provider Provider identifier. + * @param string $model Model identifier. + * @param array $payload Step payload. + * @param float $request_timeout Resolved full request timeout in seconds. + * @return float Timeout in seconds. + */ + private static function wpAiClientConnectTimeout( string $mode, string $provider, string $model, array $payload, float $request_timeout ): float { + $setting_default = PluginSettings::get( + 'wp_ai_client_connect_timeout', + PluginSettings::DEFAULT_WP_AI_CLIENT_CONNECT_TIMEOUT + ); + if ( ! is_numeric( $setting_default ) ) { + $setting_default = PluginSettings::DEFAULT_WP_AI_CLIENT_CONNECT_TIMEOUT; + } + + $default_timeout = min( max( 0.0, (float) $setting_default ), $request_timeout ); + $timeout = apply_filters( + 'datamachine_wp_ai_client_connect_timeout', + $default_timeout, + $mode, + $provider, + $model, + $payload, + $request_timeout + ); + + if ( ! is_numeric( $timeout ) ) { + return $default_timeout; + } + + return max( 0.0, (float) $timeout ); + } + /** * Assemble a provider request without dispatching it. * diff --git a/tests/wp-ai-client-request-timeout-smoke.php b/tests/wp-ai-client-request-timeout-smoke.php index abec197bc..5ba26cf07 100644 --- a/tests/wp-ai-client-request-timeout-smoke.php +++ b/tests/wp-ai-client-request-timeout-smoke.php @@ -93,6 +93,7 @@ class TimeoutPromptBuilderDouble { private string $provider = ''; private mixed $model = null; + private mixed $request_options = null; private array $history = array(); private float $request_timeout = 30.0; @@ -118,6 +119,11 @@ public function using_model_config( $model_config ): self { return $this; } + public function using_request_options( $request_options ): self { + $this->request_options = $request_options; + return $this; + } + public function using_system_instruction( string $system_instruction ): self { $this->history[] = array( 'role' => 'system', 'content' => $system_instruction ); return $this; @@ -139,6 +145,7 @@ public function generate_text_result() { self::$captured_request = array( 'provider' => $this->provider, 'model' => $this->model, + 'request_options' => $this->request_options, 'prompt' => $this->prompt, 'timeout' => $this->request_timeout, 'history' => $this->history, @@ -194,7 +201,8 @@ function timeout_smoke_filter_count( string $tag ): int { return $count; } -$timeout_context = null; +$timeout_context = null; +$connect_timeout_context = null; $GLOBALS['datamachine_test_wp_ai_client_model_with_request_options'] = true; add_filter( @@ -207,6 +215,16 @@ function ( float $timeout, string $mode, string $provider, string $model, array 5 ); +add_filter( + 'datamachine_wp_ai_client_connect_timeout', + function ( float $timeout, string $mode, string $provider, string $model, array $payload, float $request_timeout ) use ( &$connect_timeout_context ): float { + $connect_timeout_context = compact( 'timeout', 'mode', 'provider', 'model', 'payload', 'request_timeout' ); + return 120.0; + }, + 10, + 6 +); + $result = RequestBuilder::build( array( array( @@ -240,7 +258,18 @@ function ( float $timeout, string $mode, string $provider, string $model, array $captured_request_options = is_object( $captured_model ) && method_exists( $captured_model, 'getRequestOptions' ) ? $captured_model->getRequestOptions() : null; assert_timeout_smoke( $captured_request_options instanceof \WordPress\AiClient\Providers\Http\DTO\RequestOptions, 'Data Machine applies wp-ai-client RequestOptions to API-based models' ); assert_timeout_smoke( 240.0 === $captured_request_options?->getTimeout(), 'Data Machine sets RequestOptions timeout from scoped request timeout' ); -assert_timeout_smoke( 30.0 === $captured_request_options?->getConnectTimeout(), 'Data Machine caps RequestOptions connect timeout at 30 seconds' ); +assert_timeout_smoke( 120.0 === $captured_request_options?->getConnectTimeout(), 'Data Machine sets RequestOptions connect timeout from scoped connect timeout' ); + +$captured_builder_request_options = TimeoutPromptBuilderDouble::$captured_request['request_options'] ?? null; +assert_timeout_smoke( $captured_builder_request_options instanceof \WordPress\AiClient\Providers\Http\DTO\RequestOptions, 'Data Machine applies wp-ai-client RequestOptions to PromptBuilder' ); +assert_timeout_smoke( 240.0 === $captured_builder_request_options?->getTimeout(), 'Data Machine sets PromptBuilder RequestOptions timeout from scoped request timeout' ); +assert_timeout_smoke( 120.0 === $captured_builder_request_options?->getConnectTimeout(), 'Data Machine sets PromptBuilder RequestOptions connect timeout from scoped connect timeout' ); + +assert_timeout_smoke( 30.0 === ( $connect_timeout_context['timeout'] ?? null ), 'Data Machine connect timeout filter receives product default' ); +assert_timeout_smoke( 240.0 === ( $connect_timeout_context['request_timeout'] ?? null ), 'Data Machine connect timeout filter receives resolved request timeout' ); +assert_timeout_smoke( 'pipeline' === ( $connect_timeout_context['mode'] ?? null ), 'Data Machine connect timeout filter receives execution mode' ); +assert_timeout_smoke( 'openai' === ( $connect_timeout_context['provider'] ?? null ), 'Data Machine connect timeout filter receives provider' ); +assert_timeout_smoke( 'gpt-smoke' === ( $connect_timeout_context['model'] ?? null ), 'Data Machine connect timeout filter receives model' ); $captured_history = TimeoutPromptBuilderDouble::$captured_request['history'] ?? array(); $captured_prompt = TimeoutPromptBuilderDouble::$captured_request['prompt'] ?? null;