*/ private $codex_last_usage = []; private $codex_image_detail = 'low'; public function __construct() { } /** @return string */ protected function get_reasoning_effort() { $settings = WTeam_Executor_Settings::get_settings(); return WTeam_Executor_Settings::normalize_reasoning_effort( isset( $settings['codex_reasoning_effort'] ) ? $settings['codex_reasoning_effort'] : 'medium', 'medium' ); } /** @return array|WP_Error */ public function assistant_step( $model, array $context, $image_data_url = '' ) { $instructions = Client::get_assistant_instructions(); $trigger = isset( $context['automation']['trigger'] ) ? (string) $context['automation']['trigger'] : ''; $this->codex_image_detail = in_array( $trigger, [ 'manual_analyze', 'ai_requested_screenshot' ], true ) ? 'high' : 'low'; return $this->request_codex_structured( $instructions, [ [ 'role' => 'user', 'content' => [ [ 'type' => 'input_text', 'text' => wp_json_encode( self::compact_context_lists_for_ai( $context ), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ), ], ], ], ], 'wteam_executor_assistant_decision', self::get_assistant_output_schema(), 'assistant', $image_data_url, $model ); } /** @return array */ public function get_last_usage() { return $this->codex_last_usage; } /** * @param string $model * @param string $instructions * @param array> $input * @param string $schema_name * @param array $schema * @param string $response_type * @param string $image_data_url * @param string $model_override * @return array|WP_Error */ protected function request_codex_structured( $instructions, array $input, $schema_name, array $schema, $response_type, $image_data_url = '', $model_override = '' ) { $this->codex_last_usage = []; if ( ! WTeam_Executor_Codex_Runtime::has_auth() ) { return new WP_Error( 'wteam_executor_codex_not_connected', __( 'No ChatGPT account is connected for this WordPress user. Open Executor > Settings and connect ChatGPT first.', 'wteam-executor' ) ); } $settings = WTeam_Executor_Settings::get_settings(); $override = trim( (string) $model_override ); if ( '' === $override && isset( $settings['codex_model'] ) ) { $override = trim( (string) $settings['codex_model'] ); } $model = WTeam_Executor_Codex_Runtime::resolve_model( $override ); if ( is_wp_error( $model ) ) { return $model; } $input = $this->append_image_to_input( $input, $image_data_url ); if ( is_wp_error( $input ) ) { return $input; } $uses_lite = WTeam_Executor_Codex_Runtime::model_uses_responses_lite( (string) $model ); $session_id = wp_generate_uuid4(); $thread_id = wp_generate_uuid4(); $headers = [ 'Accept' => 'text/event-stream', 'Session-Id' => $session_id, 'Thread-Id' => $thread_id, 'X-Client-Request-Id' => $thread_id, ]; $text_config = [ 'format' => [ 'type' => 'json_schema', 'name' => (string) $schema_name, 'strict' => true, 'schema' => $schema, ], ]; $model_info = WTeam_Executor_Codex_Runtime::get_model_info( (string) $model ); if ( ! is_array( $model_info ) || ! isset( $model_info['support_verbosity'] ) || ! empty( $model_info['support_verbosity'] ) ) { $text_config['verbosity'] = 'low'; } $body = [ 'model' => (string) $model, 'instructions' => (string) $instructions, 'input' => $input, 'reasoning' => [ 'effort' => $this->get_reasoning_effort() ], 'store' => false, 'stream' => true, 'text' => $text_config, 'client_metadata' => [ 'client' => 'wteam-executor', 'session_id' => $session_id, 'thread_id' => $thread_id, ], ]; if ( $uses_lite ) { $prefix = [ [ 'type' => 'additional_tools', 'role' => 'developer', 'tools' => [], ], ]; if ( '' !== (string) $instructions ) { $prefix[] = [ 'type' => 'message', 'role' => 'developer', 'content' => [ [ 'type' => 'input_text', 'text' => (string) $instructions, ], ], ]; } $body['instructions'] = ''; $body['input'] = array_merge( $prefix, $input ); $body['reasoning']['context'] = 'all_turns'; $body['parallel_tool_calls'] = false; $headers['X-OpenAI-Internal-Codex-Responses-Lite'] = 'true'; } $response = WTeam_Executor_Codex_Runtime::codex_request( 'POST', '/responses', $body, 120, $headers ); if ( is_wp_error( $response ) ) { return $response; } $http_code = (int) $response['code']; $raw_body = (string) $response['body']; if ( $http_code < 200 || $http_code >= 300 ) { return $this->map_http_error( $http_code, $raw_body, (string) $model ); } $parsed = $this->parse_codex_response( $raw_body, (string) $model ); if ( is_wp_error( $parsed ) ) { return $parsed; } $this->codex_last_usage = isset( $parsed['usage'] ) && is_array( $parsed['usage'] ) ? $parsed['usage'] : []; $text = isset( $parsed['text'] ) ? (string) $parsed['text'] : ''; if ( '' === trim( $text ) ) { return new WP_Error( 'wteam_executor_empty_' . $response_type . '_response', __( 'ChatGPT/Codex did not return usable structured text output.', 'wteam-executor' ) ); } $decoded = $this->decode_output( $text ); if ( ! is_array( $decoded ) || ( 'assistant' === $response_type && ! $this->valid_assistant_shape( $decoded ) ) ) { return new WP_Error( 'wteam_executor_invalid_' . $response_type . '_response', /* translators: %s: runtime value inserted into this message. */ sprintf( __( 'ChatGPT/Codex did not return a valid %s controller response.', 'wteam-executor' ), $response_type ), [ 'response_length' => strlen( $text ), 'response_hash' => hash( 'sha256', $text ), ] ); } return $decoded; } /** * @param array> $input * @param string $image_data_url * @return array>|WP_Error */ private function append_image_to_input( array $input, $image_data_url ) { if ( '' === (string) $image_data_url ) { return $input; } if ( strlen( (string) $image_data_url ) > 4200000 || ! preg_match( '#^data:image/(?:png|jpeg|jpg|webp);base64,[A-Za-z0-9+/=\r\n]+$#', (string) $image_data_url ) ) { return new WP_Error( 'wteam_executor_codex_invalid_image', __( 'The screenshot could not be prepared for ChatGPT/Codex.', 'wteam-executor' ) ); } $image = [ 'type' => 'input_image', 'image_url' => (string) $image_data_url, 'detail' => $this->codex_image_detail, ]; $last_index = count( $input ) - 1; if ( $last_index >= 0 && isset( $input[ $last_index ]['content'] ) && is_array( $input[ $last_index ]['content'] ) ) { $input[ $last_index ]['content'][] = $image; } else { $input[] = [ 'role' => 'user', 'content' => [ $image ], ]; } return $input; } /** @return array{text:string,usage:array}|WP_Error */ private function parse_codex_response( $raw_body, $model = '' ) { $trimmed = trim( (string) $raw_body ); if ( '' === $trimmed ) { return new WP_Error( 'wteam_executor_codex_empty_response', __( 'ChatGPT/Codex returned an empty response.', 'wteam-executor' ) ); } if ( '{' === substr( $trimmed, 0, 1 ) ) { $decoded = json_decode( $trimmed, true ); if ( is_array( $decoded ) ) { if ( isset( $decoded['error'] ) ) { $api_error = $this->extract_api_error( $decoded ); if ( $this->is_usage_limit_message( $api_error ) ) { return $this->usage_limit_error( $api_error, (string) $model ); } return new WP_Error( 'wteam_executor_codex_response_error', $api_error ); } return [ 'text' => $this->extract_output_text( $decoded ), 'usage' => isset( $decoded['usage'] ) && is_array( $decoded['usage'] ) ? $decoded['usage'] : [], ]; } } $text = ''; $completed_text = ''; $usage = []; $terminal_error = ''; $response_status = ''; $incomplete_reason = ''; foreach ( preg_split( '/\r?\n/', (string) $raw_body ) as $line ) { $line = trim( (string) $line ); if ( 0 !== strpos( $line, 'data:' ) ) { continue; } $data = trim( substr( $line, 5 ) ); if ( '' === $data || '[DONE]' === $data ) { continue; } $event = json_decode( $data, true ); if ( ! is_array( $event ) ) { continue; } $type = isset( $event['type'] ) ? (string) $event['type'] : ''; if ( 'response.output_text.delta' === $type && isset( $event['delta'] ) ) { $text .= (string) $event['delta']; continue; } if ( 'response.output_text.done' === $type && isset( $event['text'] ) ) { $completed_text = (string) $event['text']; continue; } if ( 'error' === $type || 'response.failed' === $type ) { $terminal_error = $this->extract_api_error( $event ); continue; } if ( 'response.completed' === $type && isset( $event['response'] ) && is_array( $event['response'] ) ) { $response = $event['response']; $response_status = isset( $response['status'] ) ? sanitize_key( (string) $response['status'] ) : 'completed'; if ( isset( $response['error'] ) ) { $completed_error = $this->extract_api_error( [ 'response' => $response ] ); if ( '' !== $completed_error ) { $terminal_error = $completed_error; } } if ( isset( $response['usage'] ) && is_array( $response['usage'] ) ) { $usage = $response['usage']; } $from_response = $this->extract_output_text( $response ); if ( '' !== $from_response ) { $completed_text = $from_response; } continue; } if ( 'response.incomplete' === $type && isset( $event['response'] ) && is_array( $event['response'] ) ) { $response_status = 'incomplete'; $incomplete_reason = isset( $event['response']['incomplete_details']['reason'] ) ? sanitize_key( (string) $event['response']['incomplete_details']['reason'] ) : ''; } } if ( '' !== $terminal_error ) { if ( $this->is_usage_limit_message( $terminal_error ) ) { return $this->usage_limit_error( $terminal_error, (string) $model ); } return new WP_Error( 'wteam_executor_codex_response_failed', $terminal_error ); } if ( 'incomplete' === $response_status ) { return new WP_Error( 'wteam_executor_codex_response_incomplete', __( 'ChatGPT/Codex ended the response before the structured controller decision was complete.', 'wteam-executor' ), [ 'reason' => $incomplete_reason ] ); } return [ 'text' => '' !== $completed_text ? $completed_text : $text, 'usage' => $usage, ]; } /** @return string */ private function extract_output_text( array $response ) { if ( isset( $response['output_text'] ) && is_string( $response['output_text'] ) ) { return trim( $response['output_text'] ); } $parts = []; $output = isset( $response['output'] ) && is_array( $response['output'] ) ? $response['output'] : []; foreach ( $output as $item ) { if ( ! is_array( $item ) ) { continue; } if ( isset( $item['content'] ) && is_array( $item['content'] ) ) { foreach ( $item['content'] as $content ) { if ( ! is_array( $content ) ) { continue; } if ( isset( $content['text'] ) && is_string( $content['text'] ) && in_array( (string) ( $content['type'] ?? '' ), [ 'output_text', 'text' ], true ) ) { $parts[] = $content['text']; } } } } return trim( implode( '', $parts ) ); } /** @return WP_Error */ private function map_http_error( $http_code, $raw_body, $model = '' ) { $message = $this->extract_api_error( json_decode( (string) $raw_body, true ) ); if ( '' === $message ) { $message = $this->limit_text( (string) $raw_body, 600 ); } if ( in_array( (int) $http_code, [ 401, 403 ], true ) ) { return new WP_Error( 'wteam_executor_codex_auth_error', __( 'The connected ChatGPT session is not authorized for Codex. Reconnect it under Executor > Settings. ', 'wteam-executor' ) . $message ); } if ( 429 === (int) $http_code || $this->is_usage_limit_message( $message ) ) { return $this->usage_limit_error( $message, (string) $model ); } if ( 404 === (int) $http_code && false !== stripos( $message, 'model' ) ) { return new WP_Error( 'wteam_executor_codex_model_error', __( 'The selected Codex model is not available to this ChatGPT account. Leave the Codex model override blank and try again. ', 'wteam-executor' ) . $message ); } return new WP_Error( 'wteam_executor_codex_error', __( 'ChatGPT/Codex request failed (HTTP ', 'wteam-executor' ) . (int) $http_code . '): ' . $message ); } /** @return bool */ private function is_usage_limit_message( $message ) { $message = (string) $message; return false !== stripos( $message, 'usage limit' ) || false !== stripos( $message, 'rate limit' ) || false !== stripos( $message, "you've hit your usage limit" ) || false !== stripos( $message, 'you have hit your usage limit' ); } /** @return WP_Error */ private function usage_limit_error( $message, $model = '' ) { $base = __( 'The connected ChatGPT account has reached its current Codex usage/rate limit.', 'wteam-executor' ); $message = trim( (string) $message ); if ( '' !== $message && 0 !== strcasecmp( rtrim( $base, '. ' ), rtrim( $message, '. ' ) ) ) { $base .= ' ' . $message; } $reset_at = $this->get_usage_reset_timestamp( (string) $model ); if ( $reset_at > time() ) { $date_format = trim( (string) get_option( 'date_format' ) ); $time_format = trim( (string) get_option( 'time_format' ) ); if ( '' === $date_format ) { $date_format = 'Y-m-d'; } if ( '' === $time_format ) { $time_format = 'H:i'; } $local_time = wp_date( $date_format . ' ' . $time_format, $reset_at ); $remaining = human_time_diff( time(), $reset_at ); $base .= ' ' . sprintf( /* translators: 1: local date and time when Codex usage resets, 2: human-readable time remaining. */ __( 'Available again after the usage reset: %1$s (in about %2$s).', 'wteam-executor' ), $local_time, $remaining ); } return new WP_Error( 'wteam_executor_codex_usage_limit', $base ); } /** * Return the reset timestamp of the exhausted Codex rate-limit window. * * OpenAI reports reset_at as Unix seconds and reset_after_seconds as a relative fallback. * We only use a window when the usage snapshot itself indicates that its bucket is blocked * and the window is exhausted; otherwise no reset time is guessed. * * @return int Unix timestamp, or 0 when the backend does not identify an exhausted window. */ private function get_usage_reset_timestamp( $model = '' ) { $snapshot = WTeam_Executor_Codex_Runtime::get_usage_snapshot(); if ( is_wp_error( $snapshot ) ) { return 0; } $buckets = []; if ( isset( $snapshot['rate_limit'] ) && is_array( $snapshot['rate_limit'] ) ) { $buckets[] = [ 'name' => 'codex', 'rate_limit' => $snapshot['rate_limit'], ]; } if ( isset( $snapshot['additional_rate_limits'] ) && is_array( $snapshot['additional_rate_limits'] ) ) { foreach ( $snapshot['additional_rate_limits'] as $additional ) { if ( ! is_array( $additional ) || ! isset( $additional['rate_limit'] ) || ! is_array( $additional['rate_limit'] ) ) { continue; } $buckets[] = [ 'name' => (string) ( $additional['limit_name'] ?? $additional['metered_feature'] ?? '' ), 'rate_limit' => $additional['rate_limit'], ]; } } $model = strtolower( trim( (string) $model ) ); $candidate_resets = []; foreach ( $buckets as $bucket ) { $rate_limit = $bucket['rate_limit']; $blocked = ! empty( $rate_limit['limit_reached'] ) || ( array_key_exists( 'allowed', $rate_limit ) && false === (bool) $rate_limit['allowed'] ); if ( ! $blocked ) { continue; } $bucket_name = strtolower( trim( (string) $bucket['name'] ) ); if ( 'codex' !== $bucket_name && '' !== $model && false === strpos( $bucket_name, $model ) && false === strpos( $model, $bucket_name ) ) { // Keep model-specific buckets only when they plausibly apply to the active model. continue; } $exhausted = []; foreach ( [ 'primary_window', 'secondary_window' ] as $window_key ) { if ( empty( $rate_limit[ $window_key ] ) || ! is_array( $rate_limit[ $window_key ] ) ) { continue; } $window = $rate_limit[ $window_key ]; $used = isset( $window['used_percent'] ) && is_numeric( $window['used_percent'] ) ? (float) $window['used_percent'] : null; if ( null === $used || $used < 99.5 ) { continue; } $reset = $this->usage_window_reset_timestamp( $window ); if ( $reset > time() ) { $exhausted[] = $reset; } } if ( ! empty( $exhausted ) ) { // If more than one independent exhausted window blocks usage, all must clear. $candidate_resets[] = max( $exhausted ); } } return empty( $candidate_resets ) ? 0 : max( $candidate_resets ); } /** @return int */ private function usage_window_reset_timestamp( array $window ) { if ( isset( $window['reset_at'] ) && is_numeric( $window['reset_at'] ) ) { $reset_at = (int) $window['reset_at']; if ( $reset_at > 0 ) { return $reset_at; } } if ( isset( $window['reset_after_seconds'] ) && is_numeric( $window['reset_after_seconds'] ) ) { $after = max( 0, (int) $window['reset_after_seconds'] ); if ( $after > 0 ) { return time() + $after; } } return 0; } /** @return string */ private function extract_api_error( $value ) { if ( ! is_array( $value ) ) { return ''; } if ( isset( $value['error']['message'] ) ) { return sanitize_text_field( (string) $value['error']['message'] ); } if ( isset( $value['response']['error']['message'] ) ) { return sanitize_text_field( (string) $value['response']['error']['message'] ); } if ( isset( $value['message'] ) && is_string( $value['message'] ) ) { return sanitize_text_field( $value['message'] ); } if ( isset( $value['error'] ) && is_string( $value['error'] ) ) { return sanitize_text_field( $value['error'] ); } return ''; } /** @return array|null */ private function decode_output( $text ) { $text = trim( preg_replace( '/^\xEF\xBB\xBF/', '', (string) $text ) ); if ( '' === $text ) { return null; } $decoded = json_decode( $text, true ); if ( is_array( $decoded ) ) { return $decoded; } if ( preg_match( '/^```(?:json)?\s*(.*?)\s*```$/is', $text, $m ) ) { $decoded = json_decode( trim( (string) $m[1] ), true ); if ( is_array( $decoded ) ) { return $decoded; } } $start = strpos( $text, '{' ); $end = strrpos( $text, '}' ); if ( false !== $start && false !== $end && $end > $start ) { $candidate = substr( $text, $start, $end - $start + 1 ); if ( strlen( $candidate ) <= 50000 ) { $decoded = json_decode( $candidate, true ); if ( is_array( $decoded ) ) { return $decoded; } } } return null; } /** @return bool */ private function valid_assistant_shape( array $result ) { $required = [ 'status', 'message', 'state_summary', 'requires_confirmation', 'actions', 'tool' ]; foreach ( $required as $key ) { if ( ! array_key_exists( $key, $result ) ) { return false; } } if ( ! in_array( (string) $result['status'], [ 'tool', 'visual_action', 'request_screenshot', 'ask_user', 'done', 'blocked' ], true ) ) { return false; } if ( ! is_array( $result['actions'] ) || count( $result['actions'] ) > 3 || ! is_array( $result['tool'] ) ) { return false; } foreach ( $result['actions'] as $action ) { if ( ! is_array( $action ) ) { return false; } foreach ( [ 'type', 'element_id', 'target_element_id', 'value' ] as $key ) { if ( ! array_key_exists( $key, $action ) ) { return false; } } } foreach ( [ 'name', 'arguments_json' ] as $key ) { if ( ! array_key_exists( $key, $result['tool'] ) ) { return false; } } return true; } /** @return string */ private function limit_text( $text, $max ) { $text = trim( wp_strip_all_tags( (string) $text ) ); if ( function_exists( 'mb_substr' ) ) { return mb_substr( $text, 0, (int) $max ); } return substr( $text, 0, (int) $max ); } }